From b8b195ad331499cc9d123b9f36cfd8db9b76fcf7 Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Wed, 8 Jul 2026 13:27:50 +0100 Subject: [PATCH] Improve AI slideshow example (#3571) Co-authored-by: Cursor Agent Co-authored-by: Chris Nicholas --- examples/nextjs-ai-slideshow/README.md | 8 +- .../app/api/ai-reply/html-proposals.ts | 94 ++ .../app/api/ai-reply/route.ts | 158 ++- .../app/api/apply-slide/route.ts | 98 +- .../app/api/replace-room-html/route.ts | 96 ++ .../app/api/users/search/route.ts | 26 + examples/nextjs-ai-slideshow/app/chat.tsx | 133 ++- .../app/collaborative-editor.tsx | 66 +- examples/nextjs-ai-slideshow/app/database.ts | 11 +- examples/nextjs-ai-slideshow/app/globals.css | 15 +- .../app/html-source-map.ts | 130 +++ .../nextjs-ai-slideshow/app/iframe-html.ts | 187 ++++ examples/nextjs-ai-slideshow/app/page.tsx | 414 ++++++-- .../app/proposal-actions.ts | 19 +- .../nextjs-ai-slideshow/app/providers.tsx | 13 + examples/nextjs-ai-slideshow/app/slide-doc.ts | 26 + .../nextjs-ai-slideshow/app/slide-html.ts | 48 +- .../nextjs-ai-slideshow/app/slide-preview.tsx | 428 +++++++- .../nextjs-ai-slideshow/app/slide-sidebar.tsx | 295 ++++++ .../nextjs-ai-slideshow/app/slide-undo.ts | 117 +++ examples/nextjs-ai-slideshow/app/slides.ts | 192 ++++ .../nextjs-ai-slideshow/app/use-slide-html.ts | 31 - .../nextjs-ai-slideshow/app/visual-editor.tsx | 978 ++++++++++++++++++ .../components/help-button.tsx | 16 +- .../nextjs-ai-slideshow/liveblocks.config.ts | 10 +- .../nextjs-ai-slideshow/package-lock.json | 82 +- examples/nextjs-ai-slideshow/package.json | 4 + .../scripts/test-source-map.mjs | 268 +++++ 28 files changed, 3655 insertions(+), 308 deletions(-) create mode 100644 examples/nextjs-ai-slideshow/app/api/ai-reply/html-proposals.ts create mode 100644 examples/nextjs-ai-slideshow/app/api/replace-room-html/route.ts create mode 100644 examples/nextjs-ai-slideshow/app/api/users/search/route.ts create mode 100644 examples/nextjs-ai-slideshow/app/html-source-map.ts create mode 100644 examples/nextjs-ai-slideshow/app/iframe-html.ts create mode 100644 examples/nextjs-ai-slideshow/app/slide-doc.ts create mode 100644 examples/nextjs-ai-slideshow/app/slide-sidebar.tsx create mode 100644 examples/nextjs-ai-slideshow/app/slide-undo.ts create mode 100644 examples/nextjs-ai-slideshow/app/slides.ts delete mode 100644 examples/nextjs-ai-slideshow/app/use-slide-html.ts create mode 100644 examples/nextjs-ai-slideshow/app/visual-editor.tsx create mode 100644 examples/nextjs-ai-slideshow/scripts/test-source-map.mjs diff --git a/examples/nextjs-ai-slideshow/README.md b/examples/nextjs-ai-slideshow/README.md index b50d248f89..0215ea85b6 100644 --- a/examples/nextjs-ai-slideshow/README.md +++ b/examples/nextjs-ai-slideshow/README.md @@ -24,10 +24,10 @@ This example shows how to build a multiplayer AI slideshow builder with [Liveblocks Feeds](https://liveblocks.io/docs/collaboration-features/ai-collaboration), [Yjs](https://yjs.dev/), [CodeMirror](https://codemirror.net/), [Liveblocks Comments](https://liveblocks.io/docs/products/comments), and -[Next.js](https://nextjs.org/). Chat with AI to generate slides with HTML, and -it’ll create previews of new slides that you can apply. Alternatively,edit the -HTML directly in the collaborative editor. You can also leave comments on -slides. +[Next.js](https://nextjs.org/). Chat with AI to generate and edit slides with +HTML. You can drag-and-drop elements, edit the code, and leave comments for +other users. As other users edit slides, you can see their presence in the +preview and code editor. ## Getting started diff --git a/examples/nextjs-ai-slideshow/app/api/ai-reply/html-proposals.ts b/examples/nextjs-ai-slideshow/app/api/ai-reply/html-proposals.ts new file mode 100644 index 0000000000..69a7ef0c74 --- /dev/null +++ b/examples/nextjs-ai-slideshow/app/api/ai-reply/html-proposals.ts @@ -0,0 +1,94 @@ +export type HtmlProposal = { slideId: string; html: string }; + +const HTML_FENCE_OPEN_PATTERN = + /```html(?:[ \t]+(?:(?:id=([^\s`]+))|(new)))?[ \t]*(?:\r?\n|$)/gi; + +export function extractHtmlProposal(text: string, currentSlideId: string) { + return { + content: stripHtmlFencesForChat(text), + proposals: collectHtmlProposals(text, currentSlideId, false), + }; +} + +export function extractStreamingHtml( + text: string, + currentSlideId: string +): HtmlProposal[] | undefined { + const proposals = collectHtmlProposals(text, currentSlideId, true); + return proposals.length > 0 ? proposals : undefined; +} + +export function stripHtmlFencesForChat(text: string) { + const pattern = new RegExp(HTML_FENCE_OPEN_PATTERN); + let result = ""; + let lastIndex = 0; + let match = pattern.exec(text); + + while (match) { + result += text.slice(lastIndex, match.index); + + const closeIndex = text.indexOf("```", pattern.lastIndex); + if (closeIndex === -1) { + return result.trim(); + } + + lastIndex = closeIndex + "```".length; + pattern.lastIndex = lastIndex; + match = pattern.exec(text); + } + + return (result + text.slice(lastIndex)).trim(); +} + +function collectHtmlProposals( + text: string, + currentSlideId: string, + includeOpenFence: boolean +) { + const proposals: HtmlProposal[] = []; + const pattern = new RegExp(HTML_FENCE_OPEN_PATTERN); + let searchIndex = 0; + + while (searchIndex < text.length) { + pattern.lastIndex = searchIndex; + const match = pattern.exec(text); + if (!match) { + break; + } + + const bodyStart = pattern.lastIndex; + const closeIndex = text.indexOf("```", bodyStart); + const slideId = match[2] === "new" ? "new" : (match[1] ?? currentSlideId); + + if (closeIndex === -1) { + if (includeOpenFence) { + addProposal(proposals, { + slideId, + html: text.slice(bodyStart).trim(), + }); + } + break; + } + + addProposal(proposals, { + slideId, + html: text.slice(bodyStart, closeIndex).trim(), + }); + searchIndex = closeIndex + "```".length; + } + + return proposals.filter((proposal) => proposal.html.length > 0); +} + +function addProposal(proposals: HtmlProposal[], proposal: HtmlProposal) { + if (proposal.slideId !== "new") { + const existingIndex = proposals.findIndex( + (item) => item.slideId === proposal.slideId + ); + if (existingIndex !== -1) { + proposals.splice(existingIndex, 1); + } + } + + proposals.push(proposal); +} diff --git a/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts b/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts index a289a8d1d2..24f7efd0c3 100644 --- a/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts +++ b/examples/nextjs-ai-slideshow/app/api/ai-reply/route.ts @@ -1,7 +1,14 @@ import { Liveblocks } from "@liveblocks/node"; import { NextRequest, NextResponse } from "next/server"; import { AI_USER_AVATAR, AI_USER_ID, AI_USER_NAME } from "@/app/database"; +import { INITIAL_SLIDE_ID } from "@/app/slide-doc"; import { STARTER_SLIDE_HTML } from "@/app/slide-html"; +import { + extractHtmlProposal, + extractStreamingHtml, + stripHtmlFencesForChat, + type HtmlProposal, +} from "./html-proposals"; /** * Generates an assistant reply and streams it into the room's feed using @@ -10,6 +17,7 @@ import { STARTER_SLIDE_HTML } from "@/app/slide-html"; */ type ChatMessage = { role: "user" | "assistant"; content: string }; +type SlideContext = { id: string; html: string }; type Source = { title: string; url: string }; type ChainStep = { label: string; @@ -30,7 +38,7 @@ type AssistantUpdate = { suggestions?: string[]; chainOfThought?: ChainStep[]; tool?: ToolCall; - proposedHtml?: string; + proposals?: HtmlProposal[]; proposalStatus?: "pending" | "applied" | "rejected"; usedTokens?: number; maxTokens?: number; @@ -48,12 +56,17 @@ const AUTHOR = { const SYSTEM_PROMPT = [ "You are an expert slide designer inside a multiplayer slideshow builder.", - "Reply with a SHORT conversational message plus the COMPLETE slide HTML document in a single fenced ```html code block.", + "Reply with a SHORT conversational message.", "The HTML must be a full self-contained document with inline
-
-

Liveblocks AI Slideshow

-

Design together, present faster.

-

Prompt the AI, apply its slide HTML into a shared Yjs document, and leave multiplayer comments right on the preview.

-
+ ${slideContent}
`; + +export const STARTER_SLIDE_HTML = CREATE_BODY(STARTER_BODY); +export const EMPTY_SLIDE_HTML = CREATE_BODY(EMPTY_BODY); diff --git a/examples/nextjs-ai-slideshow/app/slide-preview.tsx b/examples/nextjs-ai-slideshow/app/slide-preview.tsx index d32b66f7ae..e1d779b011 100644 --- a/examples/nextjs-ai-slideshow/app/slide-preview.tsx +++ b/examples/nextjs-ai-slideshow/app/slide-preview.tsx @@ -12,21 +12,38 @@ import { } from "@dnd-kit/core"; import { useEditThreadMetadata, + useOther, + useOthers, useSelf, useThreads, + useUpdateMyPresence, } from "@liveblocks/react/suspense"; import { CommentPin, + Cursor, + Cursors, + type CursorsCursorProps, FloatingComposer, FloatingThread, } from "@liveblocks/react-ui"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import type { MouseEvent, ReactNode, RefObject } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { MouseEvent, ReactNode } from "react"; import { EyeIcon, Loader2Icon } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { getElementByPath } from "./html-source-map"; +import { patchIframeHtml } from "./iframe-html"; import type { SlideProposal } from "./proposal-actions"; import { SLIDE_HEIGHT, SLIDE_WIDTH } from "./slide-html"; -import { useSlideHtml } from "./use-slide-html"; +import { useSlideUndo } from "./slide-undo"; +import { useSlideHtml } from "./slides"; +import { useVisualEditor } from "./visual-editor"; type Coords = { x: number; y: number }; @@ -80,31 +97,145 @@ function useMaxZIndex(threads: readonly ThreadData[]) { } export function SlidePreview({ - iframeRef, + slideId, placingComment, onPlacingDone, proposal, + proposalHtml, + isNewProposal = false, resolvingProposal, onResolveProposal, }: { - iframeRef: RefObject; + slideId: string; placingComment: boolean; onPlacingDone: () => void; proposal: SlideProposal | null; + proposalHtml?: string; + isNewProposal?: boolean; resolvingProposal: "apply" | "reject" | null; onResolveProposal: (action: "apply" | "reject") => void; }) { - const documentHtml = useSlideHtml(); - // While previewing a proposal, the slide shows the proposed HTML instead of - // the shared document, and comment pins are hidden (they belong to the - // shared slide, not to an unapplied proposal). - const html = proposal ? proposal.html : documentHtml; + const documentHtml = useSlideHtml(slideId, !isNewProposal); + // While previewing, pins stay hidden even on slides unaffected by the proposal + // set because accept/reject resolves the whole set. + const html = proposalHtml ?? documentHtml; + const [iframe, setIframe] = useState(null); + const [visualGestureActive, setVisualGestureActive] = useState(false); + const [expectedVisualHtml, setExpectedVisualHtml] = useState( + null + ); + const initialSrcDocRef = useRef({ slideId, html }); + const appliedHtmlRef = useRef(html); + const latestHtmlRef = useRef(html); + const pendingHtmlRef = useRef(null); + const expectedBaseHtmlRef = useRef(null); + const updateMyPresence = useUpdateMyPresence(); + const { undo, redo, stopCapturing } = useSlideUndo(); const { threads } = useThreads(); + const slideThreads = useMemo( + () => threads.filter((thread) => thread.metadata.slideId === slideId), + [slideId, threads] + ); const editThreadMetadata = useEditThreadMetadata(); - const maxZIndex = useMaxZIndex(threads); + const maxZIndex = useMaxZIndex(slideThreads); const { ref: wrapperRef, size: wrapperSize } = useElementSize(); const [placedCoords, setPlacedCoords] = useState(null); + if (initialSrcDocRef.current.slideId !== slideId) { + initialSrcDocRef.current = { slideId, html }; + appliedHtmlRef.current = html; + pendingHtmlRef.current = null; + expectedBaseHtmlRef.current = null; + } + + useEffect(() => { + latestHtmlRef.current = html; + }, [html]); + + const handleVisualCommit = useCallback((expectedHtml: string) => { + expectedBaseHtmlRef.current = latestHtmlRef.current; + setExpectedVisualHtml(expectedHtml); + }, []); + + const handleCursorMove = useCallback( + (cursor: Coords | null) => { + updateMyPresence({ + cursor, + cursorSlideId: cursor ? slideId : null, + }); + }, + [slideId, updateMyPresence] + ); + + const handleSelectionChange = useCallback( + (path: number[] | null) => { + updateMyPresence({ + selection: path ? { slideId, path } : null, + }); + }, + [slideId, updateMyPresence] + ); + + useVisualEditor({ + iframe: proposal ? null : iframe, + slideId, + onGestureActiveChange: setVisualGestureActive, + onCommit: handleVisualCommit, + onCursorMove: handleCursorMove, + onSelectionChange: handleSelectionChange, + stopCapturing, + onUndo: undo, + onRedo: redo, + }); + + useEffect(() => { + return () => { + updateMyPresence({ + cursor: null, + cursorSlideId: null, + selection: null, + }); + }; + }, [slideId, updateMyPresence]); + + useEffect(() => { + if (proposal) { + updateMyPresence({ + cursor: null, + cursorSlideId: null, + selection: null, + }); + } + }, [proposal, updateMyPresence]); + + useEffect(() => { + if (!iframe || html === appliedHtmlRef.current) { + return; + } + + if (visualGestureActive) { + pendingHtmlRef.current = html; + return; + } + + if (expectedVisualHtml !== null && html === expectedVisualHtml) { + setExpectedVisualHtml(null); + expectedBaseHtmlRef.current = null; + appliedHtmlRef.current = html; + return; + } + + if (expectedVisualHtml !== null && html === expectedBaseHtmlRef.current) { + return; + } + + patchIframeHtml(iframe, html); + pendingHtmlRef.current = null; + appliedHtmlRef.current = html; + setExpectedVisualHtml(null); + expectedBaseHtmlRef.current = null; + }, [expectedVisualHtml, html, iframe, visualGestureActive]); + // Leave room around the slide so the shadow and proposal ring are visible // even when the slide would otherwise fit exactly edge-to-edge. const availableWidth = Math.max(1, wrapperSize.width - PREVIEW_INSET * 2); @@ -114,6 +245,14 @@ export function SlidePreview({ availableHeight / SLIDE_HEIGHT ); const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; + const cursorComponents = useMemo( + () => ({ + Cursor: function SlideCursorComponent(props: CursorsCursorProps) { + return ; + }, + }), + [safeScale, slideId] + ); const sensors = useSensors( useSensor(PointerSensor, { @@ -131,7 +270,7 @@ export function SlidePreview({ const handleDragEnd = useCallback( ({ active, delta }: DragEndEvent) => { - const thread = threads.find((item) => item.id === String(active.id)); + const thread = slideThreads.find((item) => item.id === String(active.id)); if (!thread) { return; } @@ -149,10 +288,11 @@ export function SlidePreview({ x: nextX, y: nextY, zIndex: maxZIndex + 1, + slideId, }, }); }, - [editThreadMetadata, maxZIndex, safeScale, threads] + [editThreadMetadata, maxZIndex, safeScale, slideId, slideThreads] ); return ( @@ -160,10 +300,10 @@ export function SlidePreview({ ref={wrapperRef} className="relative h-full w-full overflow-hidden bg-neutral-50" > - {proposal ? ( -
+ {proposal && proposalHtml !== undefined ? ( +
- + Previewing proposed slide
@@ -181,7 +321,7 @@ export function SlidePreview({