From 468c350ddc10bc762b5467a86857912f413614f0 Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Wed, 5 Aug 2026 11:27:15 +0100 Subject: [PATCH] Add version history to React Flow examples (#3641) Co-authored-by: Cursor Agent Co-authored-by: Chris Nicholas --- examples/nextjs-flowchart-builder/README.md | 4 +- .../app/flowchart.tsx | 2 + .../nextjs-flowchart-builder/app/globals.css | 176 +++++++ .../app/version-history-dialog.tsx | 483 ++++++++++++++++++ .../components/HelpButton.tsx | 15 + .../package-lock.json | 376 +++++++++++++- .../nextjs-flowchart-builder/package.json | 1 + examples/nextjs-react-flow-ai/README.md | 2 + .../app/flowchart/agent/agent.ts | 6 + .../app/flowchart/editor.tsx | 2 + .../app/flowchart/version-history-dialog.tsx | 436 ++++++++++++++++ examples/nextjs-react-flow-ai/app/globals.css | 175 +++++++ .../components/HelpButton.tsx | 15 + .../nextjs-react-flow-ai/package-lock.json | 376 +++++++++++++- examples/nextjs-react-flow-ai/package.json | 1 + 15 files changed, 2039 insertions(+), 31 deletions(-) create mode 100644 examples/nextjs-flowchart-builder/app/version-history-dialog.tsx create mode 100644 examples/nextjs-react-flow-ai/app/flowchart/version-history-dialog.tsx diff --git a/examples/nextjs-flowchart-builder/README.md b/examples/nextjs-flowchart-builder/README.md index 478c79556ad..d8fdbf6caf3 100644 --- a/examples/nextjs-flowchart-builder/README.md +++ b/examples/nextjs-flowchart-builder/README.md @@ -23,7 +23,9 @@ This example shows how to build a collaborative flowchart builder with [Next.js](https://nextjs.org/). You can place blocks, connect them, edit labels, undo and redo, and attach -comments to the canvas or to specific blocks. +comments to the canvas or to specific blocks. Open version history from the +clock button next to the avatars to preview and restore earlier flowchart +states. Collaborative Flowchart Builder diff --git a/examples/nextjs-flowchart-builder/app/flowchart.tsx b/examples/nextjs-flowchart-builder/app/flowchart.tsx index 687a601ecc1..f04a4d412f1 100644 --- a/examples/nextjs-flowchart-builder/app/flowchart.tsx +++ b/examples/nextjs-flowchart-builder/app/flowchart.tsx @@ -64,6 +64,7 @@ import { Icon, } from "@liveblocks/react-ui"; import { HelpControl } from "../components/HelpButton"; +import { VersionsDialog } from "./version-history-dialog"; import { DndContext, type DragEndEvent, @@ -1694,6 +1695,7 @@ function Flow({ className, ...props }: ComponentProps<"div">) {
+
diff --git a/examples/nextjs-flowchart-builder/app/globals.css b/examples/nextjs-flowchart-builder/app/globals.css index 911c606a143..cf55c68bea5 100644 --- a/examples/nextjs-flowchart-builder/app/globals.css +++ b/examples/nextjs-flowchart-builder/app/globals.css @@ -519,6 +519,9 @@ body { .flowchart-avatar-stack { position: relative; + display: flex; + align-items: center; + gap: 8px; &::before { content: ""; @@ -569,3 +572,176 @@ body { height: 4rem; opacity: 0.2; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.flowchart-version-history-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 9999px; + border: 1px solid rgb(0 0 0 / 8%); + background: rgb(255 255 255 / 85%); + color: #404040; + cursor: pointer; + box-shadow: 0 1px 2px rgb(0 0 0 / 6%); + transition: + background 120ms ease, + color 120ms ease, + border-color 120ms ease; +} + +.flowchart-version-history-trigger:hover { + background: #fff; + color: #171717; + border-color: rgb(0 0 0 / 12%); +} + +.flowchart-version-history-trigger:focus-visible { + outline: 2px solid var(--flowchart-accent); + outline-offset: 2px; +} + +.flowchart-version-history-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgb(0 0 0 / 50%); + cursor: pointer; +} + +.flowchart-version-history-dialog { + position: fixed; + top: 50%; + left: 50%; + z-index: 1001; + width: 90vw; + height: 85vh; + transform: translate(-50%, -50%); + overflow: hidden; + border-radius: 12px; + border: 1px solid #e5e5e5; + background: #fff; + color: #171717; + box-shadow: + 0 20px 25px -5px rgb(0 0 0 / 0.1), + 0 8px 10px -6px rgb(0 0 0 / 0.1); + outline: none; +} + +.flowchart-versions { + display: flex; + height: 100%; + min-height: 0; +} + +.flowchart-versions-preview { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + height: 100%; +} + +.flowchart-versions-list { + position: relative; + width: 250px; + height: 100%; + overflow: auto; + border-left: 1px solid rgb(0 0 0 / 8%); + flex: none; +} + +.flowchart-version-preview { + height: 100%; + min-height: 0; +} + +.flowchart-version-preview-content { + position: relative; + flex: 1 1 auto; + min-height: 0; + padding: 0 !important; + overflow: hidden; +} + +.flowchart-version-preview-content .react-flow { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.flowchart-version-preview-status { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + color: #737373; + font-size: 14px; + text-align: center; +} + +.flowchart-version-preview-label { + pointer-events: none; + resize: none; + overflow: hidden; + white-space: pre-wrap; + word-break: break-word; +} + +.flowchart-version-preview-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + padding: 0 14px; + border-radius: 8px; + border: 1px solid transparent; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease, + border-color 120ms ease, + opacity 120ms ease; +} + +.flowchart-version-preview-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.flowchart-version-preview-button-primary { + background: #171717; + color: #fff; +} + +.flowchart-version-preview-button-primary:hover:not(:disabled) { + background: #404040; +} + +.flowchart-version-preview-button-danger { + background: #fff; + color: #b91c1c; + border-color: #fecaca; +} + +.flowchart-version-preview-button-danger:hover:not(:disabled) { + background: #fef2f2; +} + diff --git a/examples/nextjs-flowchart-builder/app/version-history-dialog.tsx b/examples/nextjs-flowchart-builder/app/version-history-dialog.tsx new file mode 100644 index 00000000000..cd7b3060a78 --- /dev/null +++ b/examples/nextjs-flowchart-builder/app/version-history-dialog.tsx @@ -0,0 +1,483 @@ +"use client"; + +import * as Dialog from "@radix-ui/react-dialog"; +import { + useDeleteHistoryVersion, + useHistoryVersions, + useHistoryVersionStorageData, + useRestoreToStorageVersion, +} from "@liveblocks/react"; +import { + HistoryVersionSummary, + HistoryVersionSummaryList, +} from "@liveblocks/react-ui"; +import type { HistoryVersion } from "@liveblocks/client"; +import { + Background, + BaseEdge, + EdgeLabelRenderer, + Handle, + MarkerType, + Position, + ReactFlow, + ReactFlowProvider, + getSmoothStepPath, + useReactFlow, + type Edge, + type EdgeProps, + type EdgeTypes, + type Node, + type NodeProps, + type NodeTypes, +} from "@xyflow/react"; +import { + Fragment, + memo, + useCallback, + useEffect, + useMemo, + useState, + type CSSProperties, +} from "react"; + +const BLOCK_SHAPES = ["rectangle", "ellipse", "diamond"] as const; +const BLOCK_COLORS = { + blue: "#09f", + cyan: "#0cd", + green: "#3c5", + yellow: "#fb0", + orange: "#f81", + red: "#f24", + pink: "#e4b", + purple: "#85f", + gray: "#678", +} as const; +const BLOCK_HANDLE_SIDES = ["top", "right", "bottom", "left"] as const; +const FLOWCHART_STORAGE_KEY = "flow" as const; +const FLOWCHART_EDGE_TYPE = "smoothstep" as const; +const DEFAULT_BLOCK_SHAPE = "rectangle" as const; +const DEFAULT_BLOCK_COLOR = "gray" as const; + +type BlockShape = (typeof BLOCK_SHAPES)[number]; +type BlockColor = keyof typeof BLOCK_COLORS; +type BlockHandleSide = (typeof BLOCK_HANDLE_SIDES)[number]; +type BlockSourceHandleId = `src-${BlockHandleSide}`; +type BlockTargetHandleId = `tgt-${BlockHandleSide}`; + +type FlowchartNodeData = { + label: string; + shape: BlockShape; + color: BlockColor; +}; + +type FlowchartEdgeData = { + label: string; +}; + +type FlowchartNode = Node; +type FlowchartEdge = Edge; + +function blockSourceHandleId(side: BlockHandleSide): BlockSourceHandleId { + return `src-${side}`; +} + +function blockTargetHandleId(side: BlockHandleSide): BlockTargetHandleId { + return `tgt-${side}`; +} + +function isBlockShape(value: string | undefined): value is BlockShape { + return value ? (BLOCK_SHAPES as readonly string[]).includes(value) : false; +} + +function getBlockShape(shape: string | BlockShape | undefined): BlockShape { + return isBlockShape(shape) ? shape : DEFAULT_BLOCK_SHAPE; +} + +function getBlockColor(color: BlockColor | undefined): string { + return BLOCK_COLORS[color ?? DEFAULT_BLOCK_COLOR]; +} + +function getBlockStyle(color: BlockColor | undefined): CSSProperties { + return { + "--flowchart-block-color": getBlockColor(color), + } as CSSProperties; +} + +const HANDLE_POSITIONS = [ + [Position.Top, "top"], + [Position.Right, "right"], + [Position.Bottom, "bottom"], + [Position.Left, "left"], +] as const satisfies ReadonlyArray; + +const PreviewBlockNode = memo(({ data }: NodeProps) => { + return ( + <> +
+
+ {data.label || "Add text"} +
+
+ + {HANDLE_POSITIONS.map(([position, side]) => ( + + + + + ))} + + ); +}); + +const PreviewLabelEdge = memo( + ({ + data, + markerEnd, + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + style, + }: EdgeProps) => { + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 0, + }); + const label = data?.label?.trim(); + + return ( + <> + + {label ? ( + +
+ + {label} + +
+
+ ) : null} + + ); + } +); + +const previewNodeTypes: NodeTypes = { + block: PreviewBlockNode, +}; + +const previewEdgeTypes: EdgeTypes = { + [FLOWCHART_EDGE_TYPE]: PreviewLabelEdge, +}; + +function flowFromStorageData(data: { toJSON: () => unknown }): { + nodes: FlowchartNode[]; + edges: FlowchartEdge[]; +} { + const root = data.toJSON() as Record | null | undefined; + const flow = root?.[FLOWCHART_STORAGE_KEY] as + | { + nodes?: Record; + edges?: Record; + } + | undefined; + + const nodes = Object.values(flow?.nodes ?? {}).map((node) => ({ + ...node, + selected: false, + dragging: false, + })); + + const edges = Object.values(flow?.edges ?? {}).map((edge) => ({ + ...edge, + selected: false, + type: edge.type ?? FLOWCHART_EDGE_TYPE, + markerEnd: edge.markerEnd ?? { + type: MarkerType.ArrowClosed, + }, + })); + + return { nodes, edges }; +} + +function FitViewOnLoad({ + nodeCount, + edgeCount, +}: { + nodeCount: number; + edgeCount: number; +}) { + const { fitView } = useReactFlow(); + + useEffect(() => { + const frame = requestAnimationFrame(() => { + void fitView({ padding: 0.2, duration: 0 }); + }); + + return () => cancelAnimationFrame(frame); + }, [fitView, nodeCount, edgeCount]); + + return null; +} + +function StorageVersionPreview({ + version, + onVersionRestore, +}: { + version: HistoryVersion; + onVersionRestore: () => void; +}) { + const { data, isLoading, error } = useHistoryVersionStorageData(version.id); + const restoreToStorageVersion = useRestoreToStorageVersion(version.id); + const deleteHistoryVersion = useDeleteHistoryVersion(); + const [isRestoring, setIsRestoring] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const { nodes, edges } = useMemo( + () => (data ? flowFromStorageData(data) : { nodes: [], edges: [] }), + [data] + ); + + const handleRestore = useCallback(async () => { + setIsRestoring(true); + try { + await restoreToStorageVersion(); + onVersionRestore(); + } catch (restoreError) { + console.error(restoreError); + } finally { + setIsRestoring(false); + } + }, [onVersionRestore, restoreToStorageVersion]); + + const handleDelete = useCallback(async () => { + setIsDeleting(true); + try { + await deleteHistoryVersion(version.id); + } catch (deleteError) { + console.error(deleteError); + } finally { + setIsDeleting(false); + } + }, [deleteHistoryVersion, version.id]); + + return ( +
+
+ {isLoading ? ( +
+ Loading version… +
+ ) : error ? ( +
+ Failed to load version +
+ ) : ( + + + + + + + )} +
+
+
+ + +
+
+
+ ); +} + +function Versions({ onVersionRestore }: { onVersionRestore: () => void }) { + const [selectedVersionId, setSelectedVersionId] = useState(); + const { versions, isLoading } = useHistoryVersions(); + + useEffect(() => { + if (!versions?.length) { + setSelectedVersionId(undefined); + return; + } + + if ( + !selectedVersionId || + !versions.some((version) => version.id === selectedVersionId) + ) { + setSelectedVersionId(versions[0].id); + } + }, [selectedVersionId, versions]); + + const selectedVersion = useMemo( + () => versions?.find((version) => version.id === selectedVersionId), + [selectedVersionId, versions] + ); + + if (isLoading) { + return ( +
+ Loading version history… +
+ ); + } + + if (!versions?.length) { + return ( +
No versions yet
+ ); + } + + return ( +
+
+ {selectedVersion ? ( + + ) : ( +
+ No version selected +
+ )} +
+
+ + {versions.map((version) => ( + { + setSelectedVersionId(version.id); + }} + /> + ))} + +
+
+ ); +} + +export function VersionsDialog() { + const [isOpen, setOpen] = useState(false); + + const onVersionRestore = useCallback(() => { + setOpen(false); + }, []); + + return ( + + + + + + + + + Versions + + Previous versions of this flowchart + + + + + + ); +} + +function ClockIcon() { + return ( + + + + ); +} diff --git a/examples/nextjs-flowchart-builder/components/HelpButton.tsx b/examples/nextjs-flowchart-builder/components/HelpButton.tsx index b0c03130cb0..d26083f1915 100644 --- a/examples/nextjs-flowchart-builder/components/HelpButton.tsx +++ b/examples/nextjs-flowchart-builder/components/HelpButton.tsx @@ -47,6 +47,12 @@ const FEATURES: Feature[] = [ description: "Pin comment threads to the canvas or to a specific block to discuss in context.", }, + { + icon: , + title: "Version history", + description: + "Open the clock button by the avatars to preview and restore automatically saved flowchart versions.", + }, ]; const styles: Record = { @@ -338,3 +344,12 @@ function EditIcon() { ); } + +function HistoryIcon() { + return ( + + + + + ); +} diff --git a/examples/nextjs-flowchart-builder/package-lock.json b/examples/nextjs-flowchart-builder/package-lock.json index 38d1d247032..f5283555522 100644 --- a/examples/nextjs-flowchart-builder/package-lock.json +++ b/examples/nextjs-flowchart-builder/package-lock.json @@ -13,6 +13,7 @@ "@liveblocks/react": "^3.23.0", "@liveblocks/react-flow": "^3.23.0", "@liveblocks/react-ui": "^3.23.0", + "@radix-ui/react-dialog": "^1.1.23", "@xyflow/react": "^12.10.1", "nanoid": "^5.1.7", "next": "^16.2.12", @@ -895,6 +896,43 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", + "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-dismissable-layer": "1.1.17", + "@radix-ui/react-focus-guards": "1.1.5", + "@radix-ui/react-focus-scope": "1.1.14", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-portal": "1.1.15", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-slot": "1.3.1", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.13.tgz", @@ -1112,24 +1150,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", - "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.4", - "@radix-ui/react-context": "1.2.1", - "@radix-ui/react-dismissable-layer": "1.1.17", - "@radix-ui/react-focus-guards": "1.1.5", - "@radix-ui/react-focus-scope": "1.1.14", - "@radix-ui/react-id": "1.1.3", - "@radix-ui/react-portal": "1.1.15", - "@radix-ui/react-presence": "1.1.9", - "@radix-ui/react-primitive": "2.1.8", - "@radix-ui/react-slot": "1.3.1", - "@radix-ui/react-use-controllable-state": "1.2.5", - "@radix-ui/react-use-layout-effect": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1148,6 +1186,277 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.3.tgz", @@ -2912,6 +3221,43 @@ } } }, + "node_modules/radix-ui/node_modules/@radix-ui/react-dialog": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", + "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-dismissable-layer": "1.1.17", + "@radix-ui/react-focus-guards": "1.1.5", + "@radix-ui/react-focus-scope": "1.1.14", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-portal": "1.1.15", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-slot": "1.3.1", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/react": { "version": "18.3.1", "license": "MIT", diff --git a/examples/nextjs-flowchart-builder/package.json b/examples/nextjs-flowchart-builder/package.json index 37ca671b333..889d1aba1c9 100644 --- a/examples/nextjs-flowchart-builder/package.json +++ b/examples/nextjs-flowchart-builder/package.json @@ -16,6 +16,7 @@ "@liveblocks/react": "^3.23.0", "@liveblocks/react-flow": "^3.23.0", "@liveblocks/react-ui": "^3.23.0", + "@radix-ui/react-dialog": "^1.1.23", "@xyflow/react": "^12.10.1", "nanoid": "^5.1.7", "next": "^16.2.12", diff --git a/examples/nextjs-react-flow-ai/README.md b/examples/nextjs-react-flow-ai/README.md index 55443df35d3..5d694361a83 100644 --- a/examples/nextjs-react-flow-ai/README.md +++ b/examples/nextjs-react-flow-ai/README.md @@ -29,6 +29,8 @@ powered by [Liveblocks](https://liveblocks.io), You can place blocks, connect them, edit labels, undo and redo, add pinned comments on the canvas, and ask the AI to edit the diagram in real-time for everyone in the room. You can also talk to the AI Assistant in comment threads. +Before each AI run, a version snapshot is saved so you can preview and restore +earlier flowchart states from the history button next to the avatars. Collaborative React Flow with AI diff --git a/examples/nextjs-react-flow-ai/app/flowchart/agent/agent.ts b/examples/nextjs-react-flow-ai/app/flowchart/agent/agent.ts index 9e6be898a76..c3f02271b3c 100644 --- a/examples/nextjs-react-flow-ai/app/flowchart/agent/agent.ts +++ b/examples/nextjs-react-flow-ai/app/flowchart/agent/agent.ts @@ -70,6 +70,12 @@ export async function runFlowchartAgent( prompt: string, options?: RunFlowchartAgentOptions ): Promise<{ text: string }> { + try { + await liveblocks.createVersionHistorySnapshot(roomId); + } catch (error) { + console.error("Failed to create version history snapshot", error); + } + const agentUser = createAgentUser(); let lastCursor: Point | null = null; let lastThinking: boolean = true; diff --git a/examples/nextjs-react-flow-ai/app/flowchart/editor.tsx b/examples/nextjs-react-flow-ai/app/flowchart/editor.tsx index a9e90b0cdd9..dde05ae82a7 100644 --- a/examples/nextjs-react-flow-ai/app/flowchart/editor.tsx +++ b/examples/nextjs-react-flow-ai/app/flowchart/editor.tsx @@ -48,6 +48,7 @@ import { } from "@xyflow/react"; import { AvatarStack, Cursor, Icon } from "@liveblocks/react-ui"; import { HelpControl } from "../../components/HelpButton"; +import { VersionsDialog } from "./version-history-dialog"; import { useEditThreadMetadata, useThreads, @@ -1165,6 +1166,7 @@ function Flow({ className, ...props }: ComponentProps<"div">) {
+
diff --git a/examples/nextjs-react-flow-ai/app/flowchart/version-history-dialog.tsx b/examples/nextjs-react-flow-ai/app/flowchart/version-history-dialog.tsx new file mode 100644 index 00000000000..a0ef3cf0815 --- /dev/null +++ b/examples/nextjs-react-flow-ai/app/flowchart/version-history-dialog.tsx @@ -0,0 +1,436 @@ +"use client"; + +import * as Dialog from "@radix-ui/react-dialog"; +import { + useDeleteHistoryVersion, + useHistoryVersions, + useHistoryVersionStorageData, + useRestoreToStorageVersion, +} from "@liveblocks/react"; +import { + HistoryVersionSummary, + HistoryVersionSummaryList, +} from "@liveblocks/react-ui"; +import type { HistoryVersion } from "@liveblocks/client"; +import { + Background, + BaseEdge, + EdgeLabelRenderer, + Handle, + MarkerType, + Position, + ReactFlow, + ReactFlowProvider, + getSmoothStepPath, + useReactFlow, + type EdgeProps, + type EdgeTypes, + type NodeProps, + type NodeTypes, +} from "@xyflow/react"; +import { + Fragment, + memo, + useCallback, + useEffect, + useMemo, + useState, + type CSSProperties, +} from "react"; +import { + FLOWCHART_EDGE_TYPE, + FLOWCHART_STORAGE_KEY, + blockSourceHandleId, + blockTargetHandleId, + getBlockColor, + getBlockShape, + type BlockColor, + type BlockHandleSide, + type FlowchartEdge, + type FlowchartNode, +} from "./shared"; + +function getBlockStyle(color: BlockColor | undefined): CSSProperties { + return { + "--flowchart-block-color": getBlockColor(color), + } as CSSProperties; +} + +const HANDLE_POSITIONS = [ + [Position.Top, "top"], + [Position.Right, "right"], + [Position.Bottom, "bottom"], + [Position.Left, "left"], +] as const satisfies ReadonlyArray; + +const PreviewBlockNode = memo(({ data }: NodeProps) => { + return ( + <> +
+
+ {data.label || "Add text"} +
+
+ + {HANDLE_POSITIONS.map(([position, side]) => ( + + + + + ))} + + ); +}); + +const PreviewLabelEdge = memo( + ({ + data, + markerEnd, + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + style, + }: EdgeProps) => { + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 0, + }); + const label = data?.label?.trim(); + + return ( + <> + + {label ? ( + +
+ + {label} + +
+
+ ) : null} + + ); + } +); + +const previewNodeTypes: NodeTypes = { + block: PreviewBlockNode, +}; + +const previewEdgeTypes: EdgeTypes = { + [FLOWCHART_EDGE_TYPE]: PreviewLabelEdge, +}; + +function flowFromStorageData(data: { toJSON: () => unknown }): { + nodes: FlowchartNode[]; + edges: FlowchartEdge[]; +} { + const root = data.toJSON() as Record | null | undefined; + const flow = root?.[FLOWCHART_STORAGE_KEY] as + | { + nodes?: Record; + edges?: Record; + } + | undefined; + + const nodes = Object.values(flow?.nodes ?? {}).map((node) => ({ + ...node, + selected: false, + dragging: false, + })); + + const edges = Object.values(flow?.edges ?? {}).map((edge) => ({ + ...edge, + selected: false, + type: edge.type ?? FLOWCHART_EDGE_TYPE, + markerEnd: edge.markerEnd ?? { + type: MarkerType.ArrowClosed, + }, + })); + + return { nodes, edges }; +} + +function FitViewOnLoad({ + nodeCount, + edgeCount, +}: { + nodeCount: number; + edgeCount: number; +}) { + const { fitView } = useReactFlow(); + + useEffect(() => { + const frame = requestAnimationFrame(() => { + void fitView({ padding: 0.2, duration: 0 }); + }); + + return () => cancelAnimationFrame(frame); + }, [fitView, nodeCount, edgeCount]); + + return null; +} + +function StorageVersionPreview({ + version, + onVersionRestore, +}: { + version: HistoryVersion; + onVersionRestore: () => void; +}) { + const { data, isLoading, error } = useHistoryVersionStorageData(version.id); + const restoreToStorageVersion = useRestoreToStorageVersion(version.id); + const deleteHistoryVersion = useDeleteHistoryVersion(); + const [isRestoring, setIsRestoring] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const { nodes, edges } = useMemo( + () => (data ? flowFromStorageData(data) : { nodes: [], edges: [] }), + [data] + ); + + const handleRestore = useCallback(async () => { + setIsRestoring(true); + try { + await restoreToStorageVersion(); + onVersionRestore(); + } catch (restoreError) { + console.error(restoreError); + } finally { + setIsRestoring(false); + } + }, [onVersionRestore, restoreToStorageVersion]); + + const handleDelete = useCallback(async () => { + setIsDeleting(true); + try { + await deleteHistoryVersion(version.id); + } catch (deleteError) { + console.error(deleteError); + } finally { + setIsDeleting(false); + } + }, [deleteHistoryVersion, version.id]); + + return ( +
+
+ {isLoading ? ( +
+ Loading version… +
+ ) : error ? ( +
+ Failed to load version +
+ ) : ( + + + + + + + )} +
+
+
+ + +
+
+
+ ); +} + +function Versions({ onVersionRestore }: { onVersionRestore: () => void }) { + const [selectedVersionId, setSelectedVersionId] = useState(); + const { versions, isLoading } = useHistoryVersions(); + + useEffect(() => { + if (!versions?.length) { + setSelectedVersionId(undefined); + return; + } + + if ( + !selectedVersionId || + !versions.some((version) => version.id === selectedVersionId) + ) { + setSelectedVersionId(versions[0].id); + } + }, [selectedVersionId, versions]); + + const selectedVersion = useMemo( + () => versions?.find((version) => version.id === selectedVersionId), + [selectedVersionId, versions] + ); + + if (isLoading) { + return ( +
+ Loading version history… +
+ ); + } + + if (!versions?.length) { + return ( +
No versions yet
+ ); + } + + return ( +
+
+ {selectedVersion ? ( + + ) : ( +
+ No version selected +
+ )} +
+
+ + {versions.map((version) => ( + { + setSelectedVersionId(version.id); + }} + /> + ))} + +
+
+ ); +} + +export function VersionsDialog() { + const [isOpen, setOpen] = useState(false); + + const onVersionRestore = useCallback(() => { + setOpen(false); + }, []); + + return ( + + + + + + + + + Versions + + Previous versions of this flowchart + + + + + + ); +} + +function ClockIcon() { + return ( + + + + ); +} diff --git a/examples/nextjs-react-flow-ai/app/globals.css b/examples/nextjs-react-flow-ai/app/globals.css index 1bdfa5fb7c1..aafffb8a878 100644 --- a/examples/nextjs-react-flow-ai/app/globals.css +++ b/examples/nextjs-react-flow-ai/app/globals.css @@ -636,6 +636,9 @@ body { .flowchart-avatar-stack { position: relative; + display: flex; + align-items: center; + gap: 8px; &::before { content: ""; @@ -810,3 +813,175 @@ body { height: 4rem; opacity: 0.2; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.flowchart-version-history-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 9999px; + border: 1px solid rgb(0 0 0 / 8%); + background: rgb(255 255 255 / 85%); + color: #404040; + cursor: pointer; + box-shadow: 0 1px 2px rgb(0 0 0 / 6%); + transition: + background 120ms ease, + color 120ms ease, + border-color 120ms ease; +} + +.flowchart-version-history-trigger:hover { + background: #fff; + color: #171717; + border-color: rgb(0 0 0 / 12%); +} + +.flowchart-version-history-trigger:focus-visible { + outline: 2px solid var(--flowchart-accent); + outline-offset: 2px; +} + +.flowchart-version-history-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgb(0 0 0 / 50%); + cursor: pointer; +} + +.flowchart-version-history-dialog { + position: fixed; + top: 50%; + left: 50%; + z-index: 1001; + width: 90vw; + height: 85vh; + transform: translate(-50%, -50%); + overflow: hidden; + border-radius: 12px; + border: 1px solid #e5e5e5; + background: #fff; + color: #171717; + box-shadow: + 0 20px 25px -5px rgb(0 0 0 / 0.1), + 0 8px 10px -6px rgb(0 0 0 / 0.1); + outline: none; +} + +.flowchart-versions { + display: flex; + height: 100%; + min-height: 0; +} + +.flowchart-versions-preview { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + height: 100%; +} + +.flowchart-versions-list { + position: relative; + width: 250px; + height: 100%; + overflow: auto; + border-left: 1px solid rgb(0 0 0 / 8%); + flex: none; +} + +.flowchart-version-preview { + height: 100%; + min-height: 0; +} + +.flowchart-version-preview-content { + position: relative; + flex: 1 1 auto; + min-height: 0; + padding: 0 !important; + overflow: hidden; +} + +.flowchart-version-preview-content .react-flow { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.flowchart-version-preview-status { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; + color: #737373; + font-size: 14px; + text-align: center; +} + +.flowchart-version-preview-label { + pointer-events: none; + resize: none; + overflow: hidden; + white-space: pre-wrap; + word-break: break-word; +} + +.flowchart-version-preview-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 36px; + padding: 0 14px; + border-radius: 8px; + border: 1px solid transparent; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease, + border-color 120ms ease, + opacity 120ms ease; +} + +.flowchart-version-preview-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.flowchart-version-preview-button-primary { + background: #171717; + color: #fff; +} + +.flowchart-version-preview-button-primary:hover:not(:disabled) { + background: #404040; +} + +.flowchart-version-preview-button-danger { + background: #fff; + color: #b91c1c; + border-color: #fecaca; +} + +.flowchart-version-preview-button-danger:hover:not(:disabled) { + background: #fef2f2; +} diff --git a/examples/nextjs-react-flow-ai/components/HelpButton.tsx b/examples/nextjs-react-flow-ai/components/HelpButton.tsx index bfbaea69f5b..73718728a1e 100644 --- a/examples/nextjs-react-flow-ai/components/HelpButton.tsx +++ b/examples/nextjs-react-flow-ai/components/HelpButton.tsx @@ -41,6 +41,12 @@ const FEATURES: Feature[] = [ description: "Drop pinned comments on the canvas and @mention the AI Assistant to make changes and reply in threads.", }, + { + icon: , + title: "Version history", + description: + "Open the clock button by the avatars to preview and restore flowchart snapshots taken before each AI run.", + }, ]; const styles: Record = { @@ -322,3 +328,12 @@ function EditIcon() { ); } + +function HistoryIcon() { + return ( + + + + + ); +} diff --git a/examples/nextjs-react-flow-ai/package-lock.json b/examples/nextjs-react-flow-ai/package-lock.json index 9e134e49060..22359dc55e1 100644 --- a/examples/nextjs-react-flow-ai/package-lock.json +++ b/examples/nextjs-react-flow-ai/package-lock.json @@ -14,6 +14,7 @@ "@liveblocks/react": "^3.23.0", "@liveblocks/react-flow": "^3.23.0", "@liveblocks/react-ui": "^3.23.0", + "@radix-ui/react-dialog": "^1.1.23", "@xyflow/react": "^12.10.1", "ai": "^6.0.136", "dedent": "^1.7.2", @@ -968,6 +969,43 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", + "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-dismissable-layer": "1.1.17", + "@radix-ui/react-focus-guards": "1.1.5", + "@radix-ui/react-focus-scope": "1.1.14", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-portal": "1.1.15", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-slot": "1.3.1", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.13.tgz", @@ -1185,24 +1223,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", - "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.4", - "@radix-ui/react-context": "1.2.1", - "@radix-ui/react-dismissable-layer": "1.1.17", - "@radix-ui/react-focus-guards": "1.1.5", - "@radix-ui/react-focus-scope": "1.1.14", - "@radix-ui/react-id": "1.1.3", - "@radix-ui/react-portal": "1.1.15", - "@radix-ui/react-presence": "1.1.9", - "@radix-ui/react-primitive": "2.1.8", - "@radix-ui/react-slot": "1.3.1", - "@radix-ui/react-use-controllable-state": "1.2.5", - "@radix-ui/react-use-layout-effect": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1221,6 +1259,277 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.3.tgz", @@ -3041,6 +3350,43 @@ } } }, + "node_modules/radix-ui/node_modules/@radix-ui/react-dialog": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.21.tgz", + "integrity": "sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.4", + "@radix-ui/react-context": "1.2.1", + "@radix-ui/react-dismissable-layer": "1.1.17", + "@radix-ui/react-focus-guards": "1.1.5", + "@radix-ui/react-focus-scope": "1.1.14", + "@radix-ui/react-id": "1.1.3", + "@radix-ui/react-portal": "1.1.15", + "@radix-ui/react-presence": "1.1.9", + "@radix-ui/react-primitive": "2.1.8", + "@radix-ui/react-slot": "1.3.1", + "@radix-ui/react-use-controllable-state": "1.2.5", + "@radix-ui/react-use-layout-effect": "1.1.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/react": { "version": "18.3.1", "license": "MIT", diff --git a/examples/nextjs-react-flow-ai/package.json b/examples/nextjs-react-flow-ai/package.json index f54e6dd22f8..46a4a0f3d9c 100644 --- a/examples/nextjs-react-flow-ai/package.json +++ b/examples/nextjs-react-flow-ai/package.json @@ -17,6 +17,7 @@ "@liveblocks/react": "^3.23.0", "@liveblocks/react-flow": "^3.23.0", "@liveblocks/react-ui": "^3.23.0", + "@radix-ui/react-dialog": "^1.1.23", "@xyflow/react": "^12.10.1", "ai": "^6.0.136", "dedent": "^1.7.2",