Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions components/editor/canvas-node.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"use client";

import { memo, useMemo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";

import { NODE_COLORS, type NodeColorName, type NodeShape } from "@/types/canvas";

/**
* Data attached to canvas nodes.
*/
interface CanvasNodeData {
label: string;
color: NodeColorName;
shape: NodeShape;
}

/**
* Basic canvas node renderer. For this unit, renders every shape as a
* simple bordered rectangle with the label centered. Shape-specific
* visuals will be added later.
*/
function CanvasNode({ data, selected }: NodeProps) {
const { label, color, shape } = data as unknown as CanvasNodeData;

// Get color from palette
const colorPair = useMemo(() => {
return NODE_COLORS.find((c) => c.name === color) ?? NODE_COLORS[0];
}, [color]);

// Basic shape styles - for now, all shapes render as bordered rectangles
// Shape-specific rendering will be added in future features
const shapeStyles = useMemo(() => {
const baseStyles = {
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: shape === "circle" ? "9999px" : shape === "pill" ? "9999px" : "8px",
backgroundColor: colorPair.fill,
border: `2px ${selected ? "var(--accent-primary)" : colorPair.fill}`,
boxShadow: selected
? `0 0 0 2px var(--accent-primary), 0 4px 12px rgba(0,0,0,0.3)`
: "none",
Comment on lines +39 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include the border style in the shorthand.

border: "2px <color>" is invalid CSS, so nodes do not receive the required border.

Proposed fix
-      border: `2px ${selected ? "var(--accent-primary)" : colorPair.fill}`,
+      border: `2px solid ${selected ? "var(--accent-primary)" : colorPair.fill}`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
borderRadius: shape === "circle" ? "9999px" : shape === "pill" ? "9999px" : "8px",
backgroundColor: colorPair.fill,
border: `2px ${selected ? "var(--accent-primary)" : colorPair.fill}`,
boxShadow: selected
? `0 0 0 2px var(--accent-primary), 0 4px 12px rgba(0,0,0,0.3)`
: "none",
borderRadius: shape === "circle" ? "9999px" : shape === "pill" ? "9999px" : "8px",
backgroundColor: colorPair.fill,
border: `2px solid ${selected ? "var(--accent-primary)" : colorPair.fill}`,
boxShadow: selected
? `0 0 0 2px var(--accent-primary), 0 4px 12px rgba(0,0,0,0.3)`
: "none",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/editor/canvas-node.tsx` around lines 39 - 44, Update the border
declaration in the canvas node style object to include an explicit border style
between the width and color, ensuring both selected and unselected nodes receive
a valid border while preserving the existing colors.

};
return baseStyles;
}, [colorPair.fill, selected, shape]);

return (
<div style={shapeStyles}>
<Handle
type="target"
position={Position.Top}
className="!bg-text-muted !border-border-default !h-3 !w-3"
/>
<span
className="px-3 py-1 text-sm font-medium text-center"
style={{ color: colorPair.text }}
>
{label || "Node"}
</span>
<Handle
type="source"
position={Position.Bottom}
className="!bg-text-muted !border-border-default !h-3 !w-3"
Comment on lines +51 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Next.js docs mentioning important modifier syntax ==\n'
fd -a 'tailwind' node_modules/next/dist/docs 2>/dev/null | head -20 || true
rg -n "important.*suffix|suffix syntax|Tailwind v4|important modifier" node_modules/next/dist/docs -S 2>/dev/null | head -80 || true

printf '\n== Tailwind config / package signals ==\n'
git ls-files | rg '(^|/)(tailwind\.config|postcss\.config|package\.json|components/editor/(canvas-node|canvas)\.tsx)$'
printf '\n--- package.json ---\n'
cat package.json | sed -n '1,220p'

printf '\n== Tailwind-related files ==\n'
for f in $(git ls-files | rg '(^|/)(tailwind\.config|postcss\.config|components/editor/(canvas-node|canvas)\.tsx)$'); do
  echo "\n--- $f ---"
  wc -l "$f"
  cat -n "$f" | sed -n '1,220p'
done

Repository: tejasM17/ghost-dev

Length of output: 12593


🌐 Web query:

Tailwind CSS v4 important modifier suffix syntax bg-red-500! !bg-red-500``

💡 Result:

In Tailwind CSS v4, the correct syntax for the important modifier is to place the exclamation mark (!) at the end of the utility class, such as bg-red-500! [1][2]. This is a breaking change from Tailwind CSS v3, where the modifier was placed at the beginning of the class name (e.g.,!bg-red-500) [3][4][2]. The new suffix-based syntax aligns with native CSS, where!important follows the declaration [3]. For projects requiring all utilities to be marked as!important globally, Tailwind CSS v4 supports an important flag during import in your CSS file [5][6]: @import "tailwindcss" important; This replaces the previous configuration-based approach (important: true in tailwind.config.js) [5][6].

Citations:


Switch these Tailwind important modifiers to the v4 suffix form.

  • components/editor/canvas-node.tsx: !bg-text-muted, !border-border-default, !h-3, and !w-3 should be bg-text-muted!, etc.
  • components/editor/canvas.tsx: same for !bg-transparent, !bg-bg-surface/80, and !rounded-lg.
📍 Affects 2 files
  • components/editor/canvas-node.tsx#L51-L65 (this comment)
  • components/editor/canvas.tsx#L256-L281
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/editor/canvas-node.tsx` around lines 51 - 65, Update the Tailwind
important modifiers in the Handle and related elements of
components/editor/canvas-node.tsx (lines 51-65) from prefix form to v4 suffix
form: use bg-text-muted!, border-border-default!, h-3!, and w-3!. Apply the same
suffix conversion in components/editor/canvas.tsx (lines 256-281) for
bg-transparent!, bg-bg-surface/80!, and rounded-lg!.

/>
</div>
);
}

/**
* Memoized custom node component for React Flow.
*/
export const CanvasNodeComponent = memo(CanvasNode, (prev, next) => {
return (
prev.data.label === next.data.label &&
prev.data.color === next.data.color &&
prev.data.shape === next.data.shape &&
prev.selected === next.selected
);
});

CanvasNodeComponent.displayName = "CanvasNode";
203 changes: 170 additions & 33 deletions components/editor/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import {
Component,
useCallback,
useEffect,
useMemo,
useState,
type DragEvent,
type ErrorInfo,
type ReactNode,
} from "react";
Expand All @@ -13,6 +16,12 @@ import {
ConnectionMode,
MiniMap,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type NodeChange,
type EdgeChange,
type OnConnect,
type OnDelete,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";

Expand All @@ -27,7 +36,12 @@ import "@liveblocks/react-flow/styles.css";
import type {
CanvasEdge,
CanvasNode,
CanvasNodeData,
NodeShape,
} from "@/types/canvas";
import { NODE_COLORS, type NodeColorName } from "@/types/canvas";
import { ShapePanel, type ShapePanelPayload } from "./shape-panel";
import { CanvasNodeComponent } from "./canvas-node";

interface CanvasProps {
roomId: string;
Expand Down Expand Up @@ -103,9 +117,25 @@ class CanvasErrorBoundary extends Component<
}
}

/**
* Counter for generating unique node IDs.
*/
let nodeIdCounter = 0;

/**
* Generate a unique node ID using shape name, timestamp, and counter.
*/
function generateNodeId(shape: NodeShape): string {
const timestamp = Date.now();
return `${shape}-${timestamp}-${++nodeIdCounter}`;
Comment on lines +123 to +130

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make node IDs unique across collaborating clients.

The counter is local to each browser, so two clients dropping the same shape within one millisecond can generate identical IDs and corrupt subsequent node operations. Retain the requested prefix but append crypto.randomUUID().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/editor/canvas.tsx` around lines 123 - 130, Update generateNodeId
to retain the existing shape-and-timestamp prefix while appending
crypto.randomUUID() as the client-independent uniqueness component. Remove
reliance on the local nodeIdCounter for uniqueness.

}

/**
* The actual React Flow surface, rendered after Liveblocks has connected
* and the storage layer is ready (via Suspense).
*
* Note: This component is wrapped with ReactFlowProvider to allow using
* useReactFlow() hook for coordinate conversion.
*/
function CollaborativeFlow() {
// `useLiveblocksFlow` returns the synced nodes/edges plus change
Expand All @@ -119,38 +149,145 @@ function CollaborativeFlow() {
});

return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDelete={onDelete}
fitView
connectionMode={ConnectionMode.Loose}
proOptions={{ hideAttribution: true }}
className="bg-bg-base"
>
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="var(--border-subtle)"
/>
<MiniMap
pannable
zoomable
className="!bg-bg-surface !border !border-border-default !rounded-xl"
nodeColor="var(--text-muted)"
maskColor="rgba(8, 8, 9, 0.7)"
<ReactFlowProvider>
<FlowCanvas
nodes={nodes}
edges={edges}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onNodesChange={onNodesChange as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onEdgesChange={onEdgesChange as any}
onConnect={onConnect}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onDelete={onDelete as any}
/>
</ReactFlow>
</ReactFlowProvider>
);
}

/**
* Inner component that has access to ReactFlow context via ReactFlowProvider.
* This is where we can use useReactFlow() hook.
*/
function FlowCanvas({
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect,
onDelete,
}: {
nodes: CanvasNode[];
edges: CanvasEdge[];
onNodesChange: (changes: NodeChange<CanvasNode>[]) => void;
onEdgesChange: (changes: EdgeChange<CanvasEdge>[]) => void;
onConnect: OnConnect;
onDelete: (params: { nodes: CanvasNode[]; edges: CanvasEdge[] }) => void;
}) {
// Get the React Flow instance for coordinate conversion
const { screenToFlowPosition } = useReactFlow();

// Custom node types - memoized to keep references stable
const nodeTypes = useMemo(
() => ({ canvasNode: CanvasNodeComponent }),
[],
);

// Handle drag over on the canvas wrapper
const handleDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}, []);

// Handle drop on the canvas wrapper
const handleDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();

// Read the shape payload from dataTransfer
const payloadData = event.dataTransfer.getData("application/json");
if (!payloadData) return;

try {
const payload: ShapePanelPayload = JSON.parse(payloadData);
const { shape, width, height } = payload;

Comment on lines +207 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the parsed drag payload before persisting it.

A successful JSON.parse does not establish that shape, width, and height match ShapePanelPayload. Reject unknown shapes, non-finite dimensions, and non-positive or excessive sizes before writing the node to shared storage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/editor/canvas.tsx` around lines 207 - 214, Validate the parsed
payload in the drag-and-drop handler before persisting the node: ensure shape is
an allowed known shape, and width and height are finite, positive, and within
the supported maximum size. Reject invalid payloads immediately after
JSON.parse, before the shared-storage write, while preserving the existing
handling for valid ShapePanelPayload values.

// Convert screen position to canvas coordinates
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});

// Create new node - use CanvasNode type from types/canvas
const newNode: CanvasNode = {
id: generateNodeId(shape),
type: "canvasNode",
position,
data: {
label: "",
color: NODE_COLORS[0].name as NodeColorName,
shape,
},
measured: {
width,
height,
},
};

// Add the node via onNodesChange with add change
const addChange: NodeChange<CanvasNode> = {
type: "add",
item: newNode,
};
onNodesChange([addChange]);
} catch (error) {
console.error("Failed to parse shape payload:", error);
}
},
[screenToFlowPosition, onNodesChange],
);

