From 9e652cf43ed62fd75c53764bf5f5585149fabeb5 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:45:48 -0400 Subject: [PATCH 01/11] fix: remove mathlive --- next.config.ts | 1 - package.json | 1 - src/components/editor/DocumentEditor.tsx | 78 ------------ src/components/editor/MathEditDialog.tsx | 150 ----------------------- 4 files changed, 230 deletions(-) delete mode 100644 src/components/editor/MathEditDialog.tsx diff --git a/next.config.ts b/next.config.ts index a1ea7a67..fe5a5871 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,6 @@ import { withWorkflow } from "workflow/next"; const nextConfig: NextConfig = { reactCompiler: true, - reactStrictMode: false, // Transpile git-installed packages to ensure proper module resolution transpilePackages: ["react-grid-layout"], images: { diff --git a/package.json b/package.json index 93c49de5..ee6769dd 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,6 @@ "katex": "^0.16.44", "lodash.throttle": "^4.1.1", "lucide-react": "^1.7.0", - "mathlive": "^0.109.1", "motion": "^12.34.3", "next": "16.2.2", "next-themes": "^0.4.6", diff --git a/src/components/editor/DocumentEditor.tsx b/src/components/editor/DocumentEditor.tsx index 84a9bbab..f288d610 100644 --- a/src/components/editor/DocumentEditor.tsx +++ b/src/components/editor/DocumentEditor.tsx @@ -107,9 +107,6 @@ import { useUIStore } from "@/lib/stores/ui-store"; import { focusComposerInput } from "@/lib/utils/composer-utils"; import { toast } from "sonner"; -// --- Math Edit Dialog (MathLive) --- -import { MathEditDialog } from "@/components/editor/MathEditDialog"; - // --- Styles --- import "@/components/editor/document-editor.scss"; @@ -851,56 +848,6 @@ export function DocumentEditor({ const toolbarRef = useRef(null); const activeMobileView = isMobile ? mobileView : "main"; - // Stable ref so closures created inside useEditor always access the live editor - const editorRef = useRef(null); - - // Math dialog: single state object + stable open function via ref - const [mathEdit, setMathEdit] = useState<{ - open: boolean; - latex: string; - title: string; - pos: number; - type: "inline" | "block"; - }>({ open: false, latex: "", title: "", pos: 0, type: "inline" }); - - const openMathDialog = useCallback( - (latex: string, pos: number, type: "inline" | "block") => { - setMathEdit({ - open: true, - latex, - title: type === "inline" ? "Edit Inline Math" : "Edit Block Math", - pos, - type, - }); - }, - [], - ); - - const closeMathDialog = useCallback(() => { - setMathEdit((prev) => ({ ...prev, open: false })); - }, []); - - const handleMathSave = useCallback((latex: string) => { - const ed = editorRef.current; - if (!ed) return; - setMathEdit((prev) => { - if (prev.type === "inline") { - ed.chain() - .setNodeSelection(prev.pos) - .updateInlineMath({ latex }) - .focus() - .run(); - } else { - ed.chain() - .setNodeSelection(prev.pos) - .updateBlockMath({ latex }) - .focus() - .run(); - } - return { ...prev, open: false }; - }); - }, []); - const editor = useEditor({ autofocus, immediatelyRender: false, @@ -948,16 +895,6 @@ export function DocumentEditor({ Subscript, Selection, Mathematics.configure({ - inlineOptions: { - onClick: (node, pos) => { - openMathDialog(node.attrs.latex || "", pos, "inline"); - }, - }, - blockOptions: { - onClick: (node, pos) => { - openMathDialog(node.attrs.latex || "", pos, "block"); - }, - }, katexOptions: { throwOnError: false, }, @@ -999,10 +936,6 @@ export function DocumentEditor({ }); }, }); - // Keep editorRef in sync so closures always have the live editor - useEffect(() => { - editorRef.current = editor; - }, [editor]); useEffect(() => { const toolbar = toolbarRef.current; @@ -1102,17 +1035,6 @@ export function DocumentEditor({ )} /> - - {/* MathLive dialog for editing math nodes */} - { - if (!open) closeMathDialog(); - }} - initialLatex={mathEdit.latex} - onSave={handleMathSave} - title={mathEdit.title} - /> ); } diff --git a/src/components/editor/MathEditDialog.tsx b/src/components/editor/MathEditDialog.tsx deleted file mode 100644 index b12d8855..00000000 --- a/src/components/editor/MathEditDialog.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; - -import React, { useCallback, useRef, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { X } from "lucide-react"; -import { useTheme } from "next-themes"; -import "mathlive"; -import "mathlive/fonts.css"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface MathfieldElement extends HTMLElement { - value: string; - focus(): void; - blur(): void; -} - -// --------------------------------------------------------------------------- -// Shared MathLive dialog UI -// --------------------------------------------------------------------------- - -interface MathDialogUIProps { - open: boolean; - title: string; - initialLatex: string; - onSave: (latex: string) => void; - onClose: () => void; -} - -function MathDialogUI({ open, title, initialLatex, onSave, onClose }: MathDialogUIProps) { - const containerRef = useRef(null); - const mathfieldRef = useRef(null); - const { resolvedTheme } = useTheme(); - - // Ref to always read the freshest MathLive value on save - const onSaveRef = useRef(onSave); - useEffect(() => { onSaveRef.current = onSave; }, [onSave]); - - // Create / tear-down the when the dialog opens - useEffect(() => { - if (!open || !containerRef.current) return; - - containerRef.current.innerHTML = ""; - - const mf = document.createElement("math-field") as MathfieldElement; - mf.value = initialLatex; - mf.style.display = "block"; - mf.style.width = "100%"; - mf.style.minHeight = "60px"; - mf.style.fontSize = "1.25rem"; - mf.style.padding = "12px"; - mf.style.borderRadius = "8px"; - - const isDark = resolvedTheme === "dark"; - mf.style.color = isDark ? "white" : "black"; - mf.style.backgroundColor = isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)"; - mf.style.border = isDark ? "1px solid rgba(255,255,255,0.1)" : "1px solid rgba(0,0,0,0.1)"; - mf.style.setProperty("--latex-color", isDark ? "white" : "black"); - - mf.setAttribute("math-virtual-keyboard-policy", "auto"); - mf.setAttribute("virtual-keyboard-mode", "onfocus"); - - containerRef.current.appendChild(mf); - mathfieldRef.current = mf; - - requestAnimationFrame(() => mf?.focus()); - - return () => { mathfieldRef.current = null; }; - }, [open, initialLatex, resolvedTheme]); - - const handleSave = useCallback(() => { - const latex = mathfieldRef.current?.value ?? initialLatex; - onSaveRef.current(latex); - onClose(); - }, [initialLatex, onClose]); - - // Keyboard shortcuts - useEffect(() => { - if (!open) return; - - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); } - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); handleSave(); } - }; - - document.addEventListener("keydown", onKey, true); - return () => document.removeEventListener("keydown", onKey, true); - }, [open, handleSave, onClose]); - - if (!open) return null; - - return ( -
{ if (e.target === e.currentTarget) onClose(); }} - > -
-
-
-

{title}

