feat: add basic touchscreen support

This is basically a migration from mouse events to
[pointer events](
  https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
).

The `PointerEvent` interface inherits all of the `MouseEvent`
properties, meaning that existing code can essentially be left
as-is. The only major change is making sure we only respond to the
"primary" pointer.

Known issues include:
* stylus hover is not detected
* touchscreens do not have a concept of hover, making it difficult
  to e.g. resize areas
* no touch gesture support, e.g. "pinch-to-zoom"
This commit is contained in:
Felix Zedén Yverås 2024-06-26 20:43:56 +02:00
parent 075a98d444
commit cdecf7c633
8 changed files with 95 additions and 67 deletions

View File

@ -20,7 +20,7 @@ import {
import ColorPalette from "../ColorPicker"; import ColorPalette from "../ColorPicker";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function Area({ data, onMouseDown, setResize, setInitCoords }) { export default function Area({ data, onPointerDown, setResize, setInitCoords }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const { layout } = useLayout(); const { layout } = useLayout();
const { settings } = useSettings(); const { settings } = useSettings();
@ -35,8 +35,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: e.clientX / transform.zoom,
mouseY: e.clientY / transform.zoom, pointerY: e.clientY / transform.zoom,
}); });
}; };
@ -85,8 +85,8 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
return ( return (
<g <g
onMouseEnter={() => setHovered(true)} onPointerEnter={(e) => e.isPrimary && setHovered(true)}
onMouseLeave={() => setHovered(false)} onPointerLeave={(e) => e.isPrimary &&setHovered(false)}
> >
<foreignObject <foreignObject
key={data.id} key={data.id}
@ -94,7 +94,7 @@ export default function Area({ data, onMouseDown, setResize, setInitCoords }) {
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 ${
@ -149,7 +149,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 +159,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 +169,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 +179,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

@ -68,14 +68,21 @@ 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 [cursor, setCursor] = useState("default");
const canvas = useRef(null); const canvas = useRef(null);
const handleMouseDownOnElement = (e, id, type) => { /**
* @param {PointerEvent} e
* @param {*} id
* @param {ObjectType[keyof ObjectType]} type
*/
const handlePointerDownOnElement = (e, id, type) => {
if (!e.isPrimary) return;
const { clientX, clientY } = e; 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);
@ -122,7 +129,12 @@ 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(); const rect = canvas.current.getBoundingClientRect();
setLinkingLine({ setLinkingLine({
@ -164,34 +176,39 @@ export default function Canvas() {
} 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; const pointerX = e.clientX / transform.zoom;
const mouseY = e.clientY / transform.zoom; const pointerY = e.clientY / transform.zoom;
setPanning({ isPanning: false, x: 0, y: 0 }); setPanning({ isPanning: false, x: 0, y: 0 });
if (areaResize.dir === "br") { if (areaResize.dir === "br") {
newDims.width = initCoords.width + (mouseX - initCoords.mouseX); newDims.width = initCoords.width + (pointerX - initCoords.pointerX);
newDims.height = initCoords.height + (mouseY - initCoords.mouseY); newDims.height = initCoords.height + (pointerY - initCoords.pointerY);
} else if (areaResize.dir === "tl") { } else if (areaResize.dir === "tl") {
newDims.x = initCoords.x + (mouseX - initCoords.mouseX); newDims.x = initCoords.x + (pointerX - initCoords.pointerX);
newDims.y = initCoords.y + (mouseY - initCoords.mouseY); newDims.y = initCoords.y + (pointerY - initCoords.pointerY);
newDims.width = initCoords.width - (mouseX - initCoords.mouseX); newDims.width = initCoords.width - (pointerX - initCoords.pointerX);
newDims.height = initCoords.height - (mouseY - initCoords.mouseY); newDims.height = initCoords.height - (pointerY - initCoords.pointerY);
} else if (areaResize.dir === "tr") { } else if (areaResize.dir === "tr") {
newDims.y = initCoords.y + (mouseY - initCoords.mouseY); newDims.y = initCoords.y + (pointerY - initCoords.pointerY);
newDims.width = initCoords.width + (mouseX - initCoords.mouseX); newDims.width = initCoords.width + (pointerX - initCoords.pointerX);
newDims.height = initCoords.height - (mouseY - initCoords.mouseY); newDims.height = initCoords.height - (pointerY - initCoords.pointerY);
} else if (areaResize.dir === "bl") { } else if (areaResize.dir === "bl") {
newDims.x = initCoords.x + (mouseX - initCoords.mouseX); newDims.x = initCoords.x + (pointerX - initCoords.pointerX);
newDims.width = initCoords.width - (mouseX - initCoords.mouseX); newDims.width = initCoords.width - (pointerX - initCoords.pointerX);
newDims.height = initCoords.height + (mouseY - initCoords.mouseY); newDims.height = initCoords.height + (pointerY - initCoords.pointerY);
} }
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 &&
@ -268,7 +285,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) => [
@ -344,8 +366,8 @@ export default function Canvas() {
y: 0, y: 0,
width: 0, width: 0,
height: 0, height: 0,
mouseX: 0, pointerX: 0,
mouseY: 0, pointerY: 0,
}); });
}; };
@ -411,12 +433,12 @@ export default function Canvas() {
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 ref={canvas} className="w-full h-full">
<svg <svg
onMouseMove={handleMouseMove} onPointerMove={handlePointerMove}
onMouseDown={handleMouseDown} onPointerDown={handlePointerDown}
onMouseUp={handleMouseUp} onPointerUp={handlePointerUp}
className="w-full h-full" className="w-full h-full"
style={{ style={{
cursor: cursor, cursor: cursor,
@ -464,8 +486,8 @@ export default function Canvas() {
<Area <Area
key={a.id} key={a.id}
data={a} data={a}
onMouseDown={(e) => onPointerDown={(e) =>
handleMouseDownOnElement(e, a.id, ObjectType.AREA) handlePointerDownOnElement(e, a.id, ObjectType.AREA)
} }
setResize={setAreaResize} setResize={setAreaResize}
setInitCoords={setInitCoords} setInitCoords={setInitCoords}
@ -481,8 +503,8 @@ export default function Canvas() {
setHoveredTable={setHoveredTable} setHoveredTable={setHoveredTable}
handleGripField={handleGripField} handleGripField={handleGripField}
setLinkingLine={setLinkingLine} setLinkingLine={setLinkingLine}
onMouseDown={(e) => onPointerDown={(e) =>
handleMouseDownOnElement(e, table.id, ObjectType.TABLE) handlePointerDownOnElement(e, table.id, ObjectType.TABLE)
} }
/> />
))} ))}
@ -497,8 +519,8 @@ export default function Canvas() {
<Note <Note
key={n.id} key={n.id}
data={n} data={n}
onMouseDown={(e) => onPointerDown={(e) =>
handleMouseDownOnElement(e, n.id, ObjectType.NOTE) handlePointerDownOnElement(e, n.id, ObjectType.NOTE)
} }
/> />
))} ))}

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,8 @@ 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)}
> >
<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 +133,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,14 +266,18 @@ 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);
}} }}
> >
@ -284,7 +288,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

@ -1526,8 +1526,8 @@ 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)}
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

@ -24,9 +24,9 @@ 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) => e.isPrimary && grab(e)}
onMouseEnter={() => setIsHovered(true)} onPointerEnter={(e) => e.isPrimary && setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onPointerLeave={(e) => e.isPrimary && setIsHovered(false)}
> >
<div <div
className={`border-2 ${ className={`border-2 ${
@ -46,8 +46,8 @@ 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)}
> >
<div className={hoveredField === i ? "text-zinc-500" : ""}> <div className={hoveredField === i ? "text-zinc-500" : ""}>
<button <button
@ -185,9 +185,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

@ -349,9 +349,9 @@ 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)}
> >
{layout.sidebar && ( {layout.sidebar && (
<SidePanel resize={resize} setResize={setResize} width={width} /> <SidePanel resize={resize} setResize={setResize} width={width} />