Skip to content

Commit

Permalink
Refactor: give up on svg layout.
Browse files Browse the repository at this point in the history
Working with svg as a layout system is frustrating and buggy. It's
easier if you put each shape in its own svg container and move that
around using normal HTML primitives.

Also factored out a Rect shape which is stateless and a
RectController which has the smarts. I think this will make things
easier to follow when I add resize.
  • Loading branch information
aboodman committed Mar 10, 2021
1 parent 5670f1c commit 343b15c
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 172 deletions.
8 changes: 0 additions & 8 deletions frontend/collaborator.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,6 @@
pointer-events: none;
}

.collaborator > svg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}

.cursor {
position: absolute;
font-family: "Inter", sans-serif;
Expand Down
20 changes: 9 additions & 11 deletions frontend/collaborator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,15 @@ export function Collaborator({
return (
<div className={styles.collaborator} style={{ opacity: visible ? 1 : 0 }}>
{clientInfo.selectedID && (
<svg>
<Rect
{...{
data,
key: `selection-${clientInfo.selectedID}`,
id: clientInfo.selectedID,
highlight: true,
highlightColor: userInfo.color,
}}
/>
</svg>
<Rect
{...{
data,
key: `selection-${clientInfo.selectedID}`,
id: clientInfo.selectedID,
highlight: true,
highlightColor: userInfo.color,
}}
/>
)}

<div
Expand Down
166 changes: 56 additions & 110 deletions frontend/designer.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,19 @@
import React, { TouchEvent, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { Rect } from "./rect";
import { HotKeys } from "react-hotkeys";
import { Data } from "./data";
import { Collaborator } from "./collaborator";
import { Selection } from "./selection";
import { RectController } from "./rect-controller";
import { touchToMouse } from "./events";

export function Designer({ data }: { data: Data }) {
const ids = data.useShapeIDs();
const overID = data.useOverShapeID();
const selectedID = data.useSelectedShapeID();
const collaboratorIDs = data.useCollaboratorIDs(data.clientID);

const [lastDrag, setLastDrag] = useState<{
pageX: number;
pageY: number;
} | null>(null);
const nodeRef = useRef<HTMLDivElement | null>(null);

const onMouseDown = (id: string, pageX: number, pageY: number) => {
data.selectShape({ clientID: data.clientID, shapeID: id });
setLastDrag({ pageX, pageY });
};

const onMouseMove = (pageX: number, pageY: number) => {
if (!nodeRef.current) {
return;
}

data.setCursor({
id: data.clientID,
x: pageX,
y: pageY - nodeRef.current.offsetTop,
});

if (!lastDrag) {
return;
}

// This is subtle, and worth drawing attention to:
// In order to properly resolve conflicts, what we want to capture in
// mutation arguments is the *intent* of the mutation, not the effect.
// In this case, the intent is the amount the mouse was moved by, locally.
// We will apply this movement to whatever the state happens to be when we
// replay. If somebody else was moving the object at the same moment, we'll
// then end up with a union of the two vectors, which is what we want!
data.moveShape({
id: selectedID,
dx: pageX - lastDrag.pageX,
dy: pageY - lastDrag.pageY,
});
setLastDrag({ pageX, pageY });
};

const onMouseUp = () => {
setLastDrag(null);
};
const ref = useRef<HTMLDivElement | null>(null);
const [dragging, setDragging] = useState(false);

const handlers = {
moveLeft: () => data.moveShape({ id: selectedID, dx: -20, dy: 0 }),
Expand All @@ -68,6 +27,16 @@ export function Designer({ data }: { data: Data }) {
},
};

const onMouseMove = ({ pageX, pageY }: { pageX: number; pageY: number }) => {
if (ref && ref.current) {
data.setCursor({
id: data.clientID,
x: pageX,
y: pageY - ref.current.offsetTop,
});
}
};

return (
<HotKeys
{...{
Expand All @@ -78,71 +47,58 @@ export function Designer({ data }: { data: Data }) {
>
<div
{...{
ref: nodeRef,
ref,
className: "container",
style: { position: "relative", display: "flex", flex: 1, overflow: "hidden" },
onMouseMove: (e) => onMouseMove(e.pageX, e.pageY),
style: {
position: "relative",
display: "flex",
flex: 1,
overflow: "hidden",
},
onMouseMove,
onTouchMove: (e) => touchToMouse(e, onMouseMove),
onMouseUp,
onTouchEnd: () => onMouseUp(),
}}
>
<svg
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
}}
>
{ids.map((id) => (
// shapes
{ids.map((id) => (
// draggable rects
<RectController
{...{
key: `shape-${id}`,
data,
id,
onDrag: setDragging,
}}
/>
))}

{
// self-highlight
!dragging && overID && (
<Rect
{...{
key: `shape-${id}`,
key: `highlight-${overID}`,
data,
id,
onMouseEnter: () =>
data.overShape({ clientID: data.clientID, shapeID: id }),
onMouseLeave: () =>
data.overShape({ clientID: data.clientID, shapeID: "" }),
onMouseDown: (e) => onMouseDown(id, e.pageX, e.pageY),
onTouchStart: (e) =>
touchToMouse(e, (pageX, pageY) =>
onMouseDown(id, pageX, pageY)
),
id: overID,
highlight: true,
}}
/>
))}
)
}

{
// self-highlight
!lastDrag && overID && (
<Rect
{...{
key: `highlight-${overID}`,
data,
id: overID,
highlight: true,
}}
/>
)
}
{
// self-selection
selectedID && (
<Rect
{...{
key: `selection-${selectedID}`,
data,
id: selectedID,
highlight: true,
}}
/>
)
}

{
// self-selection
selectedID && (
<Selection
{...{
key: `selection-${selectedID}`,
data,
shapeID: selectedID,
}}
/>
)
}
</svg>
{
// collaborators
// foreignObject seems super buggy in Safari, so instead we do the
Expand Down Expand Up @@ -170,13 +126,3 @@ const keyMap = {
moveDown: ["down", "shift+down"],
deleteShape: ["del", "backspace"],
};

function touchToMouse(
e: TouchEvent,
handler: (pageX: number, pageY: number) => void
) {
if (e.touches.length == 1) {
const t = e.touches[0];
handler(t.pageX, t.pageY);
}
}
10 changes: 10 additions & 0 deletions frontend/events.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { TouchEvent } from "react";

export function touchToMouse(
e: TouchEvent,
handler: ({ pageX, pageY }: { pageX: number; pageY: number }) => void
) {
if (e.touches.length == 1) {
handler(e.touches[0]);
}
}
5 changes: 3 additions & 2 deletions frontend/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ export function Nav({ data }: { data: Data | null }) {
if (!data) {
return;
}
const s = randInt(100, 400);
await data.createShape({
id: newID(),
shape: {
type: "rect",
x: randInt(0, 400),
y: randInt(0, 400),
width: randInt(100, 400),
height: randInt(100, 400),
width: s,
height: s,
rotate: randInt(0, 359),
fill: colors[randInt(0, colors.length - 1)],
},
Expand Down
98 changes: 98 additions & 0 deletions frontend/rect-controller.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import { Data } from "./data";
import { touchToMouse } from "./events";
import { Rect } from "./rect";

// TODO: In the future I imagine this becoming ShapeController and
// there also be a Shape that wraps Rect and also knows how to draw Circle, etc.
export function RectController({
data,
id,
onDrag,
}: {
data: Data;
id: string;
onDrag: (dragging: boolean) => void;
}) {
const [lastDrag, setLastDrag] = useState<{
pageX: number;
pageY: number;
} | null>(null);
const shape = data.useShapeByID(id);

const onMouseEnter = () =>
data.overShape({ clientID: data.clientID, shapeID: id });
const onMouseLeave = () =>
data.overShape({ clientID: data.clientID, shapeID: "" });

const onMouseDown = ({ pageX, pageY }: { pageX: number; pageY: number }) => {
data.selectShape({ clientID: data.clientID, shapeID: id });
setLastDrag({ pageX, pageY });
onDrag && onDrag(true);
};

const onTouchMove = (e: any) => touchToMouse(e, onMouseMove);
const onMouseMove = ({ pageX, pageY }: { pageX: number; pageY: number }) => {
if (!lastDrag) {
return;
}

// This is subtle, and worth drawing attention to:
// In order to properly resolve conflicts, what we want to capture in
// mutation arguments is the *intent* of the mutation, not the effect.
// In this case, the intent is the amount the mouse was moved by, locally.
// We will apply this movement to whatever the state happens to be when we
// replay. If somebody else was moving the object at the same moment, we'll
// then end up with a union of the two vectors, which is what we want!
data.moveShape({
id,
dx: pageX - lastDrag.pageX,
dy: pageY - lastDrag.pageY,
});
setLastDrag({ pageX, pageY });
};

const onMouseUp = () => {
setLastDrag(null);
onDrag && onDrag(false);
};

const dragListeners = {
mousemove: (e: any) => onMouseMove(e),
touchmove: (e: any) => onTouchMove(e),
mouseup: onMouseUp,
touchend: onMouseUp,
};

useEffect(() => {
if (!lastDrag) {
return;
}
Object.entries(dragListeners).forEach(([key, val]) =>
window.addEventListener(key, val)
);
return () => {
Object.entries(dragListeners).forEach(([key, val]) =>
window.removeEventListener(key, val)
);
};
});

if (!shape) {
return null;
}

return (
<Rect
{...{
data,
id,
highlight: false,
onMouseDown: (e: any) => onMouseDown(e),
onTouchStart: (e: any) => touchToMouse(e, onMouseDown),
onMouseEnter,
onMouseLeave,
}}
/>
);
}
Loading

0 comments on commit 343b15c

Please sign in to comment.