- -
-
-
- - -
-
-
- ); -} - -// --------------------------------------------------------------------------- -// Standalone dialog (TipTap document editor) -// --------------------------------------------------------------------------- - -export interface MathEditDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - initialLatex: string; - onSave: (latex: string) => void; - title?: string; -} - -export function MathEditDialog({ open, onOpenChange, initialLatex, onSave, title = "Edit Math" }: MathEditDialogProps) { - const handleSave = useCallback( - (latex: string) => { onSave(latex); onOpenChange(false); }, - [onSave, onOpenChange] - ); - - const handleClose = useCallback( - () => onOpenChange(false), - [onOpenChange] - ); - - return ( - - ); -} From 241dcbd70f8056e80ae4d05485ab795754ecafc5 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:47:53 -0400 Subject: [PATCH 02/11] fix: remove unnecessary effects --- src/app/dashboard/page.tsx | 8 -------- src/hooks/workspace/use-workspace-state.ts | 16 +--------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 36bcf7f1..2a3b56fc 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -112,14 +112,6 @@ function DashboardContent({ version, } = useWorkspaceState(currentWorkspaceId); - // Clear playing YouTube videos when workspace view mounts (e.g., navigating back from home) - const clearPlayingYouTubeCards = useUIStore( - (state) => state.clearPlayingYouTubeCards, - ); - useEffect(() => { - clearPlayingYouTubeCards(); - }, [clearPlayingYouTubeCards]); - // Open audio recorder when landing from home Record flow (store flag set before navigate). const openAudioDialog = useAudioRecordingStore((s) => s.openDialog); const closeAudioDialog = useAudioRecordingStore((s) => s.closeDialog); diff --git a/src/hooks/workspace/use-workspace-state.ts b/src/hooks/workspace/use-workspace-state.ts index f01b62fc..af560b08 100644 --- a/src/hooks/workspace/use-workspace-state.ts +++ b/src/hooks/workspace/use-workspace-state.ts @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef } from "react"; +import { useMemo, useRef } from "react"; import { useWorkspaceEvents } from "./use-workspace-events"; import { replayEvents } from "@/lib/workspace/event-reducer"; import { initialState } from "@/lib/workspace-state/state"; @@ -45,20 +45,6 @@ export function useWorkspaceState(workspaceId: string | null) { prevStateRef.current = replayedState; return replayedState; }, [eventLog, workspaceId]); - - // Log when state changes trigger slow re-renders - useEffect(() => { - const renderStart = performance.now(); - const timeoutId = setTimeout(() => { - const renderTime = performance.now() - renderStart; - // Only warn if render takes longer than 500ms (indicates a serious problem) - if (renderTime > 500) { - console.warn(`[STATE-RENDER-SLOW] State update took ${renderTime.toFixed(2)}ms to render (${state.items.length} items) - this may cause UI freeze`); - } - }, 0); - - return () => clearTimeout(timeoutId); - }, [state]); return { state, From 769a9e4dc3978be79b95a0c47dd35808425e654f Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:52:22 -0400 Subject: [PATCH 03/11] fix: remove framer motion --- package.json | 1 - src/components/ui/highlight-tooltip.tsx | 138 +++++++----------------- 2 files changed, 36 insertions(+), 103 deletions(-) diff --git a/package.json b/package.json index ee6769dd..a9b13026 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,6 @@ "katex": "^0.16.44", "lodash.throttle": "^4.1.1", "lucide-react": "^1.7.0", - "motion": "^12.34.3", "next": "16.2.2", "next-themes": "^0.4.6", "parse-diff": "^0.11.1", diff --git a/src/components/ui/highlight-tooltip.tsx b/src/components/ui/highlight-tooltip.tsx index bde4b698..7babe906 100644 --- a/src/components/ui/highlight-tooltip.tsx +++ b/src/components/ui/highlight-tooltip.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react"; import { createPortal } from "react-dom"; -import { motion, AnimatePresence } from "motion/react"; import { useFloating, autoUpdate, offset, shift, flip, size, inline, arrow } from "@floating-ui/react"; import { cn } from "@/lib/utils"; @@ -49,7 +48,6 @@ export function HighlightTooltip({ const [isExpanded, setIsExpanded] = useState(!collapsed); const cleanupFnRef = useRef<(() => void) | null>(null); const isUnmountingRef = useRef(false); - const [shouldRender, setShouldRender] = useState(visible); // Store locked position for single mode (fixed position) const lockedPositionRef = useRef<{ x: number; y: number } | null>(null); @@ -540,15 +538,6 @@ export function HighlightTooltip({ } }, [visible, position.x, position.y, lockPosition, update]); - // Track shouldRender to allow exit animation to complete before removing portal - useEffect(() => { - if (visible) { - setShouldRender(true); - } - // Don't immediately set shouldRender to false when visible becomes false - // Let AnimatePresence handle the exit animation first - }, [visible]); - // Reset hovered action when tooltip becomes invisible or set default expanded useEffect(() => { if (!visible) { @@ -651,13 +640,6 @@ export function HighlightTooltip({ }; }, [visible, onHide]); - if (typeof window === "undefined") return null; - - // Don't render if position is invalid (0,0 means not set yet) - // However, if we have a referenceElement, Floating UI will position it, so we can render - const hasValidPosition = position.x !== 0 || position.y !== 0; - const hasReference = !!referenceElement; // DOM element or Range - // Track unmounting state useEffect(() => { isUnmountingRef.current = false; @@ -683,8 +665,17 @@ export function HighlightTooltip({ }; }, [refs]); + if (typeof window === "undefined") return null; + + // Don't render if position is invalid (0,0 means not set yet) + // However, if we have a referenceElement, Floating UI will position it, so we can render + const hasValidPosition = position.x !== 0 || position.y !== 0; + const hasReference = !!referenceElement; // DOM element or Range + + const transitionSnappy = "0.2s cubic-bezier(0.4, 0, 0.2, 1)"; + return createPortal( - shouldRender && (hasValidPosition || hasReference) ? ( + visible && (hasValidPosition || hasReference) ? (
{ tooltipRef.current = node; @@ -708,42 +699,7 @@ export function HighlightTooltip({ }} className="highlight-tooltip-container pointer-events-auto" > - { - // Only remove from DOM after exit animation completes - if (!visible) { - setShouldRender(false); - } - }}> - {visible && ( - { - // Safely handle animation completion - if (definition === "exit") { - // Exit animation completed, portal will be removed via onExitComplete - try { - // Clear refs after exit animation - if (isUnmountingRef.current) { - refs.setFloating(null); - refs.setReference(null); - refs.setPositionReference(null); - } - } catch (error) { - // Ignore cleanup errors - } - } - }} - className="flex flex-col items-center" - > +
{/* Badge indicator for multi-mode - positioned absolutely above */} {badge && (
@@ -775,23 +731,18 @@ export function HighlightTooltip({ } return ( - - handleActionClick(action)} onMouseEnter={() => { // Always allow hover expansion to show label @@ -800,24 +751,6 @@ export function HighlightTooltip({ onMouseLeave={() => { setHoveredActionId(null); }} - initial={false} - animate={{ - width: isHoverExpanded ? "80px" : "32px", - // Expand mostly in primary direction, but a bit in opposite direction too - ...(isFirstButton - ? { right: isHoverExpanded ? "-24px" : "0" } // First button grows 24px to the right, rest to the left - : isLastButton - ? { left: isHoverExpanded ? "-24px" : "0" } // Last button grows 24px to the left, rest to the right - : { - left: "0", - x: isHoverExpanded ? "-24px" : "0" // Middle button expands evenly from center - } - ), - }} - transition={{ - duration: 0.2, - ease: [0.4, 0, 0.2, 1], - }} className={cn( "highlight-tooltip-action absolute top-0 flex items-center", "text-xs font-medium text-white", @@ -832,8 +765,18 @@ export function HighlightTooltip({ style={{ height: "32px", padding: "0 8px", + width: isHoverExpanded ? 80 : 32, + ...(isFirstButton + ? { right: isHoverExpanded ? -24 : 0 } + : isLastButton + ? { left: isHoverExpanded ? -24 : 0 } + : { + left: 0, + transform: isHoverExpanded ? "translateX(-24px)" : "translateX(0)", + }), justifyContent: isHoverExpanded ? "flex-start" : "center", zIndex: isHoverExpanded ? 20 : 10, + transition: `width ${transitionSnappy}, left ${transitionSnappy}, right ${transitionSnappy}, transform ${transitionSnappy}`, }} aria-label={action.label} > @@ -843,26 +786,19 @@ export function HighlightTooltip({ {/* Label - expands on hover with fixed width for smooth animation */} - {action.label} - - - + + +
); })}
@@ -875,9 +811,7 @@ export function HighlightTooltip({ borderTop: `8px solid ${getArrowColor()}` }} /> -
- )} -
+
) : null, document.body From 8828567366c76360fd3a308cdaa5526c42882483 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:56:48 -0400 Subject: [PATCH 04/11] fix: gap between model name and debug button --- src/components/assistant-ui/thread.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 543f9738..76e1de6e 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -1030,7 +1030,7 @@ const ComposerAction: FC = ({ items }) => { {showAiDebugButton && (
); @@ -93,7 +95,9 @@ export function PDFViewerModal({ onMaximize={() => useUIStore.getState().setMaximizedItemId(null)} isMaximized={true} onUpdateItem={onUpdateItem} - onUpdateItemData={() => { }} // PDF doesn't use onUpdateItemData in its modal typically + onUpdateItemData={(updater) => + onUpdateItem({ data: updater(item.data) as Item["data"] }) + } /> diff --git a/src/components/pdf/LightweightPdfPreview.tsx b/src/components/pdf/LightweightPdfPreview.tsx deleted file mode 100644 index e108a0d2..00000000 --- a/src/components/pdf/LightweightPdfPreview.tsx +++ /dev/null @@ -1,381 +0,0 @@ -'use client'; - -import { useEffect, useState, useRef, useMemo } from 'react'; -import { createPluginRegistration } from '@embedpdf/core'; -import { EmbedPDF } from '@embedpdf/core/react'; -import { useEngineContext } from '@embedpdf/engines/react'; -import { DocumentContent, DocumentManagerPluginPackage } from '@embedpdf/plugin-document-manager/react'; -import { RenderPluginPackage, useRenderCapability } from '@embedpdf/plugin-render/react'; -import { Loader2 } from 'lucide-react'; - -const PDF_STATE_PREFIX = 'pdf-state-'; - -interface LightweightPdfPreviewProps { - pdfSrc: string; - className?: string; - /** Item title to show above "Page x of x" when scroll is locked */ - title?: string; -} - -interface PdfSnapshotRendererProps { - documentId: string; - pdfSrc: string; - className?: string; - pageCount: number; - title?: string; -} - -function getErrorDetails(error: unknown): Record { - if (error instanceof Error) { - return { name: error.name, message: error.message, stack: error.stack }; - } - if (typeof error === 'object' && error !== null) { - const e = error as Record; - return { - name: typeof e.name === 'string' ? e.name : undefined, - message: typeof e.message === 'string' ? e.message : undefined, - code: e.code, - reason: e.reason, - }; - } - return { value: error }; -} - -/** - * Internal component that renders the PDF snapshot once document is loaded. - * Reads the saved currentPage from localStorage and renders prev/current/next pages - * stitched into a single image, anchored to the top of the current page. - */ -function PdfSnapshotRenderer({ - documentId, - pdfSrc, - title, - className, - pageCount -}: PdfSnapshotRendererProps) { - const { provides: renderCapability } = useRenderCapability(); - - const [imageUrl, setImageUrl] = useState(null); - const [isRendering, setIsRendering] = useState(true); - const [pageInfo, setPageInfo] = useState<{ current: number; total: number } | null>(null); - const [pageTopOffset, setPageTopOffset] = useState(0); - const [displayScale, setDisplayScale] = useState(1); - const mountedRef = useRef(true); - - // Read saved page (0-indexed), zoom level, and scroll offset from localStorage - const savedPageIndex = useMemo(() => { - try { - const storageKey = `${PDF_STATE_PREFIX}${encodeURIComponent(pdfSrc)}`; - const raw = localStorage.getItem(storageKey); - if (raw) { - const saved = JSON.parse(raw); - const page = saved.currentPage ?? 1; - return Math.max(0, Math.min(page - 1, pageCount - 1)); - } - } catch (e) { - console.warn('[LightweightPdfPreview] Failed to load saved state:', e); - } - return 0; - }, [pdfSrc, pageCount]); - - const savedZoomLevel = useMemo(() => { - try { - const storageKey = `${PDF_STATE_PREFIX}${encodeURIComponent(pdfSrc)}`; - const raw = localStorage.getItem(storageKey); - if (raw) { - const saved = JSON.parse(raw); - return (typeof saved.zoomLevel === 'number' && saved.zoomLevel > 0) ? saved.zoomLevel : null; - } - } catch (e) { /* ignore */ } - return null; - }, [pdfSrc]); - - const savedScrollOffset = useMemo(() => { - try { - const storageKey = `${PDF_STATE_PREFIX}${encodeURIComponent(pdfSrc)}`; - const raw = localStorage.getItem(storageKey); - if (raw) { - const saved = JSON.parse(raw); - if (typeof saved.scrollOffsetX === 'number' && typeof saved.scrollOffsetY === 'number') { - return { x: saved.scrollOffsetX, y: saved.scrollOffsetY }; - } - } - } catch (e) { /* ignore */ } - return null; - }, [pdfSrc]); - - const savedPageRef = useRef(savedPageIndex); - savedPageRef.current = savedPageIndex; - - useEffect(() => { - if (!renderCapability || pageCount === 0) return; - - mountedRef.current = true; - - const renderSnapshot = async () => { - try { - setIsRendering(true); - const currentPageIndex = savedPageRef.current; - setPageInfo({ current: currentPageIndex + 1, total: pageCount }); - - const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; - const renderDpr = Math.min(dpr, 2); - // Use saved zoom level for rendering scale, clamped to a reasonable range - const baseScale = savedZoomLevel != null - ? Math.max(0.1, Math.min(savedZoomLevel, 4)) - : 0.5; - const scale = baseScale * renderDpr; - setDisplayScale(renderDpr); - - const renderScope = renderCapability.forDocument(documentId); - if (!renderScope) { - setIsRendering(false); - return; - } - - const pagesToRender: number[] = []; - if (currentPageIndex > 0) pagesToRender.push(currentPageIndex - 1); - pagesToRender.push(currentPageIndex); - if (currentPageIndex < pageCount - 1) pagesToRender.push(currentPageIndex + 1); - - const renderPage = (pageIdx: number): Promise => - new Promise((resolve, reject) => { - const task = renderScope.renderPage({ - pageIndex: pageIdx, - options: { - scaleFactor: scale, - imageType: 'image/webp', - imageQuality: 0.92, - withAnnotations: true, - withForms: true, - } - }); - task.wait(resolve, reject); - }); - - const renderedPages: Array<{ pageIdx: number; blob: Blob }> = []; - for (const pageIdx of pagesToRender) { - try { - const blob = await renderPage(pageIdx); - renderedPages.push({ pageIdx, blob }); - } catch (error) { - console.error('[LightweightPdfPreview] Failed to render page', { - pageIdx, error: getErrorDetails(error), - }); - } - } - - if (renderedPages.length === 0 || !mountedRef.current) { - if (mountedRef.current) { setImageUrl(null); setIsRendering(false); } - return; - } - - const images: Array<{ pageIdx: number; image: HTMLImageElement }> = []; - for (const { pageIdx, blob } of renderedPages) { - try { - const image = await new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = URL.createObjectURL(blob); - }); - images.push({ pageIdx, image }); - } catch (error) { - console.error('[LightweightPdfPreview] Failed to decode page image', { - pageIdx, error: getErrorDetails(error), - }); - } - } - - if (images.length === 0 || !mountedRef.current) { - images.forEach(({ image }) => URL.revokeObjectURL(image.src)); - if (mountedRef.current) { setImageUrl(null); setIsRendering(false); } - return; - } - - const maxWidth = Math.max(...images.map(({ image }) => image.width)); - const totalHeight = images.reduce((sum, { image }) => sum + image.height, 0); - const gap = 10 * renderDpr; - const offsetMap = new Map(); - - const canvas = document.createElement('canvas'); - canvas.width = maxWidth; - canvas.height = totalHeight + (images.length - 1) * gap; - const ctx = canvas.getContext('2d'); - - if (ctx) { - ctx.fillStyle = '#1a1a1a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - let yOffset = 0; - images.forEach(({ pageIdx, image }) => { - offsetMap.set(pageIdx, yOffset); - const xOffset = (maxWidth - image.width) / 2; - ctx.drawImage(image, xOffset, yOffset); - yOffset += image.height + gap; - }); - - const hasPage = images.some(({ pageIdx }) => pageIdx === currentPageIndex); - const displayIdx = hasPage ? currentPageIndex : images[0].pageIdx; - - canvas.toBlob((blob) => { - if (blob && mountedRef.current) { - const url = URL.createObjectURL(blob); - setImageUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return url; }); - setPageInfo({ current: displayIdx + 1, total: pageCount }); - setPageTopOffset(offsetMap.get(displayIdx) ?? 0); - setIsRendering(false); - } else if (mountedRef.current) { - setImageUrl(null); - setIsRendering(false); - } - }, 'image/webp', 0.92); - } - - images.forEach(({ image }) => URL.revokeObjectURL(image.src)); - } catch (e) { - console.error('[LightweightPdfPreview] Error:', getErrorDetails(e)); - setIsRendering(false); - } - }; - - renderSnapshot(); - return () => { mountedRef.current = false; }; - }, [renderCapability, documentId, pageCount]); - - useEffect(() => { - return () => { if (imageUrl) URL.revokeObjectURL(imageUrl); }; - }, [imageUrl]); - - if (isRendering) { - return ( -
-
- - Rendering... -
-
- ); - } - - if (!imageUrl) { - return ( -
-
PDF Preview
-
- ); - } - - return ( -
-
- PDF Preview -
- {pageInfo && ( -
- {/* Page number row - matches AppPdfViewer PageControls */} -
- {pageInfo.current} - / - {pageInfo.total} -
- {/* Document name - stacked below, matches AppPdfViewer */} - {title && ( -
- {title} -
- )} -
- )} -
- ); -} - -/** - * A lightweight PDF preview that renders a static image of the current page. - * Uses minimal plugins (no interaction layers) for fast rendering. - */ -export function LightweightPdfPreview({ pdfSrc, title, className }: LightweightPdfPreviewProps) { - const { engine, isLoading: engineLoading } = useEngineContext(); - - // Minimal plugins - just enough to render a page image - const plugins = useMemo(() => [ - createPluginRegistration(DocumentManagerPluginPackage, { - // Use full-fetch so renderPage can access deep pages in this minimal, non-scrolling instance. - initialDocuments: [{ url: pdfSrc, mode: 'full-fetch' }], - }), - createPluginRegistration(RenderPluginPackage, { - withForms: true, - withAnnotations: true, - }), - ], [pdfSrc]); - - if (engineLoading || !engine) { - return ( -
-
- - Loading... -
-
- ); - } - - return ( - - {({ activeDocumentId }) => - activeDocumentId ? ( - - {({ isLoading, isLoaded, documentState }) => ( - <> - {isLoading && ( -
-
- - Loading PDF... -
-
- )} - {isLoaded && documentState?.document && ( - - )} - - )} -
- ) : ( -
-
- - Initializing... -
-
- ) - } -
- ); -} - -export default LightweightPdfPreview; diff --git a/src/components/workspace-canvas/PanelActionMenuPortal.tsx b/src/components/workspace-canvas/PanelActionMenuPortal.tsx deleted file mode 100644 index 98fce2ac..00000000 --- a/src/components/workspace-canvas/PanelActionMenuPortal.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import { PanelRight, SplitSquareHorizontal } from "lucide-react"; - -/** Portal-rendered menu at exact cursor position (avoids transform-containing-block offset) */ -export function PanelActionMenuPortal({ - x, - y, - onReplace, - onDoublePanel, - onClose, -}: { - x: number; - y: number; - onReplace: () => void; - onDoublePanel: () => void; - onClose: () => void; -}) { - const menuRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - const handleScroll = () => onClose(); - const id = setTimeout(() => { - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("scroll", handleScroll, { capture: true }); - }, 0); - return () => { - clearTimeout(id); - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("scroll", handleScroll, { capture: true }); - }; - }, [onClose]); - - return ( -
- - -
- ); -} diff --git a/src/components/workspace-canvas/QuizContent.tsx b/src/components/workspace-canvas/QuizContent.tsx index 1c3a8259..da6693d8 100644 --- a/src/components/workspace-canvas/QuizContent.tsx +++ b/src/components/workspace-canvas/QuizContent.tsx @@ -207,10 +207,13 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN setShowHint(false); setAnsweredQuestions([]); setShowResults(false); - onUpdateData((prev) => ({ - ...prev, - session: undefined, - })); + onUpdateData((prev) => { + const current = prev as QuizData; + return { + ...current, + session: undefined, + }; + }); }; // Handle Update Quiz - programmatically send message to add more questions diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 5034c4f1..f86a4b7c 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -9,10 +9,6 @@ import { Copy, X, Pencil, - Columns, - Link2, - PanelRight, - SplitSquareHorizontal, Loader2, File, FileText, @@ -22,7 +18,6 @@ import { } from "lucide-react"; import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi"; import { useCallback, useState, memo, useRef, useEffect } from "react"; -import { createPortal } from "react-dom"; import { useTheme } from "next-themes"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -30,9 +25,7 @@ import ItemHeader from "@/components/workspace-canvas/ItemHeader"; import { getCardColorCSS, getCardAccentColor, - getDistinctCardColor, getCardColorWithBlackMix, - getIconColorFromCardColor, getIconColorFromCardColorWithOpacity, getLighterCardColor, SWATCHES_COLOR_GROUPS, @@ -43,21 +36,18 @@ import type { PdfData, FlashcardData, YouTubeData, - ImageData, WebsiteData, DocumentData, } from "@/lib/workspace-state/types"; import { SwatchesPicker, ColorResult } from "react-color"; import { AudioCardContent } from "./AudioCardContent"; import LazyAppPdfViewer from "@/components/pdf/LazyAppPdfViewer"; -import { LightweightPdfPreview } from "@/components/pdf/LightweightPdfPreview"; import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown"; import { Skeleton } from "@/components/ui/skeleton"; import { useUIStore, selectItemScrollLocked } from "@/lib/stores/ui-store"; import { Flashcard } from "react-quizlet-flashcard"; import "react-quizlet-flashcard/dist/index.css"; import { useElementSize } from "@/hooks/use-element-size"; -import { useIsVisible } from "@/hooks/use-is-visible"; import { extractYouTubeVideoId, extractYouTubePlaylistId, @@ -65,7 +55,6 @@ import { import { YouTubeCardContent } from "./YouTubeCardContent"; import { getLayoutForBreakpoint } from "@/lib/workspace-state/grid-layout-helpers"; import { SourcesDisplay } from "./SourcesDisplay"; -import { PanelActionMenuPortal } from "./PanelActionMenuPortal"; import { DropdownMenu, @@ -128,7 +117,6 @@ function WorkspaceCard({ onUpdateItem, onDeleteItem, onOpenModal, - existingColors, onMoveItem, }: WorkspaceCardProps) { const { resolvedTheme } = useTheme(); @@ -150,36 +138,17 @@ function WorkspaceCard({ const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - // Check if this card is currently open in the panel (not maximized/modal) - const isOpenInPanel = useUIStore( - (state) => - state.openPanelIds.includes(item.id) && state.maximizedItemId !== item.id, - ); - const openPanelIds = useUIStore((state) => state.openPanelIds); - const maximizedItemId = useUIStore((state) => state.maximizedItemId); - const setOpenModalItemId = useUIStore((state) => state.setOpenModalItemId); - const openPanel = useUIStore((state) => state.openPanel); - const closePanel = useUIStore((state) => state.closePanel); - const viewMode = useUIStore((state) => state.viewMode); - const splitWithItem = useUIStore((state) => state.splitWithItem); - // No dynamic calculations needed - just overflow hidden const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showMoveDialog, setShowMoveDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); - const [panelActionMenu, setPanelActionMenu] = useState<{ - x: number; - y: number; - itemId: string; - } | null>(null); // Get scroll lock state from Zustand store (persists across interactions) const isScrollLocked = useUIStore(selectItemScrollLocked(item.id)); const toggleItemScrollLocked = useUIStore( (state) => state.toggleItemScrollLocked, ); - const [isDragging, setIsDragging] = useState(false); const articleRef = useRef(null); // Measure card size to determine if we should show preview @@ -192,10 +161,6 @@ function WorkspaceCard({ const meetsHeight = cardHeight === undefined || cardHeight > 180; const shouldShowPreview = meetsWidth && meetsHeight; - // PERFORMANCE: Track visibility for PDF virtualization - // Only mount PDF content when card is visible in viewport - const isCardVisible = useIsVisible(articleRef, { rootMargin: "200px" }); - // Track minimal local drag detection (only if grid hasn't detected drag) const mouseDownRef = useRef<{ x: number; y: number } | null>(null); const hasMovedRef = useRef(false); @@ -211,54 +176,26 @@ function WorkspaceCard({ // Cleanup listeners on unmount useEffect(() => { + const handlers = handlersRef.current; return () => { if ( listenersActiveRef.current && - handlersRef.current.handleGlobalMouseMove && - handlersRef.current.handleGlobalMouseUp + handlers.handleGlobalMouseMove && + handlers.handleGlobalMouseUp ) { document.removeEventListener( "mousemove", - handlersRef.current.handleGlobalMouseMove, + handlers.handleGlobalMouseMove, ); document.removeEventListener( "mouseup", - handlersRef.current.handleGlobalMouseUp, + handlers.handleGlobalMouseUp, ); listenersActiveRef.current = false; } }; }, []); - // Check if card is being dragged by checking parent element for dragging class - useEffect(() => { - if (!articleRef.current || item.type !== "youtube") return; - - const checkDragging = () => { - const parent = articleRef.current?.closest(".react-grid-item"); - const dragging = - parent?.classList.contains("react-draggable-dragging") ?? false; - setIsDragging(dragging); - }; - - // Check initially - checkDragging(); - - // Use MutationObserver to watch for class changes on parent - const parent = articleRef.current.closest(".react-grid-item"); - if (!parent) return; - - const observer = new MutationObserver(checkDragging); - observer.observe(parent, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => { - observer.disconnect(); - }; - }, [item.type, item.id]); - // OPTIMIZED: Memoize ItemHeader callbacks to prevent inline function creation const handleNameChange = useCallback( (v: string) => { @@ -306,7 +243,7 @@ function WorkspaceCard({ onDeleteItem(item.id); setShowDeleteDialog(false); toast.success("Card deleted successfully"); - }, [item.id, onDeleteItem, item.name]); + }, [item.id, onDeleteItem]); const handleRename = useCallback( (newName: string) => { @@ -509,54 +446,18 @@ function WorkspaceCard({ return; } - // YouTube cards open in panel (same as documents/PDFs) - no special handling, fall through - - // If this card is already open in panel mode, close it instead of re-opening - if (isOpenInPanel) { - e.stopPropagation(); - closePanel(item.id); - return; - } - - // In workspace+panel mode: show Replace / Double Panel menu at cursor - if (viewMode === "workspace+panel" && !openPanelIds.includes(item.id)) { - e.preventDefault(); - e.stopPropagation(); - setPanelActionMenu({ x: e.clientX, y: e.clientY, itemId: item.id }); - return; - } - // Default: open in focus mode (maximized modal) onOpenModal(item.id); }, [ isEditingTitle, - isOpenInPanel, item.id, item.type, onOpenModal, - openPanelIds, - maximizedItemId, - viewMode, - openPanel, - closePanel, + onToggleSelection, ], ); - const handleReplacePanel = useCallback(() => { - if (panelActionMenu) { - openPanel(panelActionMenu.itemId); - setPanelActionMenu(null); - } - }, [panelActionMenu, openPanel]); - - const handleDoublePanel = useCallback(() => { - if (panelActionMenu) { - splitWithItem(panelActionMenu.itemId); - setPanelActionMenu(null); - } - }, [panelActionMenu, splitWithItem]); - return ( @@ -587,9 +488,7 @@ function WorkspaceCard({ : "var(--card)", borderColor: isSelected ? "rgba(255, 255, 255, 0.8)" - : isOpenInPanel - ? "hsl(var(--primary))" - : item.color + : item.color ? getCardAccentColor( item.color, resolvedTheme === "dark" ? 0.5 : 0.3, @@ -597,13 +496,11 @@ function WorkspaceCard({ : "transparent", borderWidth: isSelected ? "3px" - : isOpenInPanel - ? "2px" - : item.type === "youtube" || - item.type === "image" || - (item.type === "pdf" && shouldShowPreview) - ? "0px" - : "1px", + : item.type === "youtube" || + item.type === "image" || + (item.type === "pdf" && shouldShowPreview) + ? "0px" + : "1px", boxShadow: isSelected && resolvedTheme !== "dark" ? "0 0 3px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.5)" @@ -618,10 +515,9 @@ function WorkspaceCard({ onClick={handleCardClick} > {/* Floating Controls Container */} - {!isOpenInPanel && ( -
+
{/* Scroll Lock/Unlock Button - Hidden for YouTube, image, quiz, and narrow document/PDF cards */} {item.type !== "youtube" && item.type !== "image" && @@ -812,18 +708,6 @@ function WorkspaceCard({ className="w-48" onClick={(e) => e.stopPropagation()} > - {viewMode === "workspace+panel" && - !openPanelIds.includes(item.id) && ( - <> - splitWithItem(item.id)} - > - - Double Panel - - - - )} setShowRenameDialog(true)} > @@ -867,7 +751,6 @@ function WorkspaceCard({
- )} {/* Type badge - rect in bottom-left corner (when card is small) */} {(item.type === "pdf" || @@ -911,18 +794,12 @@ function WorkspaceCard({ (() => { const websiteData = item.data as WebsiteData; const favicon = websiteData.favicon; - let hostname = ""; - try { - hostname = new URL(websiteData.url).hostname.replace( - /^www\./, - "", - ); - } catch {} const fallbackId = `fallback-${item.id}`; const faviconId = `favicon-${item.id}`; return ( <> {favicon && ( + /* eslint-disable-next-line @next/next/no-img-element */ {/* PDF Content - render embedded PDF viewer if card is wide enough */} - {/* PERFORMANCE: Only mount PDF content when card is visible (virtualization) */} - {/* When scroll is locked, render lightweight placeholder instead of full PDF viewer */} - {!isOpenInPanel && - item.type === "pdf" && + {item.type === "pdf" && shouldShowPreview && (() => { const pdfData = item.data as PdfData; @@ -1072,27 +946,7 @@ function WorkspaceCard({ className={`flex-1 min-h-0 relative ${isScrollLocked ? "overflow-hidden" : "overflow-auto"}`} style={{ pointerEvents: isScrollLocked ? "none" : "auto" }} > - {!isCardVisible ? ( - // Placeholder when card is not visible - very lightweight -
- PDF -
- ) : isScrollLocked || maximizedItemId === item.id ? ( - - ) : ( + {isScrollLocked ? null : ( - onUpdateItem(item.id, { data: updater(item.data) as any }) + onUpdateItem(item.id, { + data: updater(item.data) as Item["data"], + }) } isScrollLocked={isScrollLocked} /> @@ -1194,8 +1050,7 @@ function WorkspaceCard({ })()} {/* YouTube Content - render YouTube embed */} - {!isOpenInPanel && - item.type === "youtube" && + {item.type === "youtube" && (() => { const youtubeData = item.data as YouTubeData; const hasValidUrl = @@ -1245,12 +1100,12 @@ function WorkspaceCard({ })()} {/* Image Content - render frameless image */} - {!isOpenInPanel && item.type === "image" && ( + {item.type === "image" && ( )} {/* Audio Content - render audio player and transcript */} - {!isOpenInPanel && item.type === "audio" && shouldShowPreview && ( + {item.type === "audio" && shouldShowPreview && (
)} - {!isOpenInPanel && - item.type === "document" && + {item.type === "document" && shouldShowPreview && (documentAwaitingGeneration ? (
@@ -1286,18 +1140,6 @@ function WorkspaceCard({
))} - {/* Active in Panel Overlay */} - {isOpenInPanel && ( -
-
- - -
- - Click to close - -
- )} {/* Delete Confirmation Dialog */} @@ -1392,19 +1234,6 @@ function WorkspaceCard({ - {/* Panel action menu - appears at cursor when single-clicking a card in workspace+panel mode */} - {typeof document !== "undefined" && - panelActionMenu && - createPortal( - setPanelActionMenu(null)} - />, - document.body, - )} ); } diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index fbf96d97..48a5cbde 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -8,12 +8,10 @@ import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; import { filterItemsByFolder } from "@/lib/workspace-state/search"; import { useAutoScroll } from "@/hooks/ui/use-auto-scroll"; import { WorkspaceGrid } from "./WorkspaceGrid"; -import type { LayoutItem } from "react-grid-layout"; import { useUIStore } from "@/lib/stores/ui-store"; import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids"; -import { useAui } from "@assistant-ui/react"; import { toast } from "sonner"; -import { OCR_COMPLETE_EVENT, startOcrProcessing } from "@/lib/ocr/client"; +import { OCR_COMPLETE_EVENT } from "@/lib/ocr/client"; import { WORKSPACE_FILE_UPLOAD_ACCEPT_STRING, WORKSPACE_FILE_UPLOAD_DESCRIPTION, @@ -27,7 +25,6 @@ interface WorkspaceContentProps { deleteItem: (itemId: string) => void; updateAllItems: (items: Item[]) => void; getStatePreviewJSON: (s: AgentState | undefined) => Record; - columns: number; // Pass columns from layout state instead of calculating here setOpenModalItemId: (id: string | null) => void; scrollContainerRef?: React.RefObject; onGridDragStateChange?: (isDragging: boolean) => void; @@ -51,7 +48,6 @@ export default function WorkspaceContent({ deleteItem, updateAllItems, getStatePreviewJSON, - columns, // Columns now passed from parent (calculated in use-layout-state) setOpenModalItemId, scrollContainerRef: externalScrollContainerRef, onGridDragStateChange, @@ -76,12 +72,7 @@ export default function WorkspaceContent({ // Auto-scroll during drag operations (extracted to custom hook) const { handleDragStart: onDragStart, handleDragStop: onDragStop } = useAutoScroll(scrollContainerRef); - const { selectedCardIdsArray, selectedCardIds } = useSelectedCardIds(); - const toggleCardSelection = useUIStore((state) => state.toggleCardSelection); - - - const maximizedItemId = useUIStore((state) => state.maximizedItemId); - + const { selectedCardIdsArray } = useSelectedCardIds(); // Folder filtering state from UI store @@ -93,8 +84,6 @@ export default function WorkspaceContent({ // File upload for empty state const fileInputRef = useRef(null); - const aui = useAui(); - // Handle copy JSON to clipboard const handleCopyJson = useCallback(async () => { try { @@ -171,7 +160,10 @@ export default function WorkspaceContent({ // Listen for audio processing completion events useEffect(() => { const handleAudioComplete = (e: Event) => { - const { itemId, summary, segments, duration, error, retrying } = (e as CustomEvent).detail; + const { itemId, retrying } = (e as CustomEvent<{ + itemId?: string; + retrying?: boolean; + }>).detail ?? {}; if (!itemId) return; const existingData = viewState.items.find((i) => i.id === itemId)?.data ?? {}; @@ -183,7 +175,7 @@ export default function WorkspaceContent({ ...existingData, processingStatus: "processing", error: undefined, - } as any, + } as Item["data"], }); return; } @@ -299,7 +291,7 @@ export default function WorkspaceContent({ }, [onDragStart]); // Handle drag stop - save layout and notify auto-scroll hook - const handleDragStop = useCallback((newLayout: LayoutItem[]) => { + const handleDragStop = useCallback(() => { // Always notify auto-scroll hook to reset dragging state // NOTE: WorkspaceGrid.handleDragStop already handles saving the layout, // so we don't need to save here to avoid duplicate events @@ -424,32 +416,29 @@ export default function WorkspaceContent({
) : ( - +
0 ? "pb-20" : undefined}> + +
)} ); diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index cbd18dd0..d2ea66fd 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -3,15 +3,30 @@ import { wrapCompactor, fastVerticalCompactor } from "react-grid-layout/extras"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useMemo, useCallback, useRef, useEffect, useState } from "react"; import React from "react"; -import type { Item, CardType } from "@/lib/workspace-state/types"; +import type { Item } from "@/lib/workspace-state/types"; import type { CardColor } from "@/lib/workspace-state/colors"; -import { itemsToLayout, generateMissingLayouts, updateItemsWithLayout, hasLayoutChanged } from "@/lib/workspace-state/grid-layout-helpers"; +import { itemsToLayout, generateMissingLayouts, updateItemsWithLayout } from "@/lib/workspace-state/grid-layout-helpers"; import { isDescendantOf } from "@/lib/workspace-state/search"; import { WorkspaceCard } from "./WorkspaceCard"; import { FlashcardWorkspaceCard } from "./FlashcardWorkspaceCard"; import { FolderCard } from "./FolderCard"; import { useUIStore } from "@/lib/stores/ui-store"; +const GRID_BREAKPOINTS = { lg: 0 } as const; +const GRID_COLS = { lg: 4 } as const; +const GRID_MARGIN: [number, number] = [16, 16]; +const GRID_CONTAINER_PADDING: [number, number] = [16, 0]; +const GRID_RESIZE_HANDLES = [ + "s", + "w", + "e", + "n", + "se", + "sw", + "ne", + "nw", +] as Array<"s" | "w" | "e" | "n" | "se" | "sw" | "ne" | "nw">; + interface WorkspaceGridProps { items: Item[]; // Filtered items to display (includes folder-type items) allItems: Item[]; // All items (unfiltered) for layout updates @@ -24,8 +39,6 @@ interface WorkspaceGridProps { onDeleteItem: (itemId: string) => void; onUpdateAllItems: (items: Item[]) => void; onOpenModal: (itemId: string) => void; - selectedCardIds: Set; - onToggleSelection: (itemId: string) => void; onGridDragStateChange?: (isDragging: boolean) => void; workspaceName: string; workspaceIcon?: string | null; @@ -34,16 +47,13 @@ interface WorkspaceGridProps { onMoveItems?: (itemIds: string[], folderId: string | null) => void; // Callback to move multiple items to folder (bulk move) onOpenFolder?: (folderId: string) => void; // Callback when folder is clicked onDeleteFolderWithContents?: (folderId: string) => void; // Callback to delete folder and all items inside - addItem?: (type: CardType, name?: string, initialData?: Partial) => string | void; // Function to add new items - onPDFUpload?: (files: File[]) => Promise; // Function to handle PDF upload - setOpenModalItemId?: (id: string | null) => void; // Function to open modal for newly created items } /** * Grid layout component that manages the positioning and layout of workspace cards. * Handles drag-and-drop, resizing, and layout recalculation. */ -export function WorkspaceGrid({ +function WorkspaceGridComponent({ items, allItems, isFiltered, @@ -55,8 +65,6 @@ export function WorkspaceGrid({ onDeleteItem, onUpdateAllItems, onOpenModal, - selectedCardIds, - onToggleSelection, onGridDragStateChange, workspaceName, workspaceIcon, @@ -65,9 +73,6 @@ export function WorkspaceGrid({ onMoveItems, onOpenFolder, onDeleteFolderWithContents, - addItem, - onPDFUpload, - setOpenModalItemId, }: WorkspaceGridProps) { const useWrapCompactor = useFeatureFlagEnabled("wrap-compactor"); const compactor = useWrapCompactor ? wrapCompactor : fastVerticalCompactor; @@ -91,10 +96,6 @@ export function WorkspaceGrid({ return () => cancelAnimationFrame(raf); }, [mounted]); - // Track current breakpoint for saving layouts - // Note: We use RGL's onBreakpointChange to update this, so we initialize to 'lg' - const currentBreakpointRef = useRef<'lg' | 'xxs'>('lg'); - // OPTIMIZED: Store layout in ref to avoid including it in callback dependencies // This prevents handleDragStop from changing when layout changes, which causes ReactGridLayout re-renders const layoutRef = useRef([]); @@ -108,17 +109,11 @@ export function WorkspaceGrid({ allItemsRef.current = allItems; }, [allItems]); - // Generate layouts for items that don't have them - // lg: 4 columns; xxs: 1 column with h=10 default for consistent single-column stacking - const itemsWithLayout = useMemo(() => { - const withLg = generateMissingLayouts(items, 4); - return generateMissingLayouts(withLg, 1, 'xxs'); - }, [items]); + // Generate layouts for items that don't have them. + const itemsWithLayout = useMemo(() => generateMissingLayouts(items, 4), [items]); // Display all items (no longer hiding items when panels are open) - const displayItems = useMemo(() => { - return itemsWithLayout; - }, [itemsWithLayout]); + const displayItems = itemsWithLayout; // Note: Standard react-grid-layout handles bounds automatically. @@ -129,7 +124,7 @@ export function WorkspaceGrid({ // Debounced handler for live updates during drag/resize // NOTE: We disable this and only save on drag stop to prevent unnecessary saves on clicks // react-grid-layout fires onLayoutChange even on simple clicks, causing unwanted updates - const handleLayoutChange = useCallback((newLayout: Layout, allLayouts: Partial>) => { + const handleLayoutChange = useCallback(() => { // Cancel any pending timeouts - we only save on drag stop now if (layoutChangeTimeoutRef.current) { clearTimeout(layoutChangeTimeoutRef.current); @@ -142,7 +137,9 @@ export function WorkspaceGrid({ }, []); // Handle drag start - with RGL v2, this only fires after 3px movement (real drag, not click) - const handleDragStart = useCallback((layout: Layout, oldItem: LayoutItem | null, newItem: LayoutItem | null, placeholder: LayoutItem | null, e: Event, element: HTMLElement | null) => { + const handleDragStart = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { + const oldItem = args[1]; + const e = args[4]; // Check if the click originated from a dropdown menu - if so, don't start drag const target = e.target as HTMLElement; if ( @@ -167,7 +164,8 @@ export function WorkspaceGrid({ }, [onDragStart, onGridDragStateChange]); // Handle drag to detect folder hover based on cursor position - const handleDrag = useCallback((layout: Layout, oldItem: LayoutItem | null, newItem: LayoutItem | null, placeholder: LayoutItem | null, e: Event, element: HTMLElement | null) => { + const handleDrag = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { + const e = args[4]; const draggedItemId = draggedItemIdRef.current; if (!draggedItemId || !e) return; @@ -325,6 +323,7 @@ export function WorkspaceGrid({ } // Calculate selected count - if dragged card is selected, count all selected, otherwise just 1 + const selectedCardIds = useUIStore.getState().selectedCardIds; const isDraggedCardSelected = selectedCardIds.has(draggedItemId); const selectedCount = isDraggedCardSelected ? selectedCardIds.size : 1; @@ -344,10 +343,11 @@ export function WorkspaceGrid({ detail: { folderId: eventFolderId, isHovering: true, selectedCount } })); } - }, [selectedCardIds]); + }, []); // Handle resize start - track which item is being resized - const handleResizeStart = useCallback((layout: Layout, oldItem: LayoutItem | null, newItem: LayoutItem | null, placeholder: LayoutItem | null, e: Event, element: HTMLElement | null) => { + const handleResizeStart = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { + const oldItem = args[1]; hasUserInteractedRef.current = true; // Track which item is being resized (same as drag) if (!oldItem) return; @@ -359,7 +359,8 @@ export function WorkspaceGrid({ // Handle drag stop - with RGL v2, this only fires for actual drags (not clicks) // Click handling is now done by individual card components via their onClick handlers - const handleDragStop = useCallback((newLayout: Layout, oldItem: LayoutItem | null, newItem: LayoutItem | null, placeholder: LayoutItem | null, e: Event, element: HTMLElement | null) => { + const handleDragStop = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { + const newLayout = args[0]; const draggedItemId = draggedItemIdRef.current; // If no drag was started (e.g., click on dropdown), exit early @@ -370,9 +371,6 @@ export function WorkspaceGrid({ } // Find the item - const item = allItemsRef.current.find(i => i.id === draggedItemId); - const isFolder = item?.type === 'folder'; - // Cancel any pending debounced update if (layoutChangeTimeoutRef.current) { clearTimeout(layoutChangeTimeoutRef.current); @@ -393,6 +391,7 @@ export function WorkspaceGrid({ const hoveredFolderId = hoveredFolderIdRef.current; if (hoveredFolderId !== null && draggedItemId) { // Check if dragged card is part of selection + const selectedCardIds = useUIStore.getState().selectedCardIds; const isDraggedCardSelected = selectedCardIds.has(draggedItemId); const cardsToMove = isDraggedCardSelected ? Array.from(selectedCardIds) @@ -482,7 +481,7 @@ export function WorkspaceGrid({ } if (layoutChanged) { - const updatedItems = updateItemsWithLayout(allItemsRef.current, [...newLayout], currentBreakpointRef.current); + const updatedItems = updateItemsWithLayout(allItemsRef.current, [...newLayout]); onUpdateAllItems(updatedItems); } @@ -497,14 +496,15 @@ export function WorkspaceGrid({ // Clear the dragged item reference draggedItemIdRef.current = null; onGridDragStateChange?.(false); - }, [onDragStop, isFiltered, isTemporaryFilter, onGridDragStateChange, onUpdateAllItems, onMoveItem, onMoveItems, selectedCardIds]); + }, [clearCardSelection, onDragStop, isFiltered, isTemporaryFilter, onGridDragStateChange, onUpdateAllItems, onMoveItem, onMoveItems]); // Handle resize to enforce constraints // Note cards can transition between compact (w=1, h=4) and expanded (w>=2, h>=9) modes // based on EITHER width or height changes, allowing vertical-only resizing to trigger mode switches - const handleResize = useCallback((layout: Layout, oldItem: LayoutItem | null, newItem: LayoutItem | null, placeholder: LayoutItem | null, e: Event, element: HTMLElement | null) => { - - + const handleResize = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { + const oldItem = args[1]; + const newItem = args[2]; + const placeholder = args[3]; // Normal workspace mode: enforce custom constraints if (!newItem || !oldItem) return; const itemData = allItemsRef.current.find(i => i.id === newItem.i); @@ -521,7 +521,7 @@ export function WorkspaceGrid({ } } else if (itemData.type === 'folder' || itemData.type === 'flashcard') { // Folders and flashcards don't need minimum height enforcement - skip - } else if (currentBreakpointRef.current !== 'xxs' && (itemData.type === 'document' || itemData.type === 'pdf' || itemData.type === 'quiz' || itemData.type === 'audio')) { + } else if (itemData.type === 'document' || itemData.type === 'pdf' || itemData.type === 'quiz' || itemData.type === 'audio') { // Note, Document, PDF, Quiz, and Audio (recording) cards: handle transitions between compact and expanded modes // Note/Document/Audio cards: Compact mode: w=1, h=4 | Expanded mode: w>=2, h>=9 // PDF cards: Compact mode: w=1, h=4 | Expanded mode: w>=2, h>=6 @@ -557,8 +557,7 @@ export function WorkspaceGrid({ // Clamp position to prevent off-screen glitches when resizing from left/west or top/north handles. // react-grid-layout can set negative x/y when dragging those edges; we clamp to grid bounds. - const cols = currentBreakpointRef.current === 'xxs' ? 1 : 4; - newItem.x = Math.max(0, Math.min(cols - newItem.w, newItem.x)); + newItem.x = Math.max(0, Math.min(4 - newItem.w, newItem.x)); newItem.y = Math.max(0, newItem.y); if (placeholder) { placeholder.x = newItem.x; @@ -588,7 +587,7 @@ export function WorkspaceGrid({ // For resize, we always save since resize always changes layout // Folders are now items with type: 'folder', so they're included in updateItemsWithLayout - const updatedItems = updateItemsWithLayout(allItemsRef.current, [...newLayout], currentBreakpointRef.current); + const updatedItems = updateItemsWithLayout(allItemsRef.current, [...newLayout]); onUpdateAllItems(updatedItems); draggedItemIdRef.current = null; @@ -613,10 +612,6 @@ export function WorkspaceGrid({ onOpenModal(itemId); }, [onOpenModal]); - const handleToggleSelection = useCallback((itemId: string) => { - onToggleSelection(itemId); - }, [onToggleSelection]); - // Folder operation handler (folders are now items with type: 'folder') const handleOpenFolder = useCallback((folderId: string) => { onOpenFolder?.(folderId); @@ -641,34 +636,15 @@ export function WorkspaceGrid({ }, [itemsWithLayout]); - // OPTIMIZED: Memoize array props to prevent ResponsiveGridLayout/DraggableCore re-renders - // These arrays are recreated on every render, causing unnecessary re-renders - const margin = useMemo(() => [16, 16] as [number, number], []); - const containerPadding = useMemo(() => [16, 0] as [number, number], []); - const resizeHandles = useMemo(() => ['s', 'w', 'e', 'n', 'se', 'sw', 'ne', 'nw'] as Array<'s' | 'w' | 'e' | 'n' | 'se' | 'sw' | 'ne' | 'nw'>, []); - - // OPTIMIZED: Create stable Set reference check - only recreate if Set contents changed - // Convert Set to sorted array string for stable comparison - const selectedCardIdsKey = useMemo(() => { - return Array.from(selectedCardIds).sort().join(','); - }, [selectedCardIds]); - - // OPTIMIZED: Create stable items key to prevent unnecessary children recreation - // Only recreate children when items actually change (by ID/content), not just reference - // Include data to ensure updates like thumbnail changes trigger re-renders - const itemsKey = useMemo(() => { - return displayItems.map(item => `${item.id}:${item.name}:${item.type}:${JSON.stringify(item.data)}`).join('|'); - }, [displayItems]); - // Layout for all items (including folder-type items) const combinedLayout = useMemo(() => { - const itemLayouts = itemsToLayout(displayItems); - - // Update layout ref - layoutRef.current = itemLayouts; - return itemLayouts; + return itemsToLayout(displayItems); }, [displayItems]); + useEffect(() => { + layoutRef.current = combinedLayout; + }, [combinedLayout]); + // Memoize children to take advantage of ResponsiveGridLayout's shouldComponentUpdate optimization const children = useMemo(() => { return displayItems.map((item) => ( @@ -715,10 +691,9 @@ export function WorkspaceGrid({ )} )); - // Use stable keys instead of object references to prevent unnecessary recreations - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - itemsKey, // Stable key based on item IDs/names/types - only changes when items actually change + allItems, + displayItems, handleUpdateItem, handleDeleteItem, handleOpenModal, @@ -728,6 +703,7 @@ export function WorkspaceGrid({ handleOpenFolder, folderItemCounts, workspaceName, + workspaceIcon, workspaceColor, ]); @@ -744,25 +720,12 @@ export function WorkspaceGrid({ }; }, [onDragStop, onGridDragStateChange]); - // Define breakpoints and columns - const breakpoints = useMemo(() => ({ lg: 600, xxs: 0 }), []); - const cols = useMemo(() => ({ lg: 4, xxs: 1 }), []); - - // Create layouts object for ResponsiveGridLayout with both breakpoints - // Always provide xxs layout (h=10 default) for consistent single-column stacking - const xxsLayout = useMemo(() => itemsToLayout(itemsWithLayout, 'xxs'), [itemsWithLayout]); const layouts = useMemo(() => ({ lg: combinedLayout, - xxs: xxsLayout - }), [combinedLayout, xxsLayout]); - - // Handle breakpoint changes to track current breakpoint for saving layouts - const handleBreakpointChange = useCallback((newBreakpoint: string, newCols: number) => { - currentBreakpointRef.current = newBreakpoint as 'lg' | 'xxs'; - }, []); + }), [combinedLayout]); return ( -
0 ? 'pb-20' : ''} w-full workspace-grid-container${hasMounted ? ' workspace-grid-mounted' : ''}`} ref={containerRef}> +