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

View File

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

View File

@@ -54,6 +54,7 @@ export type TableNodeType = Node<
isOverlapping: boolean;
highlightOverlappingTables?: boolean;
hasHighlightedCustomType?: boolean;
highlightTable?: boolean;
},
'table'
>;
@@ -68,6 +69,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isOverlapping,
highlightOverlappingTables,
hasHighlightedCustomType,
highlightTable,
},
}) => {
const { updateTable, relationships, readonly } = useChartDB();
@@ -321,6 +323,9 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
hasHighlightedCustomType
? '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 &&
!isSummaryOnly &&
!isDiffNewTable &&
@@ -339,6 +344,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isOverlapping,
highlightOverlappingTables,
hasHighlightedCustomType,
highlightTable,
isSummaryOnly,
isDiffTableChanged,
isDiffNewTable,

View File

@@ -31,9 +31,7 @@ import {
} from '@/components/dropdown-menu/dropdown-menu';
import { ListItemHeaderButton } from '@/pages/editor-page/side-panel/list-item-header-button/list-item-header-button';
import { mergeRefs } from '@/lib/utils';
import { useReactFlow } from '@xyflow/react';
import { useLayout } from '@/hooks/use-layout';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useFocusOn } from '@/hooks/use-focus-on';
export interface AreaListItemProps {
area: Area;
@@ -43,9 +41,7 @@ export const AreaListItem = React.forwardRef<HTMLDivElement, AreaListItemProps>(
({ area }, forwardedRef) => {
const { updateArea, removeArea, readonly } = useChartDB();
const { t } = useTranslation();
const { fitView, setNodes } = useReactFlow();
const { hideSidePanel } = useLayout();
const { isMd: isDesktop } = useBreakpoint('md');
const { focusOnArea } = useFocusOn();
const [editMode, setEditMode] = React.useState(false);
const [areaName, setAreaName] = React.useState(area.name);
const inputRef = React.useRef<HTMLInputElement>(null);
@@ -92,38 +88,12 @@ export const AreaListItem = React.forwardRef<HTMLDivElement, AreaListItemProps>(
[area.id, updateArea]
);
const focusOnArea = useCallback(
const handleFocusOnArea = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
setNodes((nodes) =>
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]
[focusOnArea, area.id]
);
useClickAway(inputRef, saveAreaName);
@@ -241,7 +211,9 @@ export const AreaListItem = React.forwardRef<HTMLDivElement, AreaListItemProps>(
disabled={readonly}
/>
<div className="hidden md:group-hover:flex">
<ListItemHeaderButton onClick={focusOnArea}>
<ListItemHeaderButton
onClick={handleFocusOnArea}
>
<CircleDotDashed />
</ListItemHeaderButton>
</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 { useReactFlow } from '@xyflow/react';
import { useChartDB } from '@/hooks/use-chartdb';
import { useFocusOn } from '@/hooks/use-focus-on';
import { useClickAway, useKeyPressEvent } from 'react-use';
import {
DropdownMenu,
@@ -21,8 +22,6 @@ import {
DropdownMenuTrigger,
} from '@/components/dropdown-menu/dropdown-menu';
import { Input } from '@/components/input/input';
import { useLayout } from '@/hooks/use-layout';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useTranslation } from 'react-i18next';
export interface RelationshipListItemHeaderProps {
@@ -33,11 +32,10 @@ export const RelationshipListItemHeader: React.FC<
RelationshipListItemHeaderProps
> = ({ relationship }) => {
const { updateRelationship, removeRelationship, readonly } = useChartDB();
const { fitView, deleteElements, setEdges } = useReactFlow();
const { deleteElements } = useReactFlow();
const { t } = useTranslation();
const { hideSidePanel } = useLayout();
const { focusOnRelationship } = useFocusOn();
const [editMode, setEditMode] = React.useState(false);
const { isMd: isDesktop } = useBreakpoint('md');
const [relationshipName, setRelationshipName] = React.useState(
relationship.name
);
@@ -70,48 +68,20 @@ export const RelationshipListItemHeader: React.FC<
setEditMode(true);
};
const focusOnRelationship = useCallback(
const handleFocusOnRelationship = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
setEdges((edges) =>
edges.map((edge) =>
edge.id == relationship.id
? {
...edge,
selected: true,
}
: {
...edge,
selected: false,
}
)
focusOnRelationship(
relationship.id,
relationship.sourceTableId,
relationship.targetTableId
);
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: relationship.sourceTableId,
},
{
id: relationship.targetTableId,
},
],
});
if (!isDesktop) {
hideSidePanel();
}
},
[
fitView,
focusOnRelationship,
relationship.id,
relationship.sourceTableId,
relationship.targetTableId,
setEdges,
relationship.id,
isDesktop,
hideSidePanel,
]
);
@@ -182,7 +152,9 @@ export const RelationshipListItemHeader: React.FC<
<Pencil />
</ListItemHeaderButton>
) : null}
<ListItemHeaderButton onClick={focusOnRelationship}>
<ListItemHeaderButton
onClick={handleFocusOnRelationship}
>
<CircleDotDashed />
</ListItemHeaderButton>
</div>

View File

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