feat: add zoom navigation buttons to canvas filter for tables and areas (#903)

* feat: add zoom navigation buttons to canvas filter for tables and areas

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
This commit is contained in:
Jonathan Fishner
2025-09-04 16:18:50 +03:00
committed by GitHub
parent ffddcdcc98
commit a0fb1ed08b
7 changed files with 316 additions and 193 deletions

142
src/hooks/use-focus-on.ts Normal file
View File

@@ -0,0 +1,142 @@
import { useCallback } from 'react';
import { useReactFlow } from '@xyflow/react';
import { useLayout } from '@/hooks/use-layout';
import { useBreakpoint } from '@/hooks/use-breakpoint';
interface FocusOptions {
select?: boolean;
}
export const useFocusOn = () => {
const { fitView, setNodes, setEdges } = useReactFlow();
const { hideSidePanel } = useLayout();
const { isMd: isDesktop } = useBreakpoint('md');
const focusOnArea = useCallback(
(areaId: string, options: FocusOptions = {}) => {
const { select = true } = options;
if (select) {
setNodes((nodes) =>
nodes.map((node) =>
node.id === areaId
? {
...node,
selected: true,
}
: {
...node,
selected: false,
}
)
);
}
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: areaId,
},
],
});
if (!isDesktop) {
hideSidePanel();
}
},
[fitView, setNodes, hideSidePanel, isDesktop]
);
const focusOnTable = useCallback(
(tableId: string, options: FocusOptions = {}) => {
const { select = true } = options;
if (select) {
setNodes((nodes) =>
nodes.map((node) =>
node.id === tableId
? {
...node,
selected: true,
}
: {
...node,
selected: false,
}
)
);
}
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: tableId,
},
],
});
if (!isDesktop) {
hideSidePanel();
}
},
[fitView, setNodes, hideSidePanel, isDesktop]
);
const focusOnRelationship = useCallback(
(
relationshipId: string,
sourceTableId: string,
targetTableId: string,
options: FocusOptions = {}
) => {
const { select = true } = options;
if (select) {
setEdges((edges) =>
edges.map((edge) =>
edge.id === relationshipId
? {
...edge,
selected: true,
}
: {
...edge,
selected: false,
}
)
);
}
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: sourceTableId,
},
{
id: targetTableId,
},
],
});
if (!isDesktop) {
hideSidePanel();
}
},
[fitView, setEdges, hideSidePanel, isDesktop]
);
return {
focusOnArea,
focusOnTable,
focusOnRelationship,
};
};

View File

@@ -47,7 +47,7 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
addTablesToFilter, addTablesToFilter,
removeTablesFromFilter, removeTablesFromFilter,
} = useDiagramFilter(); } = useDiagramFilter();
const { fitView, setNodes } = useReactFlow(); const { setNodes } = useReactFlow();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [isFilterVisible, setIsFilterVisible] = useState(false); const [isFilterVisible, setIsFilterVisible] = useState(false);
@@ -160,39 +160,53 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
] ]
); );
const focusOnTable = useCallback( const selectTable = useCallback(
(tableId: string) => { (tableId: string) => {
// Make sure the table is visible // Make sure the table is visible, selected and trigger animation
setNodes((nodes) => setNodes((nodes) =>
nodes.map((node) => nodes.map((node) => {
node.id === tableId if (node.id === tableId) {
? { return {
...node, ...node,
hidden: false,
selected: true, selected: true,
data: {
...node.data,
highlightTable: true,
},
};
} }
: {
return {
...node, ...node,
selected: false, selected: false,
} data: {
) ...node.data,
highlightTable: false,
},
};
})
); );
// Focus on the table // Remove the highlight flag after animation completes
setTimeout(() => { setTimeout(() => {
fitView({ setNodes((nodes) =>
duration: 500, nodes.map((node) => {
maxZoom: 1, if (node.id === tableId) {
minZoom: 1, return {
nodes: [ ...node,
{ data: {
id: tableId, ...node.data,
highlightTable: false,
}, },
], };
}); }
}, 100);
return node;
})
);
}, 600);
}, },
[fitView, setNodes] [setNodes]
); );
// Handle node click // Handle node click
@@ -202,13 +216,13 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
const context = node.context as TableContext; const context = node.context as TableContext;
const isTableVisible = context.visible; const isTableVisible = context.visible;
// Only focus if table is visible // Only select if table is visible
if (isTableVisible) { if (isTableVisible) {
focusOnTable(node.id); selectTable(node.id);
} }
} }
}, },
[focusOnTable] [selectTable]
); );
// Animate in on mount and focus search input // Animate in on mount and focus search input

