Merge pull request #148 from FelixZY/fzy/mobile/1

Basic touchscreen support (#147) and rewrite of coordinate management
This commit is contained in:
1ilit 2024-07-18 00:42:28 +04:00 committed by GitHub
commit 4d0983b3f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 708 additions and 241 deletions

23
package-lock.json generated
View File

@ -17,6 +17,7 @@
"@uiw/react-codemirror": "^4.21.25", "@uiw/react-codemirror": "^4.21.25",
"@vercel/analytics": "^1.2.2", "@vercel/analytics": "^1.2.2",
"axios": "^1.6.2", "axios": "^1.6.2",
"classnames": "^2.5.1",
"dexie": "^3.2.4", "dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7", "dexie-react-hooks": "^1.1.7",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@ -33,7 +34,8 @@
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-i18next": "^14.1.1", "react-i18next": "^14.1.1",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
"url": "^0.11.1" "url": "^0.11.1",
"usehooks-ts": "^3.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
@ -4283,6 +4285,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "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", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -19,6 +19,7 @@
"@uiw/react-codemirror": "^4.21.25", "@uiw/react-codemirror": "^4.21.25",
"@vercel/analytics": "^1.2.2", "@vercel/analytics": "^1.2.2",
"axios": "^1.6.2", "axios": "^1.6.2",
"classnames": "^2.5.1",
"dexie": "^3.2.4", "dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7", "dexie-react-hooks": "^1.1.7",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@ -35,7 +36,8 @@
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-i18next": "^14.1.1", "react-i18next": "^14.1.1",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
"url": "^0.11.1" "url": "^0.11.1",
"usehooks-ts": "^3.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { Button, Popover, Input } from "@douyinfe/semi-ui"; import { Button, Popover, Input } from "@douyinfe/semi-ui";
import { IconEdit, IconDeleteStroked } from "@douyinfe/semi-icons"; import { IconEdit, IconDeleteStroked } from "@douyinfe/semi-icons";
import { import {
@ -9,22 +9,33 @@ import {
State, State,
} from "../../data/constants"; } from "../../data/constants";
import { import {
useCanvas,
useLayout, useLayout,
useSettings, useSettings,
useUndoRedo, useUndoRedo,
useSelect, useSelect,
useAreas, useAreas,
useSaveState, useSaveState,
useTransform,
} from "../../hooks"; } from "../../hooks";
import ColorPalette from "../ColorPicker"; import ColorPalette from "../ColorPicker";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHover } from "usehooks-ts";
export default function Area({ data, onMouseDown, setResize, setInitCoords }) { export default function Area({
const [hovered, setHovered] = useState(false); data,
onPointerDown,
setResize,
setInitCoords,
}) {
const ref = useRef(null);
const isHovered = useHover(ref);
const {
pointer: {
spaces: { diagram: pointer },
},
} = useCanvas();
const { layout } = useLayout(); const { layout } = useLayout();
const { settings } = useSettings(); const { settings } = useSettings();
const { transform } = useTransform();
const { setSaveState } = useSaveState(); const { setSaveState } = useSaveState();
const { selectedElement, setSelectedElement } = useSelect(); const { selectedElement, setSelectedElement } = useSelect();
@ -35,8 +46,8 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
y: data.y, y: data.y,
width: data.width, width: data.width,
height: data.height, height: data.height,
mouseX: e.clientX / transform.zoom, pointerX: pointer.x,
mouseY: e.clientY / transform.zoom, pointerY: pointer.y,
}); });
}; };
@ -84,21 +95,18 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
selectedElement.open; selectedElement.open;
return ( return (
<g <g ref={ref}>
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<foreignObject <foreignObject
key={data.id} key={data.id}
x={data.x} x={data.x}
y={data.y} y={data.y}
width={data.width > 0 ? data.width : 0} width={data.width > 0 ? data.width : 0}
height={data.height > 0 ? data.height : 0} height={data.height > 0 ? data.height : 0}
onMouseDown={onMouseDown} onPointerDown={onPointerDown}
> >
<div <div
className={`border-2 ${ className={`border-2 ${
hovered isHovered
? "border-dashed border-blue-500" ? "border-dashed border-blue-500"
: selectedElement.element === ObjectType.AREA && : selectedElement.element === ObjectType.AREA &&
selectedElement.id === data.id selectedElement.id === data.id
@ -114,7 +122,7 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
<div className="text-color select-none overflow-hidden text-ellipsis"> <div className="text-color select-none overflow-hidden text-ellipsis">
{data.name} {data.name}
</div> </div>
{(hovered || (areaIsSelected() && !layout.sidebar)) && ( {(isHovered || (areaIsSelected() && !layout.sidebar)) && (
<Popover <Popover
visible={areaIsSelected() && !layout.sidebar} visible={areaIsSelected() && !layout.sidebar}
onClickOutSide={onClickOutSide} onClickOutSide={onClickOutSide}
@ -139,7 +147,7 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
</div> </div>
</div> </div>
</foreignObject> </foreignObject>
{hovered && ( {isHovered && (
<> <>
<circle <circle
cx={data.x} cx={data.x}
@ -149,7 +157,7 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
stroke="#5891db" stroke="#5891db"
strokeWidth={2} strokeWidth={2}
cursor="nwse-resize" cursor="nwse-resize"
onMouseDown={(e) => handleResize(e, "tl")} onPointerDown={(e) => e.isPrimary && handleResize(e, "tl")}
/> />
<circle <circle
cx={data.x + data.width} cx={data.x + data.width}
@ -159,7 +167,7 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
stroke="#5891db" stroke="#5891db"
strokeWidth={2} strokeWidth={2}
cursor="nesw-resize" cursor="nesw-resize"
onMouseDown={(e) => handleResize(e, "tr")} onPointerDown={(e) => e.isPrimary && handleResize(e, "tr")}
/> />
<circle <circle
cx={data.x} cx={data.x}
@ -169,7 +177,7 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
stroke="#5891db" stroke="#5891db"
strokeWidth={2} strokeWidth={2}
cursor="nesw-resize" cursor="nesw-resize"
onMouseDown={(e) => handleResize(e, "bl")} onPointerDown={(e) => e.isPrimary && handleResize(e, "bl")}
/> />
<circle <circle
cx={data.x + data.width} cx={data.x + data.width}
@ -179,7 +187,7 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
stroke="#5891db" stroke="#5891db"
strokeWidth={2} strokeWidth={2}
cursor="nwse-resize" cursor="nwse-resize"
onMouseDown={(e) => handleResize(e, "br")} onPointerDown={(e) => e.isPrimary && handleResize(e, "br")}
/> />
</> </>
)} )}

View File

@ -1,4 +1,4 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState } from "react";
import { import {
Action, Action,
Cardinality, Cardinality,
@ -11,6 +11,7 @@ import Area from "./Area";
import Relationship from "./Relationship"; import Relationship from "./Relationship";
import Note from "./Note"; import Note from "./Note";
import { import {
useCanvas,
useSettings, useSettings,
useTransform, useTransform,
useDiagram, useDiagram,
@ -22,9 +23,18 @@ import {
} from "../../hooks"; } from "../../hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { diagram } from "../../data/heroDiagram"; import { diagram } from "../../data/heroDiagram";
import { useEventListener } from "usehooks-ts";
export default function Canvas() { export default function Canvas() {
const { t } = useTranslation(); const { t } = useTranslation();
const canvasRef = useRef(null);
const canvasContextValue = useCanvas();
const {
canvas: { viewBox },
pointer,
} = canvasContextValue;
const { tables, updateTable, relationships, addRelationship } = useDiagram(); const { tables, updateTable, relationships, addRelationship } = useDiagram();
const { areas, updateArea } = useAreas(); const { areas, updateArea } = useAreas();
const { notes, updateNote } = useNotes(); const { notes, updateNote } = useNotes();
@ -50,17 +60,15 @@ export default function Canvas() {
endX: 0, endX: 0,
endY: 0, endY: 0,
}); });
const [offset, setOffset] = useState({ x: 0, y: 0 }); const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
const [hoveredTable, setHoveredTable] = useState({ const [hoveredTable, setHoveredTable] = useState({
tableId: -1, tableId: -1,
field: -2, field: -2,
}); });
const [panning, setPanning] = useState({ const [panning, setPanning] = useState({
isPanning: false, isPanning: false,
x: 0, panStart: { x: 0, y: 0 },
y: 0, cursorStart: { x: 0, y: 0 },
dx: 0,
dy: 0,
}); });
const [areaResize, setAreaResize] = useState({ id: -1, dir: "none" }); const [areaResize, setAreaResize] = useState({ id: -1, dir: "none" });
const [initCoords, setInitCoords] = useState({ const [initCoords, setInitCoords] = useState({
@ -68,20 +76,23 @@ export default function Canvas() {
y: 0, y: 0,
width: 0, width: 0,
height: 0, height: 0,
mouseX: 0, pointerX: 0,
mouseY: 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) { if (type === ObjectType.TABLE) {
const table = tables.find((t) => t.id === id); const table = tables.find((t) => t.id === id);
setOffset({ setGrabOffset({
x: clientX / transform.zoom - table.x, x: table.x - pointer.spaces.diagram.x,
y: clientY / transform.zoom - table.y, y: table.y - pointer.spaces.diagram.y,
}); });
setDragging({ setDragging({
element: type, element: type,
@ -91,9 +102,9 @@ export default function Canvas() {
}); });
} else if (type === ObjectType.AREA) { } else if (type === ObjectType.AREA) {
const area = areas.find((t) => t.id === id); const area = areas.find((t) => t.id === id);
setOffset({ setGrabOffset({
x: clientX / transform.zoom - area.x, x: area.x - pointer.spaces.diagram.x,
y: clientY / transform.zoom - area.y, y: area.y - pointer.spaces.diagram.y,
}); });
setDragging({ setDragging({
element: type, element: type,
@ -103,9 +114,9 @@ export default function Canvas() {
}); });
} else if (type === ObjectType.NOTE) { } else if (type === ObjectType.NOTE) {
const note = notes.find((t) => t.id === id); const note = notes.find((t) => t.id === id);
setOffset({ setGrabOffset({
x: clientX / transform.zoom - note.x, x: note.x - pointer.spaces.diagram.x,
y: clientY / transform.zoom - note.y, y: note.y - pointer.spaces.diagram.y,
}); });
setDragging({ setDragging({
element: type, 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) { if (linking) {
const rect = canvas.current.getBoundingClientRect();
setLinkingLine({ setLinkingLine({
...linkingLine, ...linkingLine,
endX: (e.clientX - rect.left - transform.pan?.x) / transform.zoom, endX: pointer.spaces.diagram.x,
endY: (e.clientY - rect.top - transform.pan?.y) / transform.zoom, endY: pointer.spaces.diagram.y,
}); });
} else if ( } else if (
panning.isPanning && panning.isPanning &&
@ -138,60 +153,80 @@ export default function Canvas() {
if (!settings.panning) { if (!settings.panning) {
return; return;
} }
const dx = e.clientX - panning.dx;
const dy = e.clientY - panning.dy;
setTransform((prev) => ({ setTransform((prev) => ({
...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) { } else if (dragging.element === ObjectType.TABLE && dragging.id >= 0) {
const dx = e.clientX / transform.zoom - offset.x; updateTable(dragging.id, {
const dy = e.clientY / transform.zoom - offset.y; x: pointer.spaces.diagram.x + grabOffset.x,
updateTable(dragging.id, { x: dx, y: dy }); y: pointer.spaces.diagram.y + grabOffset.y,
});
} else if ( } else if (
dragging.element === ObjectType.AREA && dragging.element === ObjectType.AREA &&
dragging.id >= 0 && dragging.id >= 0 &&
areaResize.id === -1 areaResize.id === -1
) { ) {
const dx = e.clientX / transform.zoom - offset.x; updateArea(dragging.id, {
const dy = e.clientY / transform.zoom - offset.y; x: pointer.spaces.diagram.x + grabOffset.x,
updateArea(dragging.id, { x: dx, y: dy }); y: pointer.spaces.diagram.y + grabOffset.y,
});
} else if (dragging.element === ObjectType.NOTE && dragging.id >= 0) { } else if (dragging.element === ObjectType.NOTE && dragging.id >= 0) {
const dx = e.clientX / transform.zoom - offset.x; updateNote(dragging.id, {
const dy = e.clientY / transform.zoom - offset.y; x: pointer.spaces.diagram.x + grabOffset.x,
updateNote(dragging.id, { x: dx, y: dy }); y: pointer.spaces.diagram.y + grabOffset.y,
});
} else if (areaResize.id !== -1) { } else if (areaResize.id !== -1) {
if (areaResize.dir === "none") return; if (areaResize.dir === "none") return;
let newDims = { ...initCoords }; let newDims = { ...initCoords };
delete newDims.mouseX; delete newDims.pointerX;
delete newDims.mouseY; delete newDims.pointerY;
const mouseX = e.clientX / transform.zoom; setPanning((old) => ({ ...old, isPanning: false }));
const mouseY = e.clientY / transform.zoom;
setPanning({ isPanning: false, x: 0, y: 0 }); switch (areaResize.dir) {
if (areaResize.dir === "br") { case "br":
newDims.width = initCoords.width + (mouseX - initCoords.mouseX); newDims.width = pointer.spaces.diagram.x - initCoords.x;
newDims.height = initCoords.height + (mouseY - initCoords.mouseY); newDims.height = pointer.spaces.diagram.y - initCoords.y;
} else if (areaResize.dir === "tl") { break;
newDims.x = initCoords.x + (mouseX - initCoords.mouseX); case "tl":
newDims.y = initCoords.y + (mouseY - initCoords.mouseY); newDims.x = pointer.spaces.diagram.x;
newDims.width = initCoords.width - (mouseX - initCoords.mouseX); newDims.y = pointer.spaces.diagram.y;
newDims.height = initCoords.height - (mouseY - initCoords.mouseY); newDims.width =
} else if (areaResize.dir === "tr") { initCoords.x + initCoords.width - pointer.spaces.diagram.x;
newDims.y = initCoords.y + (mouseY - initCoords.mouseY); newDims.height =
newDims.width = initCoords.width + (mouseX - initCoords.mouseX); initCoords.y + initCoords.height - pointer.spaces.diagram.y;
newDims.height = initCoords.height - (mouseY - initCoords.mouseY); break;
} else if (areaResize.dir === "bl") { case "tr":
newDims.x = initCoords.x + (mouseX - initCoords.mouseX); newDims.y = pointer.spaces.diagram.y;
newDims.width = initCoords.width - (mouseX - initCoords.mouseX); newDims.width = pointer.spaces.diagram.x - initCoords.x;
newDims.height = initCoords.height + (mouseY - initCoords.mouseY); 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 }); 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 // don't pan if the sidesheet for editing a table is open
if ( if (
selectedElement.element === ObjectType.TABLE && selectedElement.element === ObjectType.TABLE &&
@ -202,11 +237,12 @@ export default function Canvas() {
setPanning({ setPanning({
isPanning: true, isPanning: true,
...transform.pan, panStart: transform.pan,
dx: e.clientX, // Diagram space depends on the current panning.
dy: e.clientY, // Use screen space to avoid circular dependencies and undefined behavior.
cursorStart: pointer.spaces.screen,
}); });
setCursor("grabbing"); pointer.setStyle("grabbing");
}; };
const coordsDidUpdate = (element) => { const coordsDidUpdate = (element) => {
@ -241,7 +277,7 @@ export default function Canvas() {
}; };
const didPan = () => const didPan = () =>
!(transform.pan?.x === panning.x && transform.pan?.y === panning.y); !(transform.pan.x === panning.x && transform.pan.y === panning.y);
const getMovedElementDetails = () => { const getMovedElementDetails = () => {
switch (dragging.element) { 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)) { if (coordsDidUpdate(dragging.element)) {
const info = getMovedElementDetails(); const info = getMovedElementDetails();
setUndoStack((prev) => [ setUndoStack((prev) => [
@ -311,8 +352,8 @@ export default function Canvas() {
open: false, open: false,
})); }));
} }
setPanning({ isPanning: false, x: 0, y: 0 }); setPanning((old) => ({ ...old, isPanning: false }));
setCursor("default"); pointer.setStyle("default");
if (linking) handleLinking(); if (linking) handleLinking();
setLinking(false); setLinking(false);
if (areaResize.id !== -1 && didResize(areaResize.id)) { if (areaResize.id !== -1 && didResize(areaResize.id)) {
@ -344,13 +385,13 @@ export default function Canvas() {
y: 0, y: 0,
width: 0, width: 0,
height: 0, height: 0,
mouseX: 0, pointerX: 0,
mouseY: 0, pointerY: 0,
}); });
}; };
const handleGripField = () => { const handleGripField = () => {
setPanning(false); 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 });
setLinking(true); setLinking(true);
}; };
@ -390,121 +431,217 @@ export default function Canvas() {
addRelationship(newRelationship); addRelationship(newRelationship);
}; };
const handleMouseWheel = (e) => { // Handle mouse wheel scrolling
e.preventDefault(); useEventListener(
setTransform((prev) => ({ "wheel",
...prev, (e) => {
zoom: e.deltaY <= 0 ? prev.zoom * 1.05 : prev.zoom / 1.05, e.preventDefault();
}));
};
useEffect(() => { if (e.ctrlKey) {
const canvasElement = canvas.current; // How "eager" the viewport is to
canvasElement.addEventListener("wheel", handleMouseWheel, { // center the cursor's coordinates
passive: false, const eagernessFactor = 0.05;
}); setTransform((prev) => ({
return () => { pan: {
canvasElement.removeEventListener("wheel", handleMouseWheel); 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"); const theme = localStorage.getItem("theme");
return ( return (
<div className="flex-grow h-full" id="canvas"> <div className="flex-grow h-full touch-none" id="canvas">
<div ref={canvas} className="w-full h-full"> <div
<svg className="w-full h-full"
onMouseMove={handleMouseMove} style={{
onMouseDown={handleMouseDown} cursor: pointer.style,
onMouseUp={handleMouseUp} backgroundColor: theme === "dark" ? "rgba(22, 22, 26, 1)" : "white",
className="w-full h-full" }}
style={{ >
cursor: cursor, {settings.showGrid && (
backgroundColor: theme === "dark" ? "rgba(22, 22, 26, 1)" : "white", <svg className="absolute w-full h-full">
}} <defs>
> <pattern
{settings.showGrid && ( id="pattern-circles"
<>
<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" x="0"
y="0" y="0"
width="100%" width="24"
height="100%" height="24"
fill="url(#pattern-circles)" patternUnits="userSpaceOnUse"
></rect> 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>
</svg>
)}
<svg
ref={canvasRef}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
className="absolute w-full h-full touch-none"
viewBox={`${viewBox.left} ${viewBox.top} ${viewBox.width} ${viewBox.height}`}
>
{areas.map((a) => (
<Area
key={a.id}
data={a}
onPointerDown={(e) =>
handlePointerDownOnElement(e, a.id, ObjectType.AREA)
}
setResize={setAreaResize}
setInitCoords={setInitCoords}
/>
))}
{relationships.map((e, i) => (
<Relationship key={i} data={e} />
))}
{tables.map((table) => (
<Table
key={table.id}
tableData={table}
setHoveredTable={setHoveredTable}
handleGripField={handleGripField}
setLinkingLine={setLinkingLine}
onPointerDown={(e) =>
handlePointerDownOnElement(e, table.id, ObjectType.TABLE)
}
/>
))}
{linking && (
<path
d={`M ${linkingLine.startX} ${linkingLine.startY} L ${linkingLine.endX} ${linkingLine.endY}`}
stroke="red"
strokeDasharray="8,8"
className="pointer-events-none touch-none"
/>
)} )}
<g {notes.map((n) => (
style={{ <Note
transform: `translate(${transform.pan?.x}px, ${transform.pan?.y}px) scale(${transform.zoom})`, key={n.id}
transformOrigin: "top left", data={n}
}} onPointerDown={(e) =>
id="diagram" handlePointerDownOnElement(e, n.id, ObjectType.NOTE)
> }
{areas.map((a) => ( />
<Area ))}
key={a.id}
data={a}
onMouseDown={(e) =>
handleMouseDownOnElement(e, a.id, ObjectType.AREA)
}
setResize={setAreaResize}
setInitCoords={setInitCoords}
/>
))}
{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)
}
/>
))}
{linking && (
<path
d={`M ${linkingLine.startX} ${linkingLine.startY} L ${linkingLine.endX} ${linkingLine.endY}`}
stroke="red"
strokeDasharray="8,8"
/>
)}
{notes.map((n) => (
<Note
key={n.id}
data={n}
onMouseDown={(e) =>
handleMouseDownOnElement(e, n.id, ObjectType.NOTE)
}
/>
))}
</g>
</svg> </svg>
</div> </div>
{settings.showDebugCoordinates && (
<div className="fixed flex flex-col flex-wrap gap-6 bg-[rgba(var(--semi-grey-1),var(--tw-bg-opacity))]/40 border border-color bottom-4 right-4 p-4 rounded-xl backdrop-blur-sm pointer-events-none select-none">
<table className="table-auto grow">
<thead>
<tr>
<th className="text-left" colSpan={3}>
{t("transform")}
</th>
</tr>
<tr className="italic [&_th]:font-normal [&_th]:text-right">
<th>pan x</th>
<th>pan y</th>
<th>scale</th>
</tr>
</thead>
<tbody className="[&_td]:text-right [&_td]:min-w-[8ch]">
<tr>
<td>{transform.pan.x.toFixed(2)}</td>
<td>{transform.pan.y.toFixed(2)}</td>
<td>{transform.zoom.toFixed(4)}</td>
</tr>
</tbody>
</table>
<table className="table-auto grow [&_th]:text-left [&_th:not(:first-of-type)]:text-right [&_td:not(:first-of-type)]:text-right [&_td]:min-w-[8ch]">
<thead>
<tr>
<th colSpan={4}>{t("viewbox")}</th>
</tr>
<tr className="italic [&_th]:font-normal">
<th>left</th>
<th>top</th>
<th>width</th>
<th>height</th>
</tr>
</thead>
<tbody>
<tr>
<td>{viewBox.left.toFixed(2)}</td>
<td>{viewBox.top.toFixed(2)}</td>
<td>{viewBox.width.toFixed(2)}</td>
<td>{viewBox.height.toFixed(2)}</td>
</tr>
</tbody>
</table>
<table className="table-auto grow [&_th]:text-left [&_th:not(:first-of-type)]:text-right [&_td:not(:first-of-type)]:text-right [&_td]:min-w-[8ch]">
<thead>
<tr>
<th colSpan={3}>{t("cursor_coordinates")}</th>
</tr>
<tr className="italic [&_th]:font-normal">
<th>{t("coordinate_space")}</th>
<th>x</th>
<th>y</th>
</tr>
</thead>
<tbody>
<tr>
<td>{t("coordinate_space_screen")}</td>
<td>{pointer.spaces.screen.x.toFixed(2)}</td>
<td>{pointer.spaces.screen.y.toFixed(2)}</td>
</tr>
<tr>
<td>{t("coordinate_space_diagram")}</td>
<td>{pointer.spaces.diagram.x.toFixed(2)}</td>
<td>{pointer.spaces.diagram.y.toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
)}
</div> </div>
); );
} }

View File

@ -21,7 +21,7 @@ import {
} from "../../hooks"; } from "../../hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function Note({ data, onMouseDown }) { export default function Note({ data, onPointerDown }) {
const w = 180; const w = 180;
const r = 3; const r = 3;
const fold = 24; const fold = 24;
@ -83,8 +83,13 @@ export default function Note({ data, onMouseDown }) {
return ( return (
<g <g
onMouseEnter={() => setHovered(true)} onPointerEnter={(e) => e.isPrimary && setHovered(true)}
onMouseLeave={() => setHovered(false)} 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);
}}
> >
<path <path
d={`M${data.x + fold} ${data.y} L${data.x + w - r} ${ d={`M${data.x + fold} ${data.y} L${data.x + w - r} ${
@ -133,7 +138,7 @@ export default function Note({ data, onMouseDown }) {
y={data.y} y={data.y}
width={w} width={w}
height={data.height} height={data.height}
onMouseDown={onMouseDown} onPointerDown={onPointerDown}
> >
<div className="text-gray-900 select-none w-full h-full cursor-move px-3 py-2"> <div className="text-gray-900 select-none w-full h-full cursor-move px-3 py-2">
<div className="flex justify-between gap-1 w-full"> <div className="flex justify-between gap-1 w-full">

View File

@ -22,7 +22,7 @@ export default function Table(props) {
const [hoveredField, setHoveredField] = useState(-1); const [hoveredField, setHoveredField] = useState(-1);
const { const {
tableData, tableData,
onMouseDown, onPointerDown,
setHoveredTable, setHoveredTable,
handleGripField, handleGripField,
setLinkingLine, setLinkingLine,
@ -67,7 +67,7 @@ export default function Table(props) {
width={settings.tableWidth} width={settings.tableWidth}
height={height} height={height}
className="group drop-shadow-lg rounded-md cursor-move" className="group drop-shadow-lg rounded-md cursor-move"
onMouseDown={onMouseDown} onPointerDown={onPointerDown}
> >
<div <div
onDoubleClick={openEditor} onDoubleClick={openEditor}
@ -266,16 +266,25 @@ export default function Table(props) {
? "" ? ""
: "border-b border-gray-400" : "border-b border-gray-400"
} group h-[36px] px-2 py-1 flex justify-between items-center gap-1 w-full overflow-hidden`} } group h-[36px] px-2 py-1 flex justify-between items-center gap-1 w-full overflow-hidden`}
onMouseEnter={() => { onPointerEnter={(e) => {
if (!e.isPrimary) return;
setHoveredField(index); setHoveredField(index);
setHoveredTable({ setHoveredTable({
tableId: tableData.id, tableId: tableData.id,
field: index, field: index,
}); });
}} }}
onMouseLeave={() => { onPointerLeave={(e) => {
if (!e.isPrimary) return;
setHoveredField(-1); 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);
}}
> >
<div <div
className={`${ className={`${
@ -284,7 +293,9 @@ export default function Table(props) {
> >
<button <button
className="flex-shrink-0 w-[10px] h-[10px] bg-[#2f68adcc] rounded-full" className="flex-shrink-0 w-[10px] h-[10px] bg-[#2f68adcc] rounded-full"
onMouseDown={() => { onPointerDown={(e) => {
if (!e.isPrimary) return;
handleGripField(index); handleGripField(index);
setLinkingLine((prev) => ({ setLinkingLine((prev) => ({
...prev, ...prev,

View File

@ -1158,6 +1158,18 @@ export default function ControlPanel({
showCardinality: !prev.showCardinality, showCardinality: !prev.showCardinality,
})), })),
}, },
show_debug_coordinates: {
state: settings.showDebugCoordinates ? (
<i className="bi bi-toggle-on" />
) : (
<i className="bi bi-toggle-off" />
),
function: () =>
setSettings((prev) => ({
...prev,
showDebugCoordinates: !prev.showDebugCoordinates,
})),
},
theme: { theme: {
children: [ children: [
{ {
@ -1526,8 +1538,13 @@ export default function ControlPanel({
)} )}
<div <div
className="text-xl me-1" className="text-xl me-1"
onMouseEnter={() => setShowEditName(true)} onPointerEnter={(e) => e.isPrimary && setShowEditName(true)}
onMouseLeave={() => setShowEditName(false)} onPointerLeave={(e) => e.isPrimary && setShowEditName(false)}
onPointerDown={(e) => {
// Required for onPointerLeave to trigger when a touch pointer leaves
// https://stackoverflow.com/a/70976017/1137077
e.target.releasePointerCapture(e.pointerId);
}}
onClick={() => setModal(MODAL.RENAME)} onClick={() => setModal(MODAL.RENAME)}
> >
{window.name.split(" ")[0] === "t" ? "Templates/" : "Diagrams/"} {window.name.split(" ")[0] === "t" ? "Templates/" : "Diagrams/"}

View File

@ -83,7 +83,7 @@ export default function SidePanel({ width, resize, setResize }) {
className={`flex justify-center items-center p-1 h-auto hover-2 cursor-col-resize ${ className={`flex justify-center items-center p-1 h-auto hover-2 cursor-col-resize ${
resize && "bg-semi-grey-2" resize && "bg-semi-grey-2"
}`} }`}
onMouseDown={() => setResize(true)} onPointerDown={(e) => e.isPrimary && setResize(true)}
> >
<div className="w-1 border-x border-color h-1/6" /> <div className="w-1 border-x border-color h-1/6" />
</div> </div>

View File

@ -89,7 +89,6 @@ function positionEditorElement(editor, rect) {
function FloatingLinkEditor({ editor }) { function FloatingLinkEditor({ editor }) {
const editorRef = useRef(null); const editorRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const mouseDownRef = useRef(false);
const [linkUrl, setLinkUrl] = useState(""); const [linkUrl, setLinkUrl] = useState("");
const [isEditMode, setEditMode] = useState(false); const [isEditMode, setEditMode] = useState(false);
const [lastSelection, setLastSelection] = useState(null); const [lastSelection, setLastSelection] = useState(null);
@ -134,9 +133,7 @@ function FloatingLinkEditor({ editor }) {
rect = domRange.getBoundingClientRect(); rect = domRange.getBoundingClientRect();
} }
if (!mouseDownRef.current) { positionEditorElement(editorElem, rect);
positionEditorElement(editorElem, rect);
}
setLastSelection(selection); setLastSelection(selection);
} else if (!activeElement || activeElement.className !== "link-input") { } else if (!activeElement || activeElement.className !== "link-input") {
positionEditorElement(editorElem, null); positionEditorElement(editorElem, null);

View File

@ -24,9 +24,17 @@ function Table({ table, grab }) {
width={tableWidth} width={tableWidth}
height={height} height={height}
className="drop-shadow-lg rounded-md cursor-move" className="drop-shadow-lg rounded-md cursor-move"
onMouseDown={grab} onPointerDown={(e) => {
onMouseEnter={() => setIsHovered(true)} // Required for onPointerLeave to trigger when a touch pointer leaves
onMouseLeave={() => setIsHovered(false)} // https://stackoverflow.com/a/70976017/1137077
e.target.releasePointerCapture(e.pointerId);
if (!e.isPrimary) return;
grab(e);
}}
onPointerEnter={(e) => e.isPrimary && setIsHovered(true)}
onPointerLeave={(e) => e.isPrimary && setIsHovered(false)}
> >
<div <div
className={`border-2 ${ className={`border-2 ${
@ -46,8 +54,13 @@ function Table({ table, grab }) {
className={`${ className={`${
i === table.fields.length - 1 ? "" : "border-b border-gray-400" i === table.fields.length - 1 ? "" : "border-b border-gray-400"
} h-[36px] px-2 py-1 flex justify-between`} } h-[36px] px-2 py-1 flex justify-between`}
onMouseEnter={() => setHoveredField(i)} onPointerEnter={(e) => e.isPrimary && setHoveredField(i)}
onMouseLeave={() => setHoveredField(-1)} onPointerLeave={(e) => e.isPrimary && 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);
}}
> >
<div className={hoveredField === i ? "text-zinc-500" : ""}> <div className={hoveredField === i ? "text-zinc-500" : ""}>
<button <button
@ -185,9 +198,9 @@ export default function SimpleCanvas({ diagram, zoom }) {
return ( return (
<svg <svg
className="w-full h-full cursor-grab" className="w-full h-full cursor-grab"
onMouseUp={releaseTable} onPointerUp={(e) => e.isPrimary && releaseTable()}
onMouseMove={moveTable} onPointerMove={(e) => e.isPrimary && moveTable()}
onMouseLeave={releaseTable} onPointerLeave={(e) => e.isPrimary && releaseTable()}
> >
<defs> <defs>
<pattern <pattern

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import ControlPanel from "./EditorHeader/ControlPanel"; import ControlPanel from "./EditorHeader/ControlPanel";
import Canvas from "./EditorCanvas/Canvas"; import Canvas from "./EditorCanvas/Canvas";
import { CanvasContextProvider } from "../context/CanvasContext";
import SidePanel from "./EditorSidePanel/SidePanel"; import SidePanel from "./EditorSidePanel/SidePanel";
import { DB, State } from "../data/constants"; import { DB, State } from "../data/constants";
import { db } from "../data/db"; import { db } from "../data/db";
@ -338,7 +339,7 @@ export default function WorkSpace() {
}, [load]); }, [load]);
return ( return (
<div className="h-[100vh] flex flex-col overflow-hidden theme"> <div className="h-full flex flex-col overflow-hidden theme">
<ControlPanel <ControlPanel
diagramId={id} diagramId={id}
setDiagramId={setId} setDiagramId={setId}
@ -349,15 +350,22 @@ export default function WorkSpace() {
/> />
<div <div
className="flex h-full overflow-y-auto" className="flex h-full overflow-y-auto"
onMouseUp={() => setResize(false)} onPointerUp={(e) => e.isPrimary && setResize(false)}
onMouseLeave={() => setResize(false)} onPointerLeave={(e) => e.isPrimary && setResize(false)}
onMouseMove={handleResize} onPointerMove={(e) => e.isPrimary && handleResize(e)}
onPointerDown={(e) => {
// Required for onPointerLeave to trigger when a touch pointer leaves
// https://stackoverflow.com/a/70976017/1137077
e.target.releasePointerCapture(e.pointerId);
}}
> >
{layout.sidebar && ( {layout.sidebar && (
<SidePanel resize={resize} setResize={setResize} width={width} /> <SidePanel resize={resize} setResize={setResize} width={width} />
)} )}
<div className="relative w-full h-full overflow-hidden"> <div className="relative w-full h-full overflow-hidden">
<Canvas saveState={saveState} setSaveState={setSaveState} /> <CanvasContextProvider className="h-full w-full">
<Canvas saveState={saveState} setSaveState={setSaveState} />
</CanvasContextProvider>
{!(layout.sidebar || layout.toolbar || layout.header) && ( {!(layout.sidebar || layout.toolbar || layout.header) && (
<div className="fixed right-5 bottom-4"> <div className="fixed right-5 bottom-4">
<FloatingControls /> <FloatingControls />

View File

@ -23,15 +23,17 @@ export default function AreasContextProvider({ children }) {
return temp.map((t, i) => ({ ...t, id: i })); return temp.map((t, i) => ({ ...t, id: i }));
}); });
} else { } else {
const width = 200;
const height = 200;
setAreas((prev) => [ setAreas((prev) => [
...prev, ...prev,
{ {
id: prev.length, id: prev.length,
name: `area_${prev.length}`, name: `area_${prev.length}`,
x: -transform.pan.x, x: transform.pan.x - width / 2,
y: -transform.pan.y, y: transform.pan.y - height / 2,
width: 200, width,
height: 200, height,
color: defaultBlue, color: defaultBlue,
}, },
]); ]);

View File

@ -0,0 +1,167 @@
import { useTransform } from "../hooks";
import { createContext, useCallback, useMemo, useRef, 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 CanvasContextProvider({ children, ...attrs }) {
const canvasWrapRef = useRef(null);
const { transform } = useTransform();
const canvasSize = useResizeObserver({
ref: canvasWrapRef,
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, canvasWrapRef);
useEventListener("pointermove", detectPointerMovement, canvasWrapRef);
const contextValue = {
canvas: {
screenSize,
viewBox,
},
coords: {
toDiagramSpace,
toScreenSpace,
},
pointer: {
spaces: {
screen: pointerScreenCoords,
diagram: pointerDiagramCoords,
},
style: pointerStyle,
setStyle: setPointerStyle,
},
};
return (
<CanvasContext.Provider value={contextValue}>
<div {...attrs} ref={canvasWrapRef}>
{children}
</div>
</CanvasContext.Provider>
)
}

View File

@ -30,8 +30,8 @@ export default function DiagramContextProvider({ children }) {
{ {
id: prev.length, id: prev.length,
name: `table_${prev.length}`, name: `table_${prev.length}`,
x: -transform.pan.x, x: transform.pan.x,
y: -transform.pan.y, y: transform.pan.y,
fields: [ fields: [
{ {
name: "id", name: "id",

View File

@ -23,16 +23,17 @@ export default function NotesContextProvider({ children }) {
return temp.map((t, i) => ({ ...t, id: i })); return temp.map((t, i) => ({ ...t, id: i }));
}); });
} else { } else {
const height = 88;
setNotes((prev) => [ setNotes((prev) => [
...prev, ...prev,
{ {
id: prev.length, id: prev.length,
x: -transform.pan.x, x: transform.pan.x,
y: -transform.pan.y, y: transform.pan.y - height / 2,
title: `note_${prev.length}`, title: `note_${prev.length}`,
content: "", content: "",
color: defaultNoteTheme, color: defaultNoteTheme,
height: 88, height,
}, },
]); ]);
} }

View File

@ -1,7 +1,17 @@
import { createContext, useState } from "react"; import { createContext, useState } from "react";
import { tableWidth } from "../data/constants"; 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 }) { export default function SettingsContextProvider({ children }) {
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
@ -13,6 +23,7 @@ export default function SettingsContextProvider({ children }) {
panning: true, panning: true,
showCardinality: true, showCardinality: true,
tableWidth: tableWidth, tableWidth: tableWidth,
showDebugCoordinates: false,
}); });
return ( return (

View File

@ -1,13 +1,43 @@
import { createContext, useState } from "react"; import { createContext, useCallback, useState } from "react";
export const TransformContext = createContext(null); export const TransformContext = createContext(null);
export default function TransformContextProvider({ children }) { export default function TransformContextProvider({ children }) {
const [transform, setTransform] = useState({ const [transform, setTransformInternal] = useState({
zoom: 1, zoom: 1,
pan: { x: 0, y: 0 }, pan: { x: 0, y: 0 },
}); });
/**
* @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));
setTransformInternal((prev) => {
if (typeof actionOrValue === "function") {
actionOrValue = actionOrValue(prev);
}
return {
zoom: clamp(
findFirstNumber(actionOrValue.zoom, prev.zoom, 1),
0.02,
5,
),
pan: {
x: findFirstNumber(actionOrValue.pan?.x, prev.pan?.x, 0),
y: findFirstNumber(actionOrValue.pan?.y, prev.pan?.y, 0),
},
};
});
},
[setTransformInternal],
);
return ( return (
<TransformContext.Provider value={{ transform, setTransform }}> <TransformContext.Provider value={{ transform, setTransform }}>
{children} {children}

View File

@ -1,6 +1,11 @@
import { createContext, useState } from "react"; import { createContext, useState } from "react";
export const UndoRedoContext = createContext(null); export const UndoRedoContext = createContext({
undoStack: [],
setUndoStack: () => {},
redoStack: [],
setRedoStack: () => {},
});
export default function UndoRedoContextProvider({ children }) { export default function UndoRedoContextProvider({ children }) {
const [undoStack, setUndoStack] = useState([]); const [undoStack, setUndoStack] = useState([]);

View File

@ -42,7 +42,9 @@ export const shortcuts = [
title: "Reset view", title: "Reset view",
description: "Resetting view will set diagram pan to (0, 0).", description: "Resetting view will set diagram pan to (0, 0).",
}, },
{ shortcut: "CTRL+UP / Wheel up", title: "Zoom in" }, { shortcut: "CTRL+UP / CTRL+Wheel up", title: "Zoom in" },
{ shortcut: "CTRL+DOWN / Wheel down", title: "Zoom out" }, { shortcut: "CTRL+DOWN / CTRL+Wheel down", title: "Zoom out" },
{ shortcut: "Wheel up / Wheel down", title: "Pan Y" },
{ shortcut: "SHIFT+Wheel up / SHIFT+Wheel down", title: "Pan X" },
{ shortcut: "CTRL+H", title: "Open shortcuts" }, { shortcut: "CTRL+H", title: "Open shortcuts" },
]; ];

View File

@ -1,4 +1,5 @@
export { default as useAreas } from "./useAreas"; export { default as useAreas } from "./useAreas";
export { default as useCanvas } from "./useCanvas";
export { default as useLayout } from "./useLayout"; export { default as useLayout } from "./useLayout";
export { default as useNotes } from "./useNotes"; export { default as useNotes } from "./useNotes";
export { default as useSaveState } from "./useSaveState"; export { default as useSaveState } from "./useSaveState";

6
src/hooks/useCanvas.js Normal file
View File

@ -0,0 +1,6 @@
import { useContext } from "react";
import { CanvasContext } from "../context/CanvasContext";
export default function useCanvas() {
return useContext(CanvasContext);
}

View File

@ -59,6 +59,13 @@ const en = {
show_timeline: "Show timeline", show_timeline: "Show timeline",
autosave: "Autosave", autosave: "Autosave",
panning: "Panning", panning: "Panning",
show_debug_coordinates: "Show debug coordinates",
transform: "Transform",
viewbox: "View Box",
cursor_coordinates: "Cursor Coordinates",
coordinate_space: "Space",
coordinate_space_screen: "Screen",
coordinate_space_diagram: "Diagram",
table_width: "Table width", table_width: "Table width",
language: "Language", language: "Language",
flush_storage: "Flush storage", flush_storage: "Flush storage",

View File

@ -2,6 +2,22 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
/*
* Workaround to allow proper bottom positioning for fixed
* elements in mobile browsers with collapsing url bars:
* https://stackoverflow.com/a/17555766/1137077
*/
:root,
html,
body {
@apply h-full min-h-full;
}
#root {
@apply h-full min-h-full;
}
}
.semi-form-vertical .semi-form-field { .semi-form-vertical .semi-form-field {
margin: 0; margin: 0;
padding-top: 8px !important; padding-top: 8px !important;