mirror of
https://github.com/chartdb/chartdb.git
synced 2025-12-19 10:30:39 -06:00
feat: add area context menu and UI improvements (#918)
* feat: add area context menu and UI improvements - Add right-click context menu for areas with edit/delete options - Add pencil icon on hover for diagram name - Add dynamic input width for diagram name - Keep existing useClickAway behavior * fix --------- Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/context-menu/context-menu';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
export interface AreaNodeContextMenuProps {
|
||||
area: Area;
|
||||
onEditName?: () => void;
|
||||
}
|
||||
|
||||
export const AreaNodeContextMenu: React.FC<
|
||||
React.PropsWithChildren<AreaNodeContextMenuProps>
|
||||
> = ({ children, area, onEditName }) => {
|
||||
const { removeArea, readonly } = useChartDB();
|
||||
const { isMd: isDesktop } = useBreakpoint('md');
|
||||
|
||||
const removeAreaHandler = useCallback(() => {
|
||||
removeArea(area.id);
|
||||
}, [removeArea, area.id]);
|
||||
|
||||
if (!isDesktop || readonly) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onEditName ? (
|
||||
<ContextMenuItem
|
||||
onClick={onEditName}
|
||||
className="flex justify-between gap-3"
|
||||
>
|
||||
<span>Edit Area Name</span>
|
||||
<Pencil className="size-3.5" />
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem
|
||||
onClick={removeAreaHandler}
|
||||
className="flex justify-between gap-3"
|
||||
>
|
||||
<span>Delete Area</span>
|
||||
<Trash2 className="size-3.5 text-red-700" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
} from '@/components/tooltip/tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, GripVertical } from 'lucide-react';
|
||||
import { Check, GripVertical, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import { useLayout } from '@/hooks/use-layout';
|
||||
import { AreaNodeContextMenu } from './area-node-context-menu';
|
||||
|
||||
export type AreaNodeType = Node<
|
||||
{
|
||||
@@ -62,79 +63,93 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col rounded-md border-2 shadow-sm',
|
||||
selected ? 'border-pink-600' : 'border-transparent'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `${area.color}15`,
|
||||
borderColor: selected ? undefined : area.color,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.detail === 2) {
|
||||
openAreaInEditor();
|
||||
}
|
||||
}}
|
||||
<AreaNodeContextMenu
|
||||
area={area}
|
||||
onEditName={() => setEditMode(true)}
|
||||
>
|
||||
{!readonly ? (
|
||||
<NodeResizer
|
||||
isVisible={focused}
|
||||
lineClassName="!border-4 !border-transparent"
|
||||
handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600"
|
||||
minHeight={100}
|
||||
minWidth={100}
|
||||
/>
|
||||
) : null}
|
||||
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col rounded-md border-2 shadow-sm',
|
||||
selected ? 'border-pink-600' : 'border-transparent'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `${area.color}15`,
|
||||
borderColor: selected ? undefined : area.color,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.detail === 2) {
|
||||
openAreaInEditor();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!readonly ? (
|
||||
<NodeResizer
|
||||
isVisible={focused}
|
||||
lineClassName="!border-4 !border-transparent"
|
||||
handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600"
|
||||
minHeight={100}
|
||||
minWidth={100}
|
||||
/>
|
||||
) : null}
|
||||
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />
|
||||
|
||||
{editMode && !readonly ? (
|
||||
<div className="flex w-full items-center">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={area.name}
|
||||
value={areaName}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) =>
|
||||
setAreaName(e.target.value)
|
||||
}
|
||||
className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70"
|
||||
/>
|
||||
{editMode && !readonly ? (
|
||||
<div className="flex w-full items-center">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={area.name}
|
||||
value={areaName}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) =>
|
||||
setAreaName(e.target.value)
|
||||
}
|
||||
className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-1 size-6 p-0 hover:bg-white/20"
|
||||
onClick={editAreaName}
|
||||
>
|
||||
<Check className="size-3.5 text-slate-700 dark:text-slate-300" />
|
||||
</Button>
|
||||
</div>
|
||||
) : !readonly ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="text-editable truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300"
|
||||
onDoubleClick={enterEditMode}
|
||||
>
|
||||
{area.name}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('tool_tips.double_click_to_edit')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300">
|
||||
{area.name}
|
||||
</div>
|
||||
)}
|
||||
{!editMode && !readonly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-1 size-6 p-0 hover:bg-white/20"
|
||||
onClick={editAreaName}
|
||||
className="ml-auto size-5 p-0 opacity-0 transition-opacity hover:bg-white/20 group-hover:opacity-100"
|
||||
onClick={enterEditMode}
|
||||
>
|
||||
<Check className="size-3.5 text-slate-700 dark:text-slate-300" />
|
||||
<Pencil className="size-3 text-slate-700 dark:text-slate-300" />
|
||||
</Button>
|
||||
</div>
|
||||
) : !readonly ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="text-editable max-w-[200px] cursor-text truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300"
|
||||
onDoubleClick={enterEditMode}
|
||||
>
|
||||
{area.name}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('tool_tips.double_click_to_edit')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300">
|
||||
{area.name}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</AreaNodeContextMenu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/button/button';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, Pencil } from 'lucide-react';
|
||||
import { Input } from '@/components/input/input';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { useClickAway, useKeyPressEvent } from 'react-use';
|
||||
@@ -32,22 +32,39 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
|
||||
}, [diagramName]);
|
||||
|
||||
const editDiagramName = useCallback(() => {
|
||||
if (!editMode) return;
|
||||
if (editedDiagramName.trim()) {
|
||||
updateDiagramName(editedDiagramName.trim());
|
||||
}
|
||||
setEditMode(false);
|
||||
}, [editedDiagramName, updateDiagramName, editMode]);
|
||||
}, [editedDiagramName, updateDiagramName]);
|
||||
|
||||
// Handle click outside to save and exit edit mode
|
||||
useClickAway(inputRef, editDiagramName);
|
||||
|
||||
useKeyPressEvent('Enter', editDiagramName);
|
||||
|
||||
const enterEditMode = (
|
||||
event: React.MouseEvent<HTMLHeadingElement, MouseEvent>
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setEditMode(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (editMode) {
|
||||
// Small delay to ensure the input is rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 50); // Slightly longer delay to ensure DOM is ready
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const enterEditMode = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
event.stopPropagation();
|
||||
setEditedDiagramName(diagramName);
|
||||
setEditMode(true);
|
||||
},
|
||||
[diagramName]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
@@ -80,11 +97,16 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
|
||||
onChange={(e) =>
|
||||
setEditedDiagramName(e.target.value)
|
||||
}
|
||||
className="ml-1 h-7 focus-visible:ring-0"
|
||||
className="h-7 max-w-[300px] focus-visible:ring-0"
|
||||
style={{
|
||||
width: `${
|
||||
editedDiagramName.length * 8 + 30
|
||||
}px`,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex size-7 p-2 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
|
||||
className="ml-1 flex size-7 p-2 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
|
||||
onClick={editDiagramName}
|
||||
>
|
||||
<Check />
|
||||
@@ -97,7 +119,7 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
|
||||
<h1
|
||||
className={cn(
|
||||
labelVariants(),
|
||||
'group-hover:underline'
|
||||
'group-hover:underline max-w-[300px] truncate'
|
||||
)}
|
||||
onDoubleClick={(e) => {
|
||||
enterEditMode(e);
|
||||
@@ -110,6 +132,16 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
|
||||
{t('tool_tips.double_click_to_edit')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-1 hidden size-5 p-0 hover:bg-background/50 group-hover:flex"
|
||||
onClick={enterEditMode}
|
||||
>
|
||||
<Pencil
|
||||
strokeWidth="1.5"
|
||||
className="!size-3.5 text-slate-600 dark:text-slate-400"
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user