Skip to content

Commit

Permalink
undo/redo, whiteboard support, refactor layout to use abstract "node"
Browse files Browse the repository at this point in the history
  • Loading branch information
erwijet committed Feb 2, 2025
1 parent 066599a commit 84698f0
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 53 deletions.
24 changes: 21 additions & 3 deletions app/routes/_auth/classrooms/$id.edit.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ActionIcon, Group, Box, Button, Drawer, rem, Text, Title } from "@mantine/core";
import { Button, Drawer, Text, Title } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router";
Expand Down Expand Up @@ -27,7 +27,7 @@ function RouteComponent() {
width: 0,
title: classroom.title,
pods: classroom.pods,
seats: classroom.pods.flatMap((p) => p.seats),
nodes: [],
},
});

Expand Down Expand Up @@ -57,16 +57,34 @@ function RouteComponent() {
}

function handleAddSeat() {
form.setFieldValue("seats", (prev) =>
form.setFieldValue("nodes", (prev) =>
prev.concat([{ id: createCuid(), col: 10, row: 10, podId: form.getValues().pods.at(0)!.id }]),
);
}

function handleAddWhiteboard() {
form.setFieldValue("nodes", (prev) =>
prev.concat([
{
id: createCuid(),
col: 10,
row: 10,
entityType: "WHITEBOARD",
},
]),
);
}

const [opened, { open, close }] = useDisclosure(false);

return (
<ClassroomFormProvider form={form}>
<Content withBack title={classroom.title}>
<Content.Action>
<Button variant="default" onClick={handleAddWhiteboard}>
Add Whiteboard
</Button>
</Content.Action>
<Content.Action>
<Button variant="default" onClick={handleAddSeat}>
Add Seat
Expand Down
9 changes: 5 additions & 4 deletions app/shared/components/classroom/classroom-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { ActionIcon, Button, Divider, Group, Paper, SimpleGrid, Stack, Text, Tex
import { modals } from "@mantine/modals";
import { colord, extend } from "colord";
import names from "colord/plugins/names";
import { Edit, Trash2 } from "lucide-react";
import { createRandomColor } from "shared/color";
import { ClassroomState, PodFormProvider, useClassroomFormContext, usePodForm } from "components/classroom/context";
import { createCuid, titlecase } from "shared/str";
import { PodEditor } from "components/classroom/pod-editor";
import { Edit, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { createRandomColor } from "shared/color";
import { createCuid, titlecase } from "shared/str";

extend([names]);

Expand All @@ -19,7 +19,8 @@ export const ClassroomEditor = (props: { onDelete?: () => unknown; onSave?: (dat
const id = createCuid();
const hex = createRandomColor().hex();
const title = titlecase(colord(hex).toName({ closest: true })!) + " Pod";
form.insertListItem("pods", { id, title, hex });

form.setFieldValue("pods", (prev) => prev.concat([{ id, title, hex }]));
}

function handleEditPod(id: string) {
Expand Down
7 changes: 5 additions & 2 deletions app/shared/components/classroom/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createFormContext } from "@mantine/form";
import { EntityType } from "@prisma/client";
import { z } from "zod";

const podSchema = z.object({
Expand All @@ -15,12 +16,14 @@ export const classroomSchema = z.object({
height: z.number().int(),
width: z.number().int(),
pods: podSchema.array().default([]),
seats: z
nodes: z
.object({
id: z.string(),
row: z.number().int(),
col: z.number().int(),
podId: z.string().cuid(),

entityType: z.nativeEnum(EntityType).optional(),
podId: z.string().cuid().optional(),
})
.array(),
});
Expand Down
196 changes: 152 additions & 44 deletions app/shared/components/layout/seats-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,103 +1,193 @@
import "react-resizable/css/styles.css";
import "reactflow/dist/style.css";

import { useEffect, useMemo } from "react";
import ReactFlow, { Background, useNodesState } from "reactflow";
import { useEffect, useMemo, useState } from "react";
import ReactFlow, { Background, SelectionMode, useNodesState } from "reactflow";

import { Box, Button, Group, Menu, MenuDropdown, Paper, rem, Select } from "@mantine/core";
import { useClassroomFormContext } from "../classroom/context";
import { ActionIcon, Box, Group, Paper, rem, Select, Tooltip } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { Copy, Lock, Redo2, Trash, Undo2, Unlock } from "lucide-react";
import { useFormSubscription } from "shared/hooks/use-form-subscription";
import { useUndo } from "shared/hooks/use-undo";
import { createCuid } from "shared/str";
import { useClassroomFormContext } from "../classroom/context";

const gridSpacing = 10;

export const SeatsEditor = () => {
const form = useClassroomFormContext();
const [nodes, setNodes, onNodesChanged] = useNodesState([]);
const pods = useFormSubscription(form, "pods");
const [nodes, setNodes, onNodesChanged] = useNodesState([]);
const [isLocked, setIsLocked] = useState(true);

form.watch("seats", (seats) => {
if (JSON.stringify(seats.previousValue) == JSON.stringify(seats.value)) return;
console.log(form.values);

setNodes(
seats.value.map((seat) => ({
id: seat.id,
type: "resizableNode",
data: { hex: form.values.pods.find((it) => it.id == seat.podId)!.hex, podId: seat.podId },
position: { x: seat.row, y: seat.col },
})),
);
const { canUndo, canRedo, undo, redo, keep } = useUndo(nodes, { setState: setNodes });

// note: mod <=> cmd
useHotkeys([
["ctrl+z", () => undo(), { preventDefault: true }],
["mod+z", () => undo(), { preventDefault: true }],
["ctrl+shift+Z", () => redo(), { preventDefault: true }],
["mod+shift+Z", () => redo(), { preventDefault: true }],
]);

form.watch("nodes", (update) => {
console.log("got form update...");
if (JSON.stringify(update.previousValue) == JSON.stringify(update.value)) return;
console.log("acutally applying...");

const next = update.value.map((seat) => ({
id: seat.id,
type: !!seat.podId ? "seat" : "entity",
data: { podId: seat.podId, entityType: seat.entityType },
position: { x: seat.row, y: seat.col },
}));

if (next.length != nodes.length) keep(next);
setNodes(next);
});

useEffect(() => {
console.log("got state update...");
const mapped = nodes.map((node) => ({
id: node.id,
row: node.position.x,
col: node.position.y,
flag: node.data.flag,
podId: node.data.podId,
entityType: node.data.entityType,
}));

if (JSON.stringify(mapped) == JSON.stringify(form.getValues().seats)) return;
console.log({ mapped });

if (JSON.stringify(mapped) == JSON.stringify(form.getValues().nodes)) return;
console.log("actually pushing to form...");

form.setFieldValue("seats", mapped);
form.setFieldValue("nodes", mapped, { forceUpdate: true });
}, [JSON.stringify(nodes)]);

useEffect(() => {
document.querySelector(".react-flow__panel")?.remove();
}, []);

const nodeTypes = useMemo(() => ({ resizableNode: ResizableNode }), []);
const selected = nodes.find((it) => it.selected);
const nodeTypes = useMemo(() => ({ seat: SeatNode, entity: EntityNode }), []);
const selected = nodes.filter((it) => it.selected);

function handleSetPodForSelectedNode(podId: string) {
keep(nodes);

setNodes((prev) =>
prev.map((it) =>
selected.some((s) => s.id == it.id)
? {
...it,
data: {
...it.data,
podId,
hex: form.getValues().pods.find((it) => it.id == podId)!.hex,
},
}
: it,
),
);
}

function handleDuplicateSelectedNode() {
if (!selected) return;

keep(nodes);
setNodes((nodes) => nodes.concat(selected.map((each) => ({ ...each, selected: false, id: createCuid() }))));
}

function handleDeleteSelected() {
if (!selected) return;

keep(nodes);
setNodes((nodes) => nodes.filter((it) => !it.selected));
}

return (
<Paper h="66vh" w="100%" withBorder shadow="md">
<ReactFlow nodes={nodes} onNodesChange={onNodesChanged} nodeTypes={nodeTypes} snapToGrid snapGrid={[gridSpacing, gridSpacing]}>
<ReactFlow
snapToGrid
snapGrid={[gridSpacing, gridSpacing]}
nodes={nodes}
nodeTypes={nodeTypes}
onNodesChange={onNodesChanged}
onNodeDragStop={() => keep(nodes)}
nodeDragThreshold={gridSpacing}
panOnScroll
panOnDrag={[1, 2]} // somehow this magically means that we only pan around when the user is holding the middle mouse button :eyeroll:
selectionOnDrag
selectionMode={SelectionMode.Partial}
>
<Background gap={gridSpacing} />

<Box
pos="absolute"
bottom={rem(36)}
left="50%"
w="100%"
style={{
transform: "translate(-50%, 0px)",
zIndex: 10,
display: !selected ? "none" : undefined,
}}
>
<Select
searchable
value={selected?.data["podId"]}
data={pods.map((pod) => ({ value: pod.id, label: pod.title }))}
onChange={(v) =>
setNodes((prev) =>
prev.map((it) =>
it.id == selected!.id
? {
...it,
data: {
...it.data,
podId: v,
hex: form.getValues().pods.find((it) => it.id == v)!.hex,
},
}
: it,
),
)
}
/>
<Group justify="space-between" px="xl" w="100%">
<ActionIcon.Group>
<ActionIcon variant="default" size="lg" onClick={undo} disabled={!canUndo}>
<Undo2 size={16} />
</ActionIcon>
<ActionIcon variant="default" size="lg" onClick={redo} disabled={!canRedo}>
<Redo2 size={16} />
</ActionIcon>
<Tooltip label={isLocked ? "Unlock to pan around the classroon" : "Lock to disable panning"}>
<ActionIcon variant="default" size="lg" onClick={() => setIsLocked((l) => !l)}>
{isLocked ? <Lock size={16} /> : <Unlock size={16} />}
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
<Group>
<Select
disabled={selected.length == 0 || !selected.every((it) => it.type == "seat")}
searchable
value={selected.at(0)?.data["podId"]}
data={pods.map((pod) => ({ value: pod.id, label: pod.title }))}
onChange={(v) => v && handleSetPodForSelectedNode(v)}
/>
<ActionIcon.Group>
<Tooltip label="Duplicate" onClick={handleDuplicateSelectedNode} disabled={!selected}>
<ActionIcon variant="default" color="red" size="lg" disabled={!selected}>
<Copy size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete" disabled={!selected}>
<ActionIcon variant="default" size="lg" onClick={handleDeleteSelected} disabled={!selected}>
<Trash size={16} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</Group>
</Group>
</Box>
</ReactFlow>
</Paper>
);
};

const ResizableNode = ({ selected, data }: { selected: boolean; data: { hex: string } }) => {
const SeatNode = ({ selected, data }: { selected: boolean; data: { podId: string } }) => {
const form = useClassroomFormContext();
const pods = useFormSubscription(form, "pods");

const backgroundColor = pods.find((it) => it.id == data.podId)?.hex;

return (
<>
<div
style={{
padding: "20px",
backgroundColor: data.hex,
backgroundColor,
height: "100%",
border: "solid 1px",
borderRadius: "1px",
Expand All @@ -107,3 +197,21 @@ const ResizableNode = ({ selected, data }: { selected: boolean; data: { hex: str
</>
);
};

const EntityNode = ({ selected }: { selected: boolean }) => {
return (
<>
<div
style={{
padding: "10px 40px",
height: "100%",
border: "solid 1px",
borderRadius: "4px",
borderColor: selected ? "var(--mantine-primary-color-filled)" : "var(--mantine-color-gray-outline)",
}}
>
whiteboard
</div>
</>
);
};
37 changes: 37 additions & 0 deletions app/shared/hooks/use-undo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Node, Edge } from "reactflow";

export function useUndo<T>(initial: T, opts: { setState: (v: T) => unknown }) {
const [state, setState] = useState<T>(initial);
const history = useRef<T[]>([]);
const future = useRef<T[]>([]);

useEffect(() => {
opts.setState(state);
}, [state]);

const keep = useCallback(
(newState: T) => {
history.current.push(state);
future.current = []; // Clear redo stack on new changes
setState(newState);
},
[state],
);

const undo = useCallback(() => {
if (history.current.length === 0) return;
const previousState = history.current.pop()!;
future.current.push(state);
setState(previousState);
}, [state]);

const redo = useCallback(() => {
if (future.current.length === 0) return;
const nextState = future.current.pop()!;
history.current.push(state);
setState(nextState);
}, [state]);

return { keep, undo, redo, canUndo: history.current.length > 0, canRedo: future.current.length > 0 };
}

0 comments on commit 84698f0

Please sign in to comment.