View File

@@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff, CircleDotDashed } from 'lucide-react';
import { Button } from '@/components/button/button'; import { Button } from '@/components/button/button';
import type { TreeNode } from '@/components/tree-view/tree'; import type { TreeNode } from '@/components/tree-view/tree';
import { schemaNameToSchemaId } from '@/lib/domain/db-schema'; import { schemaNameToSchemaId } from '@/lib/domain/db-schema';
import { useFocusOn } from '@/hooks/use-focus-on';
import type { import type {
AreaContext, AreaContext,
NodeContext, NodeContext,
@@ -12,6 +13,7 @@ import type {
TableContext, TableContext,
} from './types'; } from './types';
import type { FilterTableInfo } from '@/lib/domain/diagram-filter/diagram-filter'; import type { FilterTableInfo } from '@/lib/domain/diagram-filter/diagram-filter';
import { cn } from '@/lib/utils';
interface FilterItemActionsProps { interface FilterItemActionsProps {
node: TreeNode<NodeType, NodeContext>; node: TreeNode<NodeType, NodeContext>;
@@ -40,6 +42,7 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
addTablesToFilter, addTablesToFilter,
removeTablesFromFilter, removeTablesFromFilter,
}) => { }) => {
const { focusOnArea, focusOnTable } = useFocusOn();
if (node.type === 'schema') { if (node.type === 'schema') {
const context = node.context as SchemaContext; const context = node.context as SchemaContext;
const schemaVisible = context.visible; const schemaVisible = context.visible;
@@ -50,7 +53,7 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="size-7 h-fit p-0" className="h-fit w-6 p-0"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -67,9 +70,9 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
}} }}
> >
{!schemaVisible ? ( {!schemaVisible ? (
<EyeOff className="size-3.5 text-muted-foreground" /> <EyeOff className="!size-3.5 text-muted-foreground" />
) : ( ) : (
<Eye className="size-3.5" /> <Eye className="!size-3.5" />
)} )}
</Button> </Button>
); );
@@ -81,11 +84,33 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
const isUngrouped = context.isUngrouped; const isUngrouped = context.isUngrouped;
const areaId = context.id; const areaId = context.id;
const handleZoomToArea = (e: React.MouseEvent) => {
e.stopPropagation();
if (!isUngrouped) {
focusOnArea(areaId);
}
};
return ( return (
<div className="flex h-full items-center gap-0.5">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="size-7 h-fit p-0" className={cn(
'flex h-fit w-6 items-center justify-center p-0 opacity-0 transition-opacity group-hover:opacity-100',
{
'!opacity-0': !areaVisible,
}
)}
onClick={handleZoomToArea}
disabled={!areaVisible}
>
<CircleDotDashed className="!size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="flex h-fit w-6 items-center justify-center p-0"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// Toggle all tables in this area // Toggle all tables in this area
@@ -107,11 +132,12 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
}} }}
> >
{!areaVisible ? ( {!areaVisible ? (
<EyeOff className="size-3.5 text-muted-foreground" /> <EyeOff className="!size-3.5 text-muted-foreground" />
) : ( ) : (
<Eye className="size-3.5" /> <Eye className="!size-3.5" />
)} )}
</Button> </Button>
</div>
); );
} }
@@ -120,22 +146,43 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
const context = node.context as TableContext; const context = node.context as TableContext;
const tableVisible = context.visible; const tableVisible = context.visible;
const handleZoomToTable = (e: React.MouseEvent) => {
e.stopPropagation();
focusOnTable(tableId);
};
return ( return (
<div className="flex h-full items-center gap-0.5">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="size-7 h-fit p-0" className={cn(
'flex h-fit w-6 items-center justify-center p-0 opacity-0 transition-opacity group-hover:opacity-100',
{
'!opacity-0': !tableVisible,
}
)}
onClick={handleZoomToTable}
disabled={!tableVisible}
>
<CircleDotDashed className="!size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="flex w-6 items-center justify-center p-0"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleTableFilter(tableId); toggleTableFilter(tableId);
}} }}
> >
{!tableVisible ? ( {!tableVisible ? (
<EyeOff className="size-3.5 text-muted-foreground" /> <EyeOff className="!size-3.5 text-muted-foreground" />
) : ( ) : (
<Eye className="size-3.5" /> <Eye className="!size-3.5" />
)} )}
</Button> </Button>
</div>
); );
} }

