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/CustomImageNode.tsx b/components/CustomImageNode.tsx index 88fa87e..47e3844 100644 --- a/components/CustomImageNode.tsx +++ b/components/CustomImageNode.tsx @@ -2,46 +2,61 @@ 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 a977ca3..32f7968 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 { @@ -35,7 +35,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, @@ -61,32 +61,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; } @@ -134,7 +123,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, @@ -245,7 +234,6 @@ function Flow({ })); setNodes((nds) => [...nds, ...newNodes]); - console.log("I pasted"); } catch (err) { /* Default to normal paste operations */ } @@ -265,7 +253,7 @@ function Flow({ ]); /** - * Handle node changes and save state + * Handle node changes and save state with debouncing and memoization */ const onNodesChange = useCallback( (changes: NodeChange[]) => { @@ -274,6 +262,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(() => { @@ -300,7 +294,7 @@ function Flow({ JSON.stringify(rfInstance.toObject()), clientId ); - }, 200); + }, 500); // Increased debounce time to reduce API calls }, [ isEditable, @@ -409,6 +403,7 @@ function Flow({ label, iconName, color: "#57B9FF", + rotation: 0, }, draggable: true, deletable: true, @@ -448,8 +443,14 @@ function Flow({ } }, [nodes, fitView]); + // Memoize the active node context value + const activeNodeContextValue = useMemo( + () => ({ activeNodeId, setActiveNodeId }), + [activeNodeId, setActiveNodeId] + ); + return ( - +
{}, }); -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 { setActiveNodeId } = useContext(ActiveNodeContext); + const { activeNodeId, setActiveNodeId } = useContext(ActiveNodeContext); const [isOpen, setIsOpen] = useState(false); const params = useParams<{ mode: string }>(); @@ -139,6 +145,7 @@ export function IconNode({ data, id }: NodeProps) { const handleResize = useCallback( (selectedSize: number) => { + console.log(selectedSize); setNodes((nds) => nds.map((node) => { if (node.id === id) { @@ -204,21 +211,119 @@ 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) => + node.id === id + ? { ...node, data: { ...node.data, rotation: newRotation } } + : node + ) + ); + }, [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; + }) + ); + }, [data.rotation, id, setNodes]); + return ( <> - +
{ + 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" + > + +
+ + )} +
+ > + +
) {
); -} +}); diff --git a/types/CustomNode.ts b/types/CustomNode.ts index bc0858c..56b5a18 100644 --- a/types/CustomNode.ts +++ b/types/CustomNode.ts @@ -8,6 +8,7 @@ export interface CustomNode extends NodeProps { color?: string; size?: number; notes?: string; + rotation: number; }; position: { x: number; y: number; z?: number }; extent?: "parent";