-
Notifications
You must be signed in to change notification settings - Fork 0
compleated 14-feat #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| > | ||
| <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> | ||
| ); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: 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' || trueRepository: 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 50Repository: 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 -nRepository: 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)
PYRepository: 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
🤖 Prompt for AI Agents |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤖 Prompt for AI Agents |
||
| ) : ( | ||
| // 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"; | ||
There was a problem hiding this comment.
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
⌘, butuseKeyboardShortcutsalso supports Ctrl. Windows/Linux users receive incorrect guidance.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents