Compare commits

...

1 Commits

Author SHA1 Message Date
johnnyfish
4fdc2ccd91 feat: implement reusable click-outside hook for edit fields
- Create custom useClickOutside and useEditClickOutside hooks
- Replace useClickAway with custom hook for better event handling
- Add click-outside behavior to all edit fields:
  - Diagram name in navbar
  - Area names on canvas (with context menu)
  - Table names in side panel
  - Relationship names in side panel
  - Table edit mode panel
- Improve edit mode UX with auto-focus and text selection
- Add pencil icons for visual edit affordance
2025-09-15 19:22:23 +03:00
7 changed files with 309 additions and 109 deletions

View File

@@ -0,0 +1,50 @@
import { useEffect, useCallback, type RefObject } from 'react';
/**
* Custom hook that handles click outside detection with capture phase
* to work properly with React Flow canvas and other event-stopping elements
*/
export function useClickOutside(
ref: RefObject<HTMLElement>,
handler: () => void,
isActive = true
) {
useEffect(() => {
if (!isActive) return;
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
handler();
}
};
// Use capture phase to catch events before React Flow or other libraries can stop them
document.addEventListener('mousedown', handleClickOutside, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
};
}, [ref, handler, isActive]);
}
/**
* Specialized version of useClickOutside for edit mode inputs
* Adds a small delay to prevent race conditions with blur events
*/
export function useEditClickOutside(
inputRef: RefObject<HTMLElement>,
editMode: boolean,
onSave: () => void,
delay = 100
) {
const handleClickOutside = useCallback(() => {
if (editMode) {
// Small delay to ensure any pending state updates are processed
setTimeout(() => {
onSave();
}, delay);
}
}, [editMode, onSave, delay]);
useClickOutside(inputRef, handleClickOutside, editMode);
}

View File

@@ -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>
)}
<ContextMenuItem
onClick={removeAreaHandler}
className="flex justify-between gap-3"
>
<span>Delete Area</span>
<Trash2 className="size-3.5 text-red-700" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View File

@@ -1,10 +1,11 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import type { NodeProps, Node } from '@xyflow/react';
import { NodeResizer } from '@xyflow/react';
import type { Area } from '@/lib/domain/area';
import { useChartDB } from '@/hooks/use-chartdb';
import { Input } from '@/components/input/input';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { useEditClickOutside } from '@/hooks/use-click-outside';
import { useKeyPressEvent } from 'react-use';
import {
Tooltip,
TooltipContent,
@@ -12,9 +13,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<
{
@@ -35,12 +37,11 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
const focused = !!selected && !dragging;
const editAreaName = useCallback(() => {
if (!editMode) return;
if (areaName.trim()) {
updateArea(area.id, { name: areaName.trim() });
}
setEditMode(false);
}, [areaName, area.id, updateArea, editMode]);
}, [areaName, area.id, updateArea]);
const abortEdit = useCallback(() => {
setEditMode(false);
@@ -52,89 +53,119 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
openAreaFromSidebar(area.id);
}, [selectSidebarSection, openAreaFromSidebar, area.id]);
useClickAway(inputRef, editAreaName);
// Handle click outside to save and exit edit mode
useEditClickOutside(inputRef, editMode, editAreaName);
useKeyPressEvent('Enter', editAreaName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = (e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
};
const enterEditMode = useCallback(
(e?: React.MouseEvent) => {
e?.stopPropagation();
setAreaName(area.name);
setEditMode(true);
},
[area.name]
);
useEffect(() => {
if (editMode) {
// Small delay to ensure the input is rendered
const timeoutId = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 50);
return () => clearTimeout(timeoutId);
}
}, [editMode]);
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();
}
}}
>
{!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" />
<AreaNodeContextMenu area={area} onEditName={enterEditMode}>
<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>
);
}
);

View File

