Add pulsing effect to the overlapping tables

This commit is contained in:
KarimTamani
2025-05-24 23:17:00 +01:00
parent 158e4ef0f3
commit 4a3f266ad1
11 changed files with 161 additions and 192 deletions
-153
View File
@@ -1,153 +0,0 @@
import { TableType } from "@/lib/schemas/table-schema";
import { areArraysEqual } from "@/utils/utils";
import { Node, useReactFlow } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useState } from "react";
// Return type of the hook
type UseOverlapingType = {
isOverlapping: boolean, // Indicates if any nodes are overlapping
puls: () => void // Triggers a short visual pulse effect for overlapping nodes
}
/**
* Custom hook to detect overlapping nodes in a React Flow diagram,
* annotate them with metadata (`overlapping`, `pulsing`),
* and provide a pulse trigger for visual feedback.
*/
const useOverlappingNodes = (nodes: Node[]): UseOverlapingType => {
const [isPulsing, setIsPulsing] = useState<boolean>(false);
const { setNodes } = useReactFlow();
/**
* Triggers a brief "pulse" effect (e.g. for animations or styles)
* by toggling a boolean flag for a short time.
*/
const puls = useCallback(() => {
setIsPulsing(true);
setTimeout(() => setIsPulsing(false), 200);
}, []);
/**
* Compute which nodes are currently overlapping.
* This is memoized to avoid recalculating unless nodes change.
*/
const overlappingNodes: Set<string> = useMemo(() => {
const overlaps: Set<string> = new Set();
// Compare every pair of nodes
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
if (isNodesOverlapping(nodes[i], nodes[j])) {
overlaps.add(nodes[i].id);
overlaps.add(nodes[j].id);
}
}
}
return overlaps;
}, [nodes]);
/**
* When overlapping nodes change, update each nodes `data.overlapping` property accordingly.
* Avoids unnecessary updates using deep comparison.
*/
useEffect(() => {
const overlappingIds: string[] = Array.from(overlappingNodes);
const previousOverlappingNodesIds: string[] = nodes
.filter((node: Node) => node.data.overlapping)
.map((node: Node) => node.id);
if (!areArraysEqual(previousOverlappingNodesIds, overlappingIds)) {
setNodes((nodes: any) => {
return nodes.map((node: any) => {
const isOverlaped: boolean = overlappingIds.includes(node.id);
if (isOverlaped === node.data.overlapping)
return node; // No change needed
else
return {
...node,
data: {
...node.data,
overlapping: isOverlaped
}
};
});
});
}
}, [overlappingNodes]);
/**
* When `isPulsing` is active, apply a `pulsing` property to overlapping nodes
* to trigger animations or visual indicators in the UI.
*/
useEffect(() => {
setNodes((nodes: any) => {
return nodes.map((node: any) => {
if (!node.data.overlapping)
return node;
return {
...node,
data: {
...node.data,
pulsing: isPulsing
}
};
});
});
}, [isPulsing]);
// True if any overlapping nodes exist
const isOverlapping: boolean = overlappingNodes.size > 0;
// Return memoized object to avoid unnecessary rerenders
return useMemo(() => ({
puls,
isOverlapping
}), [isOverlapping, puls]);
};
export default useOverlappingNodes;
/**
* Helper function to check whether two nodes are overlapping
* Uses their position and dimensions to determine intersection.
*/
function isNodesOverlapping(nodeA: Node, nodeB: Node) {
const nodeAWidth: number =
nodeA.measured?.width ||
parseInt(nodeA.style?.width as string) ||
224; // Default fallback width
const nodeAHeight: number =
nodeA.measured?.height ||
(nodeA.data.table as TableType).fields.length * 32 + 36; // Estimate height based on number of fields
const nodeBWidth: number =
nodeB.measured?.width ||
parseInt(nodeB.style?.width as string) ||
224;
const nodeBHeight: number =
nodeB.measured?.height ||
(nodeB.data.table as TableType).fields.length * 32 + 36;
// Bounding box of node A
const a = {
left: nodeA.position.x,
right: nodeA.position.x + nodeAWidth,
top: nodeA.position.y,
bottom: nodeA.position.y + nodeAHeight,
};
// Bounding box of node B
const b = {
left: nodeB.position.x,
right: nodeB.position.x + nodeBWidth,
top: nodeB.position.y,
bottom: nodeB.position.y + nodeBHeight,
};
// Check if the bounding boxes intersect
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
}
+110
View File
@@ -0,0 +1,110 @@
import { TableType } from "@/lib/schemas/table-schema";
import { isTablesOverlapping } from "@/utils/tables";
import { areArraysEqual } from "@/utils/utils";
import { Node, useReactFlow } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useState } from "react";
// Return type of the hook
type UseOverlapingType = {
isOverlapping: boolean, // Indicates if any tables are overlapping
puls: () => void // Triggers a short visual pulse effect for overlapping tables
}
/**
* Custom hook to detect overlapping tables in a React Flow diagram,
* annotate them with metadata (`overlapping`, `pulsing`),
* and provide a pulse trigger for visual feedback.
*/
const useOverlappingTables = (tables: TableType[]): UseOverlapingType => {
const [isPulsing, setIsPulsing] = useState<boolean>(false);
const { setNodes } = useReactFlow();
const [overlappingTablesIds, setOverlappingTablesIds] = useState<Set<string>>(new Set<string>());
/**
* Triggers a brief "pulse" effect (e.g. for animations or styles)
* by toggling a boolean flag for a short time.
*/
const puls = useCallback(() => {
setIsPulsing(true);
setTimeout(() => setIsPulsing(false), 200);
}, []);
/**
* Compute which tables are currently overlapping.
* This is memoized to avoid recalculating unless tables change.
*/
useEffect(() => {
const overlaps: Set<string> = new Set();
// Compare every pair of tables
for (let i = 0; i < tables.length; i++) {
for (let j = i + 1; j < tables.length; j++) {
if (isTablesOverlapping(tables[i], tables[j])) {
overlaps.add(tables[i].id);
overlaps.add(tables[j].id);
}
}
}
if (!areArraysEqual(Array.from(overlaps), Array.from(overlappingTablesIds))) {
setOverlappingTablesIds(overlaps);
}
}, [tables]);
/**
* When overlapping tables change, update each nodes `data.overlapping` property accordingly.
* Avoids unnecessary updates using deep comparison.
*/
useEffect(() => {
const overlappingIds: string[] = Array.from(overlappingTablesIds);
setNodes((nodes: any) => {
return nodes.map((node: any) => {
const isOverlaped: boolean = overlappingIds.includes(node.id);
if (isOverlaped === node.data.overlapping)
return node; // No change needed
else
return {
...node,
data: {
...node.data,
overlapping: isOverlaped
}
};
});
});
}, [overlappingTablesIds]);
/**
* When `isPulsing` is active, apply a `pulsing` property to overlapping tables
* to trigger animations or visual indicators in the UI.
*/
useEffect(() => {
setNodes((nodes: any) => {
return nodes.map((node: any) => {
if (!node.data.overlapping)
return node;
return {
...node,
data: {
...node.data,
pulsing: isPulsing
}
};
});
});
}, [isPulsing]);
// True if any overlapping tables exist
const isOverlapping: boolean = overlappingTablesIds.size > 0;
// Return memoized object to avoid unnecessary rerenders
return useMemo(() => ({
puls,
isOverlapping
}), [isOverlapping, puls]);
};
export default useOverlappingTables;
+3 -3
View File
@@ -33,9 +33,9 @@ import { AlertTriangle } from "lucide-react";
import { adjustTablesPositions } from "@/utils/tables";
import DatabaseControlButtons from "./database-control-buttons";
import { FieldType } from "@/lib/schemas/field-schema";
import useHighlightedEdges from "@/hooks/use-highlighted-edges";
import useOverlappingNodes from "@/hooks/use-overlapping-nodes";
import useHighlightedEdges from "@/hooks/use-highlighted-edges";
import { useTranslation } from "react-i18next";
import useOverlappingTables from "@/hooks/use-overlapping-tables";
const DatabasePage: React.FC<never> = () => {
@@ -152,7 +152,7 @@ const DatabasePage: React.FC<never> = () => {
useTableToNode(tables);
useRelationshipToEdge(relationships);
useHighlightedEdges(nodes, relationships, edges);
const { isOverlapping, puls } = useOverlappingNodes(nodes);
const { isOverlapping, puls } = useOverlappingTables(tables);
return (
@@ -15,14 +15,13 @@ interface RelationshipAccordionBodyProps {
const RelationshipAccordionBody: React.FC<RelationshipAccordionBodyProps> = ({ relationship }) => {
const [cardinality, setCardinality] = useState(new Set([relationship.cardinality]));
const { editRelationship, deleteRelationship } = useDatabaseOperations();
const { t } = useTranslation();
const changeCardinality = (keys: SharedSelection) => {
@@ -68,7 +67,7 @@ const RelationshipAccordionBody: React.FC<RelationshipAccordionBodyProps> = ({ r
<div className="w-full space-y-1">
<label className="font-medium flex text-slate-700 flex items-center gap-1 text-sm dark:text-default-600">
<FileMinus2 className="size-4" />
{t("db_controller.target_table")}
{t(" .target_table")}
</label>
@@ -21,9 +21,9 @@ const FieldItem: React.FC<Props> = ({ field }) => {
const [fieldName, setFieldName] = useState<string>(field.name);
const [popOverOpen, setPopOverOpen] = useState<boolean>(false);
const { data_types } = useDatabase();
const {deleteField, editField} = useDatabaseOperations() ;
const { data_types } = useDatabaseOperations();
const { deleteField, editField } = useDatabaseOperations();
const [note, setNote] = useState<string | undefined>(field.note as string | undefined);
const [selectedType, setSelectedType] = useState<string | undefined>(field.typeId as string | undefined);
const { t } = useTranslation();
@@ -37,6 +37,7 @@ const FieldItem: React.FC<Props> = ({ field }) => {
useEffect(() => {
setFieldName(field.name);
}, [field.name]);
useEffect(() => {
setSelectedType(field.typeId as string | undefined);
}, [field.typeId])
@@ -87,6 +88,7 @@ const FieldItem: React.FC<Props> = ({ field }) => {
} as FieldType);
}
return (
<div className="flex w-full gap-1 items-center " style={style} ref={setNodeRef} {...attributes}>
@@ -7,26 +7,28 @@ import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { FieldType } from "@/lib/schemas/field-schema";
import { TableType } from "@/lib/schemas/table-schema";
import { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { v4 } from "uuid";
import { getNextSequence } from "@/utils/field";
import hash from "object-hash" ;
interface Props {
table: TableType
tableFields: FieldType[] ;
tableId : string ;
}
const FieldList: React.FC<Props> = ({ table }) => {
const FieldList: React.FC<Props> = ({ tableFields , tableId}) => {
const { t } = useTranslation();
const [fields, setFields] = useState<FieldType[]>(table.fields);
const [fields, setFields] = useState<FieldType[]>(tableFields);
const { createField, orderTableFields } = useDatabaseOperations();
useEffect(() => {
setFields(table.fields)
}, [table.fields])
setFields(tableFields)
}, [tableFields])
const sensors = useSensors(
useSensor(PointerSensor)
@@ -53,14 +55,12 @@ const FieldList: React.FC<Props> = ({ table }) => {
const addField = () => {
createField({
id: v4(),
name: `field_${table.fields.length + 1}`,
tableId: table.id,
name: `field_${fields.length + 1}`,
tableId: tableId,
sequence: getNextSequence(fields) ,
nullable: true,
})
}
}
return (
<div className="w-full space-y-2 no-select">
<DndContext
@@ -99,4 +99,6 @@ const FieldList: React.FC<Props> = ({ table }) => {
}
export default FieldList;
export default React.memo(FieldList , (prevState , newState) => {
return hash(prevState) == hash(newState) ;
});
@@ -2,7 +2,7 @@
import { Accordion, AccordionItem, Button, cn, Textarea } from "@heroui/react";
import { ChevronLeft, FileKey, FileType, Key, MessageSquareQuote, Plus } from "lucide-react";
import { MouseEventHandler, useCallback, useEffect, useState } from "react";
import React, { MouseEventHandler, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ColorPicker from "@/components/color-picker/color-picker";
import FieldList from "./field/field-list";
@@ -25,7 +25,6 @@ const TableAccordionBody: React.FC<TableAccordionBodyProps> = ({ table }) => {
const { editTable, createField } = useDatabaseOperations();
const onColorChange = useCallback((color: string | undefined) => {
editTable({ id: table.id, color: color ? color : null } as TableType);
}, [table]);
@@ -51,7 +50,11 @@ const TableAccordionBody: React.FC<TableAccordionBodyProps> = ({ table }) => {
useEffect(() => {
setNote(table.note ? table.note : "");
}, [table.note])
}, [table.note]) ;
return (
<div className="w-full dark:bg-background">
<Accordion
@@ -84,7 +87,7 @@ const TableAccordionBody: React.FC<TableAccordionBodyProps> = ({ table }) => {
</div>
}
>
<FieldList table={table} />
<FieldList tableFields={table.fields} tableId = { table.id } />
</AccordionItem>
<AccordionItem key="indexes" aria-label="Indexes"
indicator={({ isOpen }) => (
@@ -178,4 +181,4 @@ const TableAccordionBody: React.FC<TableAccordionBodyProps> = ({ table }) => {
}
export default TableAccordionBody;
export default React.memo(TableAccordionBody);
@@ -2,7 +2,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/tooltip/to
import { Button, cn, Input, Listbox, ListboxItem, Popover, PopoverContent, PopoverTrigger, useDisclosure } from "@heroui/react";
import { Check, ChevronRight, Copy, EllipsisVertical, FileKey, FileType, Focus, Pencil, Trash } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TableInsertType, TableType } from "@/lib/schemas/table-schema";
@@ -10,6 +10,7 @@ import { useDatabaseOperations } from "@/providers/database-provider/database-pr
import { v4 } from "uuid";
import { getNextSequence } from "@/utils/field";
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
import hash from "object-hash";
export interface TableAccordionHeaderProps {
table: TableType,
@@ -52,6 +53,10 @@ const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOp
})
}
return (
<div className="group w-full flex h-12 gap-1 border-l-4 flex p-2 items-center border-l-[6px] border-default"
style={{
@@ -193,4 +198,6 @@ const TableAccordionHeader: React.FC<TableAccordionHeaderProps> = ({ table, isOp
}
export default TableAccordionHeader;
export default React.memo(TableAccordionHeader, (prevState, newState) => {
return hash(prevState) == hash(newState);
});
+4 -7
View File
@@ -69,10 +69,7 @@ const Table: React.FC<NodeProps<TableProps>> = ({ selected, data: { table, overl
})
}, [table.fields, selected, highlightedEdges]);
return (
<Card className={cn(
"w-full h-full bg-background rounded-lg noselect overflow-visible dark:bg-default-900",
selected
@@ -81,12 +78,12 @@ const Table: React.FC<NodeProps<TableProps>> = ({ selected, data: { table, overl
overlapping
? 'ring-2 dark:ring-offset-default-900 ring-danger ring-offset-1 scale-105 shadow-danger '
: '',
!pulsing
? 'scale-100'
: '',
pulsing
!pulsing && overlapping
? 'scale-105'
: '',
pulsing && overlapping
? 'scale-110'
: '',
)}
shadow="sm"
@@ -13,15 +13,15 @@ import { createContext } from "react";
interface DatabaseDataContextType {
data_types: DataType[],
database: DatabaseType,
isLoading: boolean,
isLoading: boolean,
getField: (tableId: string, id: string) => FieldType | undefined,
}
interface DatabaseOperationsContextType {
data_types: DataType[],
createTable: (table: TableInsertType) => Promise<void>,
editTable: (table: TableInsertType) => Promise<QueryResult>,
@@ -233,6 +233,7 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
deleteRelationship,
deleteMultiRelationships,
executeDbDiffOps,
data_types
}), [
createTable,
editTable,
@@ -248,11 +249,12 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
deleteRelationship,
deleteMultiRelationships,
executeDbDiffOps,
data_types
]);
return (
<DatabaseDataContext.Provider value={{
data_types,
database: database as unknown as DatabaseType,
isLoading,
getField,