Merge pull request #230 from presenton/fix/better_outline_ux
Fix/better outline ux
This commit is contained in:
commit
b9b4241b48
4 changed files with 140 additions and 14 deletions
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from "@dnd-kit/sortable";
|
||||
import { OutlineItem } from "./OutlineItem";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileText } from "lucide-react";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
|
|
@ -23,6 +23,8 @@ interface OutlineContentProps {
|
|||
outlines: { content: string }[] | null;
|
||||
isLoading: boolean;
|
||||
isStreaming: boolean;
|
||||
activeSlideIndex: number | null;
|
||||
highestActiveIndex: number;
|
||||
onDragEnd: (event: any) => void;
|
||||
onAddSlide: () => void;
|
||||
}
|
||||
|
|
@ -31,6 +33,8 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
outlines,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
activeSlideIndex,
|
||||
highestActiveIndex,
|
||||
onDragEnd,
|
||||
onAddSlide
|
||||
}) => {
|
||||
|
|
@ -45,6 +49,14 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
|
||||
return (
|
||||
<div className="space-y-6 font-instrument_sans">
|
||||
{isLoading && (!outlines || outlines.length === 0) && (
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 text-blue-600 px-2 py-0.5 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Thinking
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* <div className="flex items-center justify-between">
|
||||
<h5 className="text-lg font-medium">
|
||||
Presentation Outline
|
||||
|
|
@ -94,6 +106,8 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
index={index + 1}
|
||||
slideOutline={item}
|
||||
isStreaming={isStreaming}
|
||||
isActiveStreaming={activeSlideIndex === index}
|
||||
isStableStreaming={highestActiveIndex >= 0 && index < highestActiveIndex}
|
||||
/>
|
||||
))
|
||||
) :
|
||||
|
|
@ -107,6 +121,8 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
index={index + 1}
|
||||
slideOutline={item}
|
||||
isStreaming={isStreaming}
|
||||
isActiveStreaming={false}
|
||||
isStableStreaming={false}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { useDispatch, useSelector } from "react-redux"
|
|||
import { deleteSlideOutline, setOutlines } from "@/store/slices/presentationGeneration"
|
||||
import ToolTip from "@/components/ToolTip"
|
||||
import MarkdownEditor from "../../components/MarkdownEditor"
|
||||
import { useEffect } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { marked } from "marked"
|
||||
|
||||
|
||||
interface OutlineItemProps {
|
||||
|
|
@ -15,12 +16,16 @@ interface OutlineItemProps {
|
|||
},
|
||||
index: number
|
||||
isStreaming: boolean
|
||||
isActiveStreaming?: boolean
|
||||
isStableStreaming?: boolean
|
||||
}
|
||||
|
||||
export function OutlineItem({
|
||||
index,
|
||||
slideOutline,
|
||||
isStreaming,
|
||||
isActiveStreaming = false,
|
||||
isStableStreaming = false,
|
||||
}: OutlineItemProps) {
|
||||
const {
|
||||
outlines,
|
||||
|
|
@ -73,6 +78,42 @@ export function OutlineItem({
|
|||
dispatch(deleteSlideOutline({ index: index - 1 }))
|
||||
|
||||
}
|
||||
|
||||
// Throttled markdown rendering only for the active streaming item to avoid flicker
|
||||
const [renderedHtml, setRenderedHtml] = useState<string>("")
|
||||
const throttleRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !isActiveStreaming) return
|
||||
const content = slideOutline.content || ""
|
||||
// Throttle updates to ~60ms to reduce reflows/flicker
|
||||
if (throttleRef.current) {
|
||||
window.clearTimeout(throttleRef.current)
|
||||
}
|
||||
throttleRef.current = window.setTimeout(() => {
|
||||
try {
|
||||
setRenderedHtml(marked.parse(content) as string)
|
||||
} catch {
|
||||
setRenderedHtml("")
|
||||
}
|
||||
}, 60)
|
||||
return () => {
|
||||
if (throttleRef.current) {
|
||||
window.clearTimeout(throttleRef.current)
|
||||
}
|
||||
}
|
||||
}, [isStreaming, isActiveStreaming, slideOutline.content])
|
||||
|
||||
// Memoized stable HTML for previous (already completed) items during streaming
|
||||
const stableHtml = useMemo(() => {
|
||||
if (!isStreaming || isActiveStreaming) return null
|
||||
if (!isStableStreaming) return null
|
||||
try {
|
||||
return marked.parse(slideOutline.content || "") as string
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [isStreaming, isActiveStreaming, isStableStreaming, slideOutline.content])
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
{/* Main Title Row */}
|
||||
|
|
@ -99,15 +140,27 @@ export function OutlineItem({
|
|||
{/* Main Title Input - Add onFocus handler */}
|
||||
<div id={`outline-item-${index}`} className="flex flex-col basis-full gap-2">
|
||||
{/* Editable Markdown Content */}
|
||||
{isStreaming ? <p
|
||||
className="text-sm flex-1 font-normal"
|
||||
>
|
||||
{slideOutline.content || ''}
|
||||
</p> : <MarkdownEditor
|
||||
key={index}
|
||||
content={slideOutline.content || ''}
|
||||
onChange={(content) => handleSlideChange(content)}
|
||||
/>}
|
||||
{isStreaming ? (
|
||||
isActiveStreaming ? (
|
||||
<div
|
||||
className="text-sm flex-1 font-normal prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml || "" }}
|
||||
/>
|
||||
) : stableHtml ? (
|
||||
<div
|
||||
className="text-sm flex-1 font-normal prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: stableHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm flex-1 font-normal">{slideOutline.content || ''}</p>
|
||||
)
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
key={index}
|
||||
content={slideOutline.content || ''}
|
||||
onChange={(content) => handleSlideChange(content)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ const OutlinePage: React.FC = () => {
|
|||
outlines={outlines}
|
||||
isLoading={streamState.isLoading}
|
||||
isStreaming={streamState.isStreaming}
|
||||
activeSlideIndex={streamState.activeSlideIndex}
|
||||
highestActiveIndex={streamState.highestActiveIndex}
|
||||
onDragEnd={handleDragEnd}
|
||||
onAddSlide={handleAddSlide}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { toast } from "sonner";
|
||||
import { setOutlines } from "@/store/slices/presentationGeneration";
|
||||
|
|
@ -12,6 +12,11 @@ export const useOutlineStreaming = (presentationId: string | null) => {
|
|||
const { outlines } = useSelector((state: RootState) => state.presentationGeneration);
|
||||
const [isStreaming, setIsStreaming] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeSlideIndex, setActiveSlideIndex] = useState<number | null>(null);
|
||||
const [highestActiveIndex, setHighestActiveIndex] = useState<number>(-1);
|
||||
const prevSlidesRef = useRef<{ content: string }[]>([]);
|
||||
const activeIndexRef = useRef<number>(-1);
|
||||
const highestIndexRef = useRef<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!presentationId || outlines.length > 0) return;
|
||||
|
|
@ -39,7 +44,36 @@ export const useOutlineStreaming = (presentationId: string | null) => {
|
|||
const partialData = JSON.parse(repairedJson);
|
||||
|
||||
if (partialData.slides) {
|
||||
dispatch(setOutlines(partialData.slides));
|
||||
const nextSlides: { content: string }[] = partialData.slides || [];
|
||||
// Determine which slide index changed to minimize live parsing
|
||||
try {
|
||||
const prev = prevSlidesRef.current || [];
|
||||
let changedIndex: number | null = null;
|
||||
const maxLen = Math.max(prev.length, nextSlides.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const prevContent = prev[i]?.content;
|
||||
const nextContent = nextSlides[i]?.content;
|
||||
if (nextContent !== prevContent) {
|
||||
changedIndex = i;
|
||||
}
|
||||
}
|
||||
// Keep active index stable if no change detected; and ensure non-decreasing
|
||||
const prevActive = activeIndexRef.current;
|
||||
let nextActive = changedIndex ?? prevActive;
|
||||
if (nextActive < prevActive) {
|
||||
nextActive = prevActive;
|
||||
}
|
||||
activeIndexRef.current = nextActive;
|
||||
setActiveSlideIndex(nextActive);
|
||||
|
||||
if (nextActive > highestIndexRef.current) {
|
||||
highestIndexRef.current = nextActive;
|
||||
setHighestActiveIndex(nextActive);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
prevSlidesRef.current = nextSlides;
|
||||
dispatch(setOutlines(nextSlides));
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -54,6 +88,11 @@ export const useOutlineStreaming = (presentationId: string | null) => {
|
|||
dispatch(setOutlines(outlinesData));
|
||||
setIsStreaming(false)
|
||||
setIsLoading(false)
|
||||
setActiveSlideIndex(null)
|
||||
setHighestActiveIndex(-1)
|
||||
prevSlidesRef.current = outlinesData;
|
||||
activeIndexRef.current = -1;
|
||||
highestIndexRef.current = -1;
|
||||
eventSource.close();
|
||||
} catch (error) {
|
||||
console.error("Error parsing accumulated chunks:", error);
|
||||
|
|
@ -67,12 +106,20 @@ export const useOutlineStreaming = (presentationId: string | null) => {
|
|||
|
||||
setIsStreaming(false)
|
||||
setIsLoading(false)
|
||||
setActiveSlideIndex(null)
|
||||
setHighestActiveIndex(-1)
|
||||
activeIndexRef.current = -1;
|
||||
highestIndexRef.current = -1;
|
||||
eventSource.close();
|
||||
break;
|
||||
case "error":
|
||||
|
||||
setIsStreaming(false)
|
||||
setIsLoading(false)
|
||||
setActiveSlideIndex(null)
|
||||
setHighestActiveIndex(-1)
|
||||
activeIndexRef.current = -1;
|
||||
highestIndexRef.current = -1;
|
||||
eventSource.close();
|
||||
toast.error('Error in outline streaming',
|
||||
{
|
||||
|
|
@ -87,6 +134,10 @@ export const useOutlineStreaming = (presentationId: string | null) => {
|
|||
|
||||
setIsStreaming(false)
|
||||
setIsLoading(false)
|
||||
setActiveSlideIndex(null)
|
||||
setHighestActiveIndex(-1)
|
||||
activeIndexRef.current = -1;
|
||||
highestIndexRef.current = -1;
|
||||
eventSource.close();
|
||||
toast.error("Failed to connect to the server. Please try again.");
|
||||
};
|
||||
|
|
@ -94,6 +145,10 @@ export const useOutlineStreaming = (presentationId: string | null) => {
|
|||
|
||||
setIsStreaming(false)
|
||||
setIsLoading(false)
|
||||
setActiveSlideIndex(null)
|
||||
setHighestActiveIndex(-1)
|
||||
activeIndexRef.current = -1;
|
||||
highestIndexRef.current = -1;
|
||||
toast.error("Failed to initialize connection");
|
||||
}
|
||||
};
|
||||
|
|
@ -105,5 +160,5 @@ export const useOutlineStreaming = (presentationId: string | null) => {
|
|||
};
|
||||
}, [presentationId, dispatch]);
|
||||
|
||||
return { isStreaming, isLoading };
|
||||
return { isStreaming, isLoading, activeSlideIndex, highestActiveIndex };
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue