Compare commits

...

1 Commits

Author SHA1 Message Date
johnnyfish
34d6b0e525 feat: add zoom navigation buttons to canvas filter for tables and areas 2025-09-04 14:48:51 +03:00
3 changed files with 198 additions and 65 deletions

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,9 +160,9 @@ 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
@@ -170,29 +170,40 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
...node,
hidden: false,
selected: true,
data: {
...node.data,
highlightTable: true,
},
}
: {
...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) =>
node.id === tableId
? {
...node,
data: {
...node.data,
highlightTable: false,
},
}
: node
)
);
}, 600);
},
[fitView, setNodes]
[setNodes]
);
// Handle node click
@@ -202,13 +213,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 { useReactFlow } from '@xyflow/react';
import type {
AreaContext,
NodeContext,
@@ -40,6 +41,7 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
addTablesToFilter,
removeTablesFromFilter,
}) => {
const { fitView, setNodes } = useReactFlow();
if (node.type === 'schema') {
const context = node.context as SchemaContext;
const schemaVisible = context.visible;
@@ -81,37 +83,105 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
const isUngrouped = context.isUngrouped;
const areaId = context.id;
const handleZoomToArea = (e: React.MouseEvent) => {
e.stopPropagation();
// Get all table nodes in this area
const tableNodes = isUngrouped
? document.querySelectorAll('[data-id]:not([data-area-id])')
: document.querySelectorAll(`[data-area-id="${areaId}"]`);
const nodeIds: string[] = [];
tableNodes.forEach((node) => {
const nodeId = node.getAttribute('data-id');
if (nodeId) nodeIds.push(nodeId);
});
// Make sure the tables in the area are visible
setNodes((nodes) =>
nodes.map((node) => {
const shouldHighlight = isUngrouped
? node.type === 'table' &&
!(node.data as { table?: { parentAreaId?: string } })
?.table?.parentAreaId
: node.type === 'area'
? node.id === areaId
: (node.data as { table?: { parentAreaId?: string } })
?.table?.parentAreaId === areaId;
return {
...node,
hidden: shouldHighlight ? false : node.hidden,
selected: false,
};
})
);
// Focus on the area or its tables
setTimeout(() => {
if (!isUngrouped) {
// Zoom to the area node itself
fitView({
duration: 500,
maxZoom: 0.6,
minZoom: 0.3,
nodes: [{ id: areaId }],
padding: 0.2,
});
} else {
// Zoom to all ungrouped tables
fitView({
duration: 500,
maxZoom: 0.6,
minZoom: 0.3,
padding: 0.2,
});
}
}, 100);
};
return (
<Button
variant="ghost"
size="sm"
className="size-7 h-fit p-0"
onClick={(e) => {
e.stopPropagation();
// Toggle all tables in this area
if (areaVisible) {
// Hide all tables in this area
removeTablesFromFilter({
filterCallback: (table) =>
(isUngrouped && !table.areaId) ||
(!isUngrouped && table.areaId === areaId),
});
} else {
// Show all tables in this area
addTablesToFilter({
filterCallback: (table) =>
(isUngrouped && !table.areaId) ||
(!isUngrouped && table.areaId === areaId),
});
}
}}
>
{!areaVisible ? (
<EyeOff className="size-3.5 text-muted-foreground" />
) : (
<Eye className="size-3.5" />
)}
</Button>
<div className="flex gap-0.5">
<Button
variant="ghost"
size="sm"
className="size-7 h-fit p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={handleZoomToArea}
disabled={!areaVisible}
>
<CircleDotDashed className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="size-7 h-fit p-0"
onClick={(e) => {
e.stopPropagation();
// Toggle all tables in this area
if (areaVisible) {
// Hide all tables in this area
removeTablesFromFilter({
filterCallback: (table) =>
(isUngrouped && !table.areaId) ||
(!isUngrouped && table.areaId === areaId),
});
} else {
// Show all tables in this area
addTablesToFilter({
filterCallback: (table) =>
(isUngrouped && !table.areaId) ||
(!isUngrouped && table.areaId === areaId),
});
}
}}
>
{!areaVisible ? (
<EyeOff className="size-3.5 text-muted-foreground" />
) : (
<Eye className="size-3.5" />
)}
</Button>
</div>
);
}
@@ -120,22 +190,68 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
const context = node.context as TableContext;
const tableVisible = context.visible;
const handleZoomToTable = (e: React.MouseEvent) => {
e.stopPropagation();
// Make sure the table is visible and selected
setNodes((nodes) =>
nodes.map((node) =>
node.id === tableId
? {
...node,
hidden: false,
selected: true,
}
: {
...node,
selected: false,
}
)
);
// Focus on the table with less zoom
setTimeout(() => {
fitView({
duration: 500,
maxZoom: 0.7,
minZoom: 0.5,
nodes: [
{
id: tableId,
},
],
padding: 0.3,
});
}, 100);
};
return (
<Button
variant="ghost"
size="sm"
className="size-7 h-fit p-0"
onClick={(e) => {
e.stopPropagation();
toggleTableFilter(tableId);
}}
>
{!tableVisible ? (
<EyeOff className="size-3.5 text-muted-foreground" />
) : (
<Eye className="size-3.5" />
)}
</Button>
<div className="flex gap-0.5">
<Button
variant="ghost"
size="sm"
className="size-7 h-fit p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={handleZoomToTable}
disabled={!tableVisible}
>
<CircleDotDashed className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="size-7 h-fit p-0"
onClick={(e) => {
e.stopPropagation();
toggleTableFilter(tableId);
}}
>
{!tableVisible ? (
<EyeOff className="size-3.5 text-muted-foreground" />
) : (
<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,