mirror of
https://github.com/stackrender/stackrender.git
synced 2026-09-10 03:05:42 +00:00
Add pulsing effect to the overlapping tables
This commit is contained in:
@@ -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 node’s `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);
|
||||
}
|
||||
@@ -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 node’s `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;
|
||||
@@ -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 (
|
||||
|
||||
|
||||
+2
-3
@@ -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>
|
||||
|
||||
|
||||
+5
-3
@@ -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}>
|
||||
|
||||
|
||||
+15
-13
@@ -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) ;
|
||||
});
|
||||
+8
-5
@@ -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);
|
||||
+9
-2
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user