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
134 changes: 134 additions & 0 deletions components/editor/canvas-controls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="pointer-events-none fixed bottom-6 left-6 z-50">
<div
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border-default bg-bg-surface/95 px-1.5 py-1.5 shadow-xl backdrop-blur-md"
role="toolbar"
aria-label="Canvas controls"
>
{/* Zoom controls */}
<ControlButton
label="Zoom out"
onClick={handleZoomOut}
shortcut="-"
>
<ZoomOut className="h-4 w-4" />
</ControlButton>
<ControlButton
label="Fit view"
onClick={handleFitView}
>
<Maximize2 className="h-4 w-4" />
</ControlButton>
<ControlButton
label="Zoom in"
onClick={handleZoomIn}
shortcut="+"
>
<ZoomIn className="h-4 w-4" />
</ControlButton>

{/* Divider between zoom and history */}
<div
className="mx-0.5 h-5 w-px shrink-0 bg-border-default"
aria-hidden
/>

{/* History controls */}
<ControlButton
label="Undo"
onClick={undo}
disabled={!canUndo}
shortcut="⌘Z"
>
<Undo2 className="h-4 w-4" />
</ControlButton>
<ControlButton
label="Redo"
onClick={redo}
disabled={!canRedo}
shortcut="⌘⇧Z"
>
Comment on lines +86 to +99

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

Show Ctrl in the undo/redo shortcut hints.

Lines 90 and 98 advertise only , but useKeyboardShortcuts also supports Ctrl. Windows/Linux users receive incorrect guidance.

Proposed fix
-          shortcut="⌘Z"
+          shortcut="⌘/Ctrl+Z"
...
-          shortcut="⌘⇧Z"
+          shortcut="⌘/Ctrl+Shift+Z"
📝 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
<ControlButton
label="Undo"
onClick={undo}
disabled={!canUndo}
shortcut="⌘Z"
>
<Undo2 className="h-4 w-4" />
</ControlButton>
<ControlButton
label="Redo"
onClick={redo}
disabled={!canRedo}
shortcut="⌘Z"
>
<ControlButton
label="Undo"
onClick={undo}
disabled={!canUndo}
shortcut="⌘/Ctrl+Z"
>
<Undo2 className="h-4 w-4" />
</ControlButton>
<ControlButton
label="Redo"
onClick={redo}
disabled={!canRedo}
shortcut="⌘/Ctrl+Shift+Z"
>
🤖 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-controls.tsx` around lines 86 - 99, Update the
shortcut hints on the Undo and Redo ControlButton instances to display both
Command and Ctrl modifiers, matching the shortcuts supported by
useKeyboardShortcuts while preserving the existing undo/redo actions.

<Redo2 className="h-4 w-4" />
</ControlButton>
</div>
</div>
);
}

interface ControlButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
shortcut?: string;
children: React.ReactNode;
}

function ControlButton({
label,
onClick,
disabled = false,
shortcut,
children,
}: ControlButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={shortcut ? `${label} (${shortcut})` : label}
className="flex h-8 w-8 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-bg-elevated hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary disabled:pointer-events-none disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-text-secondary"
>
{children}
</button>
);
}
223 changes: 223 additions & 0 deletions components/editor/canvas-edge.tsx
Original file line number Diff line number Diff line change
@@ -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<CanvasEdge>) {
const { updateEdgeData } = useReactFlow();

const [isHovered, setIsHovered] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(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],
);
Comment on lines +82 to +97

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

# Locate the target file and inspect the relevant range.
git ls-files 'components/editor/canvas-edge.tsx' 'node_modules/next/dist/docs/**' | sed 's#^`#FILE` #'
echo '--- canvas-edge.tsx (outline if available) ---'
ast-grep outline components/editor/canvas-edge.tsx --view expanded || true
echo '--- canvas-edge.tsx lines 1-260 ---'
cat -n components/editor/canvas-edge.tsx | sed -n '1,260p'

echo '--- next docs candidates ---'
fd -a '' node_modules/next/dist/docs 2>/dev/null | head -n 40 || true

Repository: tejasM17/ghost-dev

Length of output: 9224


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the Next.js docs relevant to TSX/App Router usage if present.
fd -a '.*' node_modules/next/dist/docs 2>/dev/null | rg 'app|routing|link|navigation|components|server|client' || true

Repository: tejasM17/ghost-dev

Length of output: 156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Narrowly inspect any available Next.js docs for client-event handling patterns.
for f in \
  node_modules/next/dist/docs/*.md \
  node_modules/next/dist/docs/**/*.md \
  node_modules/next/dist/docs/**/*.mdx
do
  [ -e "$f" ] || continue
  printf '%s\n' "$f"
done | head -n 50

Repository: tejasM17/ghost-dev

Length of output: 156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the file directly if the earlier outline is unavailable.
wc -l components/editor/canvas-edge.tsx
cat -n components/editor/canvas-edge.tsx | sed -n '1,240p'

Repository: tejasM17/ghost-dev