View File

@@ -54,6 +54,7 @@ export type TableNodeType = Node<
isOverlapping: boolean; isOverlapping: boolean;
highlightOverlappingTables?: boolean; highlightOverlappingTables?: boolean;
hasHighlightedCustomType?: boolean; hasHighlightedCustomType?: boolean;
highlightTable?: boolean;
}, },
'table' 'table'
>; >;
@@ -68,6 +69,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isOverlapping, isOverlapping,
highlightOverlappingTables, highlightOverlappingTables,
hasHighlightedCustomType, hasHighlightedCustomType,
highlightTable,
}, },
}) => { }) => {
const { updateTable, relationships, readonly } = useChartDB(); const { updateTable, relationships, readonly } = useChartDB();
@@ -321,6 +323,9 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
hasHighlightedCustomType hasHighlightedCustomType
? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-yellow-500 ring-offset-2 animate-scale' ? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-yellow-500 ring-offset-2 animate-scale'
: '', : '',
highlightTable
? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-blue-500 ring-offset-2 animate-scale-2'
: '',
isDiffTableChanged && isDiffTableChanged &&
!isSummaryOnly && !isSummaryOnly &&
!isDiffNewTable && !isDiffNewTable &&
@@ -339,6 +344,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isOverlapping, isOverlapping,
highlightOverlappingTables, highlightOverlappingTables,
hasHighlightedCustomType, hasHighlightedCustomType,
highlightTable,
isSummaryOnly, isSummaryOnly,
isDiffTableChanged, isDiffTableChanged,
isDiffNewTable, isDiffNewTable,

View File

@@ -31,9 +31,7 @@ import {
} from '@/components/dropdown-menu/dropdown-menu'; } from '@/components/dropdown-menu/dropdown-menu';
import { ListItemHeaderButton } from '@/pages/editor-page/side-panel/list-item-header-button/list-item-header-button'; import { ListItemHeaderButton } from '@/pages/editor-page/side-panel/list-item-header-button/list-item-header-button';
import { mergeRefs } from '@/lib/utils'; import { mergeRefs } from '@/lib/utils';
import { useReactFlow } from '@xyflow/react'; import { useFocusOn } from '@/hooks/use-focus-on';
import { useLayout } from '@/hooks/use-layout';
import { useBreakpoint } from '@/hooks/use-breakpoint';
export interface AreaListItemProps { export interface AreaListItemProps {
area: Area; area: Area;
@@ -43,9 +41,7 @@ export const AreaListItem = React.forwardRef<HTMLDivElement, AreaListItemProps>(
({ area }, forwardedRef) => { ({ area }, forwardedRef) => {
const { updateArea, removeArea, readonly } = useChartDB(); const { updateArea, removeArea, readonly } = useChartDB();
const { t } = useTranslation(); const { t } = useTranslation();
const { fitView, setNodes } = useReactFlow(); const { focusOnArea } = useFocusOn();
const { hideSidePanel } = useLayout();
const { isMd: isDesktop } = useBreakpoint('md');
const [editMode, setEditMode] = React.useState(false); const [editMode, setEditMode] = React.useState(false);
const [areaName, setAreaName] = React.useState(area.name); const [areaName, setAreaName] = React.useState(area.name);
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
@@ -92,38 +88,12 @@ export const AreaListItem = React.forwardRef<HTMLDivElement, AreaListItemProps>(
[area.id, updateArea] [area.id, updateArea]
); );
const focusOnArea = useCallback( const handleFocusOnArea = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation(); event.stopPropagation();
setNodes((nodes) => focusOnArea(area.id);
nodes.map((node) =>
node.id == area.id
? {
...node,
selected: true,
}
: {
...node,
selected: false,
}
)
);
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: area.id,
}, },
], [focusOnArea, area.id]
});
if (!isDesktop) {
hideSidePanel();
}
},
[fitView, area.id, setNodes, hideSidePanel, isDesktop]
); );
useClickAway(inputRef, saveAreaName); useClickAway(inputRef, saveAreaName);
@@ -241,7 +211,9 @@ export const AreaListItem = React.forwardRef<HTMLDivElement, AreaListItemProps>(
disabled={readonly} disabled={readonly}
/> />
<div className="hidden md:group-hover:flex"> <div className="hidden md:group-hover:flex">
<ListItemHeaderButton onClick={focusOnArea}> <ListItemHeaderButton
onClick={handleFocusOnArea}
>
<CircleDotDashed /> <CircleDotDashed />
</ListItemHeaderButton> </ListItemHeaderButton>
</div> </div>

View File

@@ -10,6 +10,7 @@ import { ListItemHeaderButton } from '../../../../list-item-header-button/list-i
import type { DBRelationship } from '@/lib/domain/db-relationship'; import type { DBRelationship } from '@/lib/domain/db-relationship';
import { useReactFlow } from '@xyflow/react'; import { useReactFlow } from '@xyflow/react';
import { useChartDB } from '@/hooks/use-chartdb'; import { useChartDB } from '@/hooks/use-chartdb';
import { useFocusOn } from '@/hooks/use-focus-on';
import { useClickAway, useKeyPressEvent } from 'react-use'; import { useClickAway, useKeyPressEvent } from 'react-use';
import { import {
DropdownMenu, DropdownMenu,
@@ -21,8 +22,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/dropdown-menu/dropdown-menu'; } from '@/components/dropdown-menu/dropdown-menu';
import { Input } from '@/components/input/input'; import { Input } from '@/components/input/input';
import { useLayout } from '@/hooks/use-layout';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export interface RelationshipListItemHeaderProps { export interface RelationshipListItemHeaderProps {
@@ -33,11 +32,10 @@ export const RelationshipListItemHeader: React.FC<
RelationshipListItemHeaderProps RelationshipListItemHeaderProps
> = ({ relationship }) => { > = ({ relationship }) => {
const { updateRelationship, removeRelationship, readonly } = useChartDB(); const { updateRelationship, removeRelationship, readonly } = useChartDB();
const { fitView, deleteElements, setEdges } = useReactFlow(); const { deleteElements } = useReactFlow();
const { t } = useTranslation(); const { t } = useTranslation();
const { hideSidePanel } = useLayout(); const { focusOnRelationship } = useFocusOn();
const [editMode, setEditMode] = React.useState(false); const [editMode, setEditMode] = React.useState(false);
const { isMd: isDesktop } = useBreakpoint('md');
const [relationshipName, setRelationshipName] = React.useState( const [relationshipName, setRelationshipName] = React.useState(
relationship.name relationship.name
); );
@@ -70,48 +68,20 @@ export const RelationshipListItemHeader: React.FC<
setEditMode(true); setEditMode(true);
}; };
const focusOnRelationship = useCallback( const handleFocusOnRelationship = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation(); event.stopPropagation();
setEdges((edges) => focusOnRelationship(
edges.map((edge) => relationship.id,
edge.id == relationship.id relationship.sourceTableId,
? { relationship.targetTableId
...edge,
selected: true,
}
: {
...edge,
selected: false,
}
)
); );
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: relationship.sourceTableId,
},
{
id: relationship.targetTableId,
},
],
});
if (!isDesktop) {
hideSidePanel();
}
}, },
[ [
fitView, focusOnRelationship,
relationship.id,
relationship.sourceTableId, relationship.sourceTableId,
relationship.targetTableId, relationship.targetTableId,
setEdges,
relationship.id,
isDesktop,
hideSidePanel,
] ]
); );
@@ -182,7 +152,9 @@ export const RelationshipListItemHeader: React.FC<
<Pencil /> <Pencil />
</ListItemHeaderButton> </ListItemHeaderButton>
) : null} ) : null}
<ListItemHeaderButton onClick={focusOnRelationship}> <ListItemHeaderButton
onClick={handleFocusOnRelationship}
>
<CircleDotDashed /> <CircleDotDashed />
</ListItemHeaderButton> </ListItemHeaderButton>
</div> </div>

