diff --git a/apps/tldraw-sync-worker/worker/worker.ts b/apps/tldraw-sync-worker/worker/worker.ts index 1df84a3fa..44e2bdd37 100644 --- a/apps/tldraw-sync-worker/worker/worker.ts +++ b/apps/tldraw-sync-worker/worker/worker.ts @@ -6,13 +6,26 @@ import { Environment } from "./types"; export { TldrawDurableObject } from "./TldrawDurableObject"; const ALLOWED_ORIGINS = [ + "https://discoursegraphs.com", + "https://www.discoursegraphs.com", "https://roamresearch.com", "http://localhost:3000", "app://obsidian.md", ]; -const isVercelPreviewUrl = (origin: string): boolean => - /^https:\/\/.*-discourse-graph-[a-z0-9]+\.vercel\.app$/.test(origin); +const isVercelPreviewUrl = (origin: string): boolean => { + try { + const url = new URL(origin); + return ( + url.protocol === "https:" && + /^discourse-graph(?:-[a-z0-9-]+)?-discourse-graphs\.vercel\.app$/.test( + url.hostname, + ) + ); + } catch { + return false; + } +}; const isAllowedOrigin = (origin: string): boolean => ALLOWED_ORIGINS.some((allowedOrigin) => origin === allowedOrigin) || diff --git a/apps/website/app/(canvas)/canvas/[roomId]/page.tsx b/apps/website/app/(canvas)/canvas/[roomId]/page.tsx new file mode 100644 index 000000000..7f2ec8b51 --- /dev/null +++ b/apps/website/app/(canvas)/canvas/[roomId]/page.tsx @@ -0,0 +1,20 @@ +import type { ReactElement } from "react"; +import { notFound } from "next/navigation"; +import { CanvasRoomLoader } from "../components/CanvasRoomLoader"; + +type CanvasPageProps = { + params: Promise<{ roomId: string }>; +}; + +const ROOM_ID_PATTERN = /^[0-9a-f-]{36}$/i; + +const CanvasPage = async ({ + params, +}: CanvasPageProps): Promise => { + const { roomId } = await params; + if (!ROOM_ID_PATTERN.test(roomId)) notFound(); + + return ; +}; + +export default CanvasPage; diff --git a/apps/website/app/(canvas)/canvas/components/CanvasRoom.tsx b/apps/website/app/(canvas)/canvas/components/CanvasRoom.tsx new file mode 100644 index 000000000..f629cfdf3 --- /dev/null +++ b/apps/website/app/(canvas)/canvas/components/CanvasRoom.tsx @@ -0,0 +1,528 @@ +"use client"; + +import { + Check, + ChevronDown, + CircleHelp, + FileDown, + GitBranch, + Link2, + Plus, + Share2, + X, +} from "lucide-react"; +import Link from "next/link"; +import { type ReactElement, useEffect, useMemo, useRef, useState } from "react"; +import { useSync } from "@tldraw/sync"; +import { + Tldraw, + createShapeId, + type Editor, + type TLArrowShape, + type TLAssetStore, + type TLShapeId, + type TLGeoShape, + type TLShape, + useEditor, + useValue, +} from "tldraw"; +import { + formatCanvasExport, + NODE_TYPES, + type CanvasExport, + type ExportTarget, + type NodeType, +} from "../lib/exportCanvas"; + +const SYNC_SERVER_URL = + process.env.NEXT_PUBLIC_TLDRAW_SYNC_URL ?? + "https://multiplayer-dg-sync.discoursegraphs.workers.dev"; + +const NODE_CONFIG: Record< + NodeType, + { color: "green" | "red" | "yellow"; label: string; prompt: string } +> = { + claim: { + color: "green", + label: "Claim", + prompt: "State a claim...", + }, + evidence: { + color: "red", + label: "Evidence", + prompt: "Add evidence...", + }, + question: { + color: "yellow", + label: "Question", + prompt: "Ask a question...", + }, +}; + +const RELATION_TYPES = ["Supports", "Opposes", "Informs"] as const; +const COLLABORATOR_NAMES = [ + "Curious Otter", + "Careful Raven", + "Bright Finch", + "Patient Fox", + "Bold Heron", + "Quiet Badger", +] as const; + +const getCollaborator = (): { id: string; name: string } => { + const storageKey = "dg-canvas-collaborator"; + const stored = sessionStorage.getItem(storageKey); + if (stored) { + try { + return JSON.parse(stored) as { id: string; name: string }; + } catch { + sessionStorage.removeItem(storageKey); + } + } + + const id = crypto.randomUUID(); + const name = + COLLABORATOR_NAMES[Math.floor(Math.random() * COLLABORATOR_NAMES.length)] ?? + "Canvas guest"; + const collaborator = { id, name }; + sessionStorage.setItem(storageKey, JSON.stringify(collaborator)); + return collaborator; +}; + +const getNodeType = (shape: TLShape): NodeType | null => { + const candidate = shape.meta?.dgType; + return NODE_TYPES.includes(candidate as NodeType) + ? (candidate as NodeType) + : null; +}; + +const getBoundShapeId = (endpoint: unknown): TLShapeId | null => { + if (!endpoint || typeof endpoint !== "object") return null; + const candidate = (endpoint as { boundShapeId?: unknown }).boundShapeId; + return typeof candidate === "string" ? (candidate as TLShapeId) : null; +}; + +const getCanvasExport = ({ editor }: { editor: Editor }): CanvasExport => { + const shapes = editor.getCurrentPageShapes(); + const nodes = shapes + .flatMap((shape) => { + const type = getNodeType(shape); + if (!type || shape.type !== "geo") return []; + const geoShape = shape as TLGeoShape; + return [ + { + id: shape.id, + text: geoShape.props.text, + type, + x: shape.x, + y: shape.y, + }, + ]; + }) + .sort((a, b) => a.y - b.y || a.x - b.x); + const nodeIds = new Set(nodes.map((node) => node.id)); + const relations = shapes.flatMap((shape) => { + if (shape.type !== "arrow") return []; + const arrow = shape as TLArrowShape; + const fromId = getBoundShapeId(arrow.props.start); + const toId = getBoundShapeId(arrow.props.end); + if (!fromId || !toId || !nodeIds.has(fromId) || !nodeIds.has(toId)) { + return []; + } + return [ + { + fromId, + label: arrow.props.text || "Relates to", + toId, + }, + ]; + }); + + return { + nodes: nodes.map(({ id, text, type }) => ({ id, text, type })), + relations, + }; +}; + +const createNode = ({ + editor, + type, +}: { + editor: Editor; + type: NodeType; +}): void => { + const bounds = editor.getViewportPageBounds(); + const existingNodes = editor + .getCurrentPageShapes() + .filter((shape) => getNodeType(shape)); + const offset = (existingNodes.length % 5) * 22; + const id = createShapeId(); + const config = NODE_CONFIG[type]; + + editor.createShape({ + id, + type: "geo", + x: bounds.x + bounds.w / 2 - 130 + offset, + y: bounds.y + bounds.h / 2 - 58 + offset, + meta: { dgType: type }, + props: { + align: "start", + color: config.color, + fill: "solid", + font: "sans", + geo: "rectangle", + h: 116, + size: "m", + text: config.prompt, + verticalAlign: "middle", + w: 260, + }, + }); + editor.select(id); + editor.setEditingShape(id); +}; + +const CanvasHeader = ({ roomId }: { roomId: string }): ReactElement => { + const [copied, setCopied] = useState(false); + + const copyRoomLink = async (): Promise => { + await navigator.clipboard.writeText(window.location.href); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + }; + + return ( +
+
+ + + DG + + Discourse canvas + + + + Room {roomId.slice(0, 8)} + + + + Live + +
+ +
+ {/* A hard navigation requests a fresh server-generated room every time. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + + + +
+
+ ); +}; + +type ExportDialogProps = { + canvas: CanvasExport; + onClose: () => void; +}; + +const ExportDialog = ({ canvas, onClose }: ExportDialogProps): ReactElement => { + const [target, setTarget] = useState("roam"); + const [copied, setCopied] = useState(false); + const output = formatCanvasExport({ canvas, target }); + + const copyExport = async (): Promise => { + await navigator.clipboard.writeText(output); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + }; + + return ( +
+
+
+
+

+ Copy your discourse graph +

+

+ Paste as editable outline text. This prototype does not sync back. +

+
+ +
+ +
+
+ {(["roam", "obsidian"] as const).map((option) => ( + + ))} +
+