Length of output: 8799


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target component and any nearby editor logic.
wc -l components/editor/canvas-edge.tsx
sed -n '1,240p' components/editor/canvas-edge.tsx | cat -n

Repository: tejasM17/ghost-dev

Length of output: 8799


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('components/editor/canvas-edge.tsx')
print(p.exists(), p.stat().st_size if p.exists() else None)
if p.exists():
    text = p.read_text()
    for needle in ['commitAndClose', 'startEditing', 'handleLabelChange', 'updateEdgeData', 'onChange', 'onBlur', 'onKeyDown']:
        print(f'-- {needle} --', needle in text)
PY

Repository: tejasM17/ghost-dev

Length of output: 330


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show only the relevant chunk around the review lines.
nl -ba components/editor/canvas-edge.tsx | sed -n '70,120p'

Repository: tejasM17/ghost-dev

Length of output: 196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the updateEdgeData implementation and any label-editing handlers.
rg -n "updateEdgeData|commitAndClose|startEditing|handleLabelChange|onBlur|onKeyDown" components src -g '!**/node_modules/**'

Repository: tejasM17/ghost-dev

Length of output: 2208


Buffer edge label edits until commit

onChange writes each keystroke straight to updateEdgeData, so blur/Enter/Escape don’t act as a real commit boundary. Keep a local draft and persist it from commitAndClose instead.

🤖 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-edge.tsx` around lines 82 - 97, Update the edge
label editing flow around handleLabelChange and commitAndClose to maintain a
local draft value instead of calling updateEdgeData on every keystroke.
Initialize or synchronize the draft from the current label when editing starts,
update only the draft in handleLabelChange, and persist the final draft through
updateEdgeData in commitAndClose before closing.


const handleInputKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
// 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 (
<>
<g
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDoubleClick={startEditing}
>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
interactionWidth={EDGE_INTERACTION_WIDTH}
style={{
...style,
// Keep stroke settings authoritative over any partial style
// from defaultEdgeOptions / collaborators.
stroke: EDGE_STROKE,
strokeWidth: EDGE_STROKE_WIDTH,
strokeLinecap: "round",
opacity,
}}
/>
</g>

{showLabelUi && (
<EdgeLabelRenderer>
<div
className="nodrag nopan nowheel pointer-events-auto absolute"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDoubleClick={isEditing ? stopPointer : startEditing}
onMouseDown={stopPointer}
onPointerDown={stopPointer}
>
{isEditing ? (
<input
ref={inputRef}
type="text"
className="nodrag nopan nowheel max-w-[220px] rounded-full border border-border-default bg-bg-elevated/95 px-2.5 py-0.5 text-center text-[11px] font-medium text-text-primary outline-none ring-1 ring-accent-primary/40 placeholder:text-text-faint"
style={{
// Grow with label text; field-sizing for modern browsers,
// ch-based min width as a reliable fallback.
fieldSizing: "content",
minWidth: `${Math.max(label.length, LABEL_HINT.length, 4)}ch`,
width: "auto",
}}
value={label}
placeholder={LABEL_HINT}
spellCheck={false}
aria-label="Edge label"
onChange={(e) => handleLabelChange(e.target.value)}
onKeyDown={handleInputKeyDown}
onBlur={commitAndClose}
onMouseDown={stopPointer}
onPointerDown={stopPointer}
/>
) : hasLabel ? (
<button
type="button"
className="nodrag nopan nowheel max-w-[220px] truncate rounded-full border border-border-default bg-bg-elevated/90 px-2.5 py-0.5 text-[11px] font-medium text-text-secondary shadow-sm backdrop-blur-sm transition-colors hover:border-border-subtle hover:text-text-primary"
onDoubleClick={startEditing}
onMouseDown={stopPointer}
onPointerDown={stopPointer}
aria-label={`Edge label: ${label}`}
>
{label}
</button>
Comment on lines +187 to +196

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 | 🟠 Major | ⚡ Quick win

Make saved labels keyboard-editable.

This focusable button only handles double-click, so Enter and Space do nothing. Add onClick={startEditing} as used by the empty-label button.

🤖 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-edge.tsx` around lines 187 - 196, Add
onClick={startEditing} to the non-empty edge-label button in the canvas edge
component, matching the empty-label button behavior so keyboard activation via
Enter and Space starts editing while preserving the existing double-click
handler.

) : (
// Faint hint when the edge is active but has no label yet
<button
type="button"
className="nodrag nopan nowheel rounded-full border border-border-default/50 bg-bg-elevated/60 px-2.5 py-0.5 text-[11px] font-medium text-text-faint backdrop-blur-sm"
onDoubleClick={startEditing}
onClick={startEditing}
onMouseDown={stopPointer}
onPointerDown={stopPointer}
aria-label="Add edge label"
>
{LABEL_HINT}
</button>
)}
</div>
</EdgeLabelRenderer>
)}
</>
);
}

/**
* Memoized canvas edge renderer for performance.
*/
export const CanvasEdgeRenderer = memo(CanvasEdgeComponent);

CanvasEdgeRenderer.displayName = "CanvasEdgeRenderer";
Loading