import React, { useContext, useRef, useState, useEffect } from "react"; import Table from "./table"; import { Action, Cardinality, Constraint, ObjectType } from "../data/data"; import Area from "./area"; import Relationship from "./relationship"; import { AreaContext, NoteContext, SettingsContext, TableContext, UndoRedoContext, } from "../pages/editor"; import Note from "./note"; export default function Canvas(props) { const { tables, moveTable, relationships, addRelationship } = useContext(TableContext); const { areas, setAreas, moveArea } = useContext(AreaContext); const { notes, moveNote } = useContext(NoteContext); const { settings, setSettings } = useContext(SettingsContext); const { setUndoStack, setRedoStack } = useContext(UndoRedoContext); const [dragging, setDragging] = useState({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0, }); const [linking, setLinking] = useState(false); const [line, setLine] = useState({ startTableId: -1, startFieldId: -1, endTableId: -1, endFieldId: -1, startX: 0, startY: 0, endX: 0, endY: 0, name: "", cardinality: Cardinality.ONE_TO_ONE, updateConstraint: Constraint.none, deleteConstraint: Constraint.none, mandatory: false, }); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [onRect, setOnRect] = useState({ tableId: -1, field: -2, }); const [panning, setPanning] = useState({ state: false, x: 0, y: 0 }); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [areaResize, setAreaResize] = useState({ id: -1, dir: "none" }); const [initCoords, setInitCoords] = useState({ x: 0, y: 0, width: 0, height: 0, mouseX: 0, mouseY: 0, }); const [cursor, setCursor] = useState("default"); const canvas = useRef(null); const handleMouseDownRect = (e, id, type) => { const { clientX, clientY } = e; if (type === ObjectType.TABLE) { const table = tables.find((t) => t.id === id); setOffset({ x: clientX / settings.zoom - table.x, y: clientY / settings.zoom - table.y, }); setDragging({ element: ObjectType.TABLE, id: id, prevX: table.x, prevY: table.y, }); } else if (type === ObjectType.AREA) { const area = areas.find((t) => t.id === id); setOffset({ x: clientX / settings.zoom - area.x, y: clientY / settings.zoom - area.y, }); setDragging({ element: ObjectType.AREA, id: id, prevX: area.x, prevY: area.y, }); } else if (type === ObjectType.NOTE) { const note = notes.find((t) => t.id === id); setOffset({ x: clientX / settings.zoom - note.x, y: clientY / settings.zoom - note.y, }); setDragging({ element: ObjectType.NOTE, id: id, prevX: note.x, prevY: note.y, }); } }; const handleMouseMove = (e) => { if (linking) { const rect = canvas.current.getBoundingClientRect(); const offsetX = rect.left; const offsetY = rect.top; setLine({ ...line, endX: (e.clientX - offsetX) / settings.zoom - settings.pan.x, endY: (e.clientY - offsetY) / settings.zoom - settings.pan.y, }); } else if ( panning.state && dragging.element === ObjectType.NONE && areaResize.id === -1 ) { const dx = (e.clientX - panOffset.x) / settings.zoom; const dy = (e.clientY - panOffset.y) / settings.zoom; setSettings((prev) => ({ ...prev, pan: { x: prev.pan.x + dx, y: prev.pan.y + dy }, })); setPanOffset({ x: e.clientX, y: e.clientY }); } else if (dragging.element === ObjectType.TABLE && dragging.id >= 0) { const dx = e.clientX / settings.zoom - offset.x; const dy = e.clientY / settings.zoom - offset.y; moveTable(dragging.id, dx, dy); } else if ( dragging.element === ObjectType.AREA && dragging.id >= 0 && areaResize.id === -1 ) { const dx = e.clientX / settings.zoom - offset.x; const dy = e.clientY / settings.zoom - offset.y; moveArea(dragging.id, dx, dy); } else if (dragging.element === ObjectType.NOTE && dragging.id >= 0) { const dx = e.clientX / settings.zoom - offset.x; const dy = e.clientY / settings.zoom - offset.y; moveNote(dragging.id, dx, dy); } else if (areaResize.id !== -1) { if (areaResize.dir === "none") return; let newX = initCoords.x; let newY = initCoords.y; let newWidth = initCoords.width; let newHeight = initCoords.height; const mouseX = e.clientX / settings.zoom; const mouseY = e.clientY / settings.zoom; setPanning({ state: false, x: 0, y: 0 }); if (areaResize.dir === "br") { newWidth = initCoords.width + (mouseX - initCoords.mouseX); newHeight = initCoords.height + (mouseY - initCoords.mouseY); } else if (areaResize.dir === "tl") { newX = initCoords.x + (mouseX - initCoords.mouseX); newY = initCoords.y + (mouseY - initCoords.mouseY); newWidth = initCoords.width - (mouseX - initCoords.mouseX); newHeight = initCoords.height - (mouseY - initCoords.mouseY); } else if (areaResize.dir === "tr") { newY = initCoords.y + (mouseY - initCoords.mouseY); newWidth = initCoords.width + (mouseX - initCoords.mouseX); newHeight = initCoords.height - (mouseY - initCoords.mouseY); } else if (areaResize.dir === "bl") { newX = initCoords.x + (mouseX - initCoords.mouseX); newWidth = initCoords.width - (mouseX - initCoords.mouseX); newHeight = initCoords.height + (mouseY - initCoords.mouseY); } setAreas((prev) => prev.map((a) => { if (a.id === areaResize.id) { return { ...a, x: newX, y: newY, width: newWidth, height: newHeight, }; } return a; }) ); } }; const handleMouseDown = (e) => { setPanning({ state: true, ...settings.pan }); setPanOffset({ x: e.clientX, y: e.clientY }); setCursor("grabbing"); }; const coordsDidUpdate = (element) => { switch (element) { case ObjectType.TABLE: return !( dragging.prevX === tables[dragging.id].x && dragging.prevY === tables[dragging.id].y ); case ObjectType.AREA: return !( dragging.prevX === areas[dragging.id].x && dragging.prevY === areas[dragging.id].y ); case ObjectType.NOTE: return !( dragging.prevX === notes[dragging.id].x && dragging.prevY === notes[dragging.id].y ); default: return false; } }; const didResize = (id) => { return !( areas[id].x === initCoords.x && areas[id].y === initCoords.y && areas[id].width === initCoords.width && areas[id].height === initCoords.height ); }; const didPan = () => !(settings.pan.x === panning.x && settings.pan.y === panning.y); const handleMouseUp = (e) => { if (coordsDidUpdate(dragging.element)) { setUndoStack((prev) => [ ...prev, { action: Action.MOVE, element: dragging.element, x: dragging.prevX, y: dragging.prevY, id: dragging.id, }, ]); setRedoStack([]); } setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 }); // NOTE: consider just saving the offset to sub and add in undo redo if (panning.state && didPan()) { setUndoStack((prev) => [ ...prev, { action: Action.PAN, data: { undo: { x: panning.x, y: panning.y }, redo: settings.pan, }, }, ]); setRedoStack([]); } setPanning({ state: false, x: 0, y: 0 }); setCursor("default"); if (linking) handleLinking(); setLinking(false); if (areaResize.id !== -1 && didResize(areaResize.id)) { setUndoStack((prev) => [ ...prev, { action: Action.EDIT, element: ObjectType.AREA, data: { undo: { ...areas[areaResize.id], x: initCoords.x, y: initCoords.y, width: initCoords.width, height: initCoords.height, }, redo: areas[areaResize.id], }, }, ]); setRedoStack([]); } setAreaResize({ id: -1, dir: "none" }); setInitCoords({ x: 0, y: 0, width: 0, height: 0, mouseX: 0, mouseY: 0, }); }; const handleGripField = (id) => { setPanning(false); setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 }); setLinking(true); }; const handleLinking = () => { if (onRect.tableId < 0) return; if (onRect.field < 0) return; if ( line.startTableId === onRect.tableId && line.startFieldId === onRect.field ) return; addRelationship(true, { ...line, endTableId: onRect.tableId, endFieldId: onRect.field, endX: tables[onRect.tableId].x + 15, endY: tables[onRect.tableId].y + onRect.field * 36 + 69, name: `${tables[line.startTableId].name}_to_${ tables[onRect.tableId].name }`, id: relationships.length, }); }; const handleMouseWheel = (e) => { e.preventDefault(); setSettings((prev) => ({ ...prev, zoom: e.deltaY <= 0 ? prev.zoom * 1.05 : prev.zoom / 1.05, })); }; useEffect(() => { const canvasElement = canvas.current; canvasElement.addEventListener("wheel", handleMouseWheel, { passive: false, }); return () => { canvasElement.removeEventListener("wheel", handleMouseWheel); }; }); return (