diff --git a/package-lock.json b/package-lock.json index 54398b7a..cd67469a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@uiw/react-codemirror": "^4.21.25", "@vercel/analytics": "^1.2.2", "axios": "^1.6.2", + "classnames": "^2.5.1", "dexie": "^3.2.4", "dexie-react-hooks": "^1.1.7", "file-saver": "^2.0.5", @@ -33,7 +34,8 @@ "react-hotkeys-hook": "^4.4.1", "react-i18next": "^14.1.1", "react-router-dom": "^6.21.0", - "url": "^0.11.1" + "url": "^0.11.1", + "usehooks-ts": "^3.1.0" }, "devDependencies": { "@types/react": "^18.2.43", @@ -4283,6 +4285,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5882,6 +5889,20 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" }, + "node_modules/usehooks-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz", + "integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index cff6fa59..d50364da 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@uiw/react-codemirror": "^4.21.25", "@vercel/analytics": "^1.2.2", "axios": "^1.6.2", + "classnames": "^2.5.1", "dexie": "^3.2.4", "dexie-react-hooks": "^1.1.7", "file-saver": "^2.0.5", @@ -35,7 +36,8 @@ "react-hotkeys-hook": "^4.4.1", "react-i18next": "^14.1.1", "react-router-dom": "^6.21.0", - "url": "^0.11.1" + "url": "^0.11.1", + "usehooks-ts": "^3.1.0" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/src/components/EditorCanvas/Area.jsx b/src/components/EditorCanvas/Area.jsx index 5480217a..a5aca7e1 100644 --- a/src/components/EditorCanvas/Area.jsx +++ b/src/components/EditorCanvas/Area.jsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Button, Popover, Input } from "@douyinfe/semi-ui"; import { IconEdit, IconDeleteStroked } from "@douyinfe/semi-icons"; import { @@ -9,22 +9,33 @@ import { State, } from "../../data/constants"; import { + useCanvas, useLayout, useSettings, useUndoRedo, useSelect, useAreas, useSaveState, - useTransform, } from "../../hooks"; import ColorPalette from "../ColorPicker"; import { useTranslation } from "react-i18next"; +import { useHover } from "usehooks-ts"; -export default function Area({ data, onMouseDown, setResize, setInitCoords }) { - const [hovered, setHovered] = useState(false); +export default function Area({ + data, + onPointerDown, + setResize, + setInitCoords, +}) { + const ref = useRef(null); + const isHovered = useHover(ref); + const { + pointer: { + spaces: { diagram: pointer }, + }, + } = useCanvas(); const { layout } = useLayout(); const { settings } = useSettings(); - const { transform } = useTransform(); const { setSaveState } = useSaveState(); const { selectedElement, setSelectedElement } = useSelect(); @@ -35,8 +46,8 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) { y: data.y, width: data.width, height: data.height, - mouseX: e.clientX / transform.zoom, - mouseY: e.clientY / transform.zoom, + pointerX: pointer.x, + pointerY: pointer.y, }); }; @@ -84,21 +95,18 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) { selectedElement.open; return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > + 0 ? data.width : 0} height={data.height > 0 ? data.height : 0} - onMouseDown={onMouseDown} + onPointerDown={onPointerDown} >
{data.name}
- {(hovered || (areaIsSelected() && !layout.sidebar)) && ( + {(isHovered || (areaIsSelected() && !layout.sidebar)) && (
- {hovered && ( + {isHovered && ( <> handleResize(e, "tl")} + onPointerDown={(e) => e.isPrimary && handleResize(e, "tl")} /> handleResize(e, "tr")} + onPointerDown={(e) => e.isPrimary && handleResize(e, "tr")} /> handleResize(e, "bl")} + onPointerDown={(e) => e.isPrimary && handleResize(e, "bl")} /> handleResize(e, "br")} + onPointerDown={(e) => e.isPrimary && handleResize(e, "br")} /> )} diff --git a/src/components/EditorCanvas/Canvas.jsx b/src/components/EditorCanvas/Canvas.jsx index 0dab1b9a..b0cee7d7 100644 --- a/src/components/EditorCanvas/Canvas.jsx +++ b/src/components/EditorCanvas/Canvas.jsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect } from "react"; +import { useRef, useState } from "react"; import { Action, Cardinality, @@ -11,6 +11,7 @@ import Area from "./Area"; import Relationship from "./Relationship"; import Note from "./Note"; import { + useCanvas, useSettings, useTransform, useDiagram, @@ -22,9 +23,18 @@ import { } from "../../hooks"; import { useTranslation } from "react-i18next"; import { diagram } from "../../data/heroDiagram"; +import { useEventListener } from "usehooks-ts"; export default function Canvas() { const { t } = useTranslation(); + + const canvasRef = useRef(null); + const canvasContextValue = useCanvas(); + const { + canvas: { viewBox }, + pointer, + } = canvasContextValue; + const { tables, updateTable, relationships, addRelationship } = useDiagram(); const { areas, updateArea } = useAreas(); const { notes, updateNote } = useNotes(); @@ -50,17 +60,15 @@ export default function Canvas() { endX: 0, endY: 0, }); - const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [grabOffset, setGrabOffset] = 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, + panStart: { x: 0, y: 0 }, + cursorStart: { x: 0, y: 0 }, }); const [areaResize, setAreaResize] = useState({ id: -1, dir: "none" }); const [initCoords, setInitCoords] = useState({ @@ -68,20 +76,23 @@ export default function Canvas() { y: 0, width: 0, height: 0, - mouseX: 0, - mouseY: 0, + pointerX: 0, + pointerY: 0, }); - const [cursor, setCursor] = useState("default"); - const canvas = useRef(null); + /** + * @param {PointerEvent} e + * @param {*} id + * @param {ObjectType[keyof ObjectType]} type + */ + const handlePointerDownOnElement = (e, id, type) => { + if (!e.isPrimary) return; - 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, + setGrabOffset({ + x: table.x - pointer.spaces.diagram.x, + y: table.y - pointer.spaces.diagram.y, }); setDragging({ element: type, @@ -91,9 +102,9 @@ export default function Canvas() { }); } 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, + setGrabOffset({ + x: area.x - pointer.spaces.diagram.x, + y: area.y - pointer.spaces.diagram.y, }); setDragging({ element: type, @@ -103,9 +114,9 @@ export default function Canvas() { }); } 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, + setGrabOffset({ + x: note.x - pointer.spaces.diagram.x, + y: note.y - pointer.spaces.diagram.y, }); setDragging({ element: type, @@ -122,13 +133,17 @@ export default function Canvas() { })); }; - const handleMouseMove = (e) => { + /** + * @param {PointerEvent} e + */ + const handlePointerMove = (e) => { + if (!e.isPrimary) return; + if (linking) { - const rect = canvas.current.getBoundingClientRect(); setLinkingLine({ ...linkingLine, - endX: (e.clientX - rect.left - transform.pan?.x) / transform.zoom, - endY: (e.clientY - rect.top - transform.pan?.y) / transform.zoom, + endX: pointer.spaces.diagram.x, + endY: pointer.spaces.diagram.y, }); } else if ( panning.isPanning && @@ -138,60 +153,80 @@ export default function Canvas() { 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 }, + pan: { + x: + panning.panStart.x + + (panning.cursorStart.x - pointer.spaces.screen.x) / transform.zoom, + y: + panning.panStart.y + + (panning.cursorStart.y - pointer.spaces.screen.y) / transform.zoom, + }, })); - 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 }); + updateTable(dragging.id, { + x: pointer.spaces.diagram.x + grabOffset.x, + y: pointer.spaces.diagram.y + grabOffset.y, + }); } 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 }); + updateArea(dragging.id, { + x: pointer.spaces.diagram.x + grabOffset.x, + y: pointer.spaces.diagram.y + grabOffset.y, + }); } 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 }); + updateNote(dragging.id, { + x: pointer.spaces.diagram.x + grabOffset.x, + y: pointer.spaces.diagram.y + grabOffset.y, + }); } 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); + delete newDims.pointerX; + delete newDims.pointerY; + setPanning((old) => ({ ...old, isPanning: false })); + + switch (areaResize.dir) { + case "br": + newDims.width = pointer.spaces.diagram.x - initCoords.x; + newDims.height = pointer.spaces.diagram.y - initCoords.y; + break; + case "tl": + newDims.x = pointer.spaces.diagram.x; + newDims.y = pointer.spaces.diagram.y; + newDims.width = + initCoords.x + initCoords.width - pointer.spaces.diagram.x; + newDims.height = + initCoords.y + initCoords.height - pointer.spaces.diagram.y; + break; + case "tr": + newDims.y = pointer.spaces.diagram.y; + newDims.width = pointer.spaces.diagram.x - initCoords.x; + newDims.height = + initCoords.y + initCoords.height - pointer.spaces.diagram.y; + break; + case "bl": + newDims.x = pointer.spaces.diagram.x; + newDims.width = + initCoords.x + initCoords.width - pointer.spaces.diagram.x; + newDims.height = pointer.spaces.diagram.y - initCoords.y; + break; } updateArea(areaResize.id, { ...newDims }); } }; - const handleMouseDown = (e) => { + /** + * @param {PointerEvent} e + */ + const handlePointerDown = (e) => { + if (!e.isPrimary) return; + // don't pan if the sidesheet for editing a table is open if ( selectedElement.element === ObjectType.TABLE && @@ -202,11 +237,12 @@ export default function Canvas() { setPanning({ isPanning: true, - ...transform.pan, - dx: e.clientX, - dy: e.clientY, + panStart: transform.pan, + // Diagram space depends on the current panning. + // Use screen space to avoid circular dependencies and undefined behavior. + cursorStart: pointer.spaces.screen, }); - setCursor("grabbing"); + pointer.setStyle("grabbing"); }; const coordsDidUpdate = (element) => { @@ -241,7 +277,7 @@ export default function Canvas() { }; const didPan = () => - !(transform.pan?.x === panning.x && transform.pan?.y === panning.y); + !(transform.pan.x === panning.x && transform.pan.y === panning.y); const getMovedElementDetails = () => { switch (dragging.element) { @@ -268,7 +304,12 @@ export default function Canvas() { } }; - const handleMouseUp = () => { + /** + * @param {PointerEvent} e + */ + const handlePointerUp = (e) => { + if (!e.isPrimary) return; + if (coordsDidUpdate(dragging.element)) { const info = getMovedElementDetails(); setUndoStack((prev) => [ @@ -311,8 +352,8 @@ export default function Canvas() { open: false, })); } - setPanning({ isPanning: false, x: 0, y: 0 }); - setCursor("default"); + setPanning((old) => ({ ...old, isPanning: false })); + pointer.setStyle("default"); if (linking) handleLinking(); setLinking(false); if (areaResize.id !== -1 && didResize(areaResize.id)) { @@ -344,13 +385,13 @@ export default function Canvas() { y: 0, width: 0, height: 0, - mouseX: 0, - mouseY: 0, + pointerX: 0, + pointerY: 0, }); }; const handleGripField = () => { - setPanning(false); + setPanning((old) => ({ ...old, isPanning: false })); setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 }); setLinking(true); }; @@ -390,121 +431,217 @@ export default function Canvas() { addRelationship(newRelationship); }; - const handleMouseWheel = (e) => { - e.preventDefault(); - setTransform((prev) => ({ - ...prev, - zoom: e.deltaY <= 0 ? prev.zoom * 1.05 : prev.zoom / 1.05, - })); - }; + // Handle mouse wheel scrolling + useEventListener( + "wheel", + (e) => { + e.preventDefault(); - useEffect(() => { - const canvasElement = canvas.current; - canvasElement.addEventListener("wheel", handleMouseWheel, { - passive: false, - }); - return () => { - canvasElement.removeEventListener("wheel", handleMouseWheel); - }; - }); + if (e.ctrlKey) { + // How "eager" the viewport is to + // center the cursor's coordinates + const eagernessFactor = 0.05; + setTransform((prev) => ({ + pan: { + x: + prev.pan.x - + (pointer.spaces.diagram.x - prev.pan.x) * + eagernessFactor * + Math.sign(e.deltaY), + y: + prev.pan.y - + (pointer.spaces.diagram.y - prev.pan.y) * + eagernessFactor * + Math.sign(e.deltaY), + }, + zoom: e.deltaY <= 0 ? prev.zoom * 1.05 : prev.zoom / 1.05, + })); + } else if (e.shiftKey) { + setTransform((prev) => ({ + ...prev, + pan: { + ...prev.pan, + x: prev.pan.x + e.deltaY / prev.zoom, + }, + })); + } else { + setTransform((prev) => ({ + ...prev, + pan: { + ...prev.pan, + y: prev.pan.y + e.deltaY / prev.zoom, + }, + })); + } + }, + canvasRef, + { passive: false }, + ); const theme = localStorage.getItem("theme"); return ( -
-
- - {settings.showGrid && ( - <> - - - - - - +
+ {settings.showGrid && ( + + + - + width="24" + height="24" + patternUnits="userSpaceOnUse" + patternContentUnits="userSpaceOnUse" + > + + + + + + )} + + {areas.map((a) => ( + + handlePointerDownOnElement(e, a.id, ObjectType.AREA) + } + setResize={setAreaResize} + setInitCoords={setInitCoords} + /> + ))} + {relationships.map((e, i) => ( + + ))} + {tables.map((table) => ( + + handlePointerDownOnElement(e, table.id, ObjectType.TABLE) + } + /> + ))} + {linking && ( + )} - - {areas.map((a) => ( - - handleMouseDownOnElement(e, a.id, ObjectType.AREA) - } - setResize={setAreaResize} - setInitCoords={setInitCoords} - /> - ))} - {relationships.map((e, i) => ( - - ))} - {tables.map((table) => ( -
- handleMouseDownOnElement(e, table.id, ObjectType.TABLE) - } - /> - ))} - {linking && ( - - )} - {notes.map((n) => ( - - handleMouseDownOnElement(e, n.id, ObjectType.NOTE) - } - /> - ))} - + {notes.map((n) => ( + + handlePointerDownOnElement(e, n.id, ObjectType.NOTE) + } + /> + ))} + {settings.showDebugCoordinates && ( +
+
+ + + + + + + + + + + + + + + + + +
+ {t("transform")} +
pan xpan yscale
{transform.pan.x.toFixed(2)}{transform.pan.y.toFixed(2)}{transform.zoom.toFixed(4)}
+ + + + + + + + + + + + + + + + + + + + +
{t("viewbox")}
lefttopwidthheight
{viewBox.left.toFixed(2)}{viewBox.top.toFixed(2)}{viewBox.width.toFixed(2)}{viewBox.height.toFixed(2)}
+ + + + + + + + + + + + + + + + + + + + + + + +
{t("cursor_coordinates")}
{t("coordinate_space")}xy
{t("coordinate_space_screen")}{pointer.spaces.screen.x.toFixed(2)}{pointer.spaces.screen.y.toFixed(2)}
{t("coordinate_space_diagram")}{pointer.spaces.diagram.x.toFixed(2)}{pointer.spaces.diagram.y.toFixed(2)}
+
+ )}
); } diff --git a/src/components/EditorCanvas/Note.jsx b/src/components/EditorCanvas/Note.jsx index e2d67fca..cff89ba8 100644 --- a/src/components/EditorCanvas/Note.jsx +++ b/src/components/EditorCanvas/Note.jsx @@ -21,7 +21,7 @@ import { } from "../../hooks"; import { useTranslation } from "react-i18next"; -export default function Note({ data, onMouseDown }) { +export default function Note({ data, onPointerDown }) { const w = 180; const r = 3; const fold = 24; @@ -83,8 +83,13 @@ export default function Note({ data, onMouseDown }) { return ( setHovered(true)} - onMouseLeave={() => setHovered(false)} + onPointerEnter={(e) => e.isPrimary && setHovered(true)} + onPointerLeave={(e) => e.isPrimary && setHovered(false)} + onPointerDown={(e) => { + // Required for onPointerLeave to trigger when a touch pointer leaves + // https://stackoverflow.com/a/70976017/1137077 + e.target.releasePointerCapture(e.pointerId); + }} >
diff --git a/src/components/EditorCanvas/Table.jsx b/src/components/EditorCanvas/Table.jsx index e0b753a8..20936502 100644 --- a/src/components/EditorCanvas/Table.jsx +++ b/src/components/EditorCanvas/Table.jsx @@ -22,7 +22,7 @@ export default function Table(props) { const [hoveredField, setHoveredField] = useState(-1); const { tableData, - onMouseDown, + onPointerDown, setHoveredTable, handleGripField, setLinkingLine, @@ -67,7 +67,7 @@ export default function Table(props) { width={settings.tableWidth} height={height} className="group drop-shadow-lg rounded-md cursor-move" - onMouseDown={onMouseDown} + onPointerDown={onPointerDown} >
{ + onPointerEnter={(e) => { + if (!e.isPrimary) return; + setHoveredField(index); setHoveredTable({ tableId: tableData.id, field: index, }); }} - onMouseLeave={() => { + onPointerLeave={(e) => { + if (!e.isPrimary) return; + setHoveredField(-1); }} + onPointerDown={(e) => { + // Required for onPointerLeave to trigger when a touch pointer leaves + // https://stackoverflow.com/a/70976017/1137077 + e.target.releasePointerCapture(e.pointerId); + }} >