From fd6e91549719b69e82b3e5deafa02ecea732c489 Mon Sep 17 00:00:00 2001 From: "Zaine F." <48459250+ZGeek03@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:52:58 -0400 Subject: [PATCH 1/4] added rotate icon, base functionality --- app/globals.css | 13 ++++++++ components/IconNode.tsx | 70 +++++++++++++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/app/globals.css b/app/globals.css index dceacb1..5fedd04 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,6 +6,19 @@ body { font-family: Arial, Helvetica, sans-serif; } +.rotatable-node__handle { + position: absolute; + width: 10px; + height: 10px; + background: transparent; + left: 50%; + top: -30px; + border-radius: 100%; + transform: translate(-50%, -50%); + cursor: alias; +} + + @layer utilities { .text-balance { text-wrap: balance; diff --git a/components/IconNode.tsx b/components/IconNode.tsx index 89e1521..3e23ac9 100644 --- a/components/IconNode.tsx +++ b/components/IconNode.tsx @@ -19,9 +19,11 @@ import { TooltipProvider, TooltipTrigger, } from "@radix-ui/react-tooltip"; -import { NodeProps, useReactFlow } from "@xyflow/react"; +import { NodeProps, useReactFlow, useUpdateNodeInternals } from "@xyflow/react"; +import { drag } from 'd3-drag'; +import { select } from 'd3-selection'; import * as Icons from "lucide-react"; -import { Trash2 } from "lucide-react"; +import { RotateCw, Trash2 } from "lucide-react"; import { useParams } from "next/navigation"; import { createContext, @@ -51,6 +53,10 @@ export function IconNode({ data, id }: NodeProps) { const timeoutId = useRef(); + const updateNodeInternals = useUpdateNodeInternals(); + const [rotation, setRotation] = useState(0); + const rotateControlRef = useRef(null); + // Get the icon component from the Lucide icons const IconComponent = Icons[data.iconName as keyof typeof Icons.icons]; @@ -61,6 +67,24 @@ export function IconNode({ data, id }: NodeProps) { } }, [isOpen, id, setActiveNodeId]); + useEffect(() => { + if (!rotateControlRef.current) { + return; + } + + const selection = select(rotateControlRef.current as Element); + const dragHandler = drag().on('drag', (evt) => { + const dx = evt.x - 100; + const dy = evt.y - 100; + const rad = Math.atan2(dx, dy); + const deg = rad * (180 / Math.PI); + setRotation(180 - deg); + updateNodeInternals(id); + }); + + selection.call(dragHandler); + }, [id, updateNodeInternals]); + const handleCopy = useCallback(() => { try { const node = getNode(id); @@ -208,17 +232,45 @@ export function IconNode({ data, id }: NodeProps) { <> - + > +
+ +
+ + Date: Mon, 14 Apr 2025 17:11:29 -0400 Subject: [PATCH 2/4] changed to button, added 1 of 2 --- components/IconNode.tsx | 47 ++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/components/IconNode.tsx b/components/IconNode.tsx index 3e23ac9..87ea5dd 100644 --- a/components/IconNode.tsx +++ b/components/IconNode.tsx @@ -23,7 +23,8 @@ import { NodeProps, useReactFlow, useUpdateNodeInternals } from "@xyflow/react"; import { drag } from 'd3-drag'; import { select } from 'd3-selection'; import * as Icons from "lucide-react"; -import { RotateCw, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; +import NextPlanIcon from '@mui/icons-material/NextPlan'; import { useParams } from "next/navigation"; import { createContext, @@ -45,7 +46,7 @@ export const ActiveNodeContext = createContext<{ export function IconNode({ data, id }: NodeProps) { const { deleteElements, setNodes, getNode } = useReactFlow(); - const { setActiveNodeId } = useContext(ActiveNodeContext); + const { activeNodeId, setActiveNodeId } = useContext(ActiveNodeContext); const [isOpen, setIsOpen] = useState(false); const params = useParams<{ mode: string }>(); @@ -163,6 +164,7 @@ export function IconNode({ data, id }: NodeProps) { const handleResize = useCallback( (selectedSize: number) => { + console.log(selectedSize) setNodes((nds) => nds.map((node) => { if (node.id === id) { @@ -237,31 +239,32 @@ export function IconNode({ data, id }: NodeProps) { }} className="popover-trigger flex flex-col items-center justify-center cursor-move" > + {/*TODO: fix the top and right attributes to properly place the button on the node.*/} + {(activeNodeId === id || isOpen) && ( + { + e.stopPropagation(); + setRotation(rotation + 45); + }} + style={{ + position: 'relative', + top: '3rem', + right: `-${(data.size ?? 3) * 1.25}rem`, + backgroundColor: 'rgba(0, 0, 0, 1.0)', + width: '30px', + height: '30px', + zIndex: 10000, + }} + className="nodrag" + > + + + )}
-
- -
Date: Wed, 16 Apr 2025 15:50:50 -0400 Subject: [PATCH 3/4] finished rotate buttons --- components/EventFlow.tsx | 3 +- components/IconNode.tsx | 133 +++++++++++++++++++++++++++------------ types/CustomNode.ts | 1 + 3 files changed, 97 insertions(+), 40 deletions(-) diff --git a/components/EventFlow.tsx b/components/EventFlow.tsx index 5013b24..85e781c 100644 --- a/components/EventFlow.tsx +++ b/components/EventFlow.tsx @@ -132,7 +132,7 @@ function Flow({ { id: "map", type: "customImageNode", - data: { label: "map", imageURL: imageURL }, + data: { label: "map", imageURL: imageURL, rotation: 0 }, position: { x: 0, y: 0, z: -1 }, draggable: false, deletable: false, @@ -407,6 +407,7 @@ function Flow({ label, iconName, color: "#57B9FF", + rotation: 0, }, draggable: true, deletable: true, diff --git a/components/IconNode.tsx b/components/IconNode.tsx index 87ea5dd..7dd1ac4 100644 --- a/components/IconNode.tsx +++ b/components/IconNode.tsx @@ -19,9 +19,7 @@ import { TooltipProvider, TooltipTrigger, } from "@radix-ui/react-tooltip"; -import { NodeProps, useReactFlow, useUpdateNodeInternals } from "@xyflow/react"; -import { drag } from 'd3-drag'; -import { select } from 'd3-selection'; +import { NodeProps, useReactFlow } from "@xyflow/react"; import * as Icons from "lucide-react"; import { Trash2 } from "lucide-react"; import NextPlanIcon from '@mui/icons-material/NextPlan'; @@ -54,10 +52,6 @@ export function IconNode({ data, id }: NodeProps) { const timeoutId = useRef(); - const updateNodeInternals = useUpdateNodeInternals(); - const [rotation, setRotation] = useState(0); - const rotateControlRef = useRef(null); - // Get the icon component from the Lucide icons const IconComponent = Icons[data.iconName as keyof typeof Icons.icons]; @@ -66,25 +60,10 @@ export function IconNode({ data, id }: NodeProps) { if (isOpen) { setActiveNodeId(id); } - }, [isOpen, id, setActiveNodeId]); - - useEffect(() => { - if (!rotateControlRef.current) { - return; + else{ + setActiveNodeId(null); } - - const selection = select(rotateControlRef.current as Element); - const dragHandler = drag().on('drag', (evt) => { - const dx = evt.x - 100; - const dy = evt.y - 100; - const rad = Math.atan2(dx, dy); - const deg = rad * (180 / Math.PI); - setRotation(180 - deg); - updateNodeInternals(id); - }); - - selection.call(dragHandler); - }, [id, updateNodeInternals]); + }, [isOpen, id, setActiveNodeId]); const handleCopy = useCallback(() => { try { @@ -230,6 +209,51 @@ export function IconNode({ data, id }: NodeProps) { }, [id, setNodes] ); + + const handleRotateClockwise = useCallback(() => { + const newRotation = (data.rotation + 45) % 360; + + // Update the node data to persist rotation + setNodes((nodes) => + nodes.map((node) => { + if (node.id === id) { + return { + ...node, + data: { + ...node.data, + rotation: newRotation, + }, + }; + } + return node; + }) + ); + console.log("r otation", newRotation); + //updateNodeInternals(id); + }, [data.rotation, id, setNodes]); + + const handleRotateCounterClockwise = useCallback(() => { + const newRotation = (data.rotation - 45) % 360; + + // Update the node data to persist rotation + setNodes((nodes) => + nodes.map((node) => { + if (node.id === id) { + return { + ...node, + data: { + ...node.data, + rotation: newRotation, + }, + }; + } + return node; + }) + ); + console.log("rotation", newRotation); + //updateNodeInternals(id); + }, [data.rotation, id, setNodes]); + return ( <> @@ -239,30 +263,61 @@ export function IconNode({ data, id }: NodeProps) { }} className="popover-trigger flex flex-col items-center justify-center cursor-move" > - {/*TODO: fix the top and right attributes to properly place the button on the node.*/} {(activeNodeId === id || isOpen) && ( - +
{ - e.stopPropagation(); - setRotation(rotation + 45); + e.stopPropagation(); // Prevent popover from triggering + handleRotateClockwise(); }} style={{ - position: 'relative', - top: '3rem', - right: `-${(data.size ?? 3) * 1.25}rem`, - backgroundColor: 'rgba(0, 0, 0, 1.0)', - width: '30px', - height: '30px', - zIndex: 10000, + position: 'absolute', + top: '50%', + left: '50%', + transform: `translate(-50%, -50%) translate(${((data.size == 1 ? 1.5 : data.size) ?? 3) * 15}px, 0) rotate(90deg)`, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + width: '15px', + height: '15px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + zIndex: 10, }} className="nodrag" > - - )} +
+
{ + e.stopPropagation(); // Prevent popover from triggering + handleRotateCounterClockwise(); + }} + style={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: `translate(-50%, -50%) translate(-${((data.size == 1 ? 1.5 : data.size) ?? 3) * 15}px, 0) scale(-1, 1) rotate(90deg)`, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + width: '15px', + height: '15px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + zIndex: 10, + }} + className="nodrag" + > + +
+ + )}
Date: Sat, 19 Apr 2025 10:30:42 -0500 Subject: [PATCH 4/4] fixed multiple post requests and image re-fetching --- components/CustomImageNode.tsx | 36 ++++++-- components/EventFlow.tsx | 62 +++++++------- components/IconNode.tsx | 152 ++++++++++++++++----------------- 3 files changed, 131 insertions(+), 119 deletions(-) diff --git a/components/CustomImageNode.tsx b/components/CustomImageNode.tsx index fd2237b..5022d57 100644 --- a/components/CustomImageNode.tsx +++ b/components/CustomImageNode.tsx @@ -2,53 +2,68 @@ import { CustomNode } from "@/types/CustomNode"; import { NodeProps } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import Image from "next/image"; -import { useEffect, useState } from "react"; +import { useEffect, useState, memo } from "react"; -export function CustomImageNode ({ data }: NodeProps) { +// Cache for storing image dimensions +const imageDimensionsCache = new Map< + string, + { width: number; height: number } +>(); + +const CustomImageNodeComponent = ({ data }: NodeProps) => { const [dimensions, setDimensions] = useState({ width: 100, height: 100 }); + const imageUrl = data.imageURL ?? "/maps/campus.png"; useEffect(() => { const getImageDimensions = ( src: string ): Promise<{ width: number; height: number }> => { + // Check if dimensions are already in cache + if (imageDimensionsCache.has(src)) { + return Promise.resolve(imageDimensionsCache.get(src)!); + } + return new Promise((resolve, reject) => { const img: HTMLImageElement = document.createElement("img"); img.onload = () => { - resolve({ + const dimensions = { width: img.naturalWidth, height: img.naturalHeight, - }); + }; + + // Store dimensions in cache + imageDimensionsCache.set(src, dimensions); + resolve(dimensions); }; img.onerror = (error) => { reject(new Error(`Failed to load image: ${error}`)); }; - img.src = src ?? "/maps/campus.png"; + img.src = src; }); }; - getImageDimensions(data.imageURL!) + getImageDimensions(imageUrl) .then((dims) => { setDimensions(dims); }) .catch((error) => console.error("Error loading image dimensions:", error) ); - }, [data]); + }, [imageUrl]); return (
{data.label}) {
); }; + +// Memoize the component to prevent unnecessary re-renders +export const CustomImageNode = memo(CustomImageNodeComponent); diff --git a/components/EventFlow.tsx b/components/EventFlow.tsx index 85e781c..15a65df 100644 --- a/components/EventFlow.tsx +++ b/components/EventFlow.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { ChannelProvider, useChannel } from "ably/react"; import { createId } from "@paralleldrive/cuid2"; import { @@ -33,7 +33,7 @@ import { CustomNode } from "@/types/CustomNode"; const getId = () => createId(); const clientId = createId(); -// Define node types +// Define node types - memoize to prevent unnecessary re-renders const nodeTypes = { iconNode: IconNode, customImageNode: CustomImageNode, @@ -59,32 +59,20 @@ function Flow({ }) { // Refs const timeoutId = useRef(); - - useChannel("event-updates", "subscribe", (message) => { - const { eventId, locationId } = message.data; - - if (eventId !== event.id || locationId !== eventLocation?.locationId) { - return; - } - - GetEventLocationInfo(eventId, locationId).then((eventLocationInfo) => { - if (!eventLocationInfo?.state) return; - - const state = JSON.parse(eventLocationInfo.state); - - setNodes(state.nodes); - }); - }); + const isInitialLoad = useRef(true); + const eventLocation = event.locations.find((l) => l.locationId === location); // State const [nodesLoaded, setNodesLoaded] = useState(false); - const eventLocation = event.locations.find((l) => l.locationId === location); const eventLocations = useRef>( event.locations.map((l) => l.location) ).current; - const [nodes, setNodes] = useState( - JSON.parse(eventLocation?.state ?? "{}")?.nodes || [] - ); + + const [nodes, setNodes] = useState(() => { + const savedState = eventLocation?.state ? JSON.parse(eventLocation.state) : {}; + return savedState?.nodes || []; + }); + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); const [rfInstance, setRfInstance] = useState { const { eventId, locationId, senderClientId } = message.data; - + + // Skip processing messages from this client if ( - eventId !== event.id || - locationId !== eventLocation?.locationId || - senderClientId === clientId + senderClientId === clientId || + eventId !== event.id || + locationId !== eventLocation?.locationId ) { return; } @@ -243,7 +232,6 @@ function Flow({ })); setNodes((nds) => [...nds, ...newNodes]); - console.log("I pasted"); } catch (err) { /* Default to normal paste operations */ } @@ -263,7 +251,7 @@ function Flow({ ]); /** - * Handle node changes and save state + * Handle node changes and save state with debouncing and memoization */ const onNodesChange = useCallback( (changes: NodeChange[]) => { @@ -272,6 +260,12 @@ function Flow({ setNodes((nds) => applyNodeChanges(changes, nds) as CustomNode[]); + // Skip saving during initial load + if (isInitialLoad.current) { + isInitialLoad.current = false; + return; + } + // Debounce save clearTimeout(timeoutId.current); timeoutId.current = setTimeout(() => { @@ -298,7 +292,7 @@ function Flow({ JSON.stringify(rfInstance.toObject()), clientId ); - }, 200); + }, 500); // Increased debounce time to reduce API calls }, [ isEditable, @@ -447,8 +441,14 @@ function Flow({ } }, [nodes, fitView]); + // Memoize the active node context value + const activeNodeContextValue = useMemo( + () => ({ activeNodeId, setActiveNodeId }), + [activeNodeId, setActiveNodeId] + ); + return ( - +

{event.name} diff --git a/components/IconNode.tsx b/components/IconNode.tsx index 7dd1ac4..4d63123 100644 --- a/components/IconNode.tsx +++ b/components/IconNode.tsx @@ -22,7 +22,7 @@ import { import { NodeProps, useReactFlow } from "@xyflow/react"; import * as Icons from "lucide-react"; import { Trash2 } from "lucide-react"; -import NextPlanIcon from '@mui/icons-material/NextPlan'; +import NextPlanIcon from "@mui/icons-material/NextPlan"; import { useParams } from "next/navigation"; import { createContext, @@ -33,6 +33,7 @@ import { useState, } from "react"; import ResizeMenu from "./ResizeMenu"; +import { memo } from 'react'; export const ActiveNodeContext = createContext<{ activeNodeId: string | null; @@ -42,7 +43,8 @@ export const ActiveNodeContext = createContext<{ setActiveNodeId: () => {}, }); -export function IconNode({ data, id }: NodeProps) { +// Memoize the IconNode component to prevent unnecessary re-renders +export const IconNode = memo(function IconNode({ data, id }: NodeProps) { const { deleteElements, setNodes, getNode } = useReactFlow(); const { activeNodeId, setActiveNodeId } = useContext(ActiveNodeContext); const [isOpen, setIsOpen] = useState(false); @@ -59,8 +61,7 @@ export function IconNode({ data, id }: NodeProps) { useEffect(() => { if (isOpen) { setActiveNodeId(id); - } - else{ + } else { setActiveNodeId(null); } }, [isOpen, id, setActiveNodeId]); @@ -143,7 +144,7 @@ export function IconNode({ data, id }: NodeProps) { const handleResize = useCallback( (selectedSize: number) => { - console.log(selectedSize) + console.log(selectedSize); setNodes((nds) => nds.map((node) => { if (node.id === id) { @@ -212,29 +213,20 @@ export function IconNode({ data, id }: NodeProps) { const handleRotateClockwise = useCallback(() => { const newRotation = (data.rotation + 45) % 360; - + // Update the node data to persist rotation setNodes((nodes) => - nodes.map((node) => { - if (node.id === id) { - return { - ...node, - data: { - ...node.data, - rotation: newRotation, - }, - }; - } - return node; - }) + nodes.map((node) => + node.id === id + ? { ...node, data: { ...node.data, rotation: newRotation } } + : node + ) ); - console.log("r otation", newRotation); - //updateNodeInternals(id); }, [data.rotation, id, setNodes]); - + const handleRotateCounterClockwise = useCallback(() => { const newRotation = (data.rotation - 45) % 360; - + // Update the node data to persist rotation setNodes((nodes) => nodes.map((node) => { @@ -250,76 +242,78 @@ export function IconNode({ data, id }: NodeProps) { return node; }) ); - console.log("rotation", newRotation); - //updateNodeInternals(id); }, [data.rotation, id, setNodes]); return ( <> {(activeNodeId === id || isOpen) && ( - <> -
{ - e.stopPropagation(); // Prevent popover from triggering - handleRotateClockwise(); - }} - style={{ - position: 'absolute', - top: '50%', - left: '50%', - transform: `translate(-50%, -50%) translate(${((data.size == 1 ? 1.5 : data.size) ?? 3) * 15}px, 0) rotate(90deg)`, - backgroundColor: 'rgba(0, 0, 0, 0.8)', - width: '15px', - height: '15px', - borderRadius: '50%', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - cursor: 'pointer', - zIndex: 10, - }} - className="nodrag" - > - -
-
{ - e.stopPropagation(); // Prevent popover from triggering - handleRotateCounterClockwise(); - }} - style={{ - position: 'absolute', - top: '50%', - left: '50%', - transform: `translate(-50%, -50%) translate(-${((data.size == 1 ? 1.5 : data.size) ?? 3) * 15}px, 0) scale(-1, 1) rotate(90deg)`, - backgroundColor: 'rgba(0, 0, 0, 0.8)', - width: '15px', - height: '15px', - borderRadius: '50%', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - cursor: 'pointer', - zIndex: 10, - }} - className="nodrag" - > - -
- - )} + <> +
{ + e.stopPropagation(); // Prevent popover from triggering + handleRotateClockwise(); + }} + style={{ + position: "absolute", + top: "50%", + left: "50%", + transform: `translate(-50%, -50%) translate(${ + ((data.size == 1 ? 1.5 : data.size) ?? 3) * 15 + }px, 0) rotate(90deg)`, + backgroundColor: "rgba(0, 0, 0, 0.8)", + width: "15px", + height: "15px", + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + zIndex: 10, + }} + className="nodrag" + > + +
+
{ + e.stopPropagation(); // Prevent popover from triggering + handleRotateCounterClockwise(); + }} + style={{ + position: "absolute", + top: "50%", + left: "50%", + transform: `translate(-50%, -50%) translate(-${ + ((data.size == 1 ? 1.5 : data.size) ?? 3) * 15 + }px, 0) scale(-1, 1) rotate(90deg)`, + backgroundColor: "rgba(0, 0, 0, 0.8)", + width: "15px", + height: "15px", + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + zIndex: 10, + }} + className="nodrag" + > + +
+ + )}
+ > ) { ); -} +});