From 118f3ad34d6c2271953439b367969969d0bf939b Mon Sep 17 00:00:00 2001 From: Naman Rusia Date: Mon, 12 Oct 2026 23:42:17 -0400 Subject: [PATCH] Resize whiteboard shapes, and add system design shapes A selected box gets eight handles and a selected line or arrow gets one at each end; a pen stroke stretches its points into the new box. Text keeps its size. New shapes: hexagon, triangle, database, cloud and person, whose label sits under the figure. --- apps/web/src/components/Whiteboard.tsx | 226 ++++++++++++++++++++++--- 1 file changed, 201 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/Whiteboard.tsx b/apps/web/src/components/Whiteboard.tsx index ac00792..c6cd892 100644 --- a/apps/web/src/components/Whiteboard.tsx +++ b/apps/web/src/components/Whiteboard.tsx @@ -3,12 +3,41 @@ import type { KeyboardEvent, MouseEvent, PointerEvent } from "react"; import { useAuth } from "@clerk/clerk-react"; import * as Y from "yjs"; import { WebsocketProvider } from "y-websocket"; -import { Circle, Diamond, Hand, Minus, MousePointer2, MoveUpRight, Pencil, Redo2, Square, Type, Undo2 } from "lucide-react"; +import { + Circle, + Cloud, + Database, + Diamond, + Hand, + Hexagon, + Minus, + MousePointer2, + MoveUpRight, + Pencil, + Redo2, + Square, + Triangle, + Type, + Undo2, + User, +} from "lucide-react"; import { WS_BASE_URL } from "../lib/config"; export type Shape = { id: string; - type: "rect" | "diamond" | "ellipse" | "arrow" | "line" | "draw" | "text"; + type: + | "rect" + | "diamond" + | "ellipse" + | "hexagon" + | "triangle" + | "database" + | "cloud" + | "person" + | "arrow" + | "line" + | "draw" + | "text"; // An arrow or line runs from (x, y) by (w, h). A drawing is `points`, flat // x, y pairs relative to (x, y). Every other shape is a box with w, h >= 0. x: number; @@ -24,9 +53,12 @@ export type Shape = { type Tool = "select" | "hand" | Shape["type"]; type View = { x: number; y: number; zoom: number }; type Peer = { key: number; id: string; name: string; x: number; y: number }; +// Box handles are named for the side or corner they drag; lines have two ends. +type Handle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "start" | "end"; type Gesture = | { kind: "pan"; clientX: number; clientY: number; view: View } | { kind: "move" | "draw"; x: number; y: number; shape: Shape } + | { kind: "resize"; x: number; y: number; shape: Shape; handle: Handle } | { kind: "text" }; interface Props { @@ -35,12 +67,17 @@ interface Props { colorOf: (userId: string) => string; } -const TOOLS: { tool: Tool; icon: typeof Square; label: string; key: string }[] = [ +const TOOLS: { tool: Tool; icon: typeof Square; label: string; key?: string }[] = [ { tool: "hand", icon: Hand, label: "Pan", key: "h" }, { tool: "select", icon: MousePointer2, label: "Select", key: "v" }, { tool: "rect", icon: Square, label: "Rectangle", key: "r" }, { tool: "diamond", icon: Diamond, label: "Diamond", key: "d" }, { tool: "ellipse", icon: Circle, label: "Ellipse", key: "o" }, + { tool: "hexagon", icon: Hexagon, label: "Hexagon" }, + { tool: "triangle", icon: Triangle, label: "Triangle" }, + { tool: "database", icon: Database, label: "Database" }, + { tool: "cloud", icon: Cloud, label: "Cloud" }, + { tool: "person", icon: User, label: "Person" }, { tool: "arrow", icon: MoveUpRight, label: "Arrow", key: "a" }, { tool: "line", icon: Minus, label: "Line", key: "l" }, { tool: "draw", icon: Pencil, label: "Draw", key: "p" }, @@ -66,6 +103,18 @@ const TOKEN_REFRESH_MS = 30_000; const WS_URL = `${WS_BASE_URL}/ws/yjs`; const CURSORS: Partial> = { hand: "grab", select: "default", text: "text" }; +const HANDLE_CURSORS: Record = { + nw: "nwse-resize", + se: "nwse-resize", + ne: "nesw-resize", + sw: "nesw-resize", + n: "ns-resize", + s: "ns-resize", + e: "ew-resize", + w: "ew-resize", + start: "move", + end: "move", +}; const toolbar = "px-box absolute flex bg-zinc-900 [--px:#3f3f46]"; const toolButton = (on: boolean) => @@ -92,13 +141,12 @@ const edge = (x1: number, y1: number, x2: number, y2: number, rand: () => number return `M${x1 + j()} ${y1 + j()}C${x1 + dx / 3 + j()} ${y1 + dy / 3 + j()} ${x1 + (dx * 2) / 3 + j()} ${y1 + (dy * 2) / 3 + j()} ${x2 + j()} ${y2 + j()}`; }; -// A smooth curve through jittered points on the ellipse, running a little past -// where it started, as a hand would. -const loop = (cx: number, cy: number, rx: number, ry: number, rand: () => number) => { - const steps = 16; - const start = rand() * Math.PI * 2; +// A smooth curve through jittered points on an ellipse, from `start` through +// `sweep` radians. +const arc = (cx: number, cy: number, rx: number, ry: number, start: number, sweep: number, rand: () => number) => { + const steps = Math.max(4, Math.round((16 * sweep) / (Math.PI * 2))); const points = Array.from({ length: steps + 2 }, (_, i) => { - const angle = start + (i * Math.PI * 2 * 1.06) / steps; + const angle = start + ((i - 0.5) * sweep) / steps; const k = 1 + (rand() - 0.5) * 0.06; return [cx + Math.cos(angle) * rx * k, cy + Math.sin(angle) * ry * k]; }); @@ -108,6 +156,22 @@ const loop = (cx: number, cy: number, rx: number, ry: number, rand: () => number return path; }; +// A whole ellipse that runs a little past where it started, as a hand would. +const loop = (cx: number, cy: number, rx: number, ry: number, rand: () => number) => + arc(cx, cy, rx, ry, rand() * Math.PI * 2, Math.PI * 2 * 1.06, rand); + +// Scallops bulging out from an ellipse inset enough that their peaks meet the box. +const cloud = (cx: number, cy: number, rx: number, ry: number, rand: () => number) => { + const bumps = 8; + const at = (angle: number, k: number) => `${cx + Math.cos(angle) * rx * k} ${cy + Math.sin(angle) * ry * k}`; + const angle = (i: number) => (i * Math.PI * 2) / bumps; + let path = `M${at(0, 0.9)}`; + for (let i = 0; i < bumps; i++) { + path += `Q${at(angle(i + 0.5), 1.3 + (rand() - 0.5) * 0.1)} ${at(angle(i + 1), i === bumps - 1 ? 0.9 : 0.9 + (rand() - 0.5) * 0.05)}`; + } + return path; +}; + const outline = (shape: Shape): string => { const rand = random(shape.id); const { x, y, w, h } = shape; @@ -131,6 +195,47 @@ const outline = (shape: Shape): string => { ]); } if (shape.type === "ellipse") return twice(() => loop(x + w / 2, y + h / 2, w / 2, h / 2, rand)); + if (shape.type === "hexagon") { + return polygon([ + [x + w / 4, y], + [x + (w * 3) / 4, y], + [x + w, y + h / 2], + [x + (w * 3) / 4, y + h], + [x + w / 4, y + h], + [x, y + h / 2], + ]); + } + if (shape.type === "triangle") { + return polygon([ + [x + w / 2, y], + [x + w, y + h], + [x, y + h], + ]); + } + if (shape.type === "database") { + const ry = Math.min(h / 4, w / 6); + return twice( + () => + loop(x + w / 2, y + ry, w / 2, ry, rand) + + edge(x, y + ry, x, y + h - ry, rand) + + edge(x + w, y + ry, x + w, y + h - ry, rand) + + arc(x + w / 2, y + h - ry, w / 2, ry, 0, Math.PI, rand), + ); + } + if (shape.type === "cloud") return twice(() => cloud(x + w / 2, y + h / 2, w / 2, h / 2, rand)); + if (shape.type === "person") { + const r = Math.min(w / 4, h / 6); + const hip = y + h * 0.65; + const arms = y + 2 * r + h * 0.12; + return twice( + () => + loop(x + w / 2, y + r, r, r, rand) + + edge(x + w / 2, y + 2 * r, x + w / 2, hip, rand) + + edge(x + w * 0.1, arms, x + w * 0.9, arms, rand) + + edge(x + w / 2, hip, x + w * 0.15, y + h, rand) + + edge(x + w / 2, hip, x + w * 0.85, y + h, rand), + ); + } if (shape.type === "line") return twice(() => edge(x, y, x + w, y + h, rand)); if (shape.type === "arrow") { const angle = Math.atan2(h, w); @@ -170,6 +275,58 @@ const box = (shape: Shape) => { // Lines, arrows and drawings are hit along their stroke, not across their box. const stroked = (shape: Shape) => shape.type === "arrow" || shape.type === "line" || shape.type === "draw"; +// Where a shape's text sits: free text from its top left, a person's name +// under the figure, any other label in the middle. +const labelAt = (shape: Shape) => { + const { x, y, w, h } = box(shape); + if (shape.type === "text") return { x, y }; + return { x: x + w / 2, y: shape.type === "person" ? y + h + LINE * 0.7 : y + h / 2 }; +}; + +// Text keeps its font size, so it has no handles. +const handles = (shape: Shape, pad: number): [Handle, number, number][] => { + if (shape.type === "arrow" || shape.type === "line") { + return [ + ["start", shape.x, shape.y], + ["end", shape.x + shape.w, shape.y + shape.h], + ]; + } + if (shape.type === "text") return []; + const { x, y, w, h } = box(shape); + const [left, top, right, bottom] = [x - pad, y - pad, x + w + pad, y + h + pad]; + const [midX, midY] = [x + w / 2, y + h / 2]; + return [ + ["nw", left, top], + ["n", midX, top], + ["ne", right, top], + ["e", right, midY], + ["se", right, bottom], + ["s", midX, bottom], + ["sw", left, bottom], + ["w", left, midY], + ]; +}; + +const resize = (shape: Shape, handle: Handle, dx: number, dy: number): Shape => { + if (handle === "start") return { ...shape, x: shape.x + dx, y: shape.y + dy, w: shape.w - dx, h: shape.h - dy }; + if (handle === "end") return { ...shape, w: shape.w + dx, h: shape.h + dy }; + const old = box(shape); + let [left, top, right, bottom] = [old.x, old.y, old.x + old.w, old.y + old.h]; + if (handle.includes("w")) left += dx; + if (handle.includes("e")) right += dx; + if (handle.includes("n")) top += dy; + if (handle.includes("s")) bottom += dy; + const next = { x: Math.min(left, right), y: Math.min(top, bottom), w: Math.abs(right - left), h: Math.abs(bottom - top) }; + if (!shape.points) return { ...shape, ...next }; + // A drawing stretches its points into the new box, relative to its corner. + const sx = old.w ? next.w / old.w : 1; + const sy = old.h ? next.h / old.h : 1; + const points = shape.points.map((p, i) => + i % 2 ? Math.round((shape.y + p - old.y) * sy) : Math.round((shape.x + p - old.x) * sx), + ); + return { ...shape, x: next.x, y: next.y, points }; +}; + const lines = (text: string) => text.split("\n"); const textSize = (text: string) => ({ @@ -208,15 +365,7 @@ const ShapeView = memo(({ shape }: { shape: Shape }) => { )} - {shape.text && ( -