mirror of
https://github.com/drawdb-io/drawdb.git
synced 2026-02-12 02:00:40 +08:00
Reorganize files
This commit is contained in:
340
src/components/EditorCanvas/Area.jsx
Normal file
340
src/components/EditorCanvas/Area.jsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Popover, Input, Toast } from "@douyinfe/semi-ui";
|
||||
import {
|
||||
IconEdit,
|
||||
IconCheckboxTick,
|
||||
IconDeleteStroked,
|
||||
} from "@douyinfe/semi-icons";
|
||||
import {
|
||||
Tab,
|
||||
Action,
|
||||
ObjectType,
|
||||
tableThemes,
|
||||
defaultBlue,
|
||||
State,
|
||||
} from "../../data/constants";
|
||||
import {
|
||||
useLayout,
|
||||
useSettings,
|
||||
useUndoRedo,
|
||||
useSelect,
|
||||
useAreas,
|
||||
useSaveState,
|
||||
useTransform,
|
||||
} from "../../hooks";
|
||||
|
||||
export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { layout } = useLayout();
|
||||
const { settings } = useSettings();
|
||||
const { transform } = useTransform();
|
||||
const { setSaveState } = useSaveState();
|
||||
const { selectedElement, setSelectedElement } = useSelect();
|
||||
|
||||
const handleResize = (e, dir) => {
|
||||
setResize({ id: data.id, dir: dir });
|
||||
setInitCoords({
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
mouseX: e.clientX / transform.zoom,
|
||||
mouseY: e.clientY / transform.zoom,
|
||||
});
|
||||
};
|
||||
|
||||
const edit = () => {
|
||||
if (layout.sidebar) {
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
element: ObjectType.AREA,
|
||||
id: data.id,
|
||||
currentTab: Tab.AREAS,
|
||||
open: true,
|
||||
}));
|
||||
if (selectedElement.currentTab !== Tab.AREAS) return;
|
||||
document
|
||||
.getElementById(`scroll_area_${data.id}`)
|
||||
.scrollIntoView({ behavior: "smooth" });
|
||||
} else {
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
element: ObjectType.AREA,
|
||||
id: data.id,
|
||||
open: true,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const onClickOutSide = () => {
|
||||
if (selectedElement.editFromToolbar) {
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
editFromToolbar: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
open: false,
|
||||
}));
|
||||
setSaveState(State.SAVING);
|
||||
};
|
||||
|
||||
const areaIsSelected = () =>
|
||||
selectedElement.element === ObjectType.AREA &&
|
||||
selectedElement.id === data.id &&
|
||||
selectedElement.open;
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<foreignObject
|
||||
key={data.id}
|
||||
x={data.x}
|
||||
y={data.y}
|
||||
width={data.width > 0 ? data.width : 0}
|
||||
height={data.height > 0 ? data.height : 0}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div
|
||||
className={`border-2 ${
|
||||
hovered
|
||||
? "border-dashed border-blue-500"
|
||||
: selectedElement.element === ObjectType.AREA &&
|
||||
selectedElement.id === data.id
|
||||
? "border-blue-500"
|
||||
: "border-slate-400"
|
||||
} w-full h-full cursor-move rounded relative`}
|
||||
>
|
||||
<div
|
||||
className="opacity-40 w-fill p-2 h-full"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-color absolute top-2 left-3 select-none">
|
||||
{data.name}
|
||||
</div>
|
||||
{(hovered || (areaIsSelected() && !layout.sidebar)) && (
|
||||
<div className="absolute top-2 right-3">
|
||||
<Popover
|
||||
visible={areaIsSelected() && !layout.sidebar}
|
||||
onClickOutSide={onClickOutSide}
|
||||
stopPropagation
|
||||
content={<EditPopoverContent data={data} />}
|
||||
trigger="custom"
|
||||
position="rightTop"
|
||||
showArrow
|
||||
>
|
||||
<Button
|
||||
icon={<IconEdit />}
|
||||
size="small"
|
||||
theme="solid"
|
||||
style={{
|
||||
backgroundColor: "#2f68ad",
|
||||
opacity: "0.7",
|
||||
}}
|
||||
onClick={edit}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</foreignObject>
|
||||
{hovered && (
|
||||
<>
|
||||
<circle
|
||||
cx={data.x}
|
||||
cy={data.y}
|
||||
r={6}
|
||||
fill={settings.mode === "light" ? "white" : "rgb(28, 31, 35)"}
|
||||
stroke="#5891db"
|
||||
strokeWidth={2}
|
||||
cursor="nwse-resize"
|
||||
onMouseDown={(e) => handleResize(e, "tl")}
|
||||
/>
|
||||
<circle
|
||||
cx={data.x + data.width}
|
||||
cy={data.y}
|
||||
r={6}
|
||||
fill={settings.mode === "light" ? "white" : "rgb(28, 31, 35)"}
|
||||
stroke="#5891db"
|
||||
strokeWidth={2}
|
||||
cursor="nesw-resize"
|
||||
onMouseDown={(e) => handleResize(e, "tr")}
|
||||
/>
|
||||
<circle
|
||||
cx={data.x}
|
||||
cy={data.y + data.height}
|
||||
r={6}
|
||||
fill={settings.mode === "light" ? "white" : "rgb(28, 31, 35)"}
|
||||
stroke="#5891db"
|
||||
strokeWidth={2}
|
||||
cursor="nesw-resize"
|
||||
onMouseDown={(e) => handleResize(e, "bl")}
|
||||
/>
|
||||
<circle
|
||||
cx={data.x + data.width}
|
||||
cy={data.y + data.height}
|
||||
r={6}
|
||||
fill={settings.mode === "light" ? "white" : "rgb(28, 31, 35)"}
|
||||
stroke="#5891db"
|
||||
strokeWidth={2}
|
||||
cursor="nwse-resize"
|
||||
onMouseDown={(e) => handleResize(e, "br")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPopoverContent({ data }) {
|
||||
const [editField, setEditField] = useState({});
|
||||
const { setSaveState } = useSaveState();
|
||||
const { updateArea, deleteArea } = useAreas();
|
||||
const { setUndoStack, setRedoStack } = useUndoRedo();
|
||||
|
||||
return (
|
||||
<div className="popover-theme">
|
||||
<div className="font-semibold mb-2 ms-1">Edit subject area</div>
|
||||
<div className="w-[280px] flex items-center mb-2">
|
||||
<Input
|
||||
value={data.name}
|
||||
placeholder="Name"
|
||||
className="me-2"
|
||||
onChange={(value) => updateArea(data.id, { name: value })}
|
||||
onFocus={(e) => setEditField({ name: e.target.value })}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value === editField.name) return;
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
action: Action.EDIT,
|
||||
element: ObjectType.AREA,
|
||||
aid: data.id,
|
||||
undo: editField,
|
||||
redo: { name: e.target.value },
|
||||
message: `Edit area name to ${e.target.value}`,
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
}}
|
||||
/>
|
||||
<Popover
|
||||
content={
|
||||
<div className="popover-theme">
|
||||
<div className="flex justify-between items-center p-2">
|
||||
<div className="font-medium">Theme</div>
|
||||
<Button
|
||||
type="tertiary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
updateArea(data.id, {
|
||||
color: defaultBlue,
|
||||
});
|
||||
setSaveState(State.SAVING);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="py-3">
|
||||
<div>
|
||||
{tableThemes
|
||||
.slice(0, Math.ceil(tableThemes.length / 2))
|
||||
.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
style={{ backgroundColor: c }}
|
||||
className="p-3 rounded-full mx-1"
|
||||
onClick={() => {
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
action: Action.EDIT,
|
||||
element: ObjectType.AREA,
|
||||
aid: data.id,
|
||||
undo: { color: data.color },
|
||||
redo: { color: c },
|
||||
message: `Edit area color to ${c}`,
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
updateArea(data.id, {
|
||||
color: c,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{data.color === c ? (
|
||||
<IconCheckboxTick style={{ color: "white" }} />
|
||||
) : (
|
||||
<IconCheckboxTick style={{ color: c }} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{tableThemes
|
||||
.slice(Math.ceil(tableThemes.length / 2))
|
||||
.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
style={{ backgroundColor: c }}
|
||||
className="p-3 rounded-full mx-1"
|
||||
onClick={() => {
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
action: Action.EDIT,
|
||||
element: ObjectType.AREA,
|
||||
aid: data.id,
|
||||
undo: { color: data.color },
|
||||
redo: { color: c },
|
||||
message: `Edit area color to ${c}`,
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
updateArea(data.id, {
|
||||
color: c,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconCheckboxTick
|
||||
style={{
|
||||
color: data.color === c ? "white" : c,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
position="rightTop"
|
||||
showArrow
|
||||
>
|
||||
<div
|
||||
className="h-[32px] w-[32px] rounded"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={<IconDeleteStroked />}
|
||||
type="danger"
|
||||
block
|
||||
onClick={() => {
|
||||
Toast.success(`Area deleted!`);
|
||||
deleteArea(data.id, true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
488
src/components/EditorCanvas/Canvas.jsx
Normal file
488
src/components/EditorCanvas/Canvas.jsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Action, Cardinality, Constraint, ObjectType } from "../../data/constants";
|
||||
import { Toast } from "@douyinfe/semi-ui";
|
||||
import Table from "./Table";
|
||||
import Area from "./Area";
|
||||
import Relationship from "./Relationship";
|
||||
import Note from "./Note";
|
||||
import useSettings from "../../hooks/useSettings";
|
||||
import useTransform from "../../hooks/useTransform";
|
||||
import useTables from "../../hooks/useTables";
|
||||
import useUndoRedo from "../../hooks/useUndoRedo";
|
||||
import useSelect from "../../hooks/useSelect";
|
||||
import useAreas from "../../hooks/useAreas";
|
||||
import useNotes from "../../hooks/useNotes";
|
||||
|
||||
export default function Canvas() {
|
||||
const { tables, updateTable, relationships, addRelationship } = useTables();
|
||||
const { areas, updateArea } = useAreas();
|
||||
const { notes, updateNote } = useNotes();
|
||||
const { settings } = useSettings();
|
||||
const { setUndoStack, setRedoStack } = useUndoRedo();
|
||||
const { transform, setTransform } = useTransform();
|
||||
const { selectedElement, setSelectedElement } = useSelect();
|
||||
const [dragging, setDragging] = useState({
|
||||
element: ObjectType.NONE,
|
||||
id: -1,
|
||||
prevX: 0,
|
||||
prevY: 0,
|
||||
});
|
||||
const [linking, setLinking] = useState(false);
|
||||
const [linkingLink, setLinkingLine] = 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 [hoveredTable, setHoveredTable] = useState({
|
||||
tableId: -1,
|
||||
field: -2,
|
||||
});
|
||||
const [panning, setPanning] = useState({
|
||||
isPanning: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
dx: 0,
|
||||
dy: 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 handleMouseDownOnElement = (e, id, type) => {
|
||||
const { clientX, clientY } = e;
|
||||
if (type === ObjectType.TABLE) {
|
||||
const table = tables.find((t) => t.id === id);
|
||||
setOffset({
|
||||
x: clientX / transform.zoom - table.x,
|
||||
y: clientY / transform.zoom - table.y,
|
||||
});
|
||||
setDragging({
|
||||
element: type,
|
||||
id: id,
|
||||
prevX: table.x,
|
||||
prevY: table.y,
|
||||
});
|
||||
} else if (type === ObjectType.AREA) {
|
||||
const area = areas.find((t) => t.id === id);
|
||||
setOffset({
|
||||
x: clientX / transform.zoom - area.x,
|
||||
y: clientY / transform.zoom - area.y,
|
||||
});
|
||||
setDragging({
|
||||
element: type,
|
||||
id: id,
|
||||
prevX: area.x,
|
||||
prevY: area.y,
|
||||
});
|
||||
} else if (type === ObjectType.NOTE) {
|
||||
const note = notes.find((t) => t.id === id);
|
||||
setOffset({
|
||||
x: clientX / transform.zoom - note.x,
|
||||
y: clientY / transform.zoom - note.y,
|
||||
});
|
||||
setDragging({
|
||||
element: type,
|
||||
id: id,
|
||||
prevX: note.x,
|
||||
prevY: note.y,
|
||||
});
|
||||
}
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
element: type,
|
||||
id: id,
|
||||
open: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (linking) {
|
||||
const rect = canvas.current.getBoundingClientRect();
|
||||
setLinkingLine({
|
||||
...linkingLink,
|
||||
endX: (e.clientX - rect.left - transform.pan?.x) / transform.zoom,
|
||||
endY: (e.clientY - rect.top - transform.pan?.y) / transform.zoom,
|
||||
});
|
||||
} else if (
|
||||
panning.isPanning &&
|
||||
dragging.element === ObjectType.NONE &&
|
||||
areaResize.id === -1
|
||||
) {
|
||||
if (!settings.panning) {
|
||||
return;
|
||||
}
|
||||
const dx = e.clientX - panning.dx;
|
||||
const dy = e.clientY - panning.dy;
|
||||
setTransform((prev) => ({
|
||||
...prev,
|
||||
pan: { x: prev.pan?.x + dx, y: prev.pan?.y + dy },
|
||||
}));
|
||||
setPanning((prev) => ({ ...prev, dx: e.clientX, dy: e.clientY }));
|
||||
} else if (dragging.element === ObjectType.TABLE && dragging.id >= 0) {
|
||||
const dx = e.clientX / transform.zoom - offset.x;
|
||||
const dy = e.clientY / transform.zoom - offset.y;
|
||||
updateTable(dragging.id, { x: dx, y: dy }, true);
|
||||
} else if (
|
||||
dragging.element === ObjectType.AREA &&
|
||||
dragging.id >= 0 &&
|
||||
areaResize.id === -1
|
||||
) {
|
||||
const dx = e.clientX / transform.zoom - offset.x;
|
||||
const dy = e.clientY / transform.zoom - offset.y;
|
||||
updateArea(dragging.id, { x: dx, y: dy });
|
||||
} else if (dragging.element === ObjectType.NOTE && dragging.id >= 0) {
|
||||
const dx = e.clientX / transform.zoom - offset.x;
|
||||
const dy = e.clientY / transform.zoom - offset.y;
|
||||
updateNote(dragging.id, { x: dx, y: dy });
|
||||
} else if (areaResize.id !== -1) {
|
||||
if (areaResize.dir === "none") return;
|
||||
let newDims = { ...initCoords };
|
||||
delete newDims.mouseX;
|
||||
delete newDims.mouseY;
|
||||
const mouseX = e.clientX / transform.zoom;
|
||||
const mouseY = e.clientY / transform.zoom;
|
||||
setPanning({ isPanning: false, x: 0, y: 0 });
|
||||
if (areaResize.dir === "br") {
|
||||
newDims.width = initCoords.width + (mouseX - initCoords.mouseX);
|
||||
newDims.height = initCoords.height + (mouseY - initCoords.mouseY);
|
||||
} else if (areaResize.dir === "tl") {
|
||||
newDims.x = initCoords.x + (mouseX - initCoords.mouseX);
|
||||
newDims.y = initCoords.y + (mouseY - initCoords.mouseY);
|
||||
newDims.width = initCoords.width - (mouseX - initCoords.mouseX);
|
||||
newDims.height = initCoords.height - (mouseY - initCoords.mouseY);
|
||||
} else if (areaResize.dir === "tr") {
|
||||
newDims.y = initCoords.y + (mouseY - initCoords.mouseY);
|
||||
newDims.width = initCoords.width + (mouseX - initCoords.mouseX);
|
||||
newDims.height = initCoords.height - (mouseY - initCoords.mouseY);
|
||||
} else if (areaResize.dir === "bl") {
|
||||
newDims.x = initCoords.x + (mouseX - initCoords.mouseX);
|
||||
newDims.width = initCoords.width - (mouseX - initCoords.mouseX);
|
||||
newDims.height = initCoords.height + (mouseY - initCoords.mouseY);
|
||||
}
|
||||
|
||||
updateArea(areaResize.id, { ...newDims });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
setPanning({
|
||||
isPanning: true,
|
||||
...transform.pan,
|
||||
dx: e.clientX,
|
||||
dy: 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 = () =>
|
||||
!(transform.pan?.x === panning.x && transform.pan?.y === panning.y);
|
||||
|
||||
const getMovedElementDetails = () => {
|
||||
switch (dragging.element) {
|
||||
case ObjectType.TABLE:
|
||||
return {
|
||||
name: "table",
|
||||
x: tables[dragging.id].x,
|
||||
y: tables[dragging.id].y,
|
||||
};
|
||||
case ObjectType.AREA:
|
||||
return {
|
||||
name: "area",
|
||||
x: areas[dragging.id].x,
|
||||
y: areas[dragging.id].y,
|
||||
};
|
||||
case ObjectType.NOTE:
|
||||
return {
|
||||
name: "note",
|
||||
x: notes[dragging.id].x,
|
||||
y: notes[dragging.id].y,
|
||||
};
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (coordsDidUpdate(dragging.element)) {
|
||||
const info = getMovedElementDetails();
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
action: Action.MOVE,
|
||||
element: dragging.element,
|
||||
x: dragging.prevX,
|
||||
y: dragging.prevY,
|
||||
toX: info.x,
|
||||
toY: info.y,
|
||||
id: dragging.id,
|
||||
message: `Move ${info.name} to (${info.x}, ${info.y})`,
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 });
|
||||
if (panning.isPanning && didPan()) {
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
action: Action.PAN,
|
||||
undo: { x: panning.x, y: panning.y },
|
||||
redo: transform.pan,
|
||||
message: `Move diagram to (${transform.pan?.x}, ${transform.pan?.y})`,
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
element: ObjectType.NONE,
|
||||
id: -1,
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
setPanning({ isPanning: 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,
|
||||
aid: areaResize.id,
|
||||
undo: {
|
||||
...areas[areaResize.id],
|
||||
x: initCoords.x,
|
||||
y: initCoords.y,
|
||||
width: initCoords.width,
|
||||
height: initCoords.height,
|
||||
},
|
||||
redo: areas[areaResize.id],
|
||||
message: `Resize area`,
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
setAreaResize({ id: -1, dir: "none" });
|
||||
setInitCoords({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
mouseX: 0,
|
||||
mouseY: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGripField = () => {
|
||||
setPanning(false);
|
||||
setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 });
|
||||
setLinking(true);
|
||||
};
|
||||
|
||||
const handleLinking = () => {
|
||||
if (hoveredTable.tableId < 0) return;
|
||||
if (hoveredTable.field < 0) return;
|
||||
if (
|
||||
tables[linkingLink.startTableId].fields[linkingLink.startFieldId].type !==
|
||||
tables[hoveredTable.tableId].fields[hoveredTable.field].type
|
||||
) {
|
||||
Toast.info("Cannot connect");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
linkingLink.startTableId === hoveredTable.tableId &&
|
||||
linkingLink.startFieldId === hoveredTable.field
|
||||
)
|
||||
return;
|
||||
|
||||
addRelationship(true, {
|
||||
...linkingLink,
|
||||
endTableId: hoveredTable.tableId,
|
||||
endFieldId: hoveredTable.field,
|
||||
endX: tables[hoveredTable.tableId].x + 15,
|
||||
endY: tables[hoveredTable.tableId].y + hoveredTable.field * 36 + 69,
|
||||
name: `${tables[linkingLink.startTableId].name}_${
|
||||
tables[linkingLink.startTableId].fields[linkingLink.startFieldId].name
|
||||
}_fk`,
|
||||
id: relationships.length,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseWheel = (e) => {
|
||||
e.preventDefault();
|
||||
setTransform((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);
|
||||
};
|
||||
});
|
||||
|
||||
const theme = localStorage.getItem("theme");
|
||||
|
||||
return (
|
||||
<div className="flex-grow h-full" id="canvas">
|
||||
<div ref={canvas} className="w-full h-full">
|
||||
<svg
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
cursor: cursor,
|
||||
backgroundColor: theme === "dark" ? "rgba(22, 22, 26, 1)" : "white",
|
||||
}}
|
||||
>
|
||||
{settings.showGrid && (
|
||||
<>
|
||||
<defs>
|
||||
<pattern
|
||||
id="pattern-circles"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternContentUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle
|
||||
id="pattern-circle"
|
||||
cx="4"
|
||||
cy="4"
|
||||
r="0.85"
|
||||
fill="rgb(99, 152, 191)"
|
||||
></circle>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="url(#pattern-circles)"
|
||||
></rect>
|
||||
</>
|
||||
)}
|
||||
<g
|
||||
style={{
|
||||
transform: `translate(${transform.pan?.x}px, ${transform.pan?.y}px) scale(${transform.zoom})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
id="diagram"
|
||||
>
|
||||
{areas.map((a) => (
|
||||
<Area
|
||||
key={a.id}
|
||||
data={a}
|
||||
onMouseDown={(e) =>
|
||||
handleMouseDownOnElement(e, a.id, ObjectType.AREA)
|
||||
}
|
||||
setResize={setAreaResize}
|
||||
setInitCoords={setInitCoords}
|
||||
></Area>
|
||||
))}
|
||||
{relationships.map((e, i) => (
|
||||
<Relationship key={i} data={e} />
|
||||
))}
|
||||
{tables.map((table) => (
|
||||
<Table
|
||||
key={table.id}
|
||||
tableData={table}
|
||||
setHoveredTable={setHoveredTable}
|
||||
handleGripField={handleGripField}
|
||||
setLinkingLine={setLinkingLine}
|
||||
onMouseDown={(e) =>
|
||||
handleMouseDownOnElement(e, table.id, ObjectType.TABLE)
|
||||
}
|
||||
active={
|
||||
selectedElement.element === ObjectType.TABLE &&
|
||||
selectedElement.id === table.id
|
||||
}
|
||||
moving={
|
||||
dragging.element === ObjectType.TABLE &&
|
||||
dragging.id === table.id
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{linking && (
|
||||
<path
|
||||
d={`M ${linkingLink.startX} ${linkingLink.startY} L ${linkingLink.endX} ${linkingLink.endY}`}
|
||||
stroke="red"
|
||||
strokeDasharray="8,8"
|
||||
/>
|
||||
)}
|
||||
{notes.map((n) => (
|
||||
<Note
|
||||
key={n.id}
|
||||
data={n}
|
||||
onMouseDown={(e) =>
|
||||
handleMouseDownOnElement(e, n.id, ObjectType.NOTE)
|
||||
}
|
||||
></Note>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
src/components/EditorCanvas/Note.jsx
Normal file
287
src/components/EditorCanvas/Note.jsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useState } from "react";
|
||||
import { Action, ObjectType, noteThemes, Tab, State } from "../../data/constants";
|
||||
import { Input, Button, Popover, Toast } from "@douyinfe/semi-ui";
|
||||
import {
|
||||
IconEdit,
|
||||
IconDeleteStroked,
|
||||
IconCheckboxTick,
|
||||
} from "@douyinfe/semi-icons";
|
||||
import useLayout from "../../hooks/useLayout";
|
||||
import useUndoRedo from "../../hooks/useUndoRedo";
|
||||
import useSelect from "../../hooks/useSelect";
|
||||
import useNotes from "../../hooks/useNotes";
|
||||
import useSaveState from "../../hooks/useSaveState";
|
||||
|
||||
export default function Note({ data, onMouseDown }) {
|
||||
const w = 180;
|
||||
const r = 3;
|
||||
const fold = 24;
|
||||
const [editField, setEditField] = useState({});
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { layout } = useLayout();
|
||||
const { setSaveState } = useSaveState();
|
||||
const { updateNote, deleteNote } = useNotes();
|
||||
const { setUndoStack, setRedoStack } = useUndoRedo();
|
||||
const { selectedElement, setSelectedElement } = useSelect();
|
||||
|
||||
const handleChange = (e) => {
|
||||
const textarea = document.getElementById(`note_${data.id}`);
|
||||
textarea.style.height = "0";
|
||||
textarea.style.height = textarea.scrollHeight + "px";
|
||||
const newHeight = textarea.scrollHeight + 42;
|
||||
updateNote(data.id, { content: e.target.value, height: newHeight });
|
||||
};
|
||||
|
||||
const handleBlur = (e) => {
|
||||
if (e.target.value === editField.content) return;
|
||||
const textarea = document.getElementById(`note_${data.id}`);
|
||||
textarea.style.height = "0";
|
||||
textarea.style.height = textarea.scrollHeight + "px";
|
||||
const newHeight = textarea.scrollHeight + 16 + 20 + 4;
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
action: Action.EDIT,
|
||||
element: ObjectType.NOTE,
|
||||
nid: data.id,
|
||||
undo: editField,
|
||||
redo: { content: e.target.value, height: newHeight },
|
||||
message: `Edit note content to "${e.target.value}"`,
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
};
|
||||
|
||||
const edit = () => {
|
||||
if (layout.sidebar) {
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
currentTab: Tab.NOTES,
|
||||
}));
|
||||
if (selectedElement.currentTab !== Tab.NOTES) return;
|
||||
document
|
||||
.getElementById(`scroll_note_${data.id}`)
|
||||
.scrollIntoView({ behavior: "smooth" });
|
||||
} else {
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
element: ObjectType.NOTE,
|
||||
id: data.id,
|
||||
open: true,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<path
|
||||
d={`M${data.x + fold} ${data.y} L${data.x + w - r} ${
|
||||
data.y
|
||||
} A${r} ${r} 0 0 1 ${data.x + w} ${data.y + r} L${data.x + w} ${
|
||||
data.y + data.height - r
|
||||
} A${r} ${r} 0 0 1 ${data.x + w - r} ${data.y + data.height} L${
|
||||
data.x + r
|
||||
} ${data.y + data.height} A${r} ${r} 0 0 1 ${data.x} ${
|
||||
data.y + data.height - r
|
||||
} L${data.x} ${data.y + fold}`}
|
||||
fill={data.color}
|
||||
stroke={
|
||||
hovered
|
||||
? "rgb(59 130 246)"
|
||||
: selectedElement.element === ObjectType.NOTE &&
|
||||
selectedElement.id === data.id
|
||||
? "rgb(59 130 246)"
|
||||
: "rgb(168 162 158)"
|
||||
}
|
||||
strokeDasharray={hovered ? 4 : 0}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path
|
||||
d={`M${data.x} ${data.y + fold} L${data.x + fold - r} ${
|
||||
data.y + fold
|
||||
} A${r} ${r} 0 0 0 ${data.x + fold} ${data.y + fold - r} L${
|
||||
data.x + fold
|
||||
} ${data.y} L${data.x} ${data.y + fold} Z`}
|
||||
fill={data.color}
|
||||
stroke={
|
||||
hovered
|
||||
? "rgb(59 130 246)"
|
||||
: selectedElement.element === ObjectType.NOTE &&
|
||||
selectedElement.id === data.id
|
||||
? "rgb(59 130 246)"
|
||||
: "rgb(168 162 158)"
|
||||
}
|
||||
strokeDasharray={hovered ? 4 : 0}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<foreignObject
|
||||
x={data.x}
|
||||
y={data.y}
|
||||
width={w}
|
||||
height={data.height}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div className="text-gray-900 select-none w-full h-full cursor-move px-3 py-2">
|
||||
<label htmlFor={`note_${data.id}`} className="ms-5">
|
||||
{data.title}
|
||||
</label>
|
||||
<textarea
|
||||
id={`note_${data.id}`}
|
||||
value={data.content}
|
||||
onChange={handleChange}
|
||||
onFocus={(e) =>
|
||||
setEditField({
|
||||
content: e.target.value,
|
||||
height: data.height,
|
||||
})
|
||||
}
|
||||
onBlur={handleBlur}
|
||||
className="w-full resize-none outline-none overflow-y-hidden border-none select-none"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
{(hovered ||
|
||||
(selectedElement.element === ObjectType.NOTE &&
|
||||
selectedElement.id === data.id &&
|
||||
selectedElement.open &&
|
||||
!layout.sidebar)) && (
|
||||
<div className="absolute top-2 right-3">
|
||||
<Popover
|
||||
visible={
|
||||
selectedElement.element === ObjectType.NOTE &&
|
||||
selectedElement.id === data.id &&
|
||||
selectedElement.open &&
|
||||
!layout.sidebar
|
||||
}
|
||||
onClickOutSide={() => {
|
||||
if (selectedElement.editFromToolbar) {
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
editFromToolbar: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setSelectedElement((prev) => ({
|
||||
...prev,
|
||||
open: false,
|
||||
}));
|
||||
setSaveState(State.SAVING);
|
||||
}}
|
||||
stopPropagation
|
||||
content={
|
||||
<div className="popover-theme">
|
||||
<div className="font-semibold mb-2 ms-1">Edit note</div>
|
||||
<div className="w-[280px] flex items-center mb-2">
|
||||
<Input
|
||||
value={data.title}
|
||||
placeholder="Title"
|
||||
className="me-2"
|
||||
onChange={(value) =>
|
||||
updateNote(data.id, { title: value })
|
||||
}
|
||||
onFocus={(e) => setEditField({ title: e.target.value })}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value === editField.title) return;
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
action: Action.EDIT,
|
||||
element: ObjectType.NOTE,
|
||||
nid: data.id,
|
||||
undo: editField,
|
||||
redo: { title: e.target.value },
|
||||
message: `Edit note title to "${e.target.value}"`,
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
}}
|
||||
/>
|
||||
<Popover
|
||||
content={
|
||||
<div className="popover-theme">
|
||||
<div className="font-medium mb-1">Theme</div>
|
||||
<hr />
|
||||
<div className="py-3">
|
||||
{noteThemes.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
style={{ backgroundColor: c }}
|
||||
className="p-3 rounded-full mx-1"
|
||||
onClick={() => {
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
action: Action.EDIT,
|
||||
element: ObjectType.NOTE,
|
||||
nid: data.id,
|
||||
undo: { color: data.color },
|
||||
redo: { color: c },
|
||||
message: `Edit note color to ${c}`,
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
updateNote(data.id, { color: c });
|
||||
}}
|
||||
>
|
||||
{data.color === c ? (
|
||||
<IconCheckboxTick
|
||||
style={{ color: "white" }}
|
||||
/>
|
||||
) : (
|
||||
<IconCheckboxTick style={{ color: c }} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
position="rightTop"
|
||||
showArrow
|
||||
>
|
||||
<div
|
||||
className="h-[32px] w-[32px] rounded"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Button
|
||||
icon={<IconDeleteStroked />}
|
||||
type="danger"
|
||||
block
|
||||
onClick={() => {
|
||||
Toast.success(`Note deleted!`);
|
||||
deleteNote(data.id, true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
trigger="custom"
|
||||
position="rightTop"
|
||||
showArrow
|
||||
>
|
||||
<Button
|
||||
icon={<IconEdit />}
|
||||
size="small"
|
||||
theme="solid"
|
||||
style={{
|
||||
backgroundColor: "#2f68ad",
|
||||
opacity: "0.7",
|
||||
}}
|
||||
onClick={edit}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
100
src/components/EditorCanvas/Relationship.jsx
Normal file
100
src/components/EditorCanvas/Relationship.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useRef } from "react";
|
||||
import { Cardinality } from "../../data/constants";
|
||||
import useSettings from "../../hooks/useSettings";
|
||||
import { calcPath } from "../../utils/calcPath";
|
||||
|
||||
export default function Relationship({ data }) {
|
||||
const { settings } = useSettings();
|
||||
const pathRef = useRef();
|
||||
|
||||
let cardinalityStart = "1";
|
||||
let cardinalityEnd = "1";
|
||||
|
||||
switch (data.cardinality) {
|
||||
case Cardinality.MANY_TO_ONE:
|
||||
cardinalityStart = "n";
|
||||
cardinalityEnd = "1";
|
||||
break;
|
||||
case Cardinality.ONE_TO_MANY:
|
||||
cardinalityStart = "1";
|
||||
cardinalityEnd = "n";
|
||||
break;
|
||||
case Cardinality.ONE_TO_ONE:
|
||||
cardinalityStart = "1";
|
||||
cardinalityEnd = "1";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
let cardinalityStartX = 0;
|
||||
let cardinalityEndX = 0;
|
||||
let cardinalityStartY = 0;
|
||||
let cardinalityEndY = 0;
|
||||
|
||||
const cardinalityOffset = 28;
|
||||
|
||||
if (pathRef.current) {
|
||||
const pathLength = pathRef.current.getTotalLength();
|
||||
const point1 = pathRef.current.getPointAtLength(cardinalityOffset);
|
||||
cardinalityStartX = point1.x;
|
||||
cardinalityStartY = point1.y;
|
||||
const point2 = pathRef.current.getPointAtLength(
|
||||
pathLength - cardinalityOffset
|
||||
);
|
||||
cardinalityEndX = point2.x;
|
||||
cardinalityEndY = point2.y;
|
||||
}
|
||||
|
||||
return (
|
||||
<g className="select-none group">
|
||||
<path
|
||||
ref={pathRef}
|
||||
d={calcPath(data.startX, data.endX, data.startY, data.endY)}
|
||||
stroke="gray"
|
||||
className="group-hover:stroke-sky-700"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
cursor="pointer"
|
||||
/>
|
||||
{pathRef.current && settings.showCardinality && (
|
||||
<>
|
||||
<circle
|
||||
cx={cardinalityStartX}
|
||||
cy={cardinalityStartY}
|
||||
r="12"
|
||||
fill="grey"
|
||||
className="group-hover:fill-sky-700"
|
||||
></circle>
|
||||
<text
|
||||
x={cardinalityStartX}
|
||||
y={cardinalityStartY}
|
||||
fill="white"
|
||||
strokeWidth="0.5"
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="middle"
|
||||
>
|
||||
{cardinalityStart}
|
||||
</text>
|
||||
<circle
|
||||
cx={cardinalityEndX}
|
||||
cy={cardinalityEndY}
|
||||
r="12"
|
||||
fill="grey"
|
||||
className="group-hover:fill-sky-700"
|
||||
></circle>
|
||||
<text
|
||||
x={cardinalityEndX}
|
||||
y={cardinalityEndY}
|
||||
fill="white"
|
||||
strokeWidth="0.5"
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="middle"
|
||||
>
|
||||
{cardinalityEnd}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
1362
src/components/EditorCanvas/Table.jsx
Normal file
1362
src/components/EditorCanvas/Table.jsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user