From a0fb1ed08ba18b66354fa3498d610097a83d4afc Mon Sep 17 00:00:00 2001 From: Jonathan Fishner Date: Thu, 4 Sep 2025 16:18:50 +0300 Subject: [PATCH] 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 --- src/hooks/use-focus-on.ts | 142 +++++++++++++++++ .../canvas/canvas-filter/canvas-filter.tsx | 76 +++++---- .../canvas-filter/filter-item-actions.tsx | 145 ++++++++++++------ .../canvas/table-node/table-node.tsx | 6 + .../area-list-item/area-list-item.tsx | 44 +----- .../relationship-list-item-header.tsx | 54 ++----- .../table-list-item-header.tsx | 42 +---- 7 files changed, 316 insertions(+), 193 deletions(-) create mode 100644 src/hooks/use-focus-on.ts diff --git a/src/hooks/use-focus-on.ts b/src/hooks/use-focus-on.ts new file mode 100644 index 00000000..17ca76c7 --- /dev/null +++ b/src/hooks/use-focus-on.ts @@ -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, + }; +}; diff --git a/src/pages/editor-page/canvas/canvas-filter/canvas-filter.tsx b/src/pages/editor-page/canvas/canvas-filter/canvas-filter.tsx index af76f282..bd67b224 100644 --- a/src/pages/editor-page/canvas/canvas-filter/canvas-filter.tsx +++ b/src/pages/editor-page/canvas/canvas-filter/canvas-filter.tsx @@ -47,7 +47,7 @@ export const CanvasFilter: React.FC = ({ onClose }) => { addTablesToFilter, removeTablesFromFilter, } = useDiagramFilter(); - const { fitView, setNodes } = useReactFlow(); + const { setNodes } = useReactFlow(); const [searchQuery, setSearchQuery] = useState(''); const [expanded, setExpanded] = useState>({}); const [isFilterVisible, setIsFilterVisible] = useState(false); @@ -160,39 +160,53 @@ export const CanvasFilter: React.FC = ({ 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 - ? { - ...node, - hidden: false, - selected: true, - } - : { - ...node, - selected: false, - } - ) + nodes.map((node) => { + if (node.id === tableId) { + return { + ...node, + 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, - }, - ], - }); - }, 100); + setNodes((nodes) => + nodes.map((node) => { + if (node.id === tableId) { + return { + ...node, + data: { + ...node.data, + highlightTable: false, + }, + }; + } + + return node; + }) + ); + }, 600); }, - [fitView, setNodes] + [setNodes] ); // Handle node click @@ -202,13 +216,13 @@ export const CanvasFilter: React.FC = ({ 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 diff --git a/src/pages/editor-page/canvas/canvas-filter/filter-item-actions.tsx b/src/pages/editor-page/canvas/canvas-filter/filter-item-actions.tsx index 35a619fa..a19d7cb2 100644 --- a/src/pages/editor-page/canvas/canvas-filter/filter-item-actions.tsx +++ b/src/pages/editor-page/canvas/canvas-filter/filter-item-actions.tsx @@ -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; @@ -40,6 +42,7 @@ export const FilterItemActions: React.FC = ({ 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 = ({ ); @@ -81,37 +84,60 @@ export const FilterItemActions: React.FC = ({ const isUngrouped = context.isUngrouped; const areaId = context.id; + const handleZoomToArea = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!isUngrouped) { + focusOnArea(areaId); + } + }; + return ( - +
+ + +
); } @@ -120,22 +146,43 @@ export const FilterItemActions: React.FC = ({ const context = node.context as TableContext; const tableVisible = context.visible; + const handleZoomToTable = (e: React.MouseEvent) => { + e.stopPropagation(); + focusOnTable(tableId); + }; + return ( - +
+ + +
); } diff --git a/src/pages/editor-page/canvas/table-node/table-node.tsx b/src/pages/editor-page/canvas/table-node/table-node.tsx index de98a076..fd54ad7b 100644 --- a/src/pages/editor-page/canvas/table-node/table-node.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node.tsx @@ -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> = React.memo( isOverlapping, highlightOverlappingTables, hasHighlightedCustomType, + highlightTable, }, }) => { const { updateTable, relationships, readonly } = useChartDB(); @@ -321,6 +323,9 @@ export const TableNode: React.FC> = 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> = React.memo( isOverlapping, highlightOverlappingTables, hasHighlightedCustomType, + highlightTable, isSummaryOnly, isDiffTableChanged, isDiffNewTable, diff --git a/src/pages/editor-page/side-panel/areas-section/areas-list/area-list-item/area-list-item.tsx b/src/pages/editor-page/side-panel/areas-section/areas-list/area-list-item/area-list-item.tsx index c18f7131..a5030454 100644 --- a/src/pages/editor-page/side-panel/areas-section/areas-list/area-list-item/area-list-item.tsx +++ b/src/pages/editor-page/side-panel/areas-section/areas-list/area-list-item/area-list-item.tsx @@ -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( ({ 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(null); @@ -92,38 +88,12 @@ export const AreaListItem = React.forwardRef( [area.id, updateArea] ); - const focusOnArea = useCallback( + const handleFocusOnArea = useCallback( (event: React.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, - }, - ], - }); - - if (!isDesktop) { - hideSidePanel(); - } + focusOnArea(area.id); }, - [fitView, area.id, setNodes, hideSidePanel, isDesktop] + [focusOnArea, area.id] ); useClickAway(inputRef, saveAreaName); @@ -241,7 +211,9 @@ export const AreaListItem = React.forwardRef( disabled={readonly} />
- +
diff --git a/src/pages/editor-page/side-panel/refs-section/refs-list/relationship-list-item/relationship-list-item-header/relationship-list-item-header.tsx b/src/pages/editor-page/side-panel/refs-section/refs-list/relationship-list-item/relationship-list-item-header/relationship-list-item-header.tsx index 3c338e52..94ff7011 100644 --- a/src/pages/editor-page/side-panel/refs-section/refs-list/relationship-list-item/relationship-list-item-header/relationship-list-item-header.tsx +++ b/src/pages/editor-page/side-panel/refs-section/refs-list/relationship-list-item/relationship-list-item-header/relationship-list-item-header.tsx @@ -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) => { 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< ) : null} - + diff --git a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-header/table-list-item-header.tsx b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-header/table-list-item-header.tsx index 70d55001..8fad1080 100644 --- a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-header/table-list-item-header.tsx +++ b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-header/table-list-item-header.tsx @@ -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 = ({ 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(null); const { listeners } = useSortable({ id: table.id }); @@ -93,38 +89,12 @@ export const TableListItemHeader: React.FC = ({ setEditMode(true); }; - const focusOnTable = useCallback( + const handleFocusOnTable = useCallback( (event: React.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, - }, - ], - }); - - if (!isDesktop) { - hideSidePanel(); - } + focusOnTable(table.id); }, - [fitView, table.id, setNodes, hideSidePanel, isDesktop] + [focusOnTable, table.id] ); const deleteTableHandler = useCallback(() => { @@ -339,7 +309,7 @@ export const TableListItemHeader: React.FC = ({ ) : null} - +