Files
chartdb/src/pages/editor-page/canvas/canvas.tsx

1430 lines
53 KiB
TypeScript

import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
addEdge,
NodePositionChange,
NodeRemoveChange,
NodeDimensionChange,
OnEdgesChange,
OnNodesChange,
NodeTypes,
EdgeTypes,
NodeChange,
} from '@xyflow/react';
import {
ReactFlow,
useEdgesState,
useNodesState,
Background,
BackgroundVariant,
MiniMap,
Controls,
useReactFlow,
useKeyPress,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import equal from 'fast-deep-equal';
import type { TableNodeType } from './table-node/table-node';
import { TableNode } from './table-node/table-node';
import type { RelationshipEdgeType } from './relationship-edge/relationship-edge';
import { RelationshipEdge } from './relationship-edge/relationship-edge';
import { useChartDB } from '@/hooks/use-chartdb';
import {
LEFT_HANDLE_ID_PREFIX,
TARGET_ID_PREFIX,
} from './table-node/table-node-field';
import { Toolbar } from './toolbar/toolbar';
import { useToast } from '@/components/toast/use-toast';
import {
Pencil,
LayoutGrid,
AlertTriangle,
Magnet,
Highlighter,
} from 'lucide-react';
import { Button } from '@/components/button/button';
import { useLayout } from '@/hooks/use-layout';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { Badge } from '@/components/badge/badge';
import { useTheme } from '@/hooks/use-theme';
import { useTranslation } from 'react-i18next';
import type { DBTable } from '@/lib/domain/db-table';
import { MIN_TABLE_SIZE } from '@/lib/domain/db-table';
import { useLocalConfig } from '@/hooks/use-local-config';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/tooltip/tooltip';
import { MarkerDefinitions } from './marker-definitions';
import { CanvasContextMenu } from './canvas-context-menu';
import { areFieldTypesCompatible } from '@/lib/data/data-types/data-types';
import {
calcTableHeight,
findOverlappingTables,
findTableOverlapping,
} from './canvas-utils';
import type { Graph } from '@/lib/graph';
import { removeVertex } from '@/lib/graph';
import type { ChartDBEvent } from '@/context/chartdb-context/chartdb-context';
import { cn, debounce, getOperatingSystem } from '@/lib/utils';
import type { DependencyEdgeType } from './dependency-edge/dependency-edge';
import { DependencyEdge } from './dependency-edge/dependency-edge';
import {
BOTTOM_SOURCE_HANDLE_ID_PREFIX,
TARGET_DEP_PREFIX,
TOP_SOURCE_HANDLE_ID_PREFIX,
} from './table-node/table-node-dependency-indicator';
import { DatabaseType } from '@/lib/domain/database-type';
import { useAlert } from '@/context/alert-context/alert-context';
import { useCanvas } from '@/hooks/use-canvas';
import type { AreaNodeType } from './area-node/area-node';
import { AreaNode } from './area-node/area-node';
import type { Area } from '@/lib/domain/area';
import { updateTablesParentAreas, getVisibleTablesInArea } from './area-utils';
import { CanvasFilter } from './canvas-filter/canvas-filter';
import { useHotkeys } from 'react-hotkeys-hook';
import { ShowAllButton } from './show-all-button';
import { useIsLostInCanvas } from './hooks/use-is-lost-in-canvas';
import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter';
import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter';
import { filterTable } from '@/lib/domain/diagram-filter/filter';
import { defaultSchemas } from '@/lib/data/default-schemas';
const HIGHLIGHTED_EDGE_Z_INDEX = 1;
const DEFAULT_EDGE_Z_INDEX = 0;
export type EdgeType = RelationshipEdgeType | DependencyEdgeType;
export type NodeType = TableNodeType | AreaNodeType;
type AddEdgeParams = Parameters<typeof addEdge<EdgeType>>[0];
const edgeTypes: EdgeTypes = {
'relationship-edge': RelationshipEdge,
'dependency-edge': DependencyEdge,
};
const nodeTypes: NodeTypes = {
table: TableNode,
area: AreaNode,
};
const initialEdges: EdgeType[] = [];
const tableToTableNode = (
table: DBTable,
filter: DiagramFilter | undefined,
databaseType: DatabaseType
): TableNodeType => {
// Always use absolute position for now
const position = { x: table.x, y: table.y };
return {
id: table.id,
type: 'table',
position,
data: {
table,
isOverlapping: false,
},
width: table.width ?? MIN_TABLE_SIZE,
hidden: !filterTable({
table: { id: table.id, schema: table.schema },
filter,
options: { defaultSchema: defaultSchemas[databaseType] },
}),
};
};
const areaToAreaNode = (
area: Area,
tables: DBTable[],
filter?: DiagramFilter,
databaseType?: DatabaseType
): AreaNodeType => {
// Get all tables in this area
const tablesInArea = tables.filter((t) => t.parentAreaId === area.id);
// Check if at least one table in the area is visible
const hasVisibleTable =
tablesInArea.length === 0 ||
tablesInArea.some((table) =>
filterTable({
table: { id: table.id, schema: table.schema },
filter,
options: {
defaultSchema:
defaultSchemas[databaseType || DatabaseType.GENERIC],
},
})
);
return {
id: area.id,
type: 'area',
position: { x: area.x, y: area.y },
data: { area },
width: area.width,
height: area.height,
zIndex: -10,
hidden: !hasVisibleTable,
};
};
export interface CanvasProps {
initialTables: DBTable[];
}
export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const { getEdge, getInternalNode, getNode } = useReactFlow();
const [selectedTableIds, setSelectedTableIds] = useState<string[]>([]);
const [selectedRelationshipIds, setSelectedRelationshipIds] = useState<
string[]
>([]);
const { toast } = useToast();
const { t } = useTranslation();
const { isLostInCanvas } = useIsLostInCanvas();
const {
tables,
areas,
relationships,
createRelationship,
createDependency,
updateTablesState,
removeRelationships,
removeDependencies,
getField,
databaseType,
events,
dependencies,
readonly,
removeArea,
updateArea,
highlightedCustomType,
highlightCustomTypeId,
} = useChartDB();
const { showSidePanel } = useLayout();
const { effectiveTheme } = useTheme();
const { scrollAction, showDependenciesOnCanvas, showMiniMapOnCanvas } =
useLocalConfig();
const { showAlert } = useAlert();
const { isMd: isDesktop } = useBreakpoint('md');
const [highlightOverlappingTables, setHighlightOverlappingTables] =
useState(false);
const {
reorderTables,
fitView,
setOverlapGraph,
overlapGraph,
showFilter,
setShowFilter,
} = useCanvas();
const { filter } = useDiagramFilter();
const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true);
const [nodes, setNodes, onNodesChange] = useNodesState<NodeType>(
initialTables.map((table) =>
tableToTableNode(table, filter, databaseType)
)
);
const [edges, setEdges, onEdgesChange] =
useEdgesState<EdgeType>(initialEdges);
const [snapToGridEnabled, setSnapToGridEnabled] = useState(false);
useEffect(() => {
setIsInitialLoadingNodes(true);
}, [initialTables]);
useEffect(() => {
const initialNodes = initialTables.map((table) =>
tableToTableNode(table, filter, databaseType)
);
if (equal(initialNodes, nodes)) {
setIsInitialLoadingNodes(false);
}
}, [initialTables, nodes, filter, databaseType]);
useEffect(() => {
if (!isInitialLoadingNodes) {
debounce(() => {
fitView({
duration: 200,
padding: 0.1,
maxZoom: 0.8,
});
}, 500)();
}
}, [isInitialLoadingNodes, fitView]);
useEffect(() => {
const targetIndexes: Record<string, number> = relationships.reduce(
(acc, relationship) => {
acc[
`${relationship.targetTableId}${relationship.targetFieldId}`
] = 0;
return acc;
},
{} as Record<string, number>
);
const targetDepIndexes: Record<string, number> = dependencies.reduce(
(acc, dep) => {
acc[dep.tableId] = 0;
return acc;
},
{} as Record<string, number>
);
setEdges([
...relationships.map(
(relationship): RelationshipEdgeType => ({
id: relationship.id,
source: relationship.sourceTableId,
target: relationship.targetTableId,
sourceHandle: `${LEFT_HANDLE_ID_PREFIX}${relationship.sourceFieldId}`,
targetHandle: `${TARGET_ID_PREFIX}${targetIndexes[`${relationship.targetTableId}${relationship.targetFieldId}`]++}_${relationship.targetFieldId}`,
type: 'relationship-edge',
data: { relationship },
})
),
...dependencies.map(
(dep): DependencyEdgeType => ({
id: dep.id,
source: dep.dependentTableId,
target: dep.tableId,
sourceHandle: `${TOP_SOURCE_HANDLE_ID_PREFIX}${dep.dependentTableId}`,
targetHandle: `${TARGET_DEP_PREFIX}${targetDepIndexes[dep.tableId]++}_${dep.tableId}`,
type: 'dependency-edge',
data: { dependency: dep },
hidden:
!showDependenciesOnCanvas &&
databaseType !== DatabaseType.CLICKHOUSE,
})
),
]);
}, [
relationships,
dependencies,
setEdges,
showDependenciesOnCanvas,
databaseType,
]);
useEffect(() => {
const selectedNodesIds = nodes
.filter((node) => node.selected)
.map((node) => node.id);
if (equal(selectedNodesIds, selectedTableIds)) {
return;
}
setSelectedTableIds(selectedNodesIds);
}, [nodes, setSelectedTableIds, selectedTableIds]);
useEffect(() => {
const selectedEdgesIds = edges
.filter((edge) => edge.selected)
.map((edge) => edge.id);
if (equal(selectedEdgesIds, selectedRelationshipIds)) {
return;
}
setSelectedRelationshipIds(selectedEdgesIds);
}, [edges, setSelectedRelationshipIds, selectedRelationshipIds]);
useEffect(() => {
const selectedTableIdsSet = new Set(selectedTableIds);
const selectedRelationshipIdsSet = new Set(selectedRelationshipIds);
setEdges((prevEdges) => {
// Check if any edge needs updating
let hasChanges = false;
const newEdges = prevEdges.map((edge): EdgeType => {
const shouldBeHighlighted =
selectedRelationshipIdsSet.has(edge.id) ||
selectedTableIdsSet.has(edge.source) ||
selectedTableIdsSet.has(edge.target);
const currentHighlighted = edge.data?.highlighted ?? false;
const currentAnimated = edge.animated ?? false;
const currentZIndex = edge.zIndex ?? 0;
// Skip if no changes needed
if (
currentHighlighted === shouldBeHighlighted &&
currentAnimated === shouldBeHighlighted &&
currentZIndex ===
(shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX)
) {
return edge;
}
hasChanges = true;
if (edge.type === 'dependency-edge') {
const dependencyEdge = edge as DependencyEdgeType;
return {
...dependencyEdge,
data: {
...dependencyEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
} else {
const relationshipEdge = edge as RelationshipEdgeType;
return {
...relationshipEdge,
data: {
...relationshipEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
}
});
return hasChanges ? newEdges : prevEdges;
});
}, [selectedRelationshipIds, selectedTableIds, setEdges]);
useEffect(() => {
setNodes((prevNodes) => {
const newNodes = [
...tables.map((table) => {
const isOverlapping =
(overlapGraph.graph.get(table.id) ?? []).length > 0;
const node = tableToTableNode(table, filter, databaseType);
// Check if table uses the highlighted custom type
let hasHighlightedCustomType = false;
if (highlightedCustomType) {
hasHighlightedCustomType = table.fields.some(
(field) =>
field.type.name === highlightedCustomType.name
);
}
return {
...node,
data: {
...node.data,
isOverlapping,
highlightOverlappingTables,
hasHighlightedCustomType,
},
};
}),
...areas.map((area) =>
areaToAreaNode(area, tables, filter, databaseType)
),
];
// Check if nodes actually changed
if (equal(prevNodes, newNodes)) {
return prevNodes;
}
return newNodes;
});
}, [
tables,
areas,
setNodes,
filter,
databaseType,
overlapGraph.lastUpdated,
overlapGraph.graph,
highlightOverlappingTables,
highlightedCustomType,
]);
const prevFilter = useRef<DiagramFilter | undefined>(undefined);
useEffect(() => {
if (!equal(filter, prevFilter.current)) {
debounce(() => {
const overlappingTablesInDiagram = findOverlappingTables({
tables: tables.filter((table) =>
filterTable({
table: {
id: table.id,
schema: table.schema,
},
filter,
options: {
defaultSchema: defaultSchemas[databaseType],
},
})
),
});
setOverlapGraph(overlappingTablesInDiagram);
fitView({
duration: 500,
padding: 0.1,
maxZoom: 0.8,
});
}, 500)();
prevFilter.current = filter;
}
}, [filter, fitView, tables, setOverlapGraph, databaseType]);
// Handle parent area updates when tables move
const tablePositions = useMemo(
() => tables.map((t) => ({ id: t.id, x: t.x, y: t.y })),
[tables]
);
useEffect(() => {
const checkParentAreas = debounce(() => {
const updatedTables = updateTablesParentAreas(
tables,
areas,
filter,
databaseType
);
const needsUpdate: Array<{
id: string;
parentAreaId: string | null;
}> = [];
updatedTables.forEach((newTable, index) => {
const oldTable = tables[index];
if (
oldTable &&
(!!newTable.parentAreaId || !!oldTable.parentAreaId) &&
newTable.parentAreaId !== oldTable.parentAreaId
) {
needsUpdate.push({
id: newTable.id,
parentAreaId: newTable.parentAreaId || null,
});
}
});
if (needsUpdate.length > 0) {
updateTablesState(
(currentTables) =>
currentTables.map((table) => {
const update = needsUpdate.find(
(u) => u.id === table.id
);
if (update) {
return {
id: table.id,
parentAreaId: update.parentAreaId,
};
}
return table;
}),
{ updateHistory: false }
);
}
}, 300);
checkParentAreas();
}, [
tablePositions,
areas,
updateTablesState,
tables,
filter,
databaseType,
]);
const onConnectHandler = useCallback(
async (params: AddEdgeParams) => {
if (
params.sourceHandle?.startsWith?.(
TOP_SOURCE_HANDLE_ID_PREFIX
) ||
params.sourceHandle?.startsWith?.(
BOTTOM_SOURCE_HANDLE_ID_PREFIX
)
) {
const tableId = params.target;
const dependentTableId = params.source;
createDependency({
tableId,
dependentTableId,
});
return;
}
const sourceTableId = params.source;
const targetTableId = params.target;
const sourceFieldId = params.sourceHandle?.split('_')?.pop() ?? '';
const targetFieldId = params.targetHandle?.split('_')?.pop() ?? '';
const sourceField = getField(sourceTableId, sourceFieldId);
const targetField = getField(targetTableId, targetFieldId);
if (!sourceField || !targetField) {
return;
}
if (
!areFieldTypesCompatible(
sourceField.type,
targetField.type,
databaseType
)
) {
toast({
title: 'Field types are not compatible',
variant: 'destructive',
description:
'Relationships can only be created between compatible field types',
});
return;
}
createRelationship({
sourceTableId,
targetTableId,
sourceFieldId,
targetFieldId,
});
},
[createRelationship, createDependency, getField, toast, databaseType]
);
const onEdgesChangeHandler: OnEdgesChange<EdgeType> = useCallback(
(changes) => {
let changesToApply = changes;
if (readonly) {
changesToApply = changesToApply.filter(
(change) => change.type !== 'remove'
);
}
const removeChanges: NodeRemoveChange[] = changesToApply.filter(
(change) => change.type === 'remove'
) as NodeRemoveChange[];
const edgesToRemove = removeChanges
.map((change) => getEdge(change.id) as EdgeType | undefined)
.filter((edge) => !!edge);
const relationshipsToRemove: string[] = (
edgesToRemove.filter(
(edge) => edge?.type === 'relationship-edge'
) as RelationshipEdgeType[]
).map((edge) => edge?.data?.relationship?.id as string);
const dependenciesToRemove: string[] = (
edgesToRemove.filter(
(edge) => edge?.type === 'dependency-edge'
) as DependencyEdgeType[]
).map((edge) => edge?.data?.dependency?.id as string);
if (relationshipsToRemove.length > 0) {
removeRelationships(relationshipsToRemove);
}
if (dependenciesToRemove.length > 0) {
removeDependencies(dependenciesToRemove);
}
return onEdgesChange(changesToApply);
},
[
getEdge,
onEdgesChange,
removeRelationships,
removeDependencies,
readonly,
]
);
const updateOverlappingGraphOnChanges = useCallback(
({
positionChanges,
sizeChanges,
}: {
positionChanges: NodePositionChange[];
sizeChanges: NodeDimensionChange[];
}) => {
if (positionChanges.length > 0 || sizeChanges.length > 0) {
let newOverlappingGraph: Graph<string> = overlapGraph;
for (const change of positionChanges) {
const node = getNode(change.id) as NodeType;
if (!node) {
continue;
}
if (node.type !== 'table') {
continue;
}
newOverlappingGraph = findTableOverlapping(
{ node: node as TableNodeType },
{
nodes: nodes.filter(
(node) => !node.hidden && node.type === 'table'
) as TableNodeType[],
},
newOverlappingGraph
);
}
for (const change of sizeChanges) {
const node = getNode(change.id) as NodeType;
if (!node) {
continue;
}
if (node.type !== 'table') {
continue;
}
newOverlappingGraph = findTableOverlapping(
{ node: node as TableNodeType },
{
nodes: nodes.filter(
(node) => !node.hidden && node.type === 'table'
) as TableNodeType[],
},
newOverlappingGraph
);
}
setOverlapGraph(newOverlappingGraph);
}
},
[nodes, overlapGraph, setOverlapGraph, getNode]
);
const updateOverlappingGraphOnChangesDebounced = debounce(
updateOverlappingGraphOnChanges,
200
);
const findRelevantNodesChanges = useCallback(
(changes: NodeChange<NodeType>[], type: NodeType['type']) => {
const relevantChanges = changes.filter((change) => {
if (
(change.type === 'position' &&
!change.dragging &&
change.position?.x !== undefined &&
change.position?.y !== undefined &&
!isNaN(change.position.x) &&
!isNaN(change.position.y)) ||
(change.type === 'dimensions' && change.resizing) ||
change.type === 'remove'
) {
const node = getNode(change.id);
if (!node) {
return false;
}
if (node.type !== type) {
return false;
}
return true;
}
return false;
});
const positionChanges: NodePositionChange[] =
relevantChanges.filter(
(change) =>
change.type === 'position' &&
!change.dragging &&
change.position?.x !== undefined &&
change.position?.y !== undefined &&
!isNaN(change.position.x) &&
!isNaN(change.position.y)
) as NodePositionChange[];
const removeChanges: NodeRemoveChange[] = relevantChanges.filter(
(change) => change.type === 'remove'
) as NodeRemoveChange[];
const sizeChanges: NodeDimensionChange[] = relevantChanges.filter(
(change) => change.type === 'dimensions' && change.resizing
) as NodeDimensionChange[];
return {
positionChanges,
removeChanges,
sizeChanges,
};
},
[getNode]
);
const onNodesChangeHandler: OnNodesChange<NodeType> = useCallback(
(changes) => {
let changesToApply = changes;
if (readonly) {
changesToApply = changesToApply.filter(
(change) => change.type !== 'remove'
);
}
// Handle area drag changes - add child table movements for visual feedback only
const areaDragChanges = changesToApply.filter((change) => {
if (change.type === 'position') {
const node = getNode(change.id);
return node?.type === 'area' && change.dragging;
}
return false;
}) as NodePositionChange[];
// Add visual position changes for child tables during area dragging
if (areaDragChanges.length > 0) {
const additionalChanges: NodePositionChange[] = [];
areaDragChanges.forEach((areaChange) => {
const currentArea = areas.find(
(a) => a.id === areaChange.id
);
if (currentArea && areaChange.position) {
const deltaX = areaChange.position.x - currentArea.x;
const deltaY = areaChange.position.y - currentArea.y;
// Find child tables and create visual position changes
const childTables = tables.filter(
(table) => table.parentAreaId === areaChange.id
);
childTables.forEach((table) => {
additionalChanges.push({
id: table.id,
type: 'position',
position: {
x: table.x + deltaX,
y: table.y + deltaY,
},
dragging: true,
});
});
}
});
// Add visual changes to React Flow
changesToApply = [...changesToApply, ...additionalChanges];
}
// Handle table changes - only update storage when NOT dragging
const { positionChanges, removeChanges, sizeChanges } =
findRelevantNodesChanges(changesToApply, 'table');
if (
positionChanges.length > 0 ||
removeChanges.length > 0 ||
sizeChanges.length > 0
) {
updateTablesState((currentTables) => {
// First update positions
const updatedTables = currentTables
.map((currentTable) => {
const positionChange = positionChanges.find(
(change) => change.id === currentTable.id
);
const sizeChange = sizeChanges.find(
(change) => change.id === currentTable.id
);
if (positionChange || sizeChange) {
const x = positionChange?.position?.x;
const y = positionChange?.position?.y;
return {
...currentTable,
...(positionChange &&
x !== undefined &&
y !== undefined &&
!isNaN(x) &&
!isNaN(y)
? {
x,
y,
}
: {}),
...(sizeChange
? {
width:
sizeChange.dimensions
?.width ??
currentTable.width,
}
: {}),
};
}
return currentTable;
})
.filter(
(table) =>
!removeChanges.some(
(change) => change.id === table.id
)
);
return updatedTables;
});
}
updateOverlappingGraphOnChangesDebounced({
positionChanges,
sizeChanges,
});
// Handle area changes
const {
positionChanges: areaPositionChanges,
removeChanges: areaRemoveChanges,
sizeChanges: areaSizeChanges,
} = findRelevantNodesChanges(changesToApply, 'area');
if (
areaPositionChanges.length > 0 ||
areaRemoveChanges.length > 0 ||
areaSizeChanges.length > 0
) {
const areasUpdates: Record<string, Partial<Area>> = {};
// Handle area position changes and move child tables (only when drag ends)
areaPositionChanges.forEach((change) => {
if (change.type === 'position' && change.position) {
areasUpdates[change.id] = {
...areasUpdates[change.id],
x: change.position.x,
y: change.position.y,
};
if (areaSizeChanges.length !== 0) {
// If there are size changes, we don't need to move child tables
return;
}
const currentArea = areas.find(
(a) => a.id === change.id
);
if (currentArea) {
const deltaX = change.position.x - currentArea.x;
const deltaY = change.position.y - currentArea.y;
// Only move visible child tables
const childTables = getVisibleTablesInArea(
change.id,
tables,
filter,
databaseType
);
// Update child table positions in storage
if (childTables.length > 0) {
updateTablesState((currentTables) =>
currentTables.map((table) => {
// Only move visible tables that are in this area
const isVisible = filterTable({
table: {
id: table.id,
schema: table.schema,
},
filter,
options: {
defaultSchema:
defaultSchemas[
databaseType
],
},
});
if (
table.parentAreaId === change.id &&
isVisible
) {
return {
id: table.id,
x: table.x + deltaX,
y: table.y + deltaY,
};
}
return table;
})
);
}
}
}
});
// Handle area size changes
areaSizeChanges.forEach((change) => {
if (change.type === 'dimensions' && change.dimensions) {
areasUpdates[change.id] = {
...areasUpdates[change.id],
width: change.dimensions.width,
height: change.dimensions.height,
};
}
});
areaRemoveChanges.forEach((change) => {
updateTablesState((currentTables) =>
currentTables.map((table) => {
if (table.parentAreaId === change.id) {
return {
...table,
parentAreaId: null,
};
}
return table;
})
);
removeArea(change.id);
delete areasUpdates[change.id];
});
// Apply area updates to storage
if (Object.keys(areasUpdates).length > 0) {
for (const [id, updates] of Object.entries(areasUpdates)) {
updateArea(id, updates);
}
}
}
return onNodesChange(changesToApply);
},
[
onNodesChange,
updateTablesState,
updateOverlappingGraphOnChangesDebounced,
findRelevantNodesChanges,
updateArea,
removeArea,
readonly,
tables,
areas,
getNode,
databaseType,
filter,
]
);
const eventConsumer = useCallback(
(event: ChartDBEvent) => {
let newOverlappingGraph: Graph<string> = overlapGraph;
if (event.action === 'add_tables') {
for (const table of event.data.tables) {
newOverlappingGraph = findTableOverlapping(
{ node: getNode(table.id) as TableNodeType },
{
nodes: nodes.filter(
(node) => !node.hidden && node.type === 'table'
) as TableNodeType[],
},
overlapGraph
);
}
setOverlapGraph(newOverlappingGraph);
} else if (event.action === 'remove_tables') {
for (const tableId of event.data.tableIds) {
newOverlappingGraph = removeVertex(
newOverlappingGraph,
tableId
);
}
setOverlapGraph(newOverlappingGraph);
} else if (
event.action === 'update_table' &&
event.data.table.width
) {
const node = getNode(event.data.id) as TableNodeType;
const measured = {
...node.measured,
width: event.data.table.width,
};
newOverlappingGraph = findTableOverlapping(
{
node: {
...node,
measured,
},
},
{
nodes: nodes.filter(
(node) => !node.hidden && node.type === 'table'
) as TableNodeType[],
},
overlapGraph
);
setOverlapGraph(newOverlappingGraph);
setTimeout(() => {
setNodes((prevNodes) =>
prevNodes.map((n) => {
if (n.id === event.data.id) {
return {
...n,
measured,
};
}
return n;
})
);
}, 0);
} else if (
event.action === 'add_field' ||
event.action === 'remove_field'
) {
const node = getNode(event.data.tableId) as TableNodeType;
const measured = {
...(node.measured ?? {}),
height: calcTableHeight({
...node.data.table,
fields: event.data.fields,
}),
};
newOverlappingGraph = findTableOverlapping(
{
node: {
...node,
measured,
},
},
{
nodes: nodes.filter(
(node) => !node.hidden && node.type === 'table'
) as TableNodeType[],
},
overlapGraph
);
setOverlapGraph(newOverlappingGraph);
} else if (event.action === 'load_diagram') {
const diagramTables = event.data.diagram.tables ?? [];
const overlappingTablesInDiagram = findOverlappingTables({
tables: diagramTables.filter((table) =>
filterTable({
table: {
id: table.id,
schema: table.schema,
},
filter,
options: {
defaultSchema: defaultSchemas[databaseType],
},
})
),
});
setOverlapGraph(overlappingTablesInDiagram);
}
},
[
overlapGraph,
setOverlapGraph,
getNode,
nodes,
filter,
setNodes,
databaseType,
]
);
events.useSubscription(eventConsumer);
const isLoadingDOM =
tables.length > 0 ? !getInternalNode(tables[0].id) : false;
const showReorderConfirmation = useCallback(() => {
showAlert({
title: t('reorder_diagram_alert.title'),
description: t('reorder_diagram_alert.description'),
actionLabel: t('reorder_diagram_alert.reorder'),
closeLabel: t('reorder_diagram_alert.cancel'),
onAction: reorderTables,
});
}, [t, showAlert, reorderTables]);
const hasOverlappingTables = useMemo(
() =>
Array.from(overlapGraph.graph).some(
([, value]) => value.length > 0
),
[overlapGraph]
);
const pulseOverlappingTables = useCallback(() => {
setHighlightOverlappingTables(true);
setTimeout(() => setHighlightOverlappingTables(false), 600);
}, []);
const shiftPressed = useKeyPress('Shift');
const operatingSystem = getOperatingSystem();
useHotkeys(
operatingSystem === 'mac' ? 'meta+f' : 'ctrl+f',
() => {
setShowFilter((prev) => !prev);
},
{
preventDefault: true,
enableOnFormTags: true,
},
[]
);
return (
<CanvasContextMenu>
<div className="relative flex h-full" id="canvas">
<ReactFlow
onlyRenderVisibleElements
colorMode={effectiveTheme}
className="canvas-cursor-default nodes-animated"
nodes={nodes}
edges={edges}
onNodesChange={onNodesChangeHandler}
onEdgesChange={onEdgesChangeHandler}
maxZoom={5}
minZoom={0.1}
onConnect={onConnectHandler}
proOptions={{
hideAttribution: true,
}}
fitView={false}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{
animated: false,
type: 'relationship-edge',
}}
panOnScroll={scrollAction === 'pan'}
snapToGrid={shiftPressed || snapToGridEnabled}
snapGrid={[20, 20]}
>
<Controls
position="top-left"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<div className="flex flex-col items-center gap-2 md:flex-row">
{!readonly ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
className="size-8 p-1 shadow-none"
onClick={
showReorderConfirmation
}
>
<LayoutGrid className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t('toolbar.reorder_diagram')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
className={cn(
'size-8 p-1 shadow-none',
snapToGridEnabled ||
shiftPressed
? 'bg-pink-600 text-white hover:bg-pink-500 dark:hover:bg-pink-700 hover:text-white'
: ''
)}
onClick={() =>
setSnapToGridEnabled(
(prev) => !prev
)
}
>
<Magnet className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t('snap_to_grid_tooltip', {
key:
operatingSystem === 'mac'
? '⇧'
: 'Shift',
})}
</TooltipContent>
</Tooltip>
{highlightedCustomType ? (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
className="size-8 border border-yellow-400 bg-yellow-200 p-1 shadow-none hover:bg-yellow-300 dark:border-yellow-700 dark:bg-yellow-800 dark:hover:bg-yellow-700"
onClick={() =>
highlightCustomTypeId(
undefined
)
}
>
<Highlighter className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t(
'toolbar.custom_type_highlight_tooltip',
{
typeName:
highlightedCustomType.name,
}
)}
</TooltipContent>
</Tooltip>
) : null}
</>
) : null}
<div
className={`transition-opacity duration-300 ease-in-out ${
hasOverlappingTables
? 'opacity-100'
: 'opacity-0'
}`}
>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="default"
className="size-8 p-1 shadow-none"
onClick={pulseOverlappingTables}
>
<AlertTriangle className="size-4 text-white" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t(
'toolbar.highlight_overlapping_tables'
)}
</TooltipContent>
</Tooltip>
</div>
</div>
</Controls>
{isLoadingDOM ? (
<Controls
position="top-center"
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Badge
variant="default"
className="bg-pink-600 text-white"
>
{t('loading_diagram')}
</Badge>
</Controls>
) : null}
{!isDesktop && !readonly ? (
<Controls
position="bottom-left"
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Button
className="size-11 bg-pink-600 p-2 hover:bg-pink-500"
onClick={showSidePanel}
>
<Pencil />
</Button>
</Controls>
) : null}
{isLostInCanvas ? (
<Controls
position={
isDesktop ? 'bottom-center' : 'top-center'
}
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
style={{
[isDesktop ? 'bottom' : 'top']: isDesktop
? '70px'
: '70px',
}}
>
<ShowAllButton />
</Controls>
) : null}
<Controls
position={isDesktop ? 'bottom-center' : 'top-center'}
orientation="horizontal"
showZoom={false}
showFitView={false}
showInteractive={false}
className="!shadow-none"
>
<Toolbar readonly={readonly} />
</Controls>
{showMiniMapOnCanvas && (
<MiniMap
style={{
width: isDesktop ? 100 : 60,
height: isDesktop ? 100 : 60,
}}
/>
)}
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
/>
{showFilter ? (
<CanvasFilter onClose={() => setShowFilter(false)} />
) : null}
</ReactFlow>
<MarkerDefinitions />
</div>
</CanvasContextMenu>
);
};