@@ -11,6 +11,7 @@ import { Separator } from '@/components/separator/separator';
import { useChartDB } from '@/hooks/use-chartdb';
import { useUpdateTable } from '@/hooks/use-update-table';
import { useTranslation } from 'react-i18next';
import { useClickOutside } from '@/hooks/use-click-outside';
export interface TableEditModeProps {
table: DBTable;
@@ -108,6 +109,9 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
}
}, [createField, table.id]);
// Close edit mode when clicking outside
useClickOutside(containerRef, onClose, isVisible);
const handleColorChange = useCallback(
(newColor: string) => {
updateTable(table.id, { color: newColor });

View File

@@ -11,7 +11,8 @@ import type { DBRelationship } from '@/lib/domain/db-relationship';
import { useReactFlow } from '@xyflow/react';
import { useChartDB } from '@/hooks/use-chartdb';
import { useFocusOn } from '@/hooks/use-focus-on';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { useEditClickOutside } from '@/hooks/use-click-outside';
import { useKeyPressEvent } from 'react-use';
import {
DropdownMenu,
DropdownMenuContent,
@@ -42,31 +43,37 @@ export const RelationshipListItemHeader: React.FC<
const inputRef = React.useRef<HTMLInputElement>(null);
const editRelationshipName = useCallback(() => {
if (!editMode) return;
if (relationshipName.trim() && relationshipName !== relationship.name) {
updateRelationship(relationship.id, {
name: relationshipName.trim(),
});
}
setEditMode(false);
}, [
relationshipName,
relationship.id,
updateRelationship,
editMode,
relationship.name,
]);
useClickAway(inputRef, editRelationshipName);
useKeyPressEvent('Enter', editRelationshipName);
const abortEdit = useCallback(() => {
setEditMode(false);
setRelationshipName(relationship.name);
}, [relationship.name]);
const enterEditMode = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
event.stopPropagation();
setEditMode(true);
};
// Handle click outside to save and exit edit mode
useEditClickOutside(inputRef, editMode, editRelationshipName);
useKeyPressEvent('Enter', editRelationshipName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
setRelationshipName(relationship.name);
setEditMode(true);
},
[relationship.name]
);
const handleFocusOnRelationship = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {

View File

@@ -15,7 +15,8 @@ import { ListItemHeaderButton } from '@/pages/editor-page/side-panel/list-item-h
import type { DBTable } from '@/lib/domain/db-table';
import { Input } from '@/components/input/input';
import { useChartDB } from '@/hooks/use-chartdb';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { useEditClickOutside } from '@/hooks/use-click-outside';
import { useKeyPressEvent } from 'react-use';
import { useSortable } from '@dnd-kit/sortable';
import {
DropdownMenu,
@@ -67,27 +68,30 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
const { listeners } = useSortable({ id: table.id });
const editTableName = useCallback(() => {
if (!editMode) return;
if (tableName.trim()) {
updateTable(table.id, { name: tableName.trim() });
}
setEditMode(false);
}, [tableName, table.id, updateTable, editMode]);
}, [tableName, table.id, updateTable]);
const abortEdit = useCallback(() => {
setEditMode(false);
setTableName(table.name);
}, [table.name]);
useClickAway(inputRef, editTableName);
// Handle click outside to save and exit edit mode
useEditClickOutside(inputRef, editMode, editTableName);
useKeyPressEvent('Enter', editTableName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = (e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
};
const enterEditMode = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
setTableName(table.name);
setEditMode(true);
},
[table.name]
);
const handleFocusOnTable = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@@ -249,6 +253,20 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
}
}, [table.name]);
useEffect(() => {
if (editMode) {
// Small delay to ensure the input is rendered
const timeoutId = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 50);
return () => clearTimeout(timeoutId);
}
}, [editMode]);
return (
<div className="group flex h-11 flex-1 items-center justify-between gap-1 overflow-hidden">
{!readonly ? (

View File

@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useEditClickOutside } from '@/hooks/use-click-outside';
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';
import { useKeyPressEvent } from 'react-use';
import { DiagramIcon } from '@/components/diagram-icon/diagram-icon';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
@@ -31,23 +32,45 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
setEditedDiagramName(diagramName);
}, [diagramName]);
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 editDiagramName = useCallback(() => {
if (!editMode) return;
if (editedDiagramName.trim()) {
updateDiagramName(editedDiagramName.trim());
}
setEditMode(false);
}, [editedDiagramName, updateDiagramName, editMode]);
}, [editedDiagramName, updateDiagramName]);
useClickAway(inputRef, editDiagramName);
const abortEdit = useCallback(() => {
setEditMode(false);
setEditedDiagramName(diagramName);
}, [diagramName]);
// Handle click outside to save and exit edit mode
useEditClickOutside(inputRef, editMode, editDiagramName);
useKeyPressEvent('Enter', editDiagramName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = (
event: React.MouseEvent<HTMLHeadingElement, MouseEvent>
) => {
event.stopPropagation();
setEditMode(true);
};
const enterEditMode = useCallback(
(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
event.stopPropagation();
setEditedDiagramName(diagramName);
setEditMode(true);
},
[diagramName]
);
return (
<div className="group">
@@ -81,10 +104,16 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
setEditedDiagramName(e.target.value)
}
className="ml-1 h-7 focus-visible:ring-0"
style={{
width: `${Math.max(
editedDiagramName.length * 8 + 20,
100
)}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 />
@@ -110,6 +139,13 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
{t('tool_tips.double_click_to_edit')}
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
className="ml-1 size-5 p-0 opacity-0 transition-opacity hover:bg-primary-foreground group-hover:opacity-100"
onClick={enterEditMode}
>
<Pencil className="size-3 text-slate-500 dark:text-slate-400" />
</Button>
</>
)}
</div>