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..a9b13026 100644 --- a/package.json +++ b/package.json @@ -119,8 +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", "parse-diff": "^0.11.1", diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index c1de726f..49ce5b72 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -491,7 +491,7 @@ export async function POST(request: NextRequest) { const pdfCreateParams: CreateItemParams[] = []; for (const asset of documentAssets) { - const position = findNextAvailablePosition(pdfItemLayouts as Item[], "pdf", 4, "", "", AUTOGEN_LAYOUTS.pdf.w, AUTOGEN_LAYOUTS.pdf.h); + const position = findNextAvailablePosition(pdfItemLayouts as Item[], "pdf", 4, AUTOGEN_LAYOUTS.pdf.w, AUTOGEN_LAYOUTS.pdf.h); const pdfItemId = generateItemId(); const itemDefinition = buildWorkspaceItemDefinitionFromAsset(asset); if (itemDefinition.type !== "pdf") continue; @@ -507,7 +507,7 @@ export async function POST(request: NextRequest) { const imageCreateParams: CreateItemParams[] = []; for (const asset of imageAssets) { - const position = findNextAvailablePosition(pdfItemLayouts as Item[], "image", 4, "", "", AUTOGEN_LAYOUTS.image.w, AUTOGEN_LAYOUTS.image.h); + const position = findNextAvailablePosition(pdfItemLayouts as Item[], "image", 4, AUTOGEN_LAYOUTS.image.w, AUTOGEN_LAYOUTS.image.h); const imgItemId = generateItemId(); const itemDefinition = buildWorkspaceItemDefinitionFromAsset(asset); if (itemDefinition.type !== "image") continue; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 36bcf7f1..c6b62591 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import type { AgentState, @@ -8,7 +8,6 @@ import type { } from "@/lib/workspace-state/types"; import { initialState } from "@/lib/workspace-state/state"; import { useKeyboardShortcuts } from "@/hooks/ui/use-keyboard-shortcuts"; -import { useLayoutState } from "@/hooks/ui/use-layout-state"; import useMediaQuery from "@/hooks/ui/use-media-query"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; @@ -27,8 +26,6 @@ import { WorkspaceSection } from "@/components/workspace-canvas/WorkspaceSection import { ModalManager } from "@/components/modals/ModalManager"; import { AnonymousSignInPrompt } from "@/components/modals/AnonymousSignInPrompt"; import { DashboardLayout } from "@/components/layout/DashboardLayout"; -import { SplitViewLayout } from "@/components/layout/SplitViewLayout"; -import { ItemPanelContent } from "@/components/workspace-canvas/ItemPanelContent"; import WorkspaceHeader from "@/components/workspace-canvas/WorkspaceHeader"; import { WorkspaceSearchDialog } from "@/components/workspace-canvas/WorkspaceSearchDialog"; import { useSidebar } from "@/components/ui/sidebar"; @@ -96,8 +93,6 @@ function DashboardContent({ // Get save status from Zustand store const { isSaving, - lastSavedAt, - hasUnsavedChanges, updateSaveStatus, updateLastSaved, updateHasUnsavedChanges, @@ -112,14 +107,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); @@ -184,14 +171,7 @@ function DashboardContent({ dismissedSignInPromptWorkspaceId !== currentWorkspaceId; // Get sidebar state and controls - const { state: leftSidebarState, toggleSidebar } = useSidebar(); - const isLeftSidebarOpen = leftSidebarState === "expanded"; - - // Manual save is now just a no-op since events auto-persist - // But we keep it for the UI save button - const manualSave = useCallback(async () => { - updateLastSaved(new Date()); - }, [updateLastSaved]); + const { toggleSidebar } = useSidebar(); // Update save status based on mutation status useEffect(() => { @@ -255,7 +235,6 @@ function DashboardContent({ const showJsonView = useUIStore((state) => state.showJsonView); const isChatExpanded = useUIStore((state) => state.isChatExpanded); const isChatMaximized = useUIStore((state) => state.isChatMaximized); - const workspacePanelSize = useUIStore((state) => state.workspacePanelSize); const showCreateWorkspaceModal = useUIStore( (state) => state.showCreateWorkspaceModal, ); @@ -281,9 +260,6 @@ function DashboardContent({ const toggleChatExpanded = useUIStore((state) => state.toggleChatExpanded); const toggleChatMaximized = useUIStore((state) => state.toggleChatMaximized); - // View mode from store - const viewMode = useUIStore((state) => state.viewMode); - // Panel state - using new array-based system const openPanelIds = useUIStore((state) => state.openPanelIds); const closePanel = useUIStore((state) => state.closePanel); @@ -297,15 +273,6 @@ function DashboardContent({ const scrollAreaRef = useRef(null); const titleInputRef = useRef(null); - // Layout state - const layout = useLayoutState({ - isLeftSidebarOpen, - isChatExpanded, - workspacePanelSize, - isChatMaximized, - isDesktop, - }); - // Search dialog state for Cmd+K command palette const [searchDialogOpen, setSearchDialogOpen] = useState(false); @@ -423,120 +390,6 @@ function DashboardContent({ setShowVersionHistory(true); }, []); - // Build the split view layout element (for panel+panel mode only) - const splitViewContent = useMemo(() => { - if (viewMode !== "panel+panel") return undefined; - return ( - setSearchDialogOpen(true)} - isSaving={isSaving} - lastSavedAt={lastSavedAt} - hasUnsavedChanges={hasUnsavedChanges} - onManualSave={manualSave} - addItem={operations.createItem} - updateItem={operations.updateItem} - deleteItem={operations.deleteItem} - updateAllItems={operations.updateAllItems} - getStatePreviewJSON={getStatePreviewJSON} - isChatMaximized={isChatMaximized} - columns={1} - isDesktop={isDesktop} - isChatExpanded={isChatExpanded} - setIsChatExpanded={setIsChatExpanded} - isItemPanelOpen={true} - setOpenModalItemId={setOpenModalItemId} - onShowHistory={handleShowHistory} - titleInputRef={titleInputRef as React.RefObject} - operations={operations} - scrollAreaRef={scrollAreaRef as React.RefObject} - workspaceTitle={currentWorkspaceTitle} - workspaceIcon={currentWorkspaceIcon} - workspaceColor={currentWorkspaceColor} - onRenameFolder={(folderId, newName) => { - operations.updateItem(folderId, { name: newName }); - }} - onOpenSettings={() => setShowWorkspaceSettings(true)} - onOpenShare={() => setShowWorkspaceShare(true)} - activeItems={[]} // In split view, workspace header doesn't control active item - activeItemMode={null} - /> - } - onUpdateItem={operations.updateItem} - onUpdateItemData={operations.updateItemData} - onFlushPendingChanges={operations.flushPendingChanges} - /> - ); - }, [ - viewMode, - loadingCurrentWorkspace, - isLoadingWorkspace, - currentWorkspaceId, - currentSlug, - state, - showJsonView, - isSaving, - lastSavedAt, - hasUnsavedChanges, - isChatMaximized, - isDesktop, - isChatExpanded, - currentWorkspaceTitle, - currentWorkspaceIcon, - currentWorkspaceColor, - operations, - manualSave, - setSearchDialogOpen, - setIsChatExpanded, - setOpenModalItemId, - handleShowHistory, - titleInputRef, - scrollAreaRef, - getStatePreviewJSON, - ]); - - // Build the single panel content (for workspace+panel mode) - const panelContent = useMemo(() => { - if (viewMode !== "workspace+panel") return undefined; - const panelItem = openPanelIds - .map((id) => state.items.find((i) => i.id === id)) - .find((i): i is NonNullable => !!i); - if (!panelItem) return undefined; - return ( - { - operations.flushPendingChanges(panelItem.id); - closePanel(panelItem.id); - }} - onMaximize={() => setMaximizedItemId(panelItem.id)} - isMaximized={false} - onUpdateItem={(updates) => operations.updateItem(panelItem.id, updates)} - onUpdateItemData={(updater) => - operations.updateItemData(panelItem.id, updater) - } - isRightmostPanel={true} - isLeftPanel={false} - /> - ); - }, [ - viewMode, - openPanelIds, - state.items, - operations, - closePanel, - setMaximizedItemId, - ]); - return ( {/* } onOpenSearch={() => setSearchDialogOpen(true)} currentWorkspaceId={currentWorkspaceId} isSaving={isSaving} - lastSavedAt={lastSavedAt} - hasUnsavedChanges={hasUnsavedChanges} isDesktop={isDesktop} isChatExpanded={isChatExpanded} setIsChatExpanded={setIsChatExpanded} @@ -597,25 +445,14 @@ function DashboardContent({ onOpenSettings={() => setShowWorkspaceSettings(true)} onOpenShare={() => setShowWorkspaceShare(true)} onShowHistory={handleShowHistory} - isItemPanelOpen={ - viewMode === "workspace+panel" || viewMode === "panel+panel" - } + isItemPanelOpen={Boolean(maximizedItemId)} activeItems={(() => { - // Collect active items from panels + maximized if (maximizedItemId) { const item = state.items?.find( (i) => i.id === maximizedItemId, ); return item ? [item] : []; } - if ( - viewMode === "workspace+panel" || - viewMode === "panel+panel" - ) { - return openPanelIds - .map((id) => state.items?.find((i) => i.id === id)) - .filter((i): i is NonNullable => !!i); - } return []; })()} activeItemMode={maximizedItemId ? "maximized" : null} @@ -662,53 +499,23 @@ function DashboardContent({ currentSlug={currentSlug} state={state} showJsonView={showJsonView} - onOpenSearch={() => setSearchDialogOpen(true)} - isSaving={isSaving} - lastSavedAt={lastSavedAt} - hasUnsavedChanges={hasUnsavedChanges} - onManualSave={manualSave} addItem={operations.createItem} updateItem={operations.updateItem} deleteItem={operations.deleteItem} updateAllItems={operations.updateAllItems} getStatePreviewJSON={getStatePreviewJSON} isChatMaximized={isChatMaximized} - columns={layout.columns} isDesktop={isDesktop} isChatExpanded={isChatExpanded} setIsChatExpanded={setIsChatExpanded} - isItemPanelOpen={ - viewMode === "workspace+panel" || viewMode === "panel+panel" - } + isItemPanelOpen={Boolean(maximizedItemId)} setOpenModalItemId={setOpenModalItemId} - onShowHistory={handleShowHistory} titleInputRef={titleInputRef as React.RefObject} operations={operations} scrollAreaRef={scrollAreaRef as React.RefObject} workspaceTitle={currentWorkspaceTitle} workspaceIcon={currentWorkspaceIcon} workspaceColor={currentWorkspaceColor} - onRenameFolder={(folderId, newName) => { - operations.updateItem(folderId, { name: newName }); - }} - onOpenSettings={() => setShowWorkspaceSettings(true)} - onOpenShare={() => setShowWorkspaceShare(true)} - activeItems={(() => { - if (maximizedItemId) { - const item = state.items?.find((i) => i.id === maximizedItemId); - return item ? [item] : []; - } - return []; - })()} - activeItemMode={maximizedItemId ? "maximized" : null} - onCloseActiveItem={(id) => { - operations.flushPendingChanges(id); - closePanel(id); - if (maximizedItemId === id) setMaximizedItemId(null); - }} - onMinimizeActiveItem={() => setMaximizedItemId(null)} - onMaximizeActiveItem={(id) => setMaximizedItemId(id)} - onUpdateActiveItem={operations.updateItem} modalManager={modalManagerElement} /> } @@ -789,14 +596,6 @@ function DashboardView() { // and enables browser-native back/forward for folder navigation useFolderUrl(); - // Clear playing YouTube videos when workspace changes - const clearPlayingYouTubeCards = useUIStore( - (state) => state.clearPlayingYouTubeCards, - ); - useEffect(() => { - clearPlayingYouTubeCards(); - }, [currentWorkspaceId, clearPlayingYouTubeCards]); - return ( , colorClass: "bg-green-600 hover:bg-green-700", onClick: handleCreateDocument, 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 && ( - -
-
- - -
-
- - ); -} - -// --------------------------------------------------------------------------- -// 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 ( - - ); -} diff --git a/src/components/layout/DashboardLayout.tsx b/src/components/layout/DashboardLayout.tsx index 0d939432..b040f7c2 100644 --- a/src/components/layout/DashboardLayout.tsx +++ b/src/components/layout/DashboardLayout.tsx @@ -1,4 +1,4 @@ -import { Sidebar, SidebarInset, useSidebar } from "@/components/ui/sidebar"; +import { Sidebar, SidebarInset } from "@/components/ui/sidebar"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import WorkspaceSidebar from "@/components/workspace-canvas/WorkspaceSidebar"; import { AssistantPanel } from "@/components/assistant-ui/AssistantPanel"; @@ -6,9 +6,7 @@ import { WorkspaceRuntimeProvider } from "@/components/assistant-ui/WorkspaceRun import { WorkspaceCanvasDropzone } from "@/components/workspace-canvas/WorkspaceCanvasDropzone"; import { AssistantDropzone } from "@/components/assistant-ui/AssistantDropzone"; import { PANEL_DEFAULTS } from "@/lib/layout-constants"; -import React, { useCallback, useEffect, useRef } from "react"; - -import { useUIStore, type ViewMode } from "@/lib/stores/ui-store"; +import React, { useCallback } from "react"; interface DashboardLayoutProps { // Workspace sidebar @@ -33,8 +31,6 @@ interface DashboardLayoutProps { // Component slots workspaceSection: React.ReactNode; - splitViewContent?: React.ReactNode; // Split view layout for panel+panel mode - panelContent?: React.ReactNode; // Single panel content for workspace+panel mode (full height) workspaceHeader?: React.ReactNode; // Header that spans above sidebar + workspace } @@ -49,7 +45,6 @@ export function DashboardLayout({ onWorkspaceSwitch, showCreateModal, setShowCreateModal, - isDesktop, isChatExpanded, isChatMaximized, setIsChatExpanded, @@ -58,14 +53,8 @@ export function DashboardLayout({ onSingleSelect, onMultiSelect, workspaceSection, - splitViewContent, - panelContent, workspaceHeader, }: DashboardLayoutProps) { - // Get sidebar control to auto-close when panels open - const { setOpen } = useSidebar(); - const viewMode = useUIStore((state) => state.viewMode); - // OPTIMIZED: Memoize onLayoutChange callback to prevent ResizablePanelGroup re-renders // This prevents cascading re-renders of all ResizablePanel children const handlePanelLayout = useCallback((layout: { [panelId: string]: number }) => { @@ -119,90 +108,31 @@ export function DashboardLayout({ })()} minSize={effectiveChatExpanded ? `${PANEL_DEFAULTS.WORKSPACE_MIN}%` : "100%"} > - {/* workspace+panel mode: split at the outer level so panel is full height */} - {viewMode === 'workspace+panel' && panelContent ? ( - - {/* Left: header + sidebar + workspace */} - + {!!currentWorkspaceId && workspaceHeader} +
+ -
- {!!currentWorkspaceId && workspaceHeader} -
- - - - - {workspaceSection} - -
-
- - - - - {/* Right: item panel (full height) */} - - {panelContent} - - - ) : ( - /* Normal mode or panel+panel mode */ -
- {!!currentWorkspaceId && viewMode !== 'panel+panel' && workspaceHeader} -
- - - - - {viewMode === 'panel+panel' && splitViewContent ? ( - <>{splitViewContent} - ) : ( - {workspaceSection} - )} - -
+ + + + {workspaceSection} +
- )} +
{/* Chat Section - Only when expanded and workspace exists */} diff --git a/src/components/layout/SplitViewLayout.tsx b/src/components/layout/SplitViewLayout.tsx deleted file mode 100644 index 1dc20bc6..00000000 --- a/src/components/layout/SplitViewLayout.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; -import { ItemPanelContent } from "@/components/workspace-canvas/ItemPanelContent"; -import { useUIStore, type ViewMode } from "@/lib/stores/ui-store"; -import type { Item, ItemData } from "@/lib/workspace-state/types"; -import React, { useMemo } from "react"; - -interface SplitViewLayoutProps { - /** All items in the workspace (for looking up panel items) */ - items: Item[]; - /** Workspace section element — rendered on the left in workspace+panel mode */ - workspaceSection: React.ReactNode; - /** Callbacks */ - onUpdateItem: (itemId: string, updates: Partial) => void; - onUpdateItemData: (itemId: string, updater: (prev: ItemData) => ItemData) => void; - onFlushPendingChanges: (itemId: string) => void; -} - -/** - * SplitViewLayout — orchestrates workspace+panel and panel+panel layouts. - * - * Reads viewMode and openPanelIds from the UI store and renders the appropriate - * layout using resizable panels. Each item panel uses ItemPanelContent which - * already provides its own header with close/maximize buttons. - */ -export function SplitViewLayout({ - items, - workspaceSection, - onUpdateItem, - onUpdateItemData, - onFlushPendingChanges, -}: SplitViewLayoutProps) { - const viewMode = useUIStore((state) => state.viewMode); - const openPanelIds = useUIStore((state) => state.openPanelIds); - const closePanel = useUIStore((state) => state.closePanel); - const setMaximizedItemId = useUIStore((state) => state.setMaximizedItemId); - - // Look up the actual items for each panel - const panelItems = useMemo(() => { - return openPanelIds - .map(id => items.find(i => i.id === id)) - .filter((i): i is Item => !!i); - }, [openPanelIds, items]); - - if (viewMode === 'workspace+panel') { - // Workspace (left) + Item Panel (right) - const panelItem = panelItems[0]; - if (!panelItem) return <>{workspaceSection}; - - return ( - - {/* Left: Workspace (single column) */} - -
- {workspaceSection} -
-
- - - - {/* Right: Item Panel */} - - { - onFlushPendingChanges(panelItem.id); - closePanel(panelItem.id); - }} - onMaximize={() => setMaximizedItemId(panelItem.id)} - isMaximized={false} - onUpdateItem={(updates) => onUpdateItem(panelItem.id, updates)} - onUpdateItemData={(updater) => onUpdateItemData(panelItem.id, updater)} - isRightmostPanel={true} - isLeftPanel={false} - /> - -
- ); - } - - if (viewMode === 'panel+panel') { - // Two item panels side by side, no workspace - const leftItem = panelItems[0]; - const rightItem = panelItems[1]; - if (!leftItem || !rightItem) return <>{workspaceSection}; - - return ( - - {/* Left Panel */} - - { - onFlushPendingChanges(leftItem.id); - closePanel(leftItem.id); - }} - onMaximize={() => setMaximizedItemId(leftItem.id)} - isMaximized={false} - onUpdateItem={(updates) => onUpdateItem(leftItem.id, updates)} - onUpdateItemData={(updater) => onUpdateItemData(leftItem.id, updater)} - isRightmostPanel={false} - isLeftPanel={true} - /> - - - - - {/* Right Panel */} - - { - onFlushPendingChanges(rightItem.id); - closePanel(rightItem.id); - }} - onMaximize={() => setMaximizedItemId(rightItem.id)} - isMaximized={false} - onUpdateItem={(updates) => onUpdateItem(rightItem.id, updates)} - onUpdateItemData={(updater) => onUpdateItemData(rightItem.id, updater)} - isRightmostPanel={true} - isLeftPanel={false} - /> - - - ); - } - - // Default: just the workspace - return <>{workspaceSection}; -} diff --git a/src/components/modals/PDFViewerModal.tsx b/src/components/modals/PDFViewerModal.tsx index 0f1fb71e..c2adf10e 100644 --- a/src/components/modals/PDFViewerModal.tsx +++ b/src/components/modals/PDFViewerModal.tsx @@ -65,7 +65,9 @@ export function PDFViewerModal({ onMaximize={() => useUIStore.getState().setMaximizedItemId(null)} isMaximized={true} onUpdateItem={onUpdateItem} - onUpdateItemData={(data) => onUpdateItem({ data })} + onUpdateItemData={(updater) => + onUpdateItem({ data: updater(item.data) as Item["data"] }) + } /> ); @@ -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/tiptap-node/code-block-node/code-block-node.tsx b/src/components/tiptap-node/code-block-node/code-block-node.tsx index a5ab996f..6a88775d 100644 --- a/src/components/tiptap-node/code-block-node/code-block-node.tsx +++ b/src/components/tiptap-node/code-block-node/code-block-node.tsx @@ -118,10 +118,12 @@ export function CodeBlockNodeView({ node, updateAttributes, editor }: NodeViewPr const rawLanguage = node.attrs.language || "text" const language = resolveLanguageId(rawLanguage) const isEditable = editor.isEditable + const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false) const handleLanguageChange = useCallback( (lang: string) => { updateAttributes({ language: lang }) + setIsLanguageMenuOpen(false) }, [updateAttributes] ) @@ -137,19 +139,23 @@ export function CodeBlockNodeView({ node, updateAttributes, editor }: NodeViewPr {isEditable ? ( - - {sortedLanguages.map(([id, { name }]) => ( - - {name} - - ))} - + {isLanguageMenuOpen ? ( + + {sortedLanguages.map(([id, { name }]) => ( + + {name} + + ))} + + ) : null} ) : ( {getLanguageName(language)} 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 diff --git a/src/components/workspace-canvas/CardRenderer.tsx b/src/components/workspace-canvas/CardRenderer.tsx index 78a36001..e7936d46 100644 --- a/src/components/workspace-canvas/CardRenderer.tsx +++ b/src/components/workspace-canvas/CardRenderer.tsx @@ -54,13 +54,7 @@ export function CardRenderer(props: { } if (item.type === "youtube") { - return ( - {}} - /> - ); + return ; } if (item.type === "image") { 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..783528e2 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, @@ -109,7 +98,6 @@ interface WorkspaceCardProps { onUpdateItem: (itemId: string, updates: Partial) => void; onDeleteItem: (itemId: string) => void; onOpenModal: (itemId: string) => void; - existingColors: CardColor[]; // NOTE: isSelected is now subscribed directly from the store to prevent // full grid re-renders when selection changes onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move item to folder @@ -128,7 +116,6 @@ function WorkspaceCard({ onUpdateItem, onDeleteItem, onOpenModal, - existingColors, onMoveItem, }: WorkspaceCardProps) { const { resolvedTheme } = useTheme(); @@ -150,36 +137,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 +160,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 +175,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 +242,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 +445,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 +487,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 +495,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 +514,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 +707,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 +750,6 @@ function WorkspaceCard({
- )} {/* Type badge - rect in bottom-left corner (when card is small) */} {(item.type === "pdf" || @@ -911,18 +793,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,33 +945,11 @@ 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 ? ( - - ) : ( - - )} + {/* OCR processing indicator overlay */} {isOcrProcessing && pdfPreviewUrl && (
- onUpdateItem(item.id, { data: updater(item.data) as any }) + onUpdateItem(item.id, { + data: updater(item.data) as Item["data"], + }) } isScrollLocked={isScrollLocked} /> @@ -1194,8 +1047,7 @@ function WorkspaceCard({ })()} {/* YouTube Content - render YouTube embed */} - {!isOpenInPanel && - item.type === "youtube" && + {item.type === "youtube" && (() => { const youtubeData = item.data as YouTubeData; const hasValidUrl = @@ -1234,23 +1086,16 @@ function WorkspaceCard({ ); } - // Use the YouTubeCardContent component which handles play/adjust state - return ( - {}} - /> - ); + return ; })()} {/* 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 +1130,6 @@ function WorkspaceCard({
))} - {/* Active in Panel Overlay */} - {isOpenInPanel && ( -
-
- - -
- - Click to close - -
- )} {/* Delete Confirmation Dialog */} @@ -1392,19 +1224,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, - )} ); } @@ -1476,16 +1295,6 @@ export const WorkspaceCardMemoized = memo( // NOTE: isSelected is now subscribed directly from the store, not a prop - // Compare existingColors array (shallow comparison) - if (prevProps.existingColors.length !== nextProps.existingColors.length) - return false; - if ( - prevProps.existingColors.some( - (color, i) => color !== nextProps.existingColors[i], - ) - ) - return false; - // NOTE: We intentionally do NOT compare callback references (onUpdateItem, onDeleteItem, etc.) // These are action handlers that don't affect the rendered output. // React Compiler handles memoization, and checking refs here causes unnecessary re-renders 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..ddfdc8e1 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -3,15 +3,29 @@ 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 { CardColor } from "@/lib/workspace-state/colors"; -import { itemsToLayout, generateMissingLayouts, updateItemsWithLayout, hasLayoutChanged } from "@/lib/workspace-state/grid-layout-helpers"; +import type { Item } from "@/lib/workspace-state/types"; +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 +38,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 +46,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 +64,6 @@ export function WorkspaceGrid({ onDeleteItem, onUpdateAllItems, onOpenModal, - selectedCardIds, - onToggleSelection, onGridDragStateChange, workspaceName, workspaceIcon, @@ -65,9 +72,6 @@ export function WorkspaceGrid({ onMoveItems, onOpenFolder, onDeleteFolderWithContents, - addItem, - onPDFUpload, - setOpenModalItemId, }: WorkspaceGridProps) { const useWrapCompactor = useFeatureFlagEnabled("wrap-compactor"); const compactor = useWrapCompactor ? wrapCompactor : fastVerticalCompactor; @@ -91,10 +95,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 +108,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 +123,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 +136,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 +163,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 +322,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 +342,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 +358,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 +370,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 +390,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 +480,7 @@ export function WorkspaceGrid({ } if (layoutChanged) { - const updatedItems = updateItemsWithLayout(allItemsRef.current, [...newLayout], currentBreakpointRef.current); + const updatedItems = updateItemsWithLayout(allItemsRef.current, [...newLayout]); onUpdateAllItems(updatedItems); } @@ -497,14 +495,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 +520,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 +556,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 +586,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 +611,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); @@ -633,42 +627,15 @@ export function WorkspaceGrid({ return counts; }, [allItems]); - // Collect existing colors for card color generation - const existingColors = useMemo(() => { - return itemsWithLayout - .map(item => item.color) - .filter(Boolean) as CardColor[]; - }, [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) => ( @@ -709,25 +676,23 @@ export function WorkspaceGrid({ onUpdateItem={handleUpdateItem} onDeleteItem={handleDeleteItem} onOpenModal={handleOpenModal} - existingColors={existingColors} onMoveItem={onMoveItem} /> )} )); - // 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, - existingColors, onMoveItem, onDeleteFolderWithContents, handleOpenFolder, folderItemCounts, workspaceName, + workspaceIcon, workspaceColor, ]); @@ -744,25 +709,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}> +