diff --git a/app/src/components/layout/sidebarRight/Header.tsx b/app/src/components/layout/sidebarRight/Header.tsx index 2673671..77d5807 100644 --- a/app/src/components/layout/sidebarRight/Header.tsx +++ b/app/src/components/layout/sidebarRight/Header.tsx @@ -4,7 +4,7 @@ import { useActiveUsers, useCamMode } from "../../../store/builder/store"; import { ActiveUser } from "../../../types/users"; import CollaborationPopup from "../../templates/CollaborationPopup"; import { getAvatarColor } from "../../../modules/collaboration/functions/getAvatarColor"; -import { useSelectedUserStore } from "../../../store/useCollabStore"; +import { useSelectedUserStore } from "../../../store/collaboration/useCollabStore"; import useToggleStore from "../../../store/useUIToggleStore"; import { ToggleSidebarIcon } from "../../icons/HeaderIcons"; import useModuleStore from "../../../store/useModuleStore"; diff --git a/app/src/components/templates/FollowPerson.tsx b/app/src/components/templates/FollowPerson.tsx index 63fb26a..36f0b53 100644 --- a/app/src/components/templates/FollowPerson.tsx +++ b/app/src/components/templates/FollowPerson.tsx @@ -1,6 +1,6 @@ import React from "react"; import RenderOverlay from "./Overlay"; -import { useSelectedUserStore } from "../../store/useCollabStore"; +import { useSelectedUserStore } from "../../store/collaboration/useCollabStore"; import { useCamMode } from "../../store/builder/store"; const FollowPerson: React.FC = () => { diff --git a/app/src/modules/collaboration/camera/collabCams.tsx b/app/src/modules/collaboration/camera/collabCams.tsx index 5c4e8d6..f24d6aa 100644 --- a/app/src/modules/collaboration/camera/collabCams.tsx +++ b/app/src/modules/collaboration/camera/collabCams.tsx @@ -1,18 +1,19 @@ import * as THREE from "three"; import { useEffect, useRef, useState } from "react"; -import { useFrame } from "@react-three/fiber"; +import { useFrame, useThree } from "@react-three/fiber"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader"; import camModel from "../../../assets/gltf-glb/camera face 2.gltf"; import getActiveUsersData from "../../../services/factoryBuilder/collab/getActiveUsers"; -import { useActiveUsers, useSocketStore } from "../../../store/builder/store"; +import { useActiveUsers, useCamMode, useSocketStore } from "../../../store/builder/store"; import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader"; import { useNavigate } from "react-router-dom"; import { Html } from "@react-three/drei"; import CollabUserIcon from "./collabUserIcon"; import useModuleStore from "../../../store/useModuleStore"; import { getAvatarColor } from "../functions/getAvatarColor"; -import { useSelectedUserStore } from "../../../store/useCollabStore"; +import { useSelectedUserStore } from "../../../store/collaboration/useCollabStore"; import { usePlayButtonStore } from "../../../store/usePlayButtonStore"; +import setCameraView from "../functions/setCameraView"; const CamModelsGroup = () => { const navigate = useNavigate(); @@ -30,6 +31,28 @@ const CamModelsGroup = () => { dracoLoader.setDecoderPath("three/examples/jsm/libs/draco/gltf/"); loader.setDRACOLoader(dracoLoader); + + const { camMode } = useCamMode(); + const { camera, controls } = useThree(); // Access R3F camera and controls + + useEffect(() => { + if (camMode !== "FollowPerson") return; + // If a user is selected, set the camera view to their location + // and update the camera and controls accordingly + if (selectedUser?.location) { + const { position, rotation, target } = selectedUser.location; + if (rotation && target) + setCameraView({ + controls, + camera, + position, + rotation, + target, + username: selectedUser.name, + }); + } + }, [selectedUser, camera, controls, camMode]); + const [cams, setCams] = useState([]); const [models, setModels] = useState< Record< @@ -260,11 +283,10 @@ const CamModelsGroup = () => { textAlign: "center", fontFamily: "Arial, sans-serif", display: `${activeModule !== "visualization" ? "" : "none"}`, - opacity: `${ - selectedUser?.name !== cam.userData.userName && !isPlaying - ? 1 - : 0 - }`, + opacity: `${selectedUser?.name !== cam.userData.userName && !isPlaying + ? 1 + : 0 + }`, transition: "opacity .2s ease", }} position={[-0.015, 0, 0.7]} diff --git a/app/src/modules/collaboration/camera/collabUserIcon.tsx b/app/src/modules/collaboration/camera/collabUserIcon.tsx index 7a9873b..e45d7a5 100644 --- a/app/src/modules/collaboration/camera/collabUserIcon.tsx +++ b/app/src/modules/collaboration/camera/collabUserIcon.tsx @@ -1,6 +1,6 @@ import React from "react"; import CustomAvatar from "../users/Avatar"; -import { useSelectedUserStore } from "../../../store/useCollabStore"; +import { useSelectedUserStore } from "../../../store/collaboration/useCollabStore"; import { useCamMode } from "../../../store/builder/store"; interface CollabUserIconProps { diff --git a/app/src/modules/collaboration/collaboration.tsx b/app/src/modules/collaboration/collaboration.tsx index 36e7131..d3486a5 100644 --- a/app/src/modules/collaboration/collaboration.tsx +++ b/app/src/modules/collaboration/collaboration.tsx @@ -1,34 +1,18 @@ -import React, { useEffect } from "react"; +import React from "react"; import CamModelsGroup from "./camera/collabCams"; -import { useSelectedUserStore } from "../../store/useCollabStore"; -import { useThree } from "@react-three/fiber"; -import setCameraView from "./functions/setCameraView"; -import { useCamMode } from "../../store/builder/store"; +import CommentsGroup from "./comments/commentsGroup"; const Collaboration: React.FC = () => { - const { selectedUser } = useSelectedUserStore(); - const { camMode } = useCamMode(); - const { camera, controls } = useThree(); // Access R3F camera and controls - useEffect(() => { - if (camMode !== "FollowPerson") return; - // If a user is selected, set the camera view to their location - // and update the camera and controls accordingly - if (selectedUser?.location) { - const { position, rotation, target } = selectedUser.location; - if (rotation && target) - setCameraView({ - controls, - camera, - position, - rotation, - target, - username: selectedUser.name, - }); - } - }, [selectedUser, camera, controls, camMode]); + return ( + <> - return ; + + + + + + ); }; export default Collaboration; diff --git a/app/src/modules/collaboration/comments/commentsGroup.tsx b/app/src/modules/collaboration/comments/commentsGroup.tsx new file mode 100644 index 0000000..06dc20e --- /dev/null +++ b/app/src/modules/collaboration/comments/commentsGroup.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import { useActiveTool } from "../../../store/builder/store" +import { useThree } from "@react-three/fiber"; +import { MathUtils, Vector3 } from "three"; +import { useCommentStore } from "../../../store/collaboration/useCommentStore"; +import CommentInstances from "./instances/commentInstances"; +import { Sphere } from "@react-three/drei"; + +function CommentsGroup() { + const { gl, raycaster, camera, scene, pointer } = useThree(); + const { activeTool } = useActiveTool(); + const { addComment } = useCommentStore(); + const [hoverPos, setHoverPos] = useState(null); + + const userId = localStorage.getItem('userId') ?? ''; + + useEffect(() => { + const canvasElement = gl.domElement; + + let drag = false; + let isLeftMouseDown = false; + + const onMouseDown = (evt: MouseEvent) => { + if (evt.button === 0) { + isLeftMouseDown = true; + drag = false; + } + }; + + const onMouseUp = (evt: MouseEvent) => { + if (evt.button === 0) { + isLeftMouseDown = false; + } + } + + const onMouseMove = () => { + if (isLeftMouseDown) { + drag = true; + } + + raycaster.setFromCamera(pointer, camera); + const intersects = raycaster + .intersectObjects(scene.children, true) + .filter( + (intersect) => + !intersect.object.name.includes("Roof") && + !intersect.object.name.includes("MeasurementReference") && + !intersect.object.name.includes("commentHolder") && + !intersect.object.name.includes("agv-collider") && + !(intersect.object.type === "GridHelper") + ); + + if (intersects.length > 0) { + const point = intersects[0].point; + setHoverPos(new Vector3(point.x, Math.max(point.y, 0), point.z)); + } else { + setHoverPos(null); + } + }; + + const onMouseClick = () => { + if (drag) return; + + const intersects = raycaster + .intersectObjects(scene.children, true) + .filter( + (intersect) => + !intersect.object.name.includes("Roof") && + !intersect.object.name.includes("MeasurementReference") && + !intersect.object.name.includes("commentHolder") && + !intersect.object.name.includes("agv-collider") && + !(intersect.object.type === "GridHelper") + ); + if (intersects.length > 0) { + const position = new Vector3(intersects[0].point.x, Math.max(intersects[0].point.y, 0), intersects[0].point.z); + + const comment: CommentSchema = { + state: 'active', + commentId: MathUtils.generateUUID(), + creatorId: userId, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + position: position.toArray(), + rotation: [0, 0, 0], + replies: [] + } + + addComment(comment); + setHoverPos(null); + } + } + + if (activeTool === 'comment') { + canvasElement.addEventListener("mousedown", onMouseDown); + canvasElement.addEventListener("mouseup", onMouseUp); + canvasElement.addEventListener("mousemove", onMouseMove); + canvasElement.addEventListener("click", onMouseClick); + } else { + setHoverPos(null); + } + + return () => { + canvasElement.removeEventListener("mousedown", onMouseDown); + canvasElement.removeEventListener("mouseup", onMouseUp); + canvasElement.removeEventListener("mousemove", onMouseMove); + canvasElement.removeEventListener("click", onMouseClick); + }; + }, [activeTool, camera]) + + return ( + <> + + + {hoverPos && ( + + + + )} + + ) +} + +export default CommentsGroup \ No newline at end of file diff --git a/app/src/modules/collaboration/comments/instances/commentInstance/commentInstance.tsx b/app/src/modules/collaboration/comments/instances/commentInstance/commentInstance.tsx new file mode 100644 index 0000000..07b6ae7 --- /dev/null +++ b/app/src/modules/collaboration/comments/instances/commentInstance/commentInstance.tsx @@ -0,0 +1,71 @@ +import { Html, TransformControls } from '@react-three/drei'; +import { useEffect, useRef, useState } from 'react' +import { usePlayButtonStore } from '../../../../../store/usePlayButtonStore'; +import { detectModifierKeys } from '../../../../../utils/shortcutkeys/detectModifierKeys'; + +function CommentInstance({ comment }: { comment: CommentSchema }) { + const { isPlaying } = usePlayButtonStore(); + const CommentRef = useRef(null); + const [selectedComment, setSelectedComment] = useState(null); + const [transformMode, setTransformMode] = useState<"translate" | "rotate" | null>(null); + + if (comment.state === 'inactive' || isPlaying) return null; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const keyCombination = detectModifierKeys(e); + if (!selectedComment) return; + if (keyCombination === "G") { + setTransformMode((prev) => (prev === "translate" ? null : "translate")); + } + if (keyCombination === "R") { + setTransformMode((prev) => (prev === "rotate" ? null : "rotate")); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedComment]); + + return ( + + <> + + +
{ + e.stopPropagation(); + setSelectedComment(comment); + console.log("down"); + }} + onPointerOver={(e) => { + e.stopPropagation(); + }}> +
+ hii +
+
+ + {CommentRef.current && transformMode && ( + { + + }} + /> + )} + + ) +} + +export default CommentInstance; \ No newline at end of file diff --git a/app/src/modules/collaboration/comments/instances/commentInstances.tsx b/app/src/modules/collaboration/comments/instances/commentInstances.tsx new file mode 100644 index 0000000..53dc4c6 --- /dev/null +++ b/app/src/modules/collaboration/comments/instances/commentInstances.tsx @@ -0,0 +1,23 @@ +import React, { useEffect } from 'react' +import CommentInstance from './commentInstance/commentInstance' +import { useCommentStore } from '../../../../store/collaboration/useCommentStore' + +function CommentInstances() { + const { comments } = useCommentStore(); + + useEffect(() => { + console.log('comments: ', comments); + }, [comments]) + + return ( + <> + {comments.map((comment: CommentSchema) => ( + + + + ))} + + ) +} + +export default CommentInstances \ No newline at end of file diff --git a/app/src/modules/simulation/events/arrows/arrows.tsx b/app/src/modules/simulation/events/arrows/arrows.tsx index 5eb6a9f..943b614 100644 --- a/app/src/modules/simulation/events/arrows/arrows.tsx +++ b/app/src/modules/simulation/events/arrows/arrows.tsx @@ -1,6 +1,7 @@ import * as THREE from "three"; -import { useMemo, useRef } from "react"; +import { useMemo, useRef, useState } from "react"; import { useThree } from "@react-three/fiber"; +import { useDeleteTool } from "../../../../store/builder/store"; interface ConnectionLine { id: string; @@ -10,8 +11,10 @@ interface ConnectionLine { } export function Arrows({ connections }: { connections: ConnectionLine[] }) { + const [hoveredLineKey, setHoveredLineKey] = useState(null); const groupRef = useRef(null); const { scene } = useThree(); + const { deleteTool } = useDeleteTool(); const getWorldPositionFromScene = (uuid: string): THREE.Vector3 | null => { const obj = scene.getObjectByProperty("uuid", uuid); @@ -52,11 +55,25 @@ export function Arrows({ connections }: { connections: ConnectionLine[] }) { return ( - - + setHoveredLineKey(key)} + onPointerOut={() => setHoveredLineKey(null)} + > + - - + setHoveredLineKey(key)} + onPointerOut={() => setHoveredLineKey(null)} + > + ); diff --git a/app/src/pages/Project.tsx b/app/src/pages/Project.tsx index 8419e13..943323b 100644 --- a/app/src/pages/Project.tsx +++ b/app/src/pages/Project.tsx @@ -21,7 +21,7 @@ import { usePlayButtonStore } from "../store/usePlayButtonStore"; import MarketPlace from "../modules/market/MarketPlace"; import LoadingPage from "../components/templates/LoadingPage"; import KeyPressListener from "../utils/shortcutkeys/handleShortcutKeys"; -import { useSelectedUserStore } from "../store/useCollabStore"; +import { useSelectedUserStore } from "../store/collaboration/useCollabStore"; import FollowPerson from "../components/templates/FollowPerson"; import Scene from "../modules/scene/scene"; import { createHandleDrop } from "../modules/visualization/functions/handleUiDrop"; diff --git a/app/src/store/useCollabStore.ts b/app/src/store/collaboration/useCollabStore.ts similarity index 95% rename from app/src/store/useCollabStore.ts rename to app/src/store/collaboration/useCollabStore.ts index 499e857..b768126 100644 --- a/app/src/store/useCollabStore.ts +++ b/app/src/store/collaboration/useCollabStore.ts @@ -1,36 +1,36 @@ -import { create } from 'zustand'; - -interface SelectedUser { - color: string; - name: string; - id: string, - location?: { - position: { - x: number; - y: number; - z: number; - }; - rotation?: { - x: number; - y: number; - z: number; - }; - target?: { - x: number; - y: number; - z: number; - }; - } -} - -interface SelectedUserStore { - selectedUser: SelectedUser | null; - setSelectedUser: (user: SelectedUser) => void; - clearSelectedUser: () => void; -} - -export const useSelectedUserStore = create((set) => ({ - selectedUser: null, - setSelectedUser: (user) => set({ selectedUser: user }), - clearSelectedUser: () => set({ selectedUser: null }), -})); +import { create } from 'zustand'; + +interface SelectedUser { + color: string; + name: string; + id: string, + location?: { + position: { + x: number; + y: number; + z: number; + }; + rotation?: { + x: number; + y: number; + z: number; + }; + target?: { + x: number; + y: number; + z: number; + }; + } +} + +interface SelectedUserStore { + selectedUser: SelectedUser | null; + setSelectedUser: (user: SelectedUser) => void; + clearSelectedUser: () => void; +} + +export const useSelectedUserStore = create((set) => ({ + selectedUser: null, + setSelectedUser: (user) => set({ selectedUser: user }), + clearSelectedUser: () => set({ selectedUser: null }), +})); diff --git a/app/src/store/collaboration/useCommentStore.ts b/app/src/store/collaboration/useCommentStore.ts new file mode 100644 index 0000000..cd4d7c3 --- /dev/null +++ b/app/src/store/collaboration/useCommentStore.ts @@ -0,0 +1,92 @@ +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; + +interface CommentStore { + comments: CommentsSchema; + + // Comment operations + addComment: (comment: CommentSchema) => void; + setComments: (comments: CommentsSchema) => void; + updateComment: (commentId: string, updates: Partial) => void; + removeComment: (commentId: string) => void; + + // Reply operations + addReply: (commentId: string, reply: Reply) => void; + updateReply: (commentId: string, replyId: string, updates: Partial) => void; + removeReply: (commentId: string, replyId: string) => void; + + // Getters + getCommentById: (commentId: string) => CommentSchema | undefined; +} + +export const useCommentStore = create()( + immer((set, get) => ({ + comments: [], + + // Comment operations + addComment: (comment) => { + set((state) => { + if (!state.comments.find(c => c.commentId === comment.commentId)) { + state.comments.push(JSON.parse(JSON.stringify(comment))); + } + }); + }, + + setComments: (comments) => { + set((state) => { + state.comments = comments; + }); + }, + + updateComment: (commentId, updates) => { + set((state) => { + const comment = state.comments.find(c => c.commentId === commentId); + if (comment) { + Object.assign(comment, updates); + } + }); + }, + + removeComment: (commentId) => { + set((state) => { + state.comments = state.comments.filter(c => c.commentId !== commentId); + }); + }, + + // Reply operations + addReply: (commentId, reply) => { + set((state) => { + const comment = state.comments.find(c => c.commentId === commentId); + if (comment) { + comment.replies.push(reply); + } + }); + }, + + updateReply: (commentId, replyId, updates) => { + set((state) => { + const comment = state.comments.find(c => c.commentId === commentId); + if (comment) { + const reply = comment.replies.find(r => r.replyId === replyId); + if (reply) { + Object.assign(reply, updates); + } + } + }); + }, + + removeReply: (commentId, replyId) => { + set((state) => { + const comment = state.comments.find(c => c.commentId === commentId); + if (comment) { + comment.replies = comment.replies.filter(r => r.replyId !== replyId); + } + }); + }, + + // Getter + getCommentById: (commentId) => { + return get().comments.find(c => c.commentId === commentId); + }, + })) +); diff --git a/app/src/types/collaborationTypes.d.ts b/app/src/types/collaborationTypes.d.ts new file mode 100644 index 0000000..6a055a7 --- /dev/null +++ b/app/src/types/collaborationTypes.d.ts @@ -0,0 +1,20 @@ +interface CommentSchema { + state: "active" | "inactive"; + commentId: string; + creatorId: string; + createdAt: string; + lastUpdatedAt: string; + position: [number, number, number]; + rotation: [number, number, number]; + replies: Reply[]; +} + +interface Reply { + replyId: string; + creatorId: string; + createdAt: string; + lastUpdatedAt: string; + reply: string; +} + +type CommentsSchema = CommentSchema[]; \ No newline at end of file