diff --git a/package-lock.json b/package-lock.json index 54398b7..cd67469 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 cff6fa5..d50364d 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 7ebfe5a..2fe98d3 100644 --- a/src/components/EditorCanvas/Area.jsx +++ b/src/components/EditorCanvas/Area.jsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useContext, useRef, useState } from "react"; import { Button, Popover, Input } from "@douyinfe/semi-ui"; import { IconEdit, IconDeleteStroked } from "@douyinfe/semi-icons"; import { @@ -15,16 +15,27 @@ import { useSelect, useAreas, useSaveState, - useTransform, } from "../../hooks"; import ColorPalette from "../ColorPicker"; import { useTranslation } from "react-i18next"; +import { useHover } from "usehooks-ts"; +import { CanvasContext } from "../../context/CanvasContext"; -export default function Area({ data, onPointerDown, 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 }, + }, + } = useContext(CanvasContext); 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, onPointerDown, setResize, setInitCoords }) y: data.y, width: data.width, height: data.height, - pointerX: e.clientX / transform.zoom, - pointerY: e.clientY / transform.zoom, + pointerX: pointer.x, + pointerY: pointer.y, }); }; @@ -84,10 +95,7 @@ export default function Area({ data, onPointerDown, setResize, setInitCoords }) selectedElement.open; return ( - e.isPrimary && setHovered(true)} - onPointerLeave={(e) => e.isPrimary &&setHovered(false)} - > +
{data.name}
- {(hovered || (areaIsSelected() && !layout.sidebar)) && ( + {(isHovered || (areaIsSelected() && !layout.sidebar)) && (
- {hovered && ( + {isHovered && ( <> { if (!e.isPrimary) return; - 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, @@ -98,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, @@ -110,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, @@ -136,11 +140,10 @@ export default function Canvas() { 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 && @@ -150,53 +153,68 @@ 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.pointerX; delete newDims.pointerY; - const pointerX = e.clientX / transform.zoom; - const pointerY = e.clientY / transform.zoom; - setPanning({ isPanning: false, x: 0, y: 0 }); - if (areaResize.dir === "br") { - newDims.width = initCoords.width + (pointerX - initCoords.pointerX); - newDims.height = initCoords.height + (pointerY - initCoords.pointerY); - } else if (areaResize.dir === "tl") { - newDims.x = initCoords.x + (pointerX - initCoords.pointerX); - newDims.y = initCoords.y + (pointerY - initCoords.pointerY); - newDims.width = initCoords.width - (pointerX - initCoords.pointerX); - newDims.height = initCoords.height - (pointerY - initCoords.pointerY); - } else if (areaResize.dir === "tr") { - newDims.y = initCoords.y + (pointerY - initCoords.pointerY); - newDims.width = initCoords.width + (pointerX - initCoords.pointerX); - newDims.height = initCoords.height - (pointerY - initCoords.pointerY); - } else if (areaResize.dir === "bl") { - newDims.x = initCoords.x + (pointerX - initCoords.pointerX); - newDims.width = initCoords.width - (pointerX - initCoords.pointerX); - newDims.height = initCoords.height + (pointerY - initCoords.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 }); @@ -219,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) => { @@ -333,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)) { @@ -372,7 +391,7 @@ export default function Canvas() { }; const handleGripField = () => { - setPanning(false); + setPanning((old) => ({ ...old, isPanning: false })); setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 }); setLinking(true); }; @@ -412,121 +431,126 @@ 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, - })); - }; - - useEffect(() => { - const canvasElement = canvas.current; - canvasElement.addEventListener("wheel", handleMouseWheel, { - passive: false, - }); - return () => { - canvasElement.removeEventListener("wheel", handleMouseWheel); - }; - }); + // Handle mouse wheel scrolling + useEventListener( + "wheel", + (e) => { + e.preventDefault(); + // 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, + })); + }, + canvasRef, + { passive: false }, + ); const theme = localStorage.getItem("theme"); return ( - <> +
-
+
+ {settings.showGrid && ( + + + + + + + + + )} - {settings.showGrid && ( - <> - - - - - - - + {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) => ( - - handlePointerDownOnElement(e, a.id, ObjectType.AREA) - } - setResize={setAreaResize} - setInitCoords={setInitCoords} - /> - ))} - {relationships.map((e, i) => ( - - ))} - {tables.map((table) => ( -
- handlePointerDownOnElement(e, table.id, ObjectType.TABLE) - } - /> - ))} - {linking && ( - - )} - {notes.map((n) => ( - - handlePointerDownOnElement(e, n.id, ObjectType.NOTE) - } - /> - ))} - + {notes.map((n) => ( + + handlePointerDownOnElement(e, n.id, ObjectType.NOTE) + } + /> + ))} {settings.showDebugCoordinates && ( @@ -566,10 +590,10 @@ export default function Canvas() { - - - - + + + +
TODOTODOTODOTODO{viewBox.left.toFixed(2)}{viewBox.top.toFixed(2)}{viewBox.width.toFixed(2)}{viewBox.height.toFixed(2)}
@@ -587,19 +611,19 @@ export default function Canvas() { {t("coordinate_space_screen")} - TODO - TODO + {pointer.spaces.screen.x.toFixed(2)} + {pointer.spaces.screen.y.toFixed(2)} {t("coordinate_space_diagram")} - TODO - TODO + {pointer.spaces.diagram.x.toFixed(2)} + {pointer.spaces.diagram.y.toFixed(2)}
)}
- + ); } diff --git a/src/context/AreasContext.jsx b/src/context/AreasContext.jsx index 1e4d068..551b8b8 100644 --- a/src/context/AreasContext.jsx +++ b/src/context/AreasContext.jsx @@ -23,15 +23,17 @@ export default function AreasContextProvider({ children }) { return temp.map((t, i) => ({ ...t, id: i })); }); } else { + const width = 200; + const height = 200; setAreas((prev) => [ ...prev, { id: prev.length, name: `area_${prev.length}`, - x: -transform.pan.x, - y: -transform.pan.y, - width: 200, - height: 200, + x: transform.pan.x - width / 2, + y: transform.pan.y - height / 2, + width, + height, color: defaultBlue, }, ]); diff --git a/src/context/CanvasContext.js b/src/context/CanvasContext.js new file mode 100644 index 0000000..6ce7926 --- /dev/null +++ b/src/context/CanvasContext.js @@ -0,0 +1,160 @@ +// @ts-check + +import { useTransform } from "../hooks"; +import { createContext, useCallback, useMemo, useState } from "react"; +import { useEventListener, useResizeObserver } from "usehooks-ts"; + +export const CanvasContext = createContext({ + canvas: { + screenSize: { + x: 0, + y: 0, + }, + viewBox: new DOMRect(), + }, + coords: { + toDiagramSpace(coords) { + return coords; + }, + toScreenSpace(coords) { + return coords; + }, + }, + pointer: { + spaces: { + screen: { + x: 0, + y: 0, + }, + diagram: { + x: 0, + y: 0, + }, + }, + style: "default", + setStyle() {}, + }, +}); + +export function useCanvasContextProviderValue(canvasRef) { + const { transform } = useTransform(); + const canvasSize = useResizeObserver({ + ref: canvasRef, + box: "content-box", + }); + const screenSize = useMemo( + () => ({ + x: canvasSize.width ?? 0, + y: canvasSize.height ?? 0, + }), + [canvasSize.height, canvasSize.width], + ); + const viewBoxSize = useMemo( + () => ({ + x: screenSize.x / transform.zoom, + y: screenSize.y / transform.zoom, + }), + [screenSize.x, screenSize.y, transform.zoom], + ); + const viewBox = useMemo( + () => + new DOMRect( + transform.pan.x - viewBoxSize.x / 2, + transform.pan.y - viewBoxSize.y / 2, + viewBoxSize.x, + viewBoxSize.y, + ), + [transform.pan.x, transform.pan.y, viewBoxSize.x, viewBoxSize.y], + ); + + const toDiagramSpace = useCallback( + (coord) => ({ + x: + typeof coord.x === "number" + ? (coord.x / screenSize.x) * viewBox.width + viewBox.left + : undefined, + y: + typeof coord.y === "number" + ? (coord.y / screenSize.y) * viewBox.height + viewBox.top + : undefined, + }), + [ + screenSize.x, + screenSize.y, + viewBox.height, + viewBox.left, + viewBox.top, + viewBox.width, + ], + ); + + const toScreenSpace = useCallback( + (coord) => ({ + x: + typeof coord.x === "number" + ? ((coord.x - viewBox.left) / viewBox.width) * screenSize.x + : undefined, + y: + typeof coord.y === "number" + ? ((coord.y - viewBox.top) / viewBox.height) * screenSize.y + : undefined, + }), + [ + screenSize.x, + screenSize.y, + viewBox.height, + viewBox.left, + viewBox.top, + viewBox.width, + ], + ); + + const [pointerScreenCoords, setPointerScreenCoords] = useState({ + x: 0, + y: 0, + }); + const pointerDiagramCoords = useMemo( + () => toDiagramSpace(pointerScreenCoords), + [pointerScreenCoords, toDiagramSpace], + ); + const [pointerStyle, setPointerStyle] = useState("default"); + + /** + * @param {PointerEvent} e + */ + function detectPointerMovement(e) { + const targetElm = /** @type {HTMLElement | null} */ (e.currentTarget); + if (!e.isPrimary || !targetElm) return; + + const canvasBounds = targetElm.getBoundingClientRect(); + + setPointerScreenCoords({ + x: e.clientX - canvasBounds.left, + y: e.clientY - canvasBounds.top, + }); + } + + // Important for touch screen devices! + useEventListener("pointerdown", detectPointerMovement, canvasRef); + + useEventListener("pointermove", detectPointerMovement, canvasRef); + + return { + canvas: { + screenSize, + viewBox, + }, + coords: { + toDiagramSpace, + toScreenSpace, + }, + pointer: { + spaces: { + screen: pointerScreenCoords, + diagram: pointerDiagramCoords, + }, + style: pointerStyle, + setStyle: setPointerStyle, + }, + }; +} diff --git a/src/context/DiagramContext.jsx b/src/context/DiagramContext.jsx index 9797c54..e77bd5f 100644 --- a/src/context/DiagramContext.jsx +++ b/src/context/DiagramContext.jsx @@ -30,8 +30,8 @@ export default function DiagramContextProvider({ children }) { { id: prev.length, name: `table_${prev.length}`, - x: -transform.pan.x, - y: -transform.pan.y, + x: transform.pan.x, + y: transform.pan.y, fields: [ { name: "id", diff --git a/src/context/NotesContext.jsx b/src/context/NotesContext.jsx index 44950da..6df2ddf 100644 --- a/src/context/NotesContext.jsx +++ b/src/context/NotesContext.jsx @@ -23,16 +23,17 @@ export default function NotesContextProvider({ children }) { return temp.map((t, i) => ({ ...t, id: i })); }); } else { + const height = 88; setNotes((prev) => [ ...prev, { id: prev.length, - x: -transform.pan.x, - y: -transform.pan.y, + x: transform.pan.x, + y: transform.pan.y - height / 2, title: `note_${prev.length}`, content: "", color: defaultNoteTheme, - height: 88, + height, }, ]); } diff --git a/src/context/SettingsContext.jsx b/src/context/SettingsContext.jsx index 8e07a7b..7e5a8b9 100644 --- a/src/context/SettingsContext.jsx +++ b/src/context/SettingsContext.jsx @@ -1,7 +1,17 @@ import { createContext, useState } from "react"; import { tableWidth } from "../data/constants"; -export const SettingsContext = createContext(null); +export const SettingsContext = createContext({ + strictMode: false, + showFieldSummary: true, + showGrid: true, + mode: "light", + autosave: true, + panning: true, + showCardinality: true, + tableWidth: tableWidth, + showDebugCoordinates: false, +}); export default function SettingsContextProvider({ children }) { const [settings, setSettings] = useState({ @@ -13,7 +23,7 @@ export default function SettingsContextProvider({ children }) { panning: true, showCardinality: true, tableWidth: tableWidth, - showCursorCoordinates: false, + showDebugCoordinates: false, }); return ( diff --git a/src/context/TransformContext.jsx b/src/context/TransformContext.jsx index b486f8d..911ce47 100644 --- a/src/context/TransformContext.jsx +++ b/src/context/TransformContext.jsx @@ -9,10 +9,11 @@ export default function TransformContextProvider({ children }) { }); /** - * @type {typeof setTransformInternal} + * @type {typeof DrawDB.TransformContext["setTransform"]} */ const setTransform = useCallback( (actionOrValue) => { + const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const findFirstNumber = (...values) => values.find((value) => typeof value === "number" && !isNaN(value)); diff --git a/src/context/UndoRedoContext.jsx b/src/context/UndoRedoContext.jsx index bee4f31..f878d27 100644 --- a/src/context/UndoRedoContext.jsx +++ b/src/context/UndoRedoContext.jsx @@ -1,6 +1,11 @@ import { createContext, useState } from "react"; -export const UndoRedoContext = createContext(null); +export const UndoRedoContext = createContext({ + undoStack: [], + setUndoStack: () => {}, + redoStack: [], + setRedoStack: () => {}, +}); export default function UndoRedoContextProvider({ children }) { const [undoStack, setUndoStack] = useState([]);