return (
<div
className="absolute inset-0"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDelete={onDelete}
nodeTypes={nodeTypes}
fitView
connectionMode={ConnectionMode.Loose}
proOptions={{ hideAttribution: true }}
className="!bg-transparent"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--border-subtle)"
/>
<MiniMap
pannable
zoomable
className="!bg-bg-surface/80 !rounded-lg"
nodeColor="var(--text-muted)"
maskColor="rgba(8, 8, 9, 0.6)"
/>
</ReactFlow>
<ShapePanel />
</div>
);
}

/**
* Loading state shown inside the Liveblocks Suspense boundary. Matches
* the editor's dark surface palette so it doesn't flash a light fallback.
* Loading state shown inside the Liveblocks Suspense boundary.
* Uses transparent background to blend with the infinite canvas.
*/
function CanvasLoading() {
const [dots, setDots] = useState(1);
Expand All @@ -163,9 +300,9 @@ function CanvasLoading() {
}, []);

return (
<div className="flex h-full items-center justify-center bg-bg-base">
<div className="flex h-full items-center justify-center bg-transparent">
<div className="flex flex-col items-center gap-3">
<div className="h-2 w-32 overflow-hidden rounded-full bg-bg-elevated">
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-bg-surface/50">
<div className="h-full w-1/2 animate-pulse rounded-full bg-accent-primary" />
</div>
<p className="text-xs text-text-muted">
Expand All @@ -184,13 +321,13 @@ interface CanvasErrorProps {
/**
* Fallback rendered when the Liveblocks connection cannot be established
* (auth failure, room ID changed, full room, etc.). Keeps the user on the
* page with a clear next step.
* page with a clear next step. Uses transparent background for infinite canvas look.
*/
function CanvasError({ message, onRetry }: CanvasErrorProps) {
return (
<div className="flex h-full items-center justify-center bg-bg-base">
<div className="flex h-full items-center justify-center bg-transparent">
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="rounded-2xl border border-state-error/30 bg-bg-surface p-6">
<div className="rounded-2xl border border-state-error/30 bg-bg-surface/80 p-6 backdrop-blur-sm">
<h3 className="text-sm font-semibold text-text-primary">
Canvas unavailable
</h3>
Expand All @@ -199,7 +336,7 @@ function CanvasError({ message, onRetry }: CanvasErrorProps) {
<button
type="button"
onClick={onRetry}
className="rounded-xl bg-bg-elevated px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-subtle"
className="rounded-xl bg-bg-surface/80 px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-elevated backdrop-blur-sm"
>
Try again
</button>
Expand Down
Loading