View File

@@ -26,9 +26,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/dropdown-menu/dropdown-menu'; } from '@/components/dropdown-menu/dropdown-menu';
import { useReactFlow } from '@xyflow/react'; import { useFocusOn } from '@/hooks/use-focus-on';
import { useLayout } from '@/hooks/use-layout';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDialog } from '@/hooks/use-dialog'; import { useDialog } from '@/hooks/use-dialog';
import { import {
@@ -62,11 +60,9 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
const { schemasDisplayed } = useDiagramFilter(); const { schemasDisplayed } = useDiagramFilter();
const { openTableSchemaDialog } = useDialog(); const { openTableSchemaDialog } = useDialog();
const { t } = useTranslation(); const { t } = useTranslation();
const { fitView, setNodes } = useReactFlow(); const { focusOnTable } = useFocusOn();
const { hideSidePanel } = useLayout();
const [editMode, setEditMode] = React.useState(false); const [editMode, setEditMode] = React.useState(false);
const [tableName, setTableName] = React.useState(table.name); const [tableName, setTableName] = React.useState(table.name);
const { isMd: isDesktop } = useBreakpoint('md');
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const { listeners } = useSortable({ id: table.id }); const { listeners } = useSortable({ id: table.id });
@@ -93,38 +89,12 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
setEditMode(true); setEditMode(true);
}; };
const focusOnTable = useCallback( const handleFocusOnTable = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation(); event.stopPropagation();
setNodes((nodes) => focusOnTable(table.id);
nodes.map((node) =>
node.id == table.id
? {
...node,
selected: true,
}
: {
...node,
selected: false,
}
)
);
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: table.id,
}, },
], [focusOnTable, table.id]
});
if (!isDesktop) {
hideSidePanel();
}
},
[fitView, table.id, setNodes, hideSidePanel, isDesktop]
); );
const deleteTableHandler = useCallback(() => { const deleteTableHandler = useCallback(() => {
@@ -339,7 +309,7 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
<Pencil /> <Pencil />
</ListItemHeaderButton> </ListItemHeaderButton>
) : null} ) : null}
<ListItemHeaderButton onClick={focusOnTable}> <ListItemHeaderButton onClick={handleFocusOnTable}>
<CircleDotDashed /> <CircleDotDashed />
</ListItemHeaderButton> </ListItemHeaderButton>
</div> </div>