diff --git a/components/editor/canvas-controls.tsx b/components/editor/canvas-controls.tsx new file mode 100644 index 0000000..c9f5742 --- /dev/null +++ b/components/editor/canvas-controls.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useCallback } from "react"; +import { + ZoomIn, + ZoomOut, + Maximize2, + Undo2, + Redo2, +} from "lucide-react"; +import { useReactFlow } from "@xyflow/react"; +import { + useCanRedo, + useCanUndo, + useRedo, + useUndo, +} from "@liveblocks/react"; + +import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; + +const ZOOM_DURATION_MS = 200; + +/** + * Floating pill control bar at the bottom-left of the canvas. + * Zoom group (out / fit / in) + history group (undo / redo), separated + * by a thin divider. Zoom uses the React Flow instance; undo/redo use + * Liveblocks room history. + */ +export function CanvasControls() { + const reactFlow = useReactFlow(); + const undo = useUndo(); + const redo = useRedo(); + const canUndo = useCanUndo(); + const canRedo = useCanRedo(); + + useKeyboardShortcuts({ reactFlow, undo, redo }); + + const handleZoomIn = useCallback(() => { + void reactFlow.zoomIn({ duration: ZOOM_DURATION_MS }); + }, [reactFlow]); + + const handleZoomOut = useCallback(() => { + void reactFlow.zoomOut({ duration: ZOOM_DURATION_MS }); + }, [reactFlow]); + + const handleFitView = useCallback(() => { + void reactFlow.fitView({ duration: ZOOM_DURATION_MS, padding: 0.15 }); + }, [reactFlow]); + + return ( +
+
+ {/* Zoom controls */} + + + + + + + + + + + {/* Divider between zoom and history */} +
+ + {/* History controls */} + + + + + + +
+
+ ); +} + +interface ControlButtonProps { + label: string; + onClick: () => void; + disabled?: boolean; + shortcut?: string; + children: React.ReactNode; +} + +function ControlButton({ + label, + onClick, + disabled = false, + shortcut, + children, +}: ControlButtonProps) { + return ( + + ); +} diff --git a/components/editor/canvas-edge.tsx b/components/editor/canvas-edge.tsx new file mode 100644 index 0000000..c64e701 --- /dev/null +++ b/components/editor/canvas-edge.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { + memo, + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, + type SyntheticEvent, +} from "react"; +import { + BaseEdge, + EdgeLabelRenderer, + getSmoothStepPath, + useReactFlow, + type EdgeProps, +} from "@xyflow/react"; + +import type { CanvasEdge } from "@/types/canvas"; + +/** Default edge stroke from ui-context — light on the dark canvas. */ +const EDGE_STROKE = "#f8fafc"; +/** Visible stroke width — edges stay visually secondary to nodes. */ +const EDGE_STROKE_WIDTH = 1.5; +/** Invisible hit area wider than the stroke for easier hover/click. */ +const EDGE_INTERACTION_WIDTH = 24; +/** Dimmed rest opacity; full when hovered or selected. */ +const EDGE_OPACITY_REST = 0.45; +const EDGE_OPACITY_ACTIVE = 1; + +const LABEL_HINT = "Label"; + +/** + * Custom canvas edge: right-angle (smooth-step) routing, arrow markers, + * wider interaction hit area, and double-click inline label editing. + * Label updates flow through collaborative edge data via updateEdgeData. + */ +function CanvasEdgeComponent({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + selected, + data, + markerEnd, + style, +}: EdgeProps) { + const { updateEdgeData } = useReactFlow(); + + const [isHovered, setIsHovered] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + + const label = data?.label ?? ""; + const hasLabel = label.trim().length > 0; + const isActive = Boolean(selected || isHovered); + + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + // Focus the input when editing starts + useEffect(() => { + if (!isEditing) return; + const el = inputRef.current; + if (!el) return; + el.focus(); + const len = el.value.length; + el.setSelectionRange(len, len); + }, [isEditing]); + + const commitAndClose = useCallback(() => { + setIsEditing(false); + }, []); + + const startEditing = useCallback((e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setIsEditing(true); + }, []); + + const handleLabelChange = useCallback( + (value: string) => { + updateEdgeData(id, { label: value }); + }, + [id, updateEdgeData], + ); + + const handleInputKeyDown = useCallback( + (e: KeyboardEvent) => { + // Save + close on Enter or Escape (spec: save on blur, Enter, or Escape) + if (e.key === "Enter" || e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + commitAndClose(); + inputRef.current?.blur(); + return; + } + // Keep typing from bubbling to canvas shortcuts + e.stopPropagation(); + }, + [commitAndClose], + ); + + // Block pointer events from panning/selecting the canvas while interacting + // with the label control. + const stopPointer = useCallback((e: SyntheticEvent) => { + e.stopPropagation(); + }, []); + + const opacity = isActive ? EDGE_OPACITY_ACTIVE : EDGE_OPACITY_REST; + + // Show label chrome when editing, when there is a saved label, or as a + // faint hint on an active (hovered/selected) unlabeled edge. + const showLabelUi = isEditing || hasLabel || isActive; + + return ( + <> + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onDoubleClick={startEditing} + > + + + + {showLabelUi && ( + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onDoubleClick={isEditing ? stopPointer : startEditing} + onMouseDown={stopPointer} + onPointerDown={stopPointer} + > + {isEditing ? ( + handleLabelChange(e.target.value)} + onKeyDown={handleInputKeyDown} + onBlur={commitAndClose} + onMouseDown={stopPointer} + onPointerDown={stopPointer} + /> + ) : hasLabel ? ( + + ) : ( + // Faint hint when the edge is active but has no label yet + + )} +
+
+ )} + + ); +} + +/** + * Memoized canvas edge renderer for performance. + */ +export const CanvasEdgeRenderer = memo(CanvasEdgeComponent); + +CanvasEdgeRenderer.displayName = "CanvasEdgeRenderer"; diff --git a/components/editor/canvas-node.tsx b/components/editor/canvas-node.tsx index a705840..59ea884 100644 --- a/components/editor/canvas-node.tsx +++ b/components/editor/canvas-node.tsx @@ -1,52 +1,245 @@ "use client"; -import { memo } from "react"; -import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { + memo, + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, + type FocusEvent, +} from "react"; +import { + Handle, + NodeResizer, + Position, + useReactFlow, + type NodeProps, +} from "@xyflow/react"; -import type { CanvasNode } from "@/types/canvas"; +import type { CanvasNode, NodeColorName } from "@/types/canvas"; import { NODE_COLORS } from "@/types/canvas"; +import { NodeShapeVisual } from "./node-shape-visual"; +import { NodeColorToolbar } from "./node-color-toolbar"; + +/** Minimum node size — prevents nodes from collapsing to unusable dimensions. */ +const MIN_NODE_WIDTH = 80; +const MIN_NODE_HEIGHT = 40; + +const LABEL_PLACEHOLDER = "Label"; + +/** Small white dots with a dark border — hidden until the node is hovered. */ +const HANDLE_CLASS = + "!h-2 !w-2 !rounded-full !border !border-border-default !bg-white !opacity-0 !transition-opacity !duration-150 group-hover:!opacity-100"; + +/** + * Font size scales with node dimensions and shrinks when there is more + * text so longer labels still fit without overflowing awkwardly. + */ +function computeLabelFontSize( + width: number, + height: number, + text: string, +): number { + const minDim = Math.min(width, height); + // Base size tracks the smaller edge of the node. + const base = Math.max(10, Math.min(22, minDim * 0.2)); + + const charCount = Math.max(text.trim().length || 1, 1); + // Approximate characters per line from usable width. + const charsPerLine = Math.max(4, Math.floor((width * 0.75) / (base * 0.55))); + const estimatedLines = Math.max(1, Math.ceil(charCount / charsPerLine)); + + // Cap by available height for the estimated line count. + const maxByHeight = (height * 0.72) / estimatedLines; + // Cap by width for dense single-line content. + const maxByWidth = (width * 0.85) / Math.min(charCount, charsPerLine) / 0.55; + + const size = Math.min(base, maxByHeight, maxByWidth); + return Math.round(Math.max(10, Math.min(22, size)) * 10) / 10; +} /** - * Basic canvas node renderer. For this initial implementation, renders - * every shape as a simple bordered rectangle with the label centered. - * Shape-specific visuals will be added in later features. + * Custom React Flow node: per-shape visuals, resize handles when selected, + * color toolbar, four connectable points, and double-click inline label editing. + * All dimension, color, and label updates flow through the collaborative node state. */ -function CanvasNodeComponent({ data }: NodeProps) { +function CanvasNodeComponent({ + id, + data, + selected, + width, + height, +}: NodeProps) { const { label, color: colorName, shape } = data; + const { updateNodeData } = useReactFlow(); - // Get the color pair for this node - const colorPair = NODE_COLORS.find((c) => c.name === colorName) ?? NODE_COLORS[0]; + const [isEditing, setIsEditing] = useState(false); + const textareaRef = useRef(null); + + const colorPair = + NODE_COLORS.find((c) => c.name === colorName) ?? NODE_COLORS[0]; + + const nodeWidth = width ?? 160; + const nodeHeight = height ?? 80; + + const fontSize = computeLabelFontSize(nodeWidth, nodeHeight, label); + + // Subtle border at rest; brighter when selected + const borderColor = selected + ? colorPair.text + : `${colorPair.text}55`; + + // Focus the textarea when editing starts + useEffect(() => { + if (!isEditing) return; + const el = textareaRef.current; + if (!el) return; + el.focus(); + // Place caret at end without scrolling the canvas + const len = el.value.length; + el.setSelectionRange(len, len); + }, [isEditing]); + + const startEditing = useCallback((e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setIsEditing(true); + }, []); + + const handleLabelChange = useCallback( + (value: string) => { + updateNodeData(id, { label: value }); + }, + [id, updateNodeData], + ); + + const handleColorSelect = useCallback( + (color: NodeColorName) => { + updateNodeData(id, { color }); + }, + [id, updateNodeData], + ); + + const handleTextareaKeyDown = useCallback( + (e: KeyboardEvent) => { + // Escape closes editing; stop propagation so the canvas doesn't + // treat Escape as a global shortcut while typing. + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setIsEditing(false); + textareaRef.current?.blur(); + } + // Keep Enter for newlines inside the textarea; do not bubble to RF. + e.stopPropagation(); + }, + [], + ); + + const handleTextareaBlur = useCallback((_e: FocusEvent) => { + setIsEditing(false); + }, []); + + // Block pointer events from starting a node drag / canvas pan while editing. + const stopPointer = useCallback((e: MouseEvent) => { + e.stopPropagation(); + }, []); return (
- {/* Input handle */} + {selected && ( + + )} + + + + {/* Four connectable points — one per side */} + - - - {label || shape} - - - {/* Output handle */} + + + + + {isEditing && ( +
+