fix: minor styles and ux
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo), View (Fit View, Theme).
|
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Theme).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Menubar,
|
Menubar,
|
||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { usePlatform } from '@/app/platform/platformContext'
|
import { usePlatform } from '@/app/platform/platformContext'
|
||||||
import { ArrowLeft, Download, FolderOpen, Moon, Redo2, Sun, Undo2 } from 'lucide-react'
|
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Moon, Pencil, Redo2, Sun, Undo2 } from 'lucide-react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
export type CanvasMenubarProps = {
|
export type CanvasMenubarProps = {
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
@@ -28,6 +29,11 @@ export type CanvasMenubarProps = {
|
|||||||
redo: () => void
|
redo: () => void
|
||||||
canUndo: boolean
|
canUndo: boolean
|
||||||
canRedo: boolean
|
canRedo: boolean
|
||||||
|
onDuplicate?: () => void
|
||||||
|
onCopy?: () => void
|
||||||
|
onPaste?: () => void
|
||||||
|
canDuplicate?: boolean
|
||||||
|
canCopy?: boolean
|
||||||
onFitView?: () => void
|
onFitView?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,16 +52,58 @@ export function CanvasMenubar({
|
|||||||
redo,
|
redo,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
|
onDuplicate,
|
||||||
|
onCopy,
|
||||||
|
onPaste,
|
||||||
|
canDuplicate = false,
|
||||||
|
canCopy = false,
|
||||||
onFitView,
|
onFitView,
|
||||||
}: CanvasMenubarProps) {
|
}: CanvasMenubarProps) {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
const { projects } = usePlatform()
|
const { projects, renameProject } = usePlatform()
|
||||||
const projectName = useMemo(
|
const projectName = useMemo(
|
||||||
() => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null),
|
() => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null),
|
||||||
[projectId, projects]
|
[projectId, projects]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [isRenamingProject, setIsRenamingProject] = useState(false)
|
||||||
|
const [renameValue, setRenameValue] = useState('')
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const ignoreNextBlurRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRenamingProject) {
|
||||||
|
setRenameValue(projectName ?? '')
|
||||||
|
ignoreNextBlurRef.current = true
|
||||||
|
// Delay focus so the Project dropdown can close first and not steal focus back (which would trigger blur)
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
renameInputRef.current?.focus()
|
||||||
|
renameInputRef.current?.select()
|
||||||
|
}, 100)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}
|
||||||
|
}, [isRenamingProject, projectName])
|
||||||
|
|
||||||
|
const applyRename = useCallback(() => {
|
||||||
|
if (!projectId || !renameProject) return
|
||||||
|
const trimmed = renameValue.trim()
|
||||||
|
if (trimmed) renameProject(projectId, trimmed)
|
||||||
|
setIsRenamingProject(false)
|
||||||
|
}, [projectId, renameProject, renameValue])
|
||||||
|
|
||||||
|
const cancelRename = useCallback(() => {
|
||||||
|
setIsRenamingProject(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRenameBlur = useCallback(() => {
|
||||||
|
if (ignoreNextBlurRef.current) {
|
||||||
|
ignoreNextBlurRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyRename()
|
||||||
|
}, [applyRename])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (ev: KeyboardEvent) => {
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
if (matchKey(ev, UNDO_KEYS)) {
|
if (matchKey(ev, UNDO_KEYS)) {
|
||||||
@@ -99,6 +147,18 @@ export function CanvasMenubar({
|
|||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Export…
|
Export…
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
|
{projectId && (
|
||||||
|
<>
|
||||||
|
<MenubarSeparator />
|
||||||
|
<MenubarItem
|
||||||
|
onClick={() => setIsRenamingProject(true)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Rename
|
||||||
|
</MenubarItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
@@ -122,6 +182,40 @@ export function CanvasMenubar({
|
|||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
</span>
|
</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
|
{(onDuplicate != null || onCopy != null || onPaste != null) && <MenubarSeparator />}
|
||||||
|
{onDuplicate != null && (
|
||||||
|
<MenubarItem onClick={onDuplicate} disabled={!canDuplicate} className="gap-2">
|
||||||
|
<CopyPlus className="h-4 w-4" />
|
||||||
|
Duplicate
|
||||||
|
<span className="ml-auto pl-4">
|
||||||
|
<KbdGroup>
|
||||||
|
<Kbd>⌘D</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
</span>
|
||||||
|
</MenubarItem>
|
||||||
|
)}
|
||||||
|
{onCopy != null && (
|
||||||
|
<MenubarItem onClick={onCopy} disabled={!canCopy} className="gap-2">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copy
|
||||||
|
<span className="ml-auto pl-4">
|
||||||
|
<KbdGroup>
|
||||||
|
<Kbd>⌘C</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
</span>
|
||||||
|
</MenubarItem>
|
||||||
|
)}
|
||||||
|
{onPaste != null && (
|
||||||
|
<MenubarItem onClick={onPaste} className="gap-2">
|
||||||
|
<ClipboardPaste className="h-4 w-4" />
|
||||||
|
Paste
|
||||||
|
<span className="ml-auto pl-4">
|
||||||
|
<KbdGroup>
|
||||||
|
<Kbd>⌘V</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
</span>
|
||||||
|
</MenubarItem>
|
||||||
|
)}
|
||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
@@ -162,11 +256,34 @@ export function CanvasMenubar({
|
|||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
</Menubar>
|
</Menubar>
|
||||||
{projectName && (
|
{projectId && (
|
||||||
<span className="pointer-events-none absolute left-1/2 -translate-x-1/2 truncate max-w-[40%] text-sm font-medium text-foreground">
|
<div className="absolute left-1/2 -translate-x-1/2 flex justify-center max-w-[40%] min-w-[120px]">
|
||||||
{projectName}
|
{isRenamingProject ? (
|
||||||
|
<Input
|
||||||
|
ref={renameInputRef}
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
applyRename()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelRename()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={handleRenameBlur}
|
||||||
|
className="h-7 text-sm font-medium text-center"
|
||||||
|
aria-label="Project name"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="pointer-events-none truncate text-sm font-medium text-foreground">
|
||||||
|
{projectName ?? 'Untitled'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
} from '@/app/platform/projectGraphStorage'
|
} from '@/app/platform/projectGraphStorage'
|
||||||
|
|
||||||
const SNAP_GRID: [number, number] = [15, 15]
|
const SNAP_GRID: [number, number] = [15, 15]
|
||||||
|
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||||
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
||||||
x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0],
|
x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0],
|
||||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||||
@@ -333,6 +334,59 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
[setStateImmediate]
|
[setStateImmediate]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedNodes = useMemo(
|
||||||
|
() => nodes.filter((n) => (n as Node & { selected?: boolean }).selected),
|
||||||
|
[nodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (selectedNodes.length === 0 || !setNodes) return
|
||||||
|
setNodes((nds: Node[]) => {
|
||||||
|
const existingIds = nds.map((n) => n.id)
|
||||||
|
const toAdd: Node[] = []
|
||||||
|
for (const node of selectedNodes) {
|
||||||
|
const n = node as Node & { selected?: boolean }
|
||||||
|
const pos = n.position ?? { x: 0, y: 0 }
|
||||||
|
const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
|
||||||
|
existingIds.push(newId)
|
||||||
|
const newNode: Node = {
|
||||||
|
id: newId,
|
||||||
|
type: n.type,
|
||||||
|
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
|
||||||
|
data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
|
||||||
|
style: getDefaultStyle(String(n.type)),
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
newNode.data &&
|
||||||
|
typeof newNode.data === 'object' &&
|
||||||
|
'title' in newNode.data &&
|
||||||
|
String(n.type) === 'config'
|
||||||
|
) {
|
||||||
|
;(newNode.data as Record<string, unknown>).title = `${newId}`
|
||||||
|
}
|
||||||
|
toAdd.push(newNode)
|
||||||
|
}
|
||||||
|
return nds.concat(toAdd)
|
||||||
|
})
|
||||||
|
}, [selectedNodes, setNodes])
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
if (selectedNodes.length !== 1) return
|
||||||
|
const node = selectedNodes[0] as Node & { selected?: boolean }
|
||||||
|
const copy = {
|
||||||
|
id: node.id,
|
||||||
|
type: node.type,
|
||||||
|
data: node.data,
|
||||||
|
position: node.position,
|
||||||
|
style: node.style,
|
||||||
|
}
|
||||||
|
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => {})
|
||||||
|
}, [selectedNodes])
|
||||||
|
|
||||||
|
const handlePaste = useCallback(() => {
|
||||||
|
flowActionsRef.current?.pasteAtViewportCenter?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const flowContextValue = useMemo(
|
const flowContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
nodes,
|
nodes,
|
||||||
@@ -498,6 +552,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
redo={redo}
|
redo={redo}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
canRedo={canRedo}
|
canRedo={canRedo}
|
||||||
|
onDuplicate={handleDuplicate}
|
||||||
|
onCopy={handleCopy}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
canDuplicate={selectedNodes.length > 0}
|
||||||
|
canCopy={selectedNodes.length === 1}
|
||||||
onFitView={() => flowActionsRef.current?.fitView?.()}
|
onFitView={() => flowActionsRef.current?.fitView?.()}
|
||||||
/>
|
/>
|
||||||
{apiTodosCount !== null && (
|
{apiTodosCount !== null && (
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React, { useCallback, useContext } from 'react'
|
import React, { useCallback, useContext } from 'react'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '../../lib/flowContext'
|
||||||
import { getNextNodeId, getResetDataForType } from '../../lib/flowUtils'
|
|
||||||
import { getDefaultStyle } from '../../lib/nodeRegistry'
|
|
||||||
import {
|
import {
|
||||||
Menubar,
|
Menubar,
|
||||||
MenubarContent,
|
MenubarContent,
|
||||||
@@ -15,9 +13,6 @@ import {
|
|||||||
MenubarTrigger,
|
MenubarTrigger,
|
||||||
} from '../ui/menubar'
|
} from '../ui/menubar'
|
||||||
import { Kbd } from '../ui/kbd'
|
import { Kbd } from '../ui/kbd'
|
||||||
|
|
||||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
nodeId: string
|
nodeId: string
|
||||||
nodeType: string
|
nodeType: string
|
||||||
@@ -50,39 +45,6 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
|||||||
const hasEdit = nodeType === 'config' || nodeType === 'function'
|
const hasEdit = nodeType === 'config' || nodeType === 'function'
|
||||||
const hasConnectedNodes = edges.some((e: any) => e.target === nodeId)
|
const hasConnectedNodes = edges.some((e: any) => e.target === nodeId)
|
||||||
|
|
||||||
const onDuplicate = useCallback(() => {
|
|
||||||
if (!setNodes || !node) return
|
|
||||||
const pos = node.position ?? { x: 0, y: 0 }
|
|
||||||
setNodes((nds: any[]) => {
|
|
||||||
const newId = getNextNodeId(nodeType, nds.map((n: any) => n.id))
|
|
||||||
const newNode = {
|
|
||||||
id: newId,
|
|
||||||
type: node.type,
|
|
||||||
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
|
|
||||||
data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data,
|
|
||||||
style: getDefaultStyle(nodeType),
|
|
||||||
}
|
|
||||||
if (nodeType === 'config' && newNode.data && typeof newNode.data === 'object' && 'title' in newNode.data) {
|
|
||||||
(newNode.data as { title: string }).title = `${newId}`
|
|
||||||
}
|
|
||||||
return nds.concat(newNode)
|
|
||||||
})
|
|
||||||
}, [node, nodeType, setNodes])
|
|
||||||
|
|
||||||
const onCopy = useCallback(() => {
|
|
||||||
if (!node) return
|
|
||||||
const copy = { id: node.id, type: node.type, data: node.data, position: node.position, style: node.style }
|
|
||||||
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { })
|
|
||||||
}, [node])
|
|
||||||
|
|
||||||
const onReset = useCallback(() => {
|
|
||||||
if (!setNodes) return
|
|
||||||
const resetData = getResetDataForType(nodeType, nodeId)
|
|
||||||
setNodes((nds: any[]) =>
|
|
||||||
nds.map((n) => (n.id === nodeId ? { ...n, data: resetData } : n))
|
|
||||||
)
|
|
||||||
}, [nodeId, nodeType, setNodes])
|
|
||||||
|
|
||||||
const onDelete = useCallback(() => {
|
const onDelete = useCallback(() => {
|
||||||
if (!setNodes || !setEdges) return
|
if (!setNodes || !setEdges) return
|
||||||
setNodes((nds: any[]) => nds.filter((n: any) => n.id !== nodeId))
|
setNodes((nds: any[]) => nds.filter((n: any) => n.id !== nodeId))
|
||||||
@@ -93,14 +55,6 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
|||||||
ctx?.setRenamingNodeId?.(nodeId)
|
ctx?.setRenamingNodeId?.(nodeId)
|
||||||
}, [nodeId, ctx])
|
}, [nodeId, ctx])
|
||||||
|
|
||||||
const onPaste = useCallback(() => {
|
|
||||||
ctx?.flowActionsRef?.current?.pasteAtViewportCenter?.()
|
|
||||||
}, [ctx])
|
|
||||||
|
|
||||||
const onFitView = useCallback(() => {
|
|
||||||
ctx?.flowActionsRef?.current?.fitView?.()
|
|
||||||
}, [ctx])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
|
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
@@ -108,37 +62,14 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
|||||||
Node
|
Node
|
||||||
</MenubarTrigger>
|
</MenubarTrigger>
|
||||||
<MenubarContent className="min-w-[12rem]">
|
<MenubarContent className="min-w-[12rem]">
|
||||||
{/* Edit */}
|
|
||||||
<MenubarItem className="text-xs" onClick={onDuplicate}>
|
|
||||||
Duplicate
|
|
||||||
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘D</Kbd></MenubarShortcut>
|
|
||||||
</MenubarItem>
|
|
||||||
<MenubarItem className="text-xs" onClick={onCopy}>
|
|
||||||
Copy
|
|
||||||
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘C</Kbd></MenubarShortcut>
|
|
||||||
</MenubarItem>
|
|
||||||
<MenubarItem className="text-xs" onClick={onPaste}>
|
|
||||||
Paste
|
|
||||||
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘V</Kbd></MenubarShortcut>
|
|
||||||
</MenubarItem>
|
|
||||||
<MenubarSeparator />
|
|
||||||
{/* View */}
|
|
||||||
<MenubarItem className="text-xs" onClick={onFitView}>
|
|
||||||
Fit View
|
|
||||||
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌘0</Kbd></MenubarShortcut>
|
|
||||||
</MenubarItem>
|
|
||||||
<MenubarSeparator />
|
|
||||||
{/* Node */}
|
|
||||||
<MenubarItem className="text-xs" onClick={onRename}>
|
<MenubarItem className="text-xs" onClick={onRename}>
|
||||||
Rename
|
Rename
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarItem className="text-xs" onClick={onReset}>
|
|
||||||
Clear
|
|
||||||
</MenubarItem>
|
|
||||||
{nodeMenuExtraContent}
|
{nodeMenuExtraContent}
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
<MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}>
|
<MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}>
|
||||||
Delete
|
Delete
|
||||||
|
<MenubarShortcut className="ml-auto pl-4"><Kbd>⌫</Kbd></MenubarShortcut>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
|
|||||||
Reference in New Issue
Block a user