Bulk select (#395)

* add rect for select

* collect selected elements

* increase note stroke width

* move elements and undo redo

* add icon to toolbar to toggle multiselect mode

* set bulk selected elements to none when panning
This commit is contained in:
1ilit 2025-04-09 01:00:20 +04:00 committed by GitHub
parent 73ed14982c
commit 0efb5470ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 405 additions and 110 deletions

View File

@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { Button, Popover, Input, ColorPicker } from "@douyinfe/semi-ui";
import { IconEdit, IconDeleteStroked } from "@douyinfe/semi-icons";
import { Tab, Action, ObjectType, State } from "../../data/constants";
@ -30,7 +30,8 @@ export default function Area({
const { layout } = useLayout();
const { settings } = useSettings();
const { setSaveState } = useSaveState();
const { selectedElement, setSelectedElement } = useSelect();
const { selectedElement, setSelectedElement, bulkSelectedElements } =
useSelect();
const handleResize = (e, dir) => {
setResize({ id: data.id, dir: dir });
@ -82,11 +83,21 @@ export default function Area({
setSaveState(State.SAVING);
};
const areaIsSelected = () =>
const areaIsOpen = () =>
selectedElement.element === ObjectType.AREA &&
selectedElement.id === data.id &&
selectedElement.open;
const isSelected = useMemo(() => {
return (
(selectedElement.id === data.id &&
selectedElement.element === ObjectType.AREA) ||
bulkSelectedElements.some(
(e) => e.type === ObjectType.AREA && e.id === data.id,
)
);
}, [selectedElement, data, bulkSelectedElements]);
return (
<g ref={ref}>
<foreignObject
@ -101,8 +112,7 @@ export default function Area({
className={`w-full h-full p-2 rounded cursor-move border-2 ${
isHovered
? "border-dashed border-blue-500"
: selectedElement.element === ObjectType.AREA &&
selectedElement.id === data.id
: isSelected
? "border-blue-500 opacity-100"
: "border-slate-400 opacity-100"
}`}
@ -112,9 +122,9 @@ export default function Area({
<div className="text-color select-none overflow-hidden text-ellipsis">
{data.name}
</div>
{(isHovered || (areaIsSelected() && !layout.sidebar)) && (
{(isHovered || (areaIsOpen() && !layout.sidebar)) && (
<Popover
visible={areaIsSelected() && !layout.sidebar}
visible={areaIsOpen() && !layout.sidebar}
onClickOutSide={onClickOutSide}
stopPropagation
content={<EditPopoverContent data={data} />}

View File

@ -5,6 +5,8 @@ import {
Constraint,
darkBgTheme,
ObjectType,
tableFieldHeight,
tableHeaderHeight,
} from "../../data/constants";
import { Toast } from "@douyinfe/semi-ui";
import Table from "./Table";
@ -25,6 +27,7 @@ import {
import { useTranslation } from "react-i18next";
import { useEventListener } from "usehooks-ts";
import { areFieldsCompatible } from "../../utils/utils";
import { getRectFromEndpoints, isInsideRect } from "../../utils/rect";
export default function Canvas() {
const { t } = useTranslation();
@ -44,12 +47,18 @@ export default function Canvas() {
const { settings } = useSettings();
const { setUndoStack, setRedoStack } = useUndoRedo();
const { transform, setTransform } = useTransform();
const { selectedElement, setSelectedElement } = useSelect();
const {
selectedElement,
setSelectedElement,
bulkSelectedElements,
setBulkSelectedElements,
} = useSelect();
const [dragging, setDragging] = useState({
element: ObjectType.NONE,
id: -1,
prevX: 0,
prevY: 0,
initialPositions: [],
});
const [linking, setLinking] = useState(false);
const [linkingLine, setLinkingLine] = useState({
@ -81,10 +90,96 @@ export default function Canvas() {
pointerX: 0,
pointerY: 0,
});
const [bulkSelectRectPts, setBulkSelectRectPts] = useState({
x1: 0,
y1: 0,
x2: 0,
y2: 0,
show: false,
});
const collectSelectedElements = () => {
const rect = getRectFromEndpoints(bulkSelectRectPts);
const elements = [];
tables.forEach((table) => {
if (
isInsideRect(
{
x: table.x,
y: table.y,
width: settings.tableWidth,
height:
table.fields.length * tableFieldHeight + tableHeaderHeight + 7,
},
rect,
)
) {
elements.push({
id: table.id,
type: ObjectType.TABLE,
});
}
});
areas.forEach((area) => {
if (
isInsideRect(
{
x: area.x,
y: area.y,
width: area.width,
height: area.height,
},
rect,
)
) {
elements.push({
id: area.id,
type: ObjectType.AREA,
});
}
});
notes.forEach((note) => {
if (
isInsideRect(
{
x: note.x,
y: note.y,
width: 180,
height: note.height,
},
rect,
)
) {
elements.push({
id: note.id,
type: ObjectType.NOTE,
});
}
});
setBulkSelectedElements(elements);
};
const getElement = (element) => {
switch (element.type) {
case ObjectType.TABLE:
return tables[element.id];
case ObjectType.AREA:
return areas[element.id];
case ObjectType.NOTE:
return notes[element.id];
default:
return { x: 0, y: 0 };
}
};
/**
* @param {PointerEvent} e
* @param {*} id
* @param {number} id
* @param {ObjectType[keyof ObjectType]} type
*/
const handlePointerDownOnElement = (e, id, type) => {
@ -98,36 +193,52 @@ export default function Canvas() {
x: table.x - pointer.spaces.diagram.x,
y: table.y - pointer.spaces.diagram.y,
});
setDragging({
setDragging((prev) => ({
...prev,
id,
element: type,
id: id,
prevX: table.x,
prevY: table.y,
});
}));
} else if (type === ObjectType.AREA) {
const area = areas.find((t) => t.id === id);
setGrabOffset({
x: area.x - pointer.spaces.diagram.x,
y: area.y - pointer.spaces.diagram.y,
});
setDragging({
setDragging((prev) => ({
...prev,
id,
element: type,
id: id,
prevX: area.x,
prevY: area.y,
});
}));
} else if (type === ObjectType.NOTE) {
const note = notes.find((t) => t.id === id);
setGrabOffset({
x: note.x - pointer.spaces.diagram.x,
y: note.y - pointer.spaces.diagram.y,
});
setDragging({
setDragging((prev) => ({
...prev,
id,
element: type,
id: id,
prevX: note.x,
prevY: note.y,
});
}));
}
if (bulkSelectedElements.length) {
setDragging((prev) => ({
...prev,
initialPositions: bulkSelectedElements.map((element) => ({
...element,
undo: {
x: getElement(element).x,
y: getElement(element).y,
},
})),
}));
}
setSelectedElement((prev) => ({
...prev,
@ -151,6 +262,42 @@ export default function Canvas() {
endX: pointer.spaces.diagram.x,
endY: pointer.spaces.diagram.y,
});
} else if (
dragging.element !== ObjectType.NONE &&
dragging.id >= 0 &&
bulkSelectedElements.length
) {
const currentX = pointer.spaces.diagram.x + grabOffset.x;
const currentY = pointer.spaces.diagram.y + grabOffset.y;
const deltaX = currentX - dragging.prevX;
const deltaY = currentY - dragging.prevY;
for (const element of bulkSelectedElements) {
if (element.type === ObjectType.TABLE) {
updateTable(element.id, {
x: tables[element.id].x + deltaX,
y: tables[element.id].y + deltaY,
});
}
if (element.type === ObjectType.AREA) {
updateArea(element.id, {
x: areas[element.id].x + deltaX,
y: areas[element.id].y + deltaY,
});
}
if (element.type === ObjectType.NOTE) {
updateNote(element.id, {
x: notes[element.id].x + deltaX,
y: notes[element.id].y + deltaY,
});
}
}
setDragging((prev) => ({
...prev,
prevX: currentX,
prevY: currentY,
}));
} else if (
panning.isPanning &&
dragging.element === ObjectType.NONE &&
@ -224,6 +371,12 @@ export default function Canvas() {
}
updateArea(areaResize.id, { ...newDims });
} else if (bulkSelectRectPts.show) {
setBulkSelectRectPts((prev) => ({
...prev,
x2: pointer.spaces.diagram.x,
y2: pointer.spaces.diagram.y,
}));
}
};
@ -243,6 +396,16 @@ export default function Canvas() {
)
return;
if (!settings.panning) {
setBulkSelectRectPts({
x1: pointer.spaces.diagram.x,
y1: pointer.spaces.diagram.y,
x2: pointer.spaces.diagram.x,
y2: pointer.spaces.diagram.y,
show: true,
});
pointer.setStyle("crosshair");
} else {
setPanning({
isPanning: true,
panStart: transform.pan,
@ -251,28 +414,21 @@ export default function Canvas() {
cursorStart: pointer.spaces.screen,
});
pointer.setStyle("grabbing");
}
};
const coordsDidUpdate = (element) => {
switch (element) {
case ObjectType.TABLE:
return !(
dragging.prevX === tables[dragging.id].x &&
dragging.prevY === tables[dragging.id].y
const elementData = getElement(element);
const updated = !(
dragging.prevX === elementData.x && dragging.prevY === elementData.y
);
case ObjectType.AREA:
return !(
dragging.prevX === areas[dragging.id].x &&
dragging.prevY === areas[dragging.id].y
return (
updated ||
dragging.initialPositions.some(
(el) => !(el.undo.x === elementData.x && el.undo.y === elementData.y),
)
);
case ObjectType.NOTE:
return !(
dragging.prevX === notes[dragging.id].x &&
dragging.prevY === notes[dragging.id].y
);
default:
return false;
}
};
const didResize = (id) => {
@ -287,31 +443,6 @@ export default function Canvas() {
const didPan = () =>
!(transform.pan.x === panning.x && transform.pan.y === panning.y);
const getMovedElementDetails = () => {
switch (dragging.element) {
case ObjectType.TABLE:
return {
name: tables[dragging.id].name,
x: Math.round(tables[dragging.id].x),
y: Math.round(tables[dragging.id].y),
};
case ObjectType.AREA:
return {
name: areas[dragging.id].name,
x: Math.round(areas[dragging.id].x),
y: Math.round(areas[dragging.id].y),
};
case ObjectType.NOTE:
return {
name: notes[dragging.id].title,
x: Math.round(notes[dragging.id].x),
y: Math.round(notes[dragging.id].y),
};
default:
return false;
}
};
/**
* @param {PointerEvent} e
*/
@ -320,8 +451,34 @@ export default function Canvas() {
if (!e.isPrimary) return;
if (coordsDidUpdate(dragging.element)) {
const info = getMovedElementDetails();
if (coordsDidUpdate({ id: dragging.id, type: dragging.element })) {
if (bulkSelectedElements.length) {
setUndoStack((prev) => [
...prev,
{
action: Action.MOVE,
bulk: true,
message: t("bulk_update"),
elements: dragging.initialPositions.map((element) => ({
...element,
redo: {
x: getElement(element).x,
y: getElement(element).y,
},
})),
},
]);
setSelectedElement((prev) => ({
...prev,
element: ObjectType.NONE,
id: -1,
open: false,
}));
} else {
const element = getElement({
id: dragging.id,
type: dragging.element,
});
setUndoStack((prev) => [
...prev,
{
@ -329,18 +486,39 @@ export default function Canvas() {
element: dragging.element,
x: dragging.prevX,
y: dragging.prevY,
toX: info.x,
toY: info.y,
toX: element.x,
toY: element.y,
id: dragging.id,
message: t("move_element", {
coords: `(${info.x}, ${info.y})`,
name: info.name,
coords: `(${element.x}, ${element.y})`,
name: getElement({
id: dragging.id,
type: dragging.element,
}).name,
}),
},
]);
}
setRedoStack([]);
}
setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 });
setDragging({
element: ObjectType.NONE,
id: -1,
prevX: 0,
prevY: 0,
initialPositions: [],
});
if (bulkSelectRectPts.show) {
setBulkSelectRectPts((prev) => ({
...prev,
x2: pointer.spaces.diagram.x,
y2: pointer.spaces.diagram.y,
show: false,
}));
collectSelectedElements();
}
if (panning.isPanning && didPan()) {
setUndoStack((prev) => [
...prev,
@ -361,11 +539,14 @@ export default function Canvas() {
id: -1,
open: false,
}));
setBulkSelectedElements([]);
}
setPanning((old) => ({ ...old, isPanning: false }));
pointer.setStyle("default");
if (linking) handleLinking();
setLinking(false);
if (areaResize.id !== -1 && didResize(areaResize.id)) {
setUndoStack((prev) => [
...prev,
@ -402,7 +583,13 @@ export default function Canvas() {
const handleGripField = () => {
setPanning((old) => ({ ...old, isPanning: false }));
setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 });
setDragging({
element: ObjectType.NONE,
id: -1,
prevX: 0,
prevY: 0,
initialPositions: [],
});
setLinking(true);
};
@ -444,7 +631,6 @@ export default function Canvas() {
addRelationship(newRelationship);
};
// Handle mouse wheel scrolling
useEventListener(
"wheel",
(e) => {
@ -520,7 +706,7 @@ export default function Canvas() {
cy="4"
r="0.85"
fill="rgb(99, 152, 191)"
></circle>
/>
</pattern>
</defs>
<rect
@ -529,7 +715,7 @@ export default function Canvas() {
width="100%"
height="100%"
fill="url(#pattern-circles)"
></rect>
/>
</svg>
)}
<svg
@ -584,6 +770,15 @@ export default function Canvas() {
}
/>
))}
{bulkSelectRectPts.show && (
<rect
{...getRectFromEndpoints(bulkSelectRectPts)}
stroke="grey"
fill="grey"
fillOpacity={0.15}
strokeDasharray={10}
/>
)}
</svg>
</div>
{settings.showDebugCoordinates && (

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { Action, ObjectType, Tab, State } from "../../data/constants";
import { Input, Button, Popover, ColorPicker } from "@douyinfe/semi-ui";
import { IconEdit, IconDeleteStroked } from "@douyinfe/semi-icons";
@ -22,7 +22,8 @@ export default function Note({ data, onPointerDown }) {
const { setSaveState } = useSaveState();
const { updateNote, deleteNote } = useNotes();
const { setUndoStack, setRedoStack } = useUndoRedo();
const { selectedElement, setSelectedElement } = useSelect();
const { selectedElement, setSelectedElement, bulkSelectedElements } =
useSelect();
const handleChange = (e) => {
const textarea = document.getElementById(`note_${data.id}`);
@ -71,6 +72,16 @@ export default function Note({ data, onPointerDown }) {
}
};
const isSelected = useMemo(() => {
return (
(selectedElement.id === data.id &&
selectedElement.element === ObjectType.NOTE) ||
bulkSelectedElements.some(
(e) => e.type === ObjectType.NOTE && e.id === data.id,
)
);
}, [selectedElement, data, bulkSelectedElements]);
return (
<g
onPointerEnter={(e) => e.isPrimary && setHovered(true)}
@ -95,14 +106,13 @@ export default function Note({ data, onPointerDown }) {
stroke={
hovered
? "rgb(59 130 246)"
: selectedElement.element === ObjectType.NOTE &&
selectedElement.id === data.id
: isSelected
? "rgb(59 130 246)"
: "rgb(168 162 158)"
}
strokeDasharray={hovered ? 4 : 0}
strokeDasharray={hovered ? 5 : 0}
strokeLinejoin="round"
strokeWidth="1.2"
strokeWidth="2"
/>
<path
d={`M${data.x} ${data.y + fold} L${data.x + fold - r} ${
@ -114,14 +124,13 @@ export default function Note({ data, onPointerDown }) {
stroke={
hovered
? "rgb(59 130 246)"
: selectedElement.element === ObjectType.NOTE &&
selectedElement.id === data.id
: isSelected
? "rgb(59 130 246)"
: "rgb(168 162 158)"
}
strokeDasharray={hovered ? 4 : 0}
strokeDasharray={hovered ? 5 : 0}
strokeLinejoin="round"
strokeWidth="1.2"
strokeWidth="2"
/>
<foreignObject
x={data.x}

View File

@ -35,7 +35,8 @@ export default function Table(props) {
const { deleteTable, deleteField } = useDiagram();
const { settings } = useSettings();
const { t } = useTranslation();
const { selectedElement, setSelectedElement } = useSelect();
const { selectedElement, setSelectedElement, bulkSelectedElements } =
useSelect();
const borderColor = useMemo(
() => (settings.mode === "light" ? "border-zinc-300" : "border-zinc-600"),
@ -44,6 +45,15 @@ export default function Table(props) {
const height =
tableData.fields.length * tableFieldHeight + tableHeaderHeight + 7;
const isSelected = useMemo(() => {
return (
(selectedElement.id === tableData.id &&
selectedElement.element === ObjectType.TABLE) ||
bulkSelectedElements.some(
(e) => e.type === ObjectType.TABLE && e.id === tableData.id,
)
);
}, [selectedElement, tableData, bulkSelectedElements]);
const openEditor = () => {
if (!layout.sidebar) {
@ -86,12 +96,7 @@ export default function Table(props) {
settings.mode === "light"
? "bg-zinc-100 text-zinc-800"
: "bg-zinc-800 text-zinc-200"
} ${
selectedElement.id === tableData.id &&
selectedElement.element === ObjectType.TABLE
? "border-solid border-blue-500"
: borderColor
}`}
} ${isSelected ? "border-solid border-blue-500" : borderColor}`}
style={{ direction: "ltr" }}
>
<div

View File

@ -131,6 +131,22 @@ export default function ControlPanel({
if (undoStack.length === 0) return;
const a = undoStack[undoStack.length - 1];
setUndoStack((prev) => prev.filter((_, i) => i !== prev.length - 1));
if (a.bulk) {
for (const element of a.elements) {
if (element.type === ObjectType.TABLE) {
updateTable(element.id, element.undo);
} else if (element.type === ObjectType.AREA) {
updateArea(element.id, element.undo);
} else if (element.type === ObjectType.NOTE) {
updateNote(element.id, element.undo);
}
}
setRedoStack((prev) => [...prev, a]);
console.log(a);
return;
}
if (a.action === Action.ADD) {
if (a.element === ObjectType.TABLE) {
deleteTable(tables[tables.length - 1].id, false);
@ -341,6 +357,21 @@ export default function ControlPanel({
if (redoStack.length === 0) return;
const a = redoStack[redoStack.length - 1];
setRedoStack((prev) => prev.filter((e, i) => i !== prev.length - 1));
if (a.bulk) {
for (const element of a.elements) {
if (element.type === ObjectType.TABLE) {
updateTable(element.id, element.redo);
} else if (element.type === ObjectType.AREA) {
updateArea(element.id, element.redo);
} else if (element.type === ObjectType.NOTE) {
updateNote(element.id, element.redo);
}
}
setUndoStack((prev) => [...prev, a]);
return;
}
if (a.action === Action.ADD) {
if (a.element === ObjectType.TABLE) {
addTable(null, false);
@ -1579,6 +1610,23 @@ export default function ControlPanel({
<i className="fa-solid fa-magnifying-glass-minus" />
</button>
</Tooltip>
<Tooltip
content={settings.panning ? t("multiselect") : t("panning")}
position="bottom"
>
<button
className="py-1 px-2 hover-2 rounded-sm text-lg w-10"
onClick={() =>
setSettings((prev) => ({ ...prev, panning: !prev.panning }))
}
>
{settings.panning ? (
<i className="fa-solid fa-expand" />
) : (
<i className="fa-regular fa-hand"></i>
)}
</button>
</Tooltip>
<Divider layout="vertical" margin="8px" />
<Tooltip content={t("undo")} position="bottom">
<button

View File

@ -13,9 +13,17 @@ export default function SelectContextProvider({ children }) {
open: false, // open popover or sidesheet when sidebar is disabled
openFromToolbar: false, // this is to handle triggering onClickOutside when sidebar is disabled
});
const [bulkSelectedElements, setBulkSelectedElements] = useState([]);
return (
<SelectContext.Provider value={{ selectedElement, setSelectedElement }}>
<SelectContext.Provider
value={{
selectedElement,
setSelectedElement,
bulkSelectedElements,
setBulkSelectedElements,
}}
>
{children}
</SelectContext.Provider>
);

View File

@ -247,6 +247,8 @@ const en = {
show_relationship_labels: "Show relationship labels",
docs: "Docs",
supported_types: "Supported file types:",
bulk_update: "Bulk update",
multiselect: "Multiselect",
},
};

18
src/utils/rect.js Normal file
View File

@ -0,0 +1,18 @@
export function getRectFromEndpoints({ x1, x2, y1, y2 }) {
const width = Math.abs(x1 - x2);
const height = Math.abs(y1 - y2);
const x = Math.min(x1, x2);
const y = Math.min(y1, y2);
return { x, y, width, height };
}
export function isInsideRect(rect1, rect2) {
return (
rect1.x > rect2.x &&
rect1.x + rect1.width < rect2.x + rect2.width &&
rect1.y > rect2.y &&
rect1.y + rect1.height < rect2.y + rect2.height
);
}