diff --git a/package-lock.json b/package-lock.json index d3f5e0d8..2a29552a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@radix-ui/react-tooltip": "^1.1.2", "@uidotdev/usehooks": "^2.4.1", "@xyflow/react": "^12.0.4", + "ahooks": "^3.8.1", "ai": "^3.3.14", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -3294,6 +3295,38 @@ "node": ">=12" } }, + "node_modules/ahooks": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.8.1.tgz", + "integrity": "sha512-JoP9+/RWO7MnI/uSKdvQ8WB10Y3oo1PjLv+4Sv4Vpm19Z86VUMdXh+RhWvMGxZZs06sq2p0xVtFk8Oh5ZObsoA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "dayjs": "^1.9.1", + "intersection-observer": "^0.12.0", + "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "react-fast-compare": "^3.2.2", + "resize-observer-polyfill": "^1.5.1", + "screenfull": "^5.0.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/ahooks/node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/ai": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/ai/-/ai-3.4.0.tgz", @@ -4311,6 +4344,12 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -9182,6 +9221,12 @@ "node": ">= 4" } }, + "node_modules/intersection-observer": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz", + "integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==", + "license": "Apache-2.0" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9260,7 +9305,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/loose-envify": { @@ -10117,6 +10161,12 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, "node_modules/react-hotkeys-hook": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.5.1.tgz", diff --git a/package.json b/package.json index 87df7d5e..499f9bcb 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-tooltip": "^1.1.2", "@uidotdev/usehooks": "^2.4.1", "@xyflow/react": "^12.0.4", + "ahooks": "^3.8.1", "ai": "^3.3.14", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/src/context/chartdb-context/chartdb-context.tsx b/src/context/chartdb-context/chartdb-context.tsx index 7d0c3722..7c6ef1e7 100644 --- a/src/context/chartdb-context/chartdb-context.tsx +++ b/src/context/chartdb-context/chartdb-context.tsx @@ -8,6 +8,58 @@ import type { DBRelationship } from '@/lib/domain/db-relationship'; import type { Diagram } from '@/lib/domain/diagram'; import type { DatabaseEdition } from '@/lib/domain/database-edition'; import type { DBSchema } from '@/lib/domain/db-schema'; +import { EventEmitter } from 'ahooks/lib/useEventEmitter'; + +export type ChartDBEventType = + | 'add_tables' + | 'update_table' + | 'remove_tables' + | 'add_field' + | 'remove_field' + | 'load_diagram'; + +export type ChartDBEventBase = { + action: T; + data: D; +}; + +export type CreateTableEvent = ChartDBEventBase< + 'add_tables', + { tables: DBTable[] } +>; + +export type UpdateTableEvent = ChartDBEventBase< + 'update_table', + { id: string; table: Partial } +>; + +export type RemoveTableEvent = ChartDBEventBase< + 'remove_tables', + { tableIds: string[] } +>; + +export type AddFieldEvent = ChartDBEventBase< + 'add_field', + { tableId: string; field: DBField; fields: DBField[] } +>; + +export type RemoveFieldEvent = ChartDBEventBase< + 'remove_field', + { tableId: string; fieldId: string; fields: DBField[] } +>; + +export type LoadDiagramEvent = ChartDBEventBase< + 'load_diagram', + { diagram: Diagram } +>; + +export type ChartDBEvent = + | CreateTableEvent + | UpdateTableEvent + | RemoveTableEvent + | AddFieldEvent + | RemoveFieldEvent + | LoadDiagramEvent; export interface ChartDBContext { diagramId: string; @@ -17,6 +69,7 @@ export interface ChartDBContext { schemas: DBSchema[]; relationships: DBRelationship[]; currentDiagram: Diagram; + events: EventEmitter; filteredSchemas?: string[]; filterSchemas: (schemaIds: string[]) => void; @@ -154,6 +207,7 @@ export const chartDBContext = createContext({ createdAt: new Date(), updatedAt: new Date(), }, + events: new EventEmitter(), // General operations updateDiagramId: emptyFn, diff --git a/src/context/chartdb-context/chartdb-provider.tsx b/src/context/chartdb-context/chartdb-provider.tsx index da7d3ef2..c3fa5e65 100644 --- a/src/context/chartdb-context/chartdb-provider.tsx +++ b/src/context/chartdb-context/chartdb-provider.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { DBTable } from '@/lib/domain/db-table'; import { deepCopy, generateId } from '@/lib/utils'; import { randomColor } from '@/lib/colors'; -import type { ChartDBContext } from './chartdb-context'; +import type { ChartDBContext, ChartDBEvent } from './chartdb-context'; import { chartDBContext } from './chartdb-context'; import { DatabaseType } from '@/lib/domain/database-type'; import type { DBField } from '@/lib/domain/db-field'; @@ -18,11 +18,13 @@ import type { DBSchema } from '@/lib/domain/db-schema'; import { schemaNameToSchemaId } from '@/lib/domain/db-schema'; import { useLocalConfig } from '@/hooks/use-local-config'; import { defaultSchemas } from '@/lib/data/default-schemas'; +import { useEventEmitter } from 'ahooks'; export const ChartDBProvider: React.FC = ({ children, }) => { const db = useStorage(); + const events = useEventEmitter(); const navigate = useNavigate(); const { setSchemasFilter, schemasFilter } = useLocalConfig(); const { addUndoAction, resetRedoStack, resetUndoStack } = @@ -272,6 +274,8 @@ export const ChartDBProvider: React.FC = ({ ...tables.map((table) => db.addTable({ diagramId, table })), ]); + events.emit({ action: 'add_tables', data: { tables } }); + if (options.updateHistory) { addUndoAction({ action: 'addTables', @@ -281,7 +285,7 @@ export const ChartDBProvider: React.FC = ({ resetRedoStack(); } }, - [db, diagramId, setTables, addUndoAction, resetRedoStack] + [db, diagramId, setTables, addUndoAction, resetRedoStack, events] ); const addTable: ChartDBContext['addTable'] = useCallback( @@ -352,6 +356,8 @@ export const ChartDBProvider: React.FC = ({ tables.filter((table) => !ids.includes(table.id)) ); + events.emit({ action: 'remove_tables', data: { tableIds: ids } }); + const updatedAt = new Date(); setDiagramUpdatedAt(updatedAt); await Promise.all([ @@ -381,6 +387,7 @@ export const ChartDBProvider: React.FC = ({ resetRedoStack, getTable, relationships, + events, ] ); @@ -401,6 +408,12 @@ export const ChartDBProvider: React.FC = ({ setTables((tables) => tables.map((t) => (t.id === id ? { ...t, ...table } : t)) ); + + events.emit({ + action: 'update_table', + data: { id, table }, + }); + const updatedAt = new Date(); setDiagramUpdatedAt(updatedAt); await Promise.all([ @@ -417,7 +430,15 @@ export const ChartDBProvider: React.FC = ({ resetRedoStack(); } }, - [db, setTables, addUndoAction, resetRedoStack, getTable, diagramId] + [ + db, + setTables, + addUndoAction, + resetRedoStack, + getTable, + diagramId, + events, + ] ); const updateTablesState: ChartDBContext['updateTablesState'] = useCallback( @@ -471,6 +492,11 @@ export const ChartDBProvider: React.FC = ({ setTables(updateTables); + events.emit({ + action: 'remove_tables', + data: { tableIds: tablesToDelete.map((t) => t.id) }, + }); + const promises = []; for (const updatedTable of updatedTables) { promises.push( @@ -519,6 +545,7 @@ export const ChartDBProvider: React.FC = ({ addUndoAction, resetRedoStack, relationships, + events, ] ); @@ -593,6 +620,7 @@ export const ChartDBProvider: React.FC = ({ fieldId: string, options = { updateHistory: true } ) => { + const fields = getTable(tableId)?.fields ?? []; const prevField = getField(tableId, fieldId); setTables((tables) => tables.map((table) => @@ -607,6 +635,15 @@ export const ChartDBProvider: React.FC = ({ ) ); + events.emit({ + action: 'remove_field', + data: { + tableId: tableId, + fieldId, + fields: fields.filter((f) => f.id !== fieldId), + }, + }); + const table = await db.getTable({ diagramId, id: tableId }); if (!table) { return; @@ -634,7 +671,16 @@ export const ChartDBProvider: React.FC = ({ resetRedoStack(); } }, - [db, diagramId, setTables, addUndoAction, resetRedoStack, getField] + [ + db, + diagramId, + setTables, + addUndoAction, + resetRedoStack, + getField, + getTable, + events, + ] ); const addField: ChartDBContext['addField'] = useCallback( @@ -643,6 +689,7 @@ export const ChartDBProvider: React.FC = ({ field: DBField, options = { updateHistory: true } ) => { + const fields = getTable(tableId)?.fields ?? []; setTables((tables) => tables.map((table) => table.id === tableId @@ -651,6 +698,15 @@ export const ChartDBProvider: React.FC = ({ ) ); + events.emit({ + action: 'add_field', + data: { + tableId: tableId, + field, + fields: [...fields, field], + }, + }); + const table = await db.getTable({ diagramId, id: tableId }); if (!table) { @@ -679,7 +735,15 @@ export const ChartDBProvider: React.FC = ({ resetRedoStack(); } }, - [db, diagramId, setTables, addUndoAction, resetRedoStack] + [ + db, + diagramId, + setTables, + addUndoAction, + resetRedoStack, + events, + getTable, + ] ); const createField: ChartDBContext['createField'] = useCallback( @@ -1138,6 +1202,8 @@ export const ChartDBProvider: React.FC = ({ setRelationships(diagram?.relationships ?? []); setDiagramCreatedAt(diagram.createdAt); setDiagramUpdatedAt(diagram.updatedAt); + + events.emit({ action: 'load_diagram', data: { diagram } }); } return diagram; @@ -1152,6 +1218,7 @@ export const ChartDBProvider: React.FC = ({ setRelationships, setDiagramCreatedAt, setDiagramUpdatedAt, + events, ] ); @@ -1166,6 +1233,7 @@ export const ChartDBProvider: React.FC = ({ currentDiagram, schemas, filteredSchemas, + events, filterSchemas, updateDiagramId, updateDiagramName, diff --git a/src/lib/graph.ts b/src/lib/graph.ts new file mode 100644 index 00000000..36a3662e --- /dev/null +++ b/src/lib/graph.ts @@ -0,0 +1,73 @@ +export interface Graph { + graph: Map; + lastUpdated: number; +} + +export const createGraph = (): Graph => ({ + graph: new Map(), + lastUpdated: Date.now(), +}); + +export const addVertex = (graph: Graph, vertex: T): Graph => { + if (!graph.graph.has(vertex)) { + graph.graph.set(vertex, []); + } + return { ...graph, lastUpdated: Date.now() }; +}; + +export const addEdge = ( + graph: Graph, + source: T, + destination: T +): Graph => { + if (!graph.graph.has(source)) { + addVertex(graph, source); + } + if (!graph.graph.has(destination)) { + addVertex(graph, destination); + } + + if (!graph.graph.get(source)?.includes(destination)) { + graph.graph.get(source)?.push(destination); + } + + if (!graph.graph.get(destination)?.includes(source)) { + graph.graph.get(destination)?.push(source); + } + + return { ...graph, lastUpdated: Date.now() }; +}; + +export const getNeighbors = (graph: Graph, vertex: T): T[] | undefined => + graph.graph.get(vertex); + +export const removeVertex = (graph: Graph, vertex: T): Graph => { + graph.graph.delete(vertex); + graph.graph.forEach((neighbors) => { + const index = neighbors.indexOf(vertex); + if (index !== -1) { + neighbors.splice(index, 1); // Remove the edge + } + }); + return { ...graph, lastUpdated: Date.now() }; +}; + +export const removeEdge = ( + graph: Graph, + source: T, + destination: T +): Graph => { + if (graph.graph.has(source)) { + const index = graph.graph.get(source)?.indexOf(destination) ?? -1; + if (index !== -1) { + graph.graph.get(source)?.splice(index, 1); + } + } + if (graph.graph.has(destination)) { + const index = graph.graph.get(destination)?.indexOf(source) ?? -1; + if (index !== -1) { + graph.graph.get(destination)?.splice(index, 1); // For undirected graph + } + } + return { ...graph, lastUpdated: Date.now() }; +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9a0546d0..9348937a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -55,3 +55,7 @@ export const debounce = ) => ReturnType>( timeout = setTimeout(() => func(...args), waitFor); }; }; + +export const removeDups = (array: T[]): T[] => { + return [...new Set(array)]; +}; diff --git a/src/pages/editor-page/canvas/canvas-utils.ts b/src/pages/editor-page/canvas/canvas-utils.ts index a85bfd95..196c92f1 100644 --- a/src/pages/editor-page/canvas/canvas-utils.ts +++ b/src/pages/editor-page/canvas/canvas-utils.ts @@ -1,4 +1,7 @@ import type { Cardinality } from '@/lib/domain/db-relationship'; +import { MIN_TABLE_SIZE, type TableNodeType } from './table-node/table-node'; +import { addEdge, createGraph, removeEdge, type Graph } from '@/lib/graph'; +import type { DBTable } from '@/lib/domain/db-table'; export const getCardinalityMarkerId = ({ cardinality, @@ -10,3 +13,112 @@ export const getCardinalityMarkerId = ({ side: 'left' | 'right'; }) => `cardinality_${selected ? 'selected' : 'not_selected'}_${cardinality}_${side}`; + +const calcRect = ({ + node, + table, +}: ExactlyOne<{ table: DBTable; node: TableNodeType }>) => { + const id = node?.id ?? table?.id ?? ''; + const x = node?.position.x ?? table?.x ?? 0; + const y = node?.position.y ?? table?.y ?? 0; + const width = + node?.measured?.width ?? + node?.data.table.width ?? + table?.width ?? + MIN_TABLE_SIZE; + const height = node + ? (node?.measured?.height ?? + calcTableHeight(node?.data.table.fields.length ?? 0)) + : calcTableHeight(table?.fields.length ?? 0); + + return { + id, + left: x, + right: x + width, + top: y, + bottom: y + height, + }; +}; + +export const findTableOverlapping = ( + { + node, + table, + }: ExactlyOne<{ + node: TableNodeType; + table: DBTable; + }>, + { + nodes, + tables, + }: ExactlyOne<{ + nodes: TableNodeType[]; + tables: DBTable[]; + }>, + graph: Graph +): Graph => { + const id = node?.id ?? table?.id ?? ''; + const tableRect = calcRect(node ? { node } : { table }); + + for (const otherTable of nodes ?? tables ?? []) { + if (id === otherTable.id) { + continue; + } + + const isNode = !!nodes; + + const otherTableRect = isNode + ? calcRect({ node: otherTable as TableNodeType }) + : calcRect({ table: otherTable as DBTable }); + + if ( + tableRect.left < otherTableRect.right && + tableRect.right > otherTableRect.left && + tableRect.top < otherTableRect.bottom && + tableRect.bottom > otherTableRect.top + ) { + graph = addEdge(graph, id, otherTable.id); + } else { + graph = removeEdge(graph, id, otherTable.id); + } + } + + return graph; +}; + +export const findOverlappingTables = ({ + tables, + nodes, +}: ExactlyOne<{ + nodes: TableNodeType[]; + tables: DBTable[]; +}>): Graph => { + let graph = createGraph(); + + if (tables) { + for (const table of tables) { + graph = findTableOverlapping({ table }, { tables }, graph); + } + } else { + for (const node of nodes) { + graph = findTableOverlapping({ node }, { nodes }, graph); + } + } + + return graph; +}; + +export const calcTableHeight = (fieldCount: number): number => { + const fieldHeight = 32; // h-8 per field + + return Math.min(fieldCount, 11) * fieldHeight + 48; +}; + +export const getTableDimensions = ( + table: DBTable +): { width: number; height: number } => { + const fieldCount = table.fields.length; + const height = calcTableHeight(fieldCount); + const width = table.width || MIN_TABLE_SIZE; + return { width, height }; +}; diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index 1582af8d..8efa4d27 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { addEdge, NodePositionChange, @@ -52,6 +58,15 @@ import { useDialog } from '@/hooks/use-dialog'; import { MarkerDefinitions } from './marker-definitions'; import { CanvasContextMenu } from './canvas-context-menu'; import { areFieldTypesCompatible } from '@/lib/data/data-types'; +import { + calcTableHeight, + findOverlappingTables, + findTableOverlapping, +} from './canvas-utils'; +import type { Graph } from '@/lib/graph'; +import { createGraph, removeVertex } from '@/lib/graph'; +import type { ChartDBEvent } from '@/context/chartdb-context/chartdb-context'; +import { debounce } from '@/lib/utils'; type AddEdgeParams = Parameters>[0]; @@ -66,6 +81,7 @@ const tableToTableNode = ( position: { x: table.x, y: table.y }, data: { table, + isOverlapping: false, }, width: table.width ?? MIN_TABLE_SIZE, hidden: !shouldShowTablesBySchemaFilter(table, filteredSchemas), @@ -76,12 +92,12 @@ export interface CanvasProps { } export const Canvas: React.FC = ({ initialTables }) => { - const { getEdge, getInternalNode, fitView, getEdges } = useReactFlow(); + const { getEdge, getInternalNode, fitView, getEdges, getNode } = + useReactFlow(); const [selectedTableIds, setSelectedTableIds] = useState([]); const [selectedRelationshipIds, setSelectedRelationshipIds] = useState< string[] >([]); - const { filteredSchemas } = useChartDB(); const { toast } = useToast(); const { t } = useTranslation(); const { @@ -92,6 +108,8 @@ export const Canvas: React.FC = ({ initialTables }) => { removeRelationships, getField, databaseType, + filteredSchemas, + events, } = useChartDB(); const { showSidePanel } = useLayout(); const { effectiveTheme } = useTheme(); @@ -101,6 +119,8 @@ export const Canvas: React.FC = ({ initialTables }) => { const nodeTypes = useMemo(() => ({ table: TableNode }), []); const edgeTypes = useMemo(() => ({ 'table-edge': TableEdge }), []); const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true); + const [overlapGraph, setOverlapGraph] = + useState>(createGraph()); const [nodes, setNodes, onNodesChange] = useNodesState( initialTables.map((table) => tableToTableNode(table, filteredSchemas)) @@ -207,9 +227,47 @@ export const Canvas: React.FC = ({ initialTables }) => { useEffect(() => { setNodes( - tables.map((table) => tableToTableNode(table, filteredSchemas)) + tables.map((table) => { + const isOverlapping = + (overlapGraph.graph.get(table.id) ?? []).length > 0; + const node = tableToTableNode(table, filteredSchemas); + + return { + ...node, + data: { + ...node.data, + isOverlapping, + }, + }; + }) ); - }, [tables, setNodes, filteredSchemas]); + }, [ + tables, + setNodes, + filteredSchemas, + overlapGraph.lastUpdated, + overlapGraph.graph, + ]); + + const prevFilteredSchemas = useRef(undefined); + useEffect(() => { + if (!equal(filteredSchemas, prevFilteredSchemas.current)) { + debounce(() => { + const overlappingTablesInDiagram = findOverlappingTables({ + tables: tables.filter((table) => + shouldShowTablesBySchemaFilter(table, filteredSchemas) + ), + }); + setOverlapGraph(overlappingTablesInDiagram); + fitView({ + duration: 500, + padding: 0.1, + maxZoom: 0.8, + }); + }, 500)(); + prevFilteredSchemas.current = filteredSchemas; + } + }, [filteredSchemas, fitView, tables]); const onConnectHandler = useCallback( async (params: AddEdgeParams) => { @@ -279,11 +337,50 @@ export const Canvas: React.FC = ({ initialTables }) => { [getEdge, onEdgesChange, removeRelationships] ); + const updateOverlappingGraphOnChanges = useCallback( + ({ + positionChanges, + sizeChanges, + }: { + positionChanges: NodePositionChange[]; + sizeChanges: NodeDimensionChange[]; + }) => { + if (positionChanges.length > 0 || sizeChanges.length > 0) { + let newOverlappingGraph: Graph = overlapGraph; + + for (const change of positionChanges) { + newOverlappingGraph = findTableOverlapping( + { node: getNode(change.id) as TableNodeType }, + { nodes: nodes.filter((node) => !node.hidden) }, + newOverlappingGraph + ); + } + + for (const change of sizeChanges) { + newOverlappingGraph = findTableOverlapping( + { node: getNode(change.id) as TableNodeType }, + { nodes: nodes.filter((node) => !node.hidden) }, + newOverlappingGraph + ); + } + + setOverlapGraph(newOverlappingGraph); + } + }, + [nodes, overlapGraph, setOverlapGraph, getNode] + ); + + const updateOverlappingGraphOnChangesDebounced = debounce( + updateOverlappingGraphOnChanges, + 200 + ); + const onNodesChangeHandler: OnNodesChange = useCallback( (changes) => { const positionChanges: NodePositionChange[] = changes.filter( (change) => change.type === 'position' && !change.dragging ) as NodePositionChange[]; + const removeChanges: NodeRemoveChange[] = changes.filter( (change) => change.type === 'remove' ) as NodeRemoveChange[]; @@ -336,11 +433,101 @@ export const Canvas: React.FC = ({ initialTables }) => { ); } + updateOverlappingGraphOnChangesDebounced({ + positionChanges, + sizeChanges, + }); + return onNodesChange(changes); }, - [onNodesChange, updateTablesState] + [ + onNodesChange, + updateTablesState, + updateOverlappingGraphOnChangesDebounced, + ] ); + const eventConsumer = useCallback( + (event: ChartDBEvent) => { + let newOverlappingGraph: Graph = 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) }, + 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) }, + overlapGraph + ); + setOverlapGraph(newOverlappingGraph); + } else if ( + event.action === 'add_field' || + event.action === 'remove_field' + ) { + const node = getNode(event.data.tableId) as TableNodeType; + + const measured = { + ...(node.measured ?? {}), + height: calcTableHeight(event.data.fields.length), + }; + + newOverlappingGraph = findTableOverlapping( + { + node: { + ...node, + measured, + }, + }, + { nodes: nodes.filter((node) => !node.hidden) }, + overlapGraph + ); + setOverlapGraph(newOverlappingGraph); + } else if (event.action === 'load_diagram') { + const diagramTables = event.data.diagram.tables ?? []; + const overlappingTablesInDiagram = findOverlappingTables({ + tables: diagramTables.filter((table) => + shouldShowTablesBySchemaFilter(table, filteredSchemas) + ), + }); + setOverlapGraph(overlappingTablesInDiagram); + } + }, + [overlapGraph, setOverlapGraph, getNode, nodes, filteredSchemas] + ); + + events.useSubscription(eventConsumer); + const isLoadingDOM = tables.length > 0 ? !getInternalNode(tables[0].id) : false; @@ -353,6 +540,10 @@ export const Canvas: React.FC = ({ initialTables }) => { mode: 'all', // Use 'all' mode for manual reordering }); + const updatedOverlapGraph = findOverlappingTables({ + tables: newTables, + }); + updateTablesState((currentTables) => currentTables.map((table) => { const newTable = newTables.find((t) => t.id === table.id); @@ -363,6 +554,8 @@ export const Canvas: React.FC = ({ initialTables }) => { }; }) ); + + setOverlapGraph(updatedOverlapGraph); }, [filteredSchemas, relationships, tables, updateTablesState]); const showReorderConfirmation = useCallback(() => { 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 411f3f45..d53568ee 100644 --- a/src/pages/editor-page/canvas/table-node/table-node.tsx +++ b/src/pages/editor-page/canvas/table-node/table-node.tsx @@ -19,10 +19,12 @@ import type { TableEdgeType } from '../table-edge'; import type { DBField } from '@/lib/domain/db-field'; import { useTranslation } from 'react-i18next'; import { TableNodeContextMenu } from './table-node-context-menu'; +import { cn } from '@/lib/utils'; export type TableNodeType = Node< { table: DBTable; + isOverlapping: boolean; }, 'table' >; @@ -36,7 +38,7 @@ export const TableNode: React.FC> = ({ selected, dragging, id, - data: { table }, + data: { table, isOverlapping }, }) => { const { updateTable, relationships } = useChartDB(); const edges = useStore((store) => store.edges) as TableEdgeType[]; @@ -121,7 +123,15 @@ export const TableNode: React.FC> = ({ return (
{ if (e.detail === 2) { openTableInEditor(); diff --git a/src/pages/editor-page/side-panel/side-panel.tsx b/src/pages/editor-page/side-panel/side-panel.tsx index f26440f5..d88efa84 100644 --- a/src/pages/editor-page/side-panel/side-panel.tsx +++ b/src/pages/editor-page/side-panel/side-panel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Select, SelectContent, @@ -15,15 +15,12 @@ import { useTranslation } from 'react-i18next'; import type { SelectBoxOption } from '@/components/select-box/select-box'; import { SelectBox } from '@/components/select-box/select-box'; import { useChartDB } from '@/hooks/use-chartdb'; -import { useReactFlow } from '@xyflow/react'; -import { debounce } from '@/lib/utils'; export interface SidePanelProps {} export const SidePanel: React.FC = () => { const { t } = useTranslation(); const { schemas, filterSchemas, filteredSchemas } = useChartDB(); - const { fitView } = useReactFlow(); const { selectSidebarSection, selectedSidebarSection, @@ -32,18 +29,6 @@ export const SidePanel: React.FC = () => { closeSelectSchema, } = useLayout(); - useEffect(() => { - if (filteredSchemas !== undefined) { - debounce(() => { - fitView({ - duration: 500, - padding: 0.1, - maxZoom: 0.8, - }); - }, 500)(); - } - }, [filteredSchemas, fitView]); - const schemasOptions: SelectBoxOption[] = useMemo( () => schemas.map( diff --git a/src/types.d.ts b/src/types.d.ts index d05e4b38..9fb6e277 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -3,3 +3,13 @@ type PartialExcept< ParameterField extends keyof ParameterType, > = Pick & Partial>; + +type Explode = keyof T extends infer K + ? K extends unknown + ? { [I in keyof T]: I extends K ? T[I] : never } + : never + : never; +type AtMostOne = Explode>; +type AtLeastOne }> = Partial & + U[keyof U]; +type ExactlyOne = AtMostOne & AtLeastOne; diff --git a/tailwind.config.js b/tailwind.config.js index 048df511..d1186899 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -67,6 +67,15 @@ module.exports = { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' }, }, + 'pulse-border': { + '0%, 100%': { borderColor: 'rgba(59, 130, 246, 0.5)' }, + '50%': { borderColor: 'rgba(59, 130, 246, 1)' }, + }, + scale: { + '0%': { transform: 'scale(1)' }, + '50%': { transform: 'scale(1.05)' }, + '100%': { transform: 'scale(1)' }, + }, }, animation: { 'accordion-down': 'accordion-down 0.2s ease-out',