From 30e6ca857db72c4a535c0e6d37d228e66a553c50 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:20:22 +0000 Subject: [PATCH 1/3] Replace workspace RGL layout with plain CSS grid. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- next.config.ts | 2 - package.json | 1 - pnpm-lock.yaml | 57 -- src/app/api/workspaces/autogen/route.ts | 55 +- .../workspace-canvas/FolderCard.tsx | 36 +- .../WorkspaceCanvasDropzone.tsx | 7 +- .../workspace-canvas/WorkspaceCard.tsx | 17 +- .../workspace-canvas/WorkspaceContent.tsx | 41 - .../workspace-canvas/WorkspaceGrid.tsx | 787 +++--------------- .../workspace-canvas/WorkspaceHeader.tsx | 81 +- .../workspace-canvas/WorkspaceSection.tsx | 14 +- .../workspace/use-workspace-operations.ts | 102 +-- src/lib/ai/workers/workspace-worker.ts | 19 +- src/lib/uploads/uploaded-asset.ts | 16 +- .../workspace-state/grid-layout-helpers.ts | 390 --------- 15 files changed, 127 insertions(+), 1498 deletions(-) delete mode 100644 src/lib/workspace-state/grid-layout-helpers.ts diff --git a/next.config.ts b/next.config.ts index fe5a5871..1c2c1994 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,8 +4,6 @@ import { withWorkflow } from "workflow/next"; const nextConfig: NextConfig = { reactCompiler: true, - // Transpile git-installed packages to ensure proper module resolution - transpilePackages: ["react-grid-layout"], images: { remotePatterns: [ { diff --git a/package.json b/package.json index 30deb2f6..b34941eb 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,6 @@ "react-dom": "19.2.5", "react-dropzone": "^15.0.0", "react-email": "^6.0.0", - "react-grid-layout": "git+https://github.com/ThinkEx-OSS/thinkex-grid.git", "react-hotkeys-hook": "^5.2.4", "react-icons": "^5.5.0", "react-jsx-parser": "^2.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fea08bf7..eae7355b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,9 +365,6 @@ importers: react-email: specifier: ^6.0.0 version: 6.0.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-grid-layout: - specifier: git+https://github.com/ThinkEx-OSS/thinkex-grid.git - version: https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-hotkeys-hook: specifier: ^5.2.4 version: 5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -7156,9 +7153,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@4.0.3: - resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} - fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} @@ -9130,12 +9124,6 @@ packages: peerDependencies: react: ^19.2.5 - react-draggable@4.5.0: - resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - react-dropzone@15.0.0: resolution: {integrity: sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==} engines: {node: '>= 10.13'} @@ -9147,13 +9135,6 @@ packages: engines: {node: '>=20.0.0'} hasBin: true - react-grid-layout@https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6: - resolution: {tarball: https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6} - version: 2.2.3 - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - react-hotkeys-hook@5.2.4: resolution: {integrity: sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==} peerDependencies: @@ -9213,12 +9194,6 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-resizable@3.1.3: - resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==} - peerDependencies: - react: '>= 16.3' - react-dom: '>= 16.3' - react-shiki@0.9.3: resolution: {integrity: sha512-F2Uju1/BeUTFQeS+3v3HM0Ry4p+8gcLC4ssObmXxwrzlwPJYq5RGAKcA1r5JBEnJCpEVKf9PajnwM+JMwZnzGg==} peerDependencies: @@ -9357,9 +9332,6 @@ packages: '@react-email/render': optional: true - resize-observer-polyfill@1.5.1: - resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} - resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -17450,8 +17422,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-equals@4.0.3: {} - fast-equals@5.4.0: {} fast-fifo@1.3.2: {} @@ -19827,13 +19797,6 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-draggable@4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - clsx: 2.1.1 - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-dropzone@15.0.0(react@19.2.5): dependencies: attr-accept: 2.2.5 @@ -19872,17 +19835,6 @@ snapshots: - supports-color - utf-8-validate - react-grid-layout@https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - clsx: 2.1.1 - fast-equals: 4.0.3 - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-resizable: 3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - resize-observer-polyfill: 1.5.1 - react-hotkeys-hook@5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: react: 19.2.5 @@ -19951,13 +19903,6 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - react-resizable@3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-shiki@0.9.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: clsx: 2.1.1 @@ -20130,8 +20075,6 @@ snapshots: optionalDependencies: '@react-email/render': 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - resize-observer-polyfill@1.5.1: {} - resolve-alpn@1.2.1: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index 9de67682..5b2fac2c 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -2,17 +2,15 @@ import { NextRequest } from "next/server"; import { streamText, generateText, Output } from "ai"; import { z } from "zod"; import { executeWebSearch } from "@/lib/ai/tools/web-search"; -import { randomUUID } from "crypto"; -import { desc, eq, sql } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { requireAuthWithUserInfo } from "@/lib/api/workspace-helpers"; import { db, workspaces } from "@/lib/db/client"; import { generateSlug } from "@/lib/workspace/slug"; import { workspaceWorker, type CreateItemParams } from "@/lib/ai/workers"; import { searchVideos } from "@/lib/youtube"; import { FirecrawlClient } from "@/lib/ai/utils/firecrawl"; -import { findNextAvailablePosition } from "@/lib/workspace-state/grid-layout-helpers"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; -import type { Item, QuizQuestion } from "@/lib/workspace-state/types"; +import type { QuizQuestion } from "@/lib/workspace-state/types"; import { quizQuestionInputSchema } from "@/lib/workspace-state/item-data-schemas"; import { materializeQuizQuestion } from "@/lib/workspace-state/quiz-shuffle"; import { CANVAS_CARD_COLORS } from "@/lib/workspace-state/colors"; @@ -45,14 +43,6 @@ function truncateForLog(s: string, max = LOG_TRUNCATE): string { return s.slice(0, max) + "..."; } -/** Layout positions for autogen items (matches desired workspace arrangement) */ -const AUTOGEN_LAYOUTS = { - youtube: { x: 0, y: 0, w: 2, h: 7 }, - document: { x: 2, y: 0, w: 2, h: 9 }, - quiz: { x: 0, y: 7, w: 2, h: 13 }, - pdf: { w: 1, h: 4 }, - image: { w: 2, h: 8 }, -} as const; type FileUrlItem = { url: string; @@ -603,21 +593,6 @@ export async function POST(request: NextRequest) { workspaceId, }); - // Create PDF and image items immediately so OCR can start (runs in parallel with Phase 1) - // Seed with known content-item positions so files are placed around them (matching pre-restructuring layout) - // Always reserve YouTube footprint so a later searched YouTube item (AUTOGEN_LAYOUTS.youtube) never overlaps early PDFs/images - const pdfItemLayouts: Pick[] = [ - { - type: "document", - layout: AUTOGEN_LAYOUTS.document as Item["layout"], - }, - { type: "quiz", layout: AUTOGEN_LAYOUTS.quiz as Item["layout"] }, - { - type: "youtube", - layout: AUTOGEN_LAYOUTS.youtube as Item["layout"], - }, - ]; - const documentAssets: UploadedAsset[] = documentFileUrls.map((pdf) => createUploadedAsset({ fileUrl: pdf.url, @@ -637,15 +612,9 @@ export async function POST(request: NextRequest) { }), ); + // Create PDF and image items immediately so OCR can start (runs in parallel with Phase 1) 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 pdfItemId = generateItemId(); const itemDefinition = buildWorkspaceItemDefinitionFromAsset(asset); if (itemDefinition.type !== "pdf") continue; @@ -654,20 +623,11 @@ export async function POST(request: NextRequest) { title: asset.name, itemType: "pdf", pdfData: itemDefinition.initialData as CreateItemParams["pdfData"], - layout: position, }); - pdfItemLayouts.push({ type: "pdf", layout: position }); } 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 imgItemId = generateItemId(); const itemDefinition = buildWorkspaceItemDefinitionFromAsset(asset); if (itemDefinition.type !== "image") continue; @@ -677,9 +637,7 @@ export async function POST(request: NextRequest) { itemType: "image", imageData: itemDefinition.initialData as CreateItemParams["imageData"], - layout: position, }); - pdfItemLayouts.push({ type: "image", layout: position }); } const fileCreateParams = [...pdfCreateParams, ...imageCreateParams]; @@ -921,12 +879,10 @@ export async function POST(request: NextRequest) { document: { title: output.document.title, content: output.document.content, - layout: AUTOGEN_LAYOUTS.document, }, quiz: { title: output.quiz.title, questions, - layout: AUTOGEN_LAYOUTS.quiz, }, }; }; @@ -945,7 +901,6 @@ export async function POST(request: NextRequest) { return { title: "YouTube Video", url: youtubeUrlFromLinks, - layout: AUTOGEN_LAYOUTS.youtube, }; } const videos = await searchVideos(youtubeSearchTerm, 3); @@ -958,7 +913,6 @@ export async function POST(request: NextRequest) { return { title: video.title, url: video.url, - layout: AUTOGEN_LAYOUTS.youtube, }; } catch (err) { logger.warn("[AUTOGEN] YouTube search failed", { @@ -984,13 +938,11 @@ export async function POST(request: NextRequest) { title: generatedDocument.title, content: generatedDocument.content, itemType: "document", - layout: generatedDocument.layout, }, { title: quizContent.title, itemType: "quiz", quizData: { questions: quizContent.questions }, - layout: quizContent.layout, }, ...(youtubeResult ? [ @@ -998,7 +950,6 @@ export async function POST(request: NextRequest) { title: youtubeResult.title, itemType: "youtube" as const, youtubeData: { url: youtubeResult.url }, - layout: youtubeResult.layout, }, ] : []), diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index e19b1456..876e70ff 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -81,8 +81,6 @@ function FolderCardComponent({ const [showMoveDialog, setShowMoveDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isDragHover, setIsDragHover] = useState(false); - const [selectedCount, setSelectedCount] = useState(null); const [isEditingTitle, setIsEditingTitle] = useState(false); const [shouldAutoFocus, setShouldAutoFocus] = useState(false); @@ -113,26 +111,6 @@ function FolderCardComponent({ } }, [item.id, item.name]); - // Listen for drag hover events - useEffect(() => { - const handleDragHover = (e: Event) => { - const customEvent = e as CustomEvent; - const { folderId, isHovering, selectedCount: count } = customEvent.detail || {}; - if (folderId === item.id) { - setIsDragHover(isHovering); - setSelectedCount(count ?? null); - } else { - setIsDragHover(false); - setSelectedCount(null); - } - }; - - window.addEventListener('folder-drag-hover', handleDragHover); - return () => { - window.removeEventListener('folder-drag-hover', handleDragHover); - }; - }, [item.id]); - // Handle mouse down - track initial position for drag detection const handleMouseDown = useCallback((e: React.MouseEvent) => { // Don't track if clicking on interactive elements @@ -254,11 +232,9 @@ function FolderCardComponent({
- - {/* Drag hover overlay - shows when item is dragged over folder */} - {isDragHover && ( -
-
- Move items here ({(selectedCount ?? 1)} {(selectedCount ?? 1) === 1 ? 'item' : 'items'}) -
-
- )}
@@ -607,4 +574,3 @@ function FolderCardComponent({ } export const FolderCard = memo(FolderCardComponent); - diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index 22c9e523..de3c63a1 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -5,9 +5,8 @@ import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import { CgNotes } from "react-icons/cg"; -import { useCallback, useState, useRef, useEffect } from "react"; +import { useCallback, useState, useRef } from "react"; import { toast } from "sonner"; -import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; import { buildWorkspaceItemDefinitionsFromAssets, @@ -137,9 +136,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro try { const { uploads, failedFiles } = await uploadSelectedFiles(filteredFiles); if (uploads.length > 0) { - const itemDefinitions = buildWorkspaceItemDefinitionsFromAssets(uploads, { - imageLayout: DEFAULT_CARD_DIMENSIONS.image, - }); + const itemDefinitions = buildWorkspaceItemDefinitionsFromAssets(uploads); const createdIds = operations.createItems(itemDefinitions, { showSuccessToast: false, }); diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 781ccd84..4952a0b2 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -9,7 +9,6 @@ import { import type { Item, DocumentData } from "@/lib/workspace-state/types"; import type { ColorResult } from "react-color"; import { useUIStore, selectItemScrollLocked } from "@/lib/stores/ui-store"; -import { getLayoutForBreakpoint } from "@/lib/workspace-state/grid-layout-helpers"; import { ContextMenu, ContextMenuContent, @@ -82,10 +81,11 @@ function WorkspaceCard({ const toggleItemScrollLocked = useUIStore( (state) => state.toggleItemScrollLocked, ); - const layout = getLayoutForBreakpoint(item, "lg"); - - // Derive preview mode from the grid layout instead of observing DOM size. - const shouldShowPreview = (layout?.w ?? 1) > 1 && (layout?.h ?? 4) > 4; + const shouldShowPreview = + item.type === "document" || + item.type === "pdf" || + item.type === "quiz" || + item.type === "audio"; // Track minimal local drag detection (only if grid hasn't detected drag) const mouseDownRef = useRef<{ x: number; y: number } | null>(null); @@ -596,13 +596,6 @@ export const WorkspaceCardMemoized = memo( if (JSON.stringify(prevData) !== JSON.stringify(nextData)) return false; } - // Compare layout (use lg breakpoint for comparison) - const prevLayout = getLayoutForBreakpoint(prevProps.item, "lg"); - const nextLayout = getLayoutForBreakpoint(nextProps.item, "lg"); - if (prevLayout?.x !== nextLayout?.x) return false; - if (prevLayout?.y !== nextLayout?.y) return false; - if (prevLayout?.w !== nextLayout?.w) return false; - if (prevLayout?.h !== nextLayout?.h) return false; // NOTE: isSelected is now subscribed directly from the store, not a prop diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 1cccec17..3614d0a8 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -5,7 +5,6 @@ import { Plus, Upload } from "lucide-react"; import { EmptyState } from "@/components/empty-state"; import type { Item, CardType } from "@/lib/workspace-state/types"; import { filterItemsByFolder } from "@/lib/workspace-state/search"; -import { useAutoScroll } from "@/hooks/ui/use-auto-scroll"; import { transcriptSegmentsQueryKey } from "@/hooks/workspace/use-transcript-segments"; import { AUDIO_COMPLETE_EVENT } from "@/lib/audio/poll-audio-processing"; import { WorkspaceGrid } from "./WorkspaceGrid"; @@ -27,15 +26,11 @@ interface WorkspaceContentProps { ) => string; updateItem: (itemId: string, updates: Partial) => void; deleteItem: (itemId: string) => void; - updateAllItems: (items: Item[]) => void; openWorkspaceItem: (itemId: string | null) => void; - scrollContainerRef?: React.RefObject; - onGridDragStateChange?: (isDragging: boolean) => void; workspaceName?: string; workspaceIcon?: string | null; workspaceColor?: string | null; onMoveItem?: (itemId: string, folderId: string | null) => void; - onMoveItems?: (itemIds: string[], folderId: string | null) => void; onOpenFolder?: (folderId: string) => void; onDeleteFolderWithContents?: (folderId: string) => void; onPDFUpload?: (files: File[]) => Promise; @@ -47,15 +42,11 @@ export default function WorkspaceContent({ addItem, updateItem, deleteItem, - updateAllItems, openWorkspaceItem, - scrollContainerRef: externalScrollContainerRef, - onGridDragStateChange, workspaceName, workspaceIcon, workspaceColor, onMoveItem, - onMoveItems, onOpenFolder, onDeleteFolderWithContents, onPDFUpload, @@ -63,19 +54,9 @@ export default function WorkspaceContent({ }: WorkspaceContentProps) { const queryClient = useQueryClient(); const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - - const localScrollContainerRef = useRef(null); - const scrollContainerRef = - externalScrollContainerRef || localScrollContainerRef; - - const { handleDragStart: onDragStart, handleDragStop: onDragStop } = - useAutoScroll(scrollContainerRef); - const { selectedCardIdsArray } = useSelectedCardIds(); - const activeFolderId = useUIStore((state) => state.activeFolderId); const setActiveFolderId = useUIStore((state) => state.setActiveFolderId); - const fileInputRef = useRef(null); const filteredItems = useMemo(() => { @@ -157,13 +138,6 @@ export default function WorkspaceContent({ [deleteItem], ); - const handleUpdateAllItems = useCallback( - (items: Item[]) => { - updateAllItems(items); - }, - [updateAllItems], - ); - const handleOpenModal = useCallback( (itemId: string) => { openWorkspaceItem(itemId); @@ -241,14 +215,6 @@ export default function WorkspaceContent({ [onPDFUpload], ); - const handleDragStart = useCallback(() => { - onDragStart(); - }, [onDragStart]); - - const handleDragStop = useCallback(() => { - onDragStop(); - }, [onDragStop]); - const isFiltering = activeFolderId !== null; if (viewState.length === 0 || (isFiltering && filteredItems.length === 0)) { @@ -330,20 +296,13 @@ export default function WorkspaceContent({ key={activeFolderId ?? "root"} items={filteredItems} allItems={viewState} - isFiltered={isFiltering} - isTemporaryFilter={false} - onDragStart={handleDragStart} - onDragStop={handleDragStop} onUpdateItem={handleUpdateItem} onDeleteItem={handleDeleteItem} - onUpdateAllItems={handleUpdateAllItems} onOpenModal={handleOpenModal} - onGridDragStateChange={onGridDragStateChange} workspaceName={workspaceName || "Workspace"} workspaceIcon={workspaceIcon} workspaceColor={workspaceColor} onMoveItem={onMoveItem} - onMoveItems={onMoveItems} onOpenFolder={handleOpenFolder} onDeleteFolderWithContents={onDeleteFolderWithContents} /> diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index ddfdc8e1..2ad05632 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -1,625 +1,80 @@ -import { Responsive as ResponsiveGridLayout, type Layout, type LayoutItem, useContainerWidth } from "react-grid-layout"; -import { wrapCompactor, fastVerticalCompactor } from "react-grid-layout/extras"; -import { useFeatureFlagEnabled } from "posthog-js/react"; -import { useMemo, useCallback, useRef, useEffect, useState } from "react"; +import { useCallback, useMemo } from "react"; import React from "react"; 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 - isFiltered: boolean; // Whether currently in filtered mode - isTemporaryFilter?: boolean; // Whether in temporary filter mode (search) - prevents layout saves - - onDragStart: () => void; - onDragStop: (layout: LayoutItem[]) => void; + items: Item[]; + allItems: Item[]; onUpdateItem: (itemId: string, updates: Partial) => void; onDeleteItem: (itemId: string) => void; - onUpdateAllItems: (items: Item[]) => void; onOpenModal: (itemId: string) => void; - onGridDragStateChange?: (isDragging: boolean) => void; workspaceName: string; workspaceIcon?: string | null; workspaceColor?: string | null; - onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move item to folder - 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 + onMoveItem?: (itemId: string, folderId: string | null) => void; + onOpenFolder?: (folderId: string) => void; + onDeleteFolderWithContents?: (folderId: string) => void; } -/** - * Grid layout component that manages the positioning and layout of workspace cards. - * Handles drag-and-drop, resizing, and layout recalculation. - */ +const GRID_ITEM_HEIGHTS: Record = { + pdf: "min-h-[22rem]", + flashcard: "min-h-[24rem]", + folder: "min-h-[14rem]", + youtube: "min-h-[22rem]", + quiz: "min-h-[28rem]", + image: "min-h-[22rem]", + audio: "min-h-[22rem]", + website: "min-h-[14rem]", + document: "min-h-[22rem]", +}; + function WorkspaceGridComponent({ items, allItems, - isFiltered, - isTemporaryFilter = false, - - onDragStart, - onDragStop, onUpdateItem, onDeleteItem, - onUpdateAllItems, onOpenModal, - onGridDragStateChange, workspaceName, workspaceIcon, workspaceColor, onMoveItem, - onMoveItems, onOpenFolder, onDeleteFolderWithContents, }: WorkspaceGridProps) { - const useWrapCompactor = useFeatureFlagEnabled("wrap-compactor"); - const compactor = useWrapCompactor ? wrapCompactor : fastVerticalCompactor; - - const layoutChangeTimeoutRef = useRef(null); - const hasUserInteractedRef = useRef(false); - const draggedItemIdRef = useRef(null); - const hoveredFolderIdRef = useRef(null); // '__root__' is sentinel for root drop - const clearCardSelection = useUIStore((state) => state.clearCardSelection); - - // Use container width hook for v2 API - const { width, containerRef, mounted } = useContainerWidth(); - - // Track post-mount state to enable transform transitions only after initial layout - const [hasMounted, setHasMounted] = useState(false); - useEffect(() => { - if (!mounted) return; - const raf = requestAnimationFrame(() => { - setHasMounted(true); - }); - return () => cancelAnimationFrame(raf); - }, [mounted]); - - // 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([]); - - // OPTIMIZED: Store allItems in ref to avoid callback dependencies - // This prevents callbacks from changing when these values change - const allItemsRef = useRef(allItems); - - // Update refs whenever values change - React.useEffect(() => { - allItemsRef.current = allItems; - }, [allItems]); - - // 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 = itemsWithLayout; - - - // Note: Standard react-grid-layout handles bounds automatically. - // Custom constraints (Youtube height, etc) are handled in onResize callback. - - // Note: Layout is now computed in combinedLayout below to include folders - - // 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(() => { - // Cancel any pending timeouts - we only save on drag stop now - if (layoutChangeTimeoutRef.current) { - clearTimeout(layoutChangeTimeoutRef.current); - layoutChangeTimeoutRef.current = null; - } - - // Don't save anything from onLayoutChange - handleDragStop handles all saves - // This prevents unwanted BULK-UPDATE events on simple clicks - return; - }, []); - - // Handle drag start - with RGL v2, this only fires after 3px movement (real drag, not click) - 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 ( - target.closest('[data-slot="dropdown-menu-item"]') || - target.closest('[data-slot="dropdown-menu-content"]') || - target.closest('[data-slot="dropdown-menu-trigger"]') || - target.closest('[role="menuitem"]') - ) { - // Prevent drag by not setting draggedItemIdRef - return; - } - - // Extract item ID from the element - if (!oldItem) return; // Safety check for null oldItem - const itemId = oldItem.i; - draggedItemIdRef.current = itemId; - hoveredFolderIdRef.current = null; // Reset hover state - - hasUserInteractedRef.current = true; - onDragStart(); - onGridDragStateChange?.(true); - }, [onDragStart, onGridDragStateChange]); - - // Handle drag to detect folder hover based on cursor position - 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; - - - - const draggedItem = allItemsRef.current.find(i => i.id === draggedItemId); - if (!draggedItem) { - if (hoveredFolderIdRef.current !== null) { - hoveredFolderIdRef.current = null; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: null, isHovering: false } - })); - } - return; - } - - // Cast Event to MouseEvent to access clientX/clientY - const mouseEvent = e as MouseEvent; - const cursorX = mouseEvent.clientX; - const cursorY = mouseEvent.clientY; - - // Find all folder cards and check if cursor is within any folder's bounding box - // Use elementFromPoint first for more accurate detection, then fallback to bounding box - let hoveredFolder: string | null = null; - - // First, try elementFromPoint for more accurate detection - const elementAtPoint = document.elementFromPoint(cursorX, cursorY); - if (elementAtPoint) { - // Check if the element or its parents have data-folder-id - const folderElement = elementAtPoint.closest('[data-folder-id]') as HTMLElement; - if (folderElement) { - const folderId = folderElement.getAttribute('data-folder-id'); - if (folderId && folderId !== draggedItemId) { - const folderItem = allItemsRef.current.find(i => i.id === folderId && i.type === 'folder'); - if (folderItem) { - hoveredFolder = folderId; - } - } - } - } - - // Fallback to bounding box check if elementFromPoint didn't find anything - if (!hoveredFolder) { - // Get all folder items from all items - const folderItems = allItemsRef.current.filter(item => item.type === 'folder'); - - // Check each folder card's bounding box - for (const folderItem of folderItems) { - // Skip if this is the folder being dragged - if (folderItem.id === draggedItemId) continue; - - // Find the folder card element by its ID - const folderCardElement = document.querySelector(`[data-folder-id="${folderItem.id}"]`) as HTMLElement; - if (!folderCardElement) continue; - - // Get bounding box of the folder card - const rect = folderCardElement.getBoundingClientRect(); - - // Check if cursor is within the folder card's bounds - if ( - cursorX >= rect.left && - cursorX <= rect.right && - cursorY >= rect.top && - cursorY <= rect.bottom - ) { - // Validate before setting - check if already in this folder - if (draggedItem.folderId !== folderItem.id) { - hoveredFolder = folderItem.id; - break; - } - } - } - } - - // Check breadcrumb elements if no folder card is hovered - if (!hoveredFolder) { - // Find all breadcrumb target elements - const breadcrumbTargets = document.querySelectorAll('[data-breadcrumb-target]'); - - for (const target of breadcrumbTargets) { - // Skip if element is not visible - const rect = target.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) continue; - - // Check if cursor is within the breadcrumb element's bounds - if ( - cursorX >= rect.left && - cursorX <= rect.right && - cursorY >= rect.top && - cursorY <= rect.bottom - ) { - const targetType = target.getAttribute('data-breadcrumb-target'); - - if (targetType === 'root') { - // Workspace root drop - only valid if dragged item is in a folder - if (draggedItem.folderId) { - hoveredFolder = '__root__'; // Special value for root - break; - } - } else if (targetType === 'folder') { - const folderId = target.getAttribute('data-folder-id'); - if (folderId) { - // Validate the drop target - let isValidTarget = true; - - // Check if dragging folder onto itself - if (draggedItemId === folderId) { - isValidTarget = false; - } else if (draggedItem.type === 'folder') { - // Check for circular references - if (isDescendantOf(folderId, draggedItemId, allItemsRef.current)) { - isValidTarget = false; - } - } - - // Check if already in target folder - if (draggedItem.folderId === folderId) { - isValidTarget = false; - } - - if (isValidTarget) { - hoveredFolder = folderId; - break; - } - } - } - } - } - } - - // If dragging a folder onto another folder, check for circular references - // (This check is already done in breadcrumb validation, but needed here for folder cards) - if (draggedItem.type === 'folder' && hoveredFolder && hoveredFolder !== '__root__') { - // Prevent dropping folder onto itself - if (draggedItemId === hoveredFolder) { - hoveredFolder = null; - } else { - // Prevent dropping folder onto its descendant - if (isDescendantOf(hoveredFolder, draggedItemId, allItemsRef.current)) { - hoveredFolder = null; - } - } - } - - // Check if already in target location (for both folder cards and breadcrumbs) - if (hoveredFolder && hoveredFolder !== '__root__') { - if (draggedItem.folderId === hoveredFolder) { - hoveredFolder = null; - } - } else if (hoveredFolder === '__root__') { - // For root drops, only valid if item is currently in a folder - if (!draggedItem.folderId) { - hoveredFolder = null; - } - } - - // 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; - - // Update hover state if changed - if (hoveredFolder !== hoveredFolderIdRef.current) { - hoveredFolderIdRef.current = hoveredFolder; - // Convert '__root__' sentinel to null for event (header expects null for root) - const eventFolderId = hoveredFolder === '__root__' ? null : hoveredFolder; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: eventFolderId, isHovering: hoveredFolder !== null, selectedCount } - })); - } else if (hoveredFolder !== null) { - // Even if folder hasn't changed, update selected count in case selection changed during drag - // Convert '__root__' sentinel to null for event (header expects null for root) - const eventFolderId = hoveredFolder === '__root__' ? null : hoveredFolder; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: eventFolderId, isHovering: true, selectedCount } - })); - } - }, []); - - // Handle resize start - track which item is being resized - 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; - draggedItemIdRef.current = oldItem.i; - // Enable autoscroll during resize to help with grid expansion - onDragStart(); - onGridDragStateChange?.(true); - }, [onDragStart, onGridDragStateChange]); - - // 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((...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 - if (!draggedItemId) { - onDragStop([]); - onGridDragStateChange?.(false); - return; - } - - // Find the item - // Cancel any pending debounced update - if (layoutChangeTimeoutRef.current) { - clearTimeout(layoutChangeTimeoutRef.current); - } - - // Always call onDragStop to reset auto-scroll state - onDragStop(newLayout.length > 0 && !isFiltered ? [...newLayout] : []); - - // Don't save layout if in temporary filter mode (search) - if (isTemporaryFilter) { - hoveredFolderIdRef.current = null; - draggedItemIdRef.current = null; - onGridDragStateChange?.(false); - return; - } - - // Handle folder drop - if dropping on a folder or root, move the item (works for both items and folders) - 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) - : [draggedItemId]; - - // Convert '__root__' sentinel to null for root drops - const targetFolderId = hoveredFolderId === '__root__' ? null : hoveredFolderId; - - // Filter out invalid moves (already in folder, circular references, etc.) - const validCardsToMove: string[] = []; - for (const cardId of cardsToMove) { - const card = allItemsRef.current.find(i => i.id === cardId); - if (!card) continue; - - // Skip if already in target location - if (card.folderId === targetFolderId) continue; - - // For root drops, only valid if item is currently in a folder - if (targetFolderId === null && !card.folderId) { - continue; // Already at root - } - - // Check circular references for folders - if (card.type === 'folder' && targetFolderId !== null) { - if (cardId === targetFolderId || - isDescendantOf(targetFolderId, cardId, allItemsRef.current)) { - continue; // Skip invalid moves - } - } - - validCardsToMove.push(cardId); - } - - // Use bulk move if multiple cards, otherwise use single move - // Only proceed if we have at least one move handler - if (validCardsToMove.length > 0 && (onMoveItem || onMoveItems)) { - // Hide the card elements to prevent React Grid Layout from animating them back - // Only hide if we're actually going to move them (handlers exist) - for (const cardId of validCardsToMove) { - const cardElement = document.querySelector(`[id="item-${cardId}"]`)?.closest('.react-grid-item') as HTMLElement; - if (cardElement) { - cardElement.style.display = 'none'; - cardElement.style.visibility = 'hidden'; - cardElement.style.opacity = '0'; - cardElement.style.pointerEvents = 'none'; - } - } - - if (validCardsToMove.length === 1 && onMoveItem) { - // Single item move - onMoveItem(validCardsToMove[0], targetFolderId); - } else if (validCardsToMove.length > 1 && onMoveItems) { - // Bulk move - onMoveItems(validCardsToMove, targetFolderId); - } else if (onMoveItem) { - // Fallback to single move if bulk not available - validCardsToMove.forEach(cardId => onMoveItem(cardId, targetFolderId)); - } - - // Clear selection after successful move (only if we moved selected cards) - if (isDraggedCardSelected) { - clearCardSelection(); - } - } - - // Clear state and exit - hoveredFolderIdRef.current = null; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: null, isHovering: false } - })); - draggedItemIdRef.current = null; - onGridDragStateChange?.(false); - return; - } - - // Check if layout actually changed (position or size) - let layoutChanged = false; - const newItemLayout = newLayout.find(l => l.i === draggedItemId); - const currentItemLayout = layoutRef.current.find(l => l.i === draggedItemId); - - if (newItemLayout && currentItemLayout) { - layoutChanged = - newItemLayout.x !== currentItemLayout.x || - newItemLayout.y !== currentItemLayout.y || - newItemLayout.w !== currentItemLayout.w || - newItemLayout.h !== currentItemLayout.h; - } - - if (layoutChanged) { - const updatedItems = updateItemsWithLayout(allItemsRef.current, [...newLayout]); - onUpdateAllItems(updatedItems); - } - - // Clear hover state - if (hoveredFolderIdRef.current !== null) { - hoveredFolderIdRef.current = null; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: null, isHovering: false } - })); - } - - // Clear the dragged item reference - draggedItemIdRef.current = null; - onGridDragStateChange?.(false); - }, [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((...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); - - if (itemData) { - if (itemData.type === 'image') { - // No aspect ratio snapping - free resize within min/max bounds - } else if (itemData.type === 'youtube') { - // At w=1: force h=4 (matches compact document/PDF cards). At w=2: force h=7 - if (newItem.w === 1) { - newItem.h = 4; - } else { - newItem.h = 7; - } - } else if (itemData.type === 'folder' || itemData.type === 'flashcard') { - // Folders and flashcards don't need minimum height enforcement - skip - } 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 - // Quiz cards: Compact mode: w=1, h=4 | Expanded mode: w>=2, h>=13 - const wasCompact = oldItem.w === 1; - const widthChanged = oldItem.w !== newItem.w; - const minExpandedHeight = itemData.type === 'pdf' ? 6 : itemData.type === 'quiz' ? 13 : 9; - - // Check for mode transitions triggered by height-only resize - if (!widthChanged) { - if (wasCompact && newItem.h > 4) { - // Growing a compact card taller → expand to wide mode - newItem.w = 2; - } else if (!wasCompact && newItem.h < minExpandedHeight) { - // Shrinking a wide card shorter → collapse to compact mode - newItem.w = 1; - } - } - - // Apply constraints based on final width - if (newItem.w >= 2) { - newItem.h = Math.max(newItem.h, minExpandedHeight); - } else { - newItem.h = 4; - } - } - - // Sync placeholder if it exists - if (placeholder) { - placeholder.w = newItem.w; - placeholder.h = newItem.h; - } - - // 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. - 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; - placeholder.y = newItem.y; - } - } - }, []); - - // Handle resize stop - save immediately - const handleResizeStop = useCallback((newLayout: Layout) => { - // Cancel any pending debounced update - if (layoutChangeTimeoutRef.current) { - clearTimeout(layoutChangeTimeoutRef.current); - } - - // Note: resize doesn't use autoscroll, but we call onDragStop anyway as a safety measure - // in case there's any edge case where drag state got stuck - onDragStop([]); - - // Don't save if in temporary filter mode (search), but folder views should save - if (isTemporaryFilter) { - onGridDragStateChange?.(false); - draggedItemIdRef.current = null; - return; - } - - // 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]); - onUpdateAllItems(updatedItems); - - draggedItemIdRef.current = null; - onGridDragStateChange?.(false); - }, [isTemporaryFilter, onUpdateAllItems, onGridDragStateChange, onDragStop]); - - - // Handle item updates - no automatic height recalculation - // Height calculations are only used during initial card placement - // Users can manually resize cards as needed - // OPTIMIZED: Wrap parent callback to ensure stable reference - const handleUpdateItem = useCallback((itemId: string, updates: Partial) => { - onUpdateItem(itemId, updates); - }, [onUpdateItem]); + const handleUpdateItem = useCallback( + (itemId: string, updates: Partial) => { + onUpdateItem(itemId, updates); + }, + [onUpdateItem], + ); - // OPTIMIZED: Wrap all callbacks to ensure stable references - const handleDeleteItem = useCallback((itemId: string) => { - onDeleteItem(itemId); - }, [onDeleteItem]); + const handleDeleteItem = useCallback( + (itemId: string) => { + onDeleteItem(itemId); + }, + [onDeleteItem], + ); - const handleOpenModal = useCallback((itemId: string) => { - onOpenModal(itemId); - }, [onOpenModal]); + const handleOpenModal = useCallback( + (itemId: string) => { + onOpenModal(itemId); + }, + [onOpenModal], + ); - // Folder operation handler (folders are now items with type: 'folder') - const handleOpenFolder = useCallback((folderId: string) => { - onOpenFolder?.(folderId); - }, [onOpenFolder]); + const handleOpenFolder = useCallback( + (folderId: string) => { + onOpenFolder?.(folderId); + }, + [onOpenFolder], + ); - // Create a map of folder item counts (for folder-type items) const folderItemCounts = useMemo(() => { const counts = new Map(); - allItems.forEach(item => { + allItems.forEach((item) => { if (item.folderId) { counts.set(item.folderId, (counts.get(item.folderId) || 0) + 1); } @@ -627,46 +82,50 @@ function WorkspaceGridComponent({ return counts; }, [allItems]); - // Layout for all items (including folder-type items) - const combinedLayout = useMemo(() => { - return itemsToLayout(displayItems); - }, [displayItems]); + const children = useMemo(() => { + return items.map((item) => { + const wrapperClass = `min-w-0 ${GRID_ITEM_HEIGHTS[item.type]}`; + + if (item.type === "folder") { + return ( +
+ +
+ ); + } - useEffect(() => { - layoutRef.current = combinedLayout; - }, [combinedLayout]); + if (item.type === "flashcard") { + return ( +
+ +
+ ); + } - // Memoize children to take advantage of ResponsiveGridLayout's shouldComponentUpdate optimization - const children = useMemo(() => { - return displayItems.map((item) => ( -
- {item.type === 'folder' ? ( - - ) : item.type === 'flashcard' ? ( - - ) : ( + return ( +
- )} -
- )); +
+ ); + }); }, [ allItems, - displayItems, - handleUpdateItem, + folderItemCounts, handleDeleteItem, + handleOpenFolder, handleOpenModal, - onMoveItem, + handleUpdateItem, + items, onDeleteFolderWithContents, - handleOpenFolder, - folderItemCounts, - workspaceName, - workspaceIcon, + onMoveItem, workspaceColor, + workspaceIcon, + workspaceName, ]); - // Cleanup on unmount - ensure drag state is reset - useEffect(() => { - return () => { - if (layoutChangeTimeoutRef.current) { - clearTimeout(layoutChangeTimeoutRef.current); - } - // Ensure autoscroll state is cleaned up on unmount - // This handles edge cases where component unmounts during drag - onDragStop([]); - onGridDragStateChange?.(false); - }; - }, [onDragStop, onGridDragStateChange]); - - const layouts = useMemo(() => ({ - lg: combinedLayout, - }), [combinedLayout]); - return ( -
- - {mounted && ( - - {children} - - )} +
+
+ {children} +
); } diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index a89b19bb..1069e438 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -64,7 +64,6 @@ import { exportMarkdownToGoogleDoc, getGoogleOAuthClientId, } from "@/lib/exportToGoogleDocs"; -import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; import { getFolderPath } from "@/lib/workspace-state/search"; import { CreateYouTubeDialog } from "@/components/modals/CreateYouTubeDialog"; import { CreateWebsiteDialog } from "@/components/modals/CreateWebsiteDialog"; @@ -87,8 +86,6 @@ const BREADCRUMB_ITEM_TEXT_CLASS = "min-w-0 max-w-[240px] truncate text-sidebar-foreground"; const BREADCRUMB_INTERACTIVE_CLASS = "cursor-pointer text-sidebar-foreground/75 hover:text-sidebar-foreground hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring/60"; -const BREADCRUMB_DRAG_TARGET_CLASS = - "bg-blue-500/10 text-sidebar-foreground ring-1 ring-inset ring-blue-500/50"; const BREADCRUMB_MENU_ITEM_CLASS = "flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer"; /** Shared shell for header toolbar controls — same border/hover as labeled actions; icon-only uses square hit target */ @@ -241,7 +238,6 @@ interface WorkspaceHeaderProps { type: CardType, name?: string, initialData?: Partial, - initialLayout?: { w: number; h: number }, ) => string; onPDFUpload?: (files: File[]) => Promise; // Callback for when items are created (for auto-scroll/selection) @@ -316,11 +312,6 @@ export function WorkspaceHeader({ const pathname = usePathname(); const isWorkspaceRoute = pathname.startsWith("/workspace"); - // Track drag hover state for breadcrumb elements - const [hoveredBreadcrumbTarget, setHoveredBreadcrumbTarget] = useState< - string | null - >(null); // 'root' or folderId - const isDraggingRef = useRef(false); const breadcrumbNavRef = useRef(null); const breadcrumbMeasureRefs = useRef>({}); const breadcrumbSeparatorMeasureRef = useRef(null); @@ -627,18 +618,13 @@ export function WorkspaceHeader({ ); const renderRootBreadcrumb = useCallback(() => { - const rootHighlightClass = - hoveredBreadcrumbTarget === "root" && BREADCRUMB_DRAG_TARGET_CLASS; - if (activeFolderId || activeOpenWorkspaceItem) { return (
diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index 06b415c8..3325e611 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -14,10 +14,6 @@ import { getRandomCardColor } from "@/lib/workspace-state/colors"; import { logger } from "@/lib/utils/logger"; import { mutators } from "@/lib/zero/mutators"; import { useUIStore } from "@/lib/stores/ui-store"; -import { - getLayoutForBreakpoint, - findNextAvailablePosition, -} from "@/lib/workspace-state/grid-layout-helpers"; import { hasDuplicateName, getNextUniqueDefaultName, @@ -106,14 +102,12 @@ export interface WorkspaceOperations { type: CardType, name?: string, initialData?: Partial, - initialLayout?: { w: number; h: number }, ) => string; createItems: ( items: Array<{ type: CardType; name?: string; initialData?: Partial; - initialLayout?: { w: number; h: number }; }>, options?: { showSuccessToast?: boolean }, ) => string[]; @@ -369,7 +363,6 @@ export function useWorkspaceOperations( type: CardType, name?: string, initialData?: Partial, - initialLayout?: { w: number; h: number }, ) => { const validTypes: CardType[] = [ "pdf", @@ -395,20 +388,6 @@ export function useWorkspaceOperations( ? { ...baseData, ...initialData } : baseData; - let layout: Item["layout"] = undefined; - if (initialLayout) { - const itemsInView = currentItemsRef.current.filter((item) => - activeFolderId ? item.folderId === activeFolderId : !item.folderId, - ); - const position = findNextAvailablePosition( - itemsInView, - validType, - 4, - initialLayout.w, - initialLayout.h, - ); - layout = { lg: position }; - } const item = sanitizeWorkspaceItemForPersistence({ id, @@ -418,7 +397,6 @@ export function useWorkspaceOperations( data: mergedData as ItemData, color: getRandomCardColor(), folderId: activeFolderId ?? undefined, - ...(layout && { layout }), }); if (workspaceIdRef.current) { @@ -434,7 +412,6 @@ export function useWorkspaceOperations( data: item.data as JsonObject, color: item.color ?? null, folderId: item.folderId ?? null, - layout: (item.layout as JsonObject | undefined) ?? null, }, }), ); @@ -451,7 +428,6 @@ export function useWorkspaceOperations( type: CardType; name?: string; initialData?: Partial; - initialLayout?: { w: number; h: number }; }>, options?: { showSuccessToast?: boolean }, ): string[] => { @@ -460,14 +436,10 @@ export function useWorkspaceOperations( } const activeFolderId = useUIStore.getState().activeFolderId; - const itemsInCurrentView = currentItemsRef.current.filter((item) => - activeFolderId ? item.folderId === activeFolderId : !item.folderId, - ); - const itemsForLayout = [...itemsInCurrentView]; const itemsSoFar: Item[] = []; const createdItems: Item[] = items - .map(({ type, name, initialData, initialLayout }) => { + .map(({ type, name, initialData }) => { const validTypes: CardType[] = [ "pdf", "flashcard", @@ -482,7 +454,7 @@ export function useWorkspaceOperations( const validType = validTypes.includes(type) ? type : "document"; const id = crypto.randomUUID(); const folderId = activeFolderId ?? null; - const allItemsSoFar = [...itemsInCurrentView, ...itemsSoFar]; + const allItemsSoFar = [...currentItemsRef.current, ...itemsSoFar]; const finalName = name || getNextUniqueDefaultName(allItemsSoFar, validType, folderId); @@ -499,28 +471,6 @@ export function useWorkspaceOperations( ? { ...baseData, ...initialData } : baseData; - let layout: Item["layout"] | undefined; - if (initialLayout) { - const position = findNextAvailablePosition( - itemsForLayout, - validType, - 4, - initialLayout.w, - initialLayout.h, - ); - - layout = { lg: position }; - itemsForLayout.push({ - id, - type: validType, - name: finalName, - subtitle: "", - data: baseData as ItemData, - color: getRandomCardColor(), - folderId: activeFolderId ?? undefined, - layout, - }); - } const newItem = sanitizeWorkspaceItemForPersistence({ id, @@ -530,7 +480,6 @@ export function useWorkspaceOperations( data: mergedData as ItemData, color: getRandomCardColor(), folderId: activeFolderId ?? undefined, - layout, }); itemsSoFar.push(newItem); return newItem; @@ -560,7 +509,6 @@ export function useWorkspaceOperations( data: item.data as JsonObject, color: item.color ?? null, folderId: item.folderId ?? null, - layout: (item.layout as JsonObject | undefined) ?? null, })), }), ); @@ -686,7 +634,6 @@ export function useWorkspaceOperations( data: item.data as JsonObject, color: item.color ?? null, folderId: item.folderId ?? null, - layout: (item.layout as JsonObject | undefined) ?? null, })), previousItemCount, }), @@ -695,49 +642,6 @@ export function useWorkspaceOperations( return; } - const layoutUpdates: Array<{ - id: string; - x: number; - y: number; - w: number; - h: number; - }> = []; - const currentItemsMap = new Map(latestState.map((item) => [item.id, item])); - - for (const item of items) { - const currentItem = currentItemsMap.get(item.id); - const currentLayout = currentItem - ? getLayoutForBreakpoint(currentItem, "lg") - : undefined; - const newLayout = getLayoutForBreakpoint(item, "lg"); - - if ( - newLayout && - (!currentLayout || - currentLayout.x !== newLayout.x || - currentLayout.y !== newLayout.y || - currentLayout.w !== newLayout.w || - currentLayout.h !== newLayout.h) - ) { - layoutUpdates.push({ - id: item.id, - x: newLayout.x, - y: newLayout.y, - w: newLayout.w, - h: newLayout.h, - }); - } - } - - if (layoutUpdates.length > 0) { - zeroRef.current.mutate( - mutators.item.updateMany({ - workspaceId: currentWorkspaceId, - layoutUpdates, - previousItemCount, - }), - ); - } }, []); const createFolder = useCallback( @@ -767,7 +671,6 @@ export function useWorkspaceOperations( data: folder.data as JsonObject, color: folder.color ?? null, folderId: folder.folderId ?? null, - layout: null, }, }), ); @@ -817,7 +720,6 @@ export function useWorkspaceOperations( data: folder.data as JsonObject, color: folder.color ?? null, folderId: folder.folderId ?? null, - layout: null, }, itemIds: safeItemIds, }), diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 558a8c24..722b971d 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -76,7 +76,6 @@ export type CreateItemParams = { }; sources?: Array<{ title: string; url: string; favicon?: string }>; folderId?: string; - layout?: { x: number; y: number; w: number; h: number }; }; /** @@ -178,7 +177,6 @@ async function buildItemFromCreateParams(p: CreateItemParams): Promise { data: itemData, color: getRandomCardColor(), folderId: p.folderId, - ...(p.layout && { layout: p.layout }), }; } @@ -304,8 +302,7 @@ export async function workspaceWorker( }>; folderId?: string; /** Optional layout { x, y, w, h } for the item (lg breakpoint) */ - layout?: { x: number; y: number; w: number; h: number }; - }, + }, ): Promise<{ success: boolean; message: string; @@ -991,13 +988,13 @@ export async function workspaceWorker( ); await deleteWorkspaceItem(params.workspaceId, params.itemId); - return { - success: true, - itemId: params.itemId, - message: existingItem - ? `Deleted \"${existingItem.name}\" successfully` - : "Deleted item successfully", - }; + return { + success: true, + itemId: params.itemId, + message: existingItem + ? `Deleted "${existingItem.name}" successfully` + : "Deleted item successfully", + }; } // Fallback for unhandled actions diff --git a/src/lib/uploads/uploaded-asset.ts b/src/lib/uploads/uploaded-asset.ts index 3787c644..a7fe7b74 100644 --- a/src/lib/uploads/uploaded-asset.ts +++ b/src/lib/uploads/uploaded-asset.ts @@ -24,19 +24,16 @@ export type WorkspaceItemDefinition = type: "pdf"; name: string; initialData: Partial; - initialLayout?: { w: number; h: number }; } | { type: "image"; name: string; initialData: Partial; - initialLayout?: { w: number; h: number }; } | { type: "audio"; name: string; initialData: Partial; - initialLayout?: { w: number; h: number }; }; function getBaseName(filename: string): string { @@ -95,7 +92,6 @@ export function createUploadedAsset(params: { export function buildWorkspaceItemDefinitionFromAsset( asset: UploadedAsset, - options?: { imageLayout?: { w: number; h: number } } ): WorkspaceItemDefinition { if (asset.kind === "image") { return { @@ -107,7 +103,6 @@ export function buildWorkspaceItemDefinitionFromAsset( ocrStatus: "processing", ocrPages: [], }, - ...(options?.imageLayout ? { initialLayout: options.imageLayout } : {}), }; } @@ -140,20 +135,17 @@ export function buildWorkspaceItemDefinitionFromAsset( export function buildWorkspaceItemDefinitionsFromAssets( assets: UploadedAsset[], - options?: { imageLayout?: { w: number; h: number } } ): WorkspaceItemDefinition[] { - return assets.map((asset) => - buildWorkspaceItemDefinitionFromAsset(asset, options) - ); + return assets.map((asset) => buildWorkspaceItemDefinitionFromAsset(asset)); } export function buildOcrCandidatesFromAssets( assets: UploadedAsset[], - itemIds: Array + itemIds: Array, ): OcrCandidate[] { if (assets.length !== itemIds.length) { throw new Error( - `Expected assets and itemIds to align, received ${assets.length} assets and ${itemIds.length} itemIds` + `Expected assets and itemIds to align, received ${assets.length} assets and ${itemIds.length} itemIds`, ); } @@ -183,7 +175,7 @@ export function buildOcrCandidatesFromAssets( contentType: asset.contentType, fileSize: asset.fileSize, displayName: asset.displayName, - }) + }), ); if (sourceUrl) { diff --git a/src/lib/workspace-state/grid-layout-helpers.ts b/src/lib/workspace-state/grid-layout-helpers.ts deleted file mode 100644 index d442c993..00000000 --- a/src/lib/workspace-state/grid-layout-helpers.ts +++ /dev/null @@ -1,390 +0,0 @@ -import type { LayoutItem } from "react-grid-layout"; -import type { Item, CardType, LayoutPosition, ResponsiveLayouts } from "./types"; - -/** - * Default dimensions for each card type in grid units - */ -export const DEFAULT_CARD_DIMENSIONS: Record = { - pdf: { w: 1, h: 4 }, - flashcard: { w: 2, h: 5 }, - folder: { w: 1, h: 4 }, - youtube: { w: 2, h: 7 }, - quiz: { w: 2, h: 13 }, - image: { w: 2, h: 5 }, - audio: { w: 2, h: 10 }, - website: { w: 1, h: 4 }, - document: { w: 1, h: 4 }, -}; - -/** - * Helper to check if a layout is in the old flat format or new responsive format. - * Old format: { x, y, w, h } - * New format: { lg?: {...} } - */ -function isLegacyLayout(layout: ResponsiveLayouts | LayoutPosition | undefined): layout is LayoutPosition { - if (!layout) return false; - // If it has 'x' property directly, it's the old flat format - return 'x' in layout && typeof layout.x === 'number'; -} - -/** - * Get the layout for the workspace grid from an item. - * Handles backwards compatibility with old flat layout format. - */ -export function getLayoutForBreakpoint(item: Item, breakpoint: 'lg' = 'lg'): LayoutPosition | undefined { - if (!item.layout) return undefined; - - if (isLegacyLayout(item.layout)) { - return item.layout; - } - - return item.layout[breakpoint]; -} - -export const DEFAULT_COLS = 4; - -/** - * Convert items to LayoutItem array for the workspace grid. - */ -export function itemsToLayout(items: Item[], breakpoint: 'lg' = 'lg'): LayoutItem[] { - return items.map((item) => { - const layout = getLayoutForBreakpoint(item, breakpoint); - - // YouTube: resizable smaller but not larger than 2x7; at w=1 force h=4 (matches compact text cards) - if (item.type === 'youtube') { - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - minW: 1, - minH: 4, - maxW: 2, - maxH: 7, - }; - } - - // Images: free resize within min/max bounds - if (item.type === 'image') { - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - minW: 1, // Allow narrow 1-column images - minH: 3, // Minimum height for 1-col images (fits 4:3 and 3:2 roughly) - maxW: 4, - maxH: 20, // Increased to support tall/square images - }; - } - - // Folders are anchor items - they act as obstacles but can be dragged/resized - if (item.type === 'folder') { - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - minW: 1, - minH: 4, - maxW: 4, - maxH: 25, - anchor: true, // Anchor items act as obstacles but can be moved - }; - } - - // Website cards are not resizable - fixed size, draggable only - if (item.type === 'website') { - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - isResizable: false, // Not resizable - }; - } - - // Default constraints for other card types - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - minW: 1, - minH: 4, - maxW: 4, - maxH: 25, - }; - }); -} - -export function findNextAvailablePosition( - existingItems: Item[], - newItemType: CardType, - cols: number = DEFAULT_COLS, - customW?: number, - customH?: number, - breakpoint: 'lg' = 'lg' -): { x: number; y: number; w: number; h: number } { - const validType = (newItemType in DEFAULT_CARD_DIMENSIONS) ? newItemType : 'document'; - const dimensions = DEFAULT_CARD_DIMENSIONS[validType]; - const w = customW ?? Math.min(dimensions.w, cols); - const h = customH ?? dimensions.h; - - if (existingItems.length === 0) { - return { x: 0, y: 0, w, h }; - } - - // Build an occupancy grid so we can detect gaps between items, not just column bottoms. - // Find the maximum Y (bottom edge) across all existing items. - let maxY = 0; - const occupiedRects: { x: number; y: number; w: number; h: number }[] = []; - - existingItems.forEach((item) => { - const layout = getLayoutForBreakpoint(item, breakpoint); - const ix = layout?.x ?? 0; - const iy = layout?.y ?? 0; - const iw = layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type]?.w ?? 1; - const ih = layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type]?.h ?? 4; - occupiedRects.push({ x: ix, y: iy, w: iw, h: ih }); - maxY = Math.max(maxY, iy + ih); - }); - - // Check if a w×h block at (startX, startY) overlaps any occupied rect - function isPositionFree(startX: number, startY: number): boolean { - for (const rect of occupiedRects) { - // Two rectangles overlap if they overlap on both axes - if ( - startX < rect.x + rect.w && - startX + w > rect.x && - startY < rect.y + rect.h && - startY + h > rect.y - ) { - return false; - } - } - return true; - } - - // Scan row-by-row, column-by-column for the first position that fits. - // This naturally fills gaps left by deleted or moved items. - // We scan up to maxY (to find gaps) and then one row beyond (guaranteed empty). - for (let y = 0; y <= maxY; y++) { - for (let x = 0; x <= cols - w; x++) { - if (isPositionFree(x, y)) { - return { x, y, w, h }; - } - } - } - - // Fallback: place below everything (should always be reached by the loop above at y=maxY) - return { x: 0, y: maxY, w, h }; -} - -/** - * Generate missing layouts for items that don't have them. - * Works with the workspace grid layout by default. - */ -export function generateMissingLayouts(items: Item[], cols: number = DEFAULT_COLS, breakpoint: 'lg' = 'lg'): Item[] { - const result: Item[] = []; - - items.forEach((item) => { - const existingLayout = getLayoutForBreakpoint(item, breakpoint); - - if (existingLayout) { - // Clamp width first, then compute x based on the clamped width - const clampedW = Math.min(existingLayout.w, cols); - const adjustedLayout = { - ...existingLayout, - w: clampedW, - x: Math.max(0, Math.min(existingLayout.x, cols - clampedW)), - }; - - // Preserve the responsive structure - const newLayouts: ResponsiveLayouts = isLegacyLayout(item.layout) - ? { [breakpoint]: adjustedLayout } - : { ...item.layout as ResponsiveLayouts, [breakpoint]: adjustedLayout }; - - result.push({ - ...item, - layout: newLayouts, - }); - } else { - const position = findNextAvailablePosition( - result, - item.type, - cols, - ); - - // For items without layout, create new responsive structure - const existingResponsive = isLegacyLayout(item.layout) - ? { lg: item.layout } - : (item.layout as ResponsiveLayouts | undefined) ?? {}; - - result.push({ - ...item, - layout: { ...existingResponsive, [breakpoint]: position }, - }); - } - }); - - return result; -} - -export function recompactLayout(items: Item[], cols: number): Item[] { - if (items.length === 0) return items; - - const sortedItems = [...items].sort((a, b) => { - const aLayout = getLayoutForBreakpoint(a, 'lg'); - const bLayout = getLayoutForBreakpoint(b, 'lg'); - const aY = aLayout?.y ?? 0; - const bY = bLayout?.y ?? 0; - if (aY !== bY) return aY - bY; - return (aLayout?.x ?? 0) - (bLayout?.x ?? 0); - }); - - const columnHeights = new Array(cols).fill(0); - - return sortedItems.map((item) => { - const existingLayout = getLayoutForBreakpoint(item, 'lg'); - // Use default dimensions as fallback to ensure quiz cards get height 13 - const h = existingLayout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type]?.h ?? 4; - const dimensions = existingLayout - ? { w: Math.min(existingLayout.w, cols), h } - : { w: DEFAULT_CARD_DIMENSIONS[item.type].w, h: DEFAULT_CARD_DIMENSIONS[item.type].h }; - - const w = Math.min(dimensions.w, cols); - - let bestColumn = 0; - let minHeight = columnHeights[0]; - - for (let col = 0; col <= cols - w; col++) { - let maxHeightInRange = columnHeights[col]; - for (let i = col; i < col + w; i++) { - maxHeightInRange = Math.max(maxHeightInRange, columnHeights[i] || 0); - } - - if (maxHeightInRange < minHeight) { - minHeight = maxHeightInRange; - bestColumn = col; - } - } - - const y = minHeight; - for (let i = bestColumn; i < bestColumn + w; i++) { - columnHeights[i] = y + dimensions.h; - } - - const newPosition: LayoutPosition = { - x: bestColumn, - y, - w, - h: dimensions.h, - }; - - // Preserve existing responsive layouts - const existingResponsive = isLegacyLayout(item.layout) - ? {} - : (item.layout as ResponsiveLayouts | undefined) ?? {}; - - return { - ...item, - layout: { ...existingResponsive, lg: newPosition }, - }; - }); -} - -export function hasLayoutChanged(items: Item[], newLayout: LayoutItem[], breakpoint: 'lg' = 'lg'): boolean { - const itemsMap = new Map(items.map((item) => [item.id, item])); - - return newLayout.some((newItem) => { - const currentItem = itemsMap.get(newItem.i); - const currentLayout = currentItem ? getLayoutForBreakpoint(currentItem, breakpoint) : undefined; - return ( - !currentItem || - !currentLayout || - currentLayout.x !== newItem.x || - currentLayout.y !== newItem.y || - currentLayout.w !== newItem.w || - currentLayout.h !== newItem.h - ); - }); -} - -/** - * Update items with new layout positions for the workspace grid. - */ -export function updateItemsWithLayout(items: Item[], layout: LayoutItem[], breakpoint: 'lg' = 'lg'): Item[] { - const itemLayoutMap = new Map(layout.map((l) => [l.i, l])); - - return items.map((item) => { - const newLayoutItem = itemLayoutMap.get(item.id); - if (newLayoutItem) { - const newPosition: LayoutPosition = { - x: newLayoutItem.x, - y: newLayoutItem.y, - w: newLayoutItem.w, - h: newLayoutItem.h, - }; - - return { - ...item, - layout: { [breakpoint]: newPosition }, - }; - } - return item; - }); -} - -export function moveItemToTopLayout(items: Item[], itemId: string): Item[] { - return items.map((item) => { - const existingLayout = getLayoutForBreakpoint(item, 'lg'); - if (item.id === itemId && existingLayout) { - const newPosition: LayoutPosition = { ...existingLayout, y: 0 }; - - const existingResponsive = isLegacyLayout(item.layout) - ? {} - : (item.layout as ResponsiveLayouts | undefined) ?? {}; - - return { - ...item, - layout: { ...existingResponsive, lg: newPosition }, - }; - } - return item; - }); -} - -export function moveItemToBottomLayout(items: Item[], itemId: string): Item[] { - const maxY = Math.max( - 0, - ...items.map((item) => { - const layout = getLayoutForBreakpoint(item, 'lg'); - return item.id !== itemId && layout - ? layout.y + layout.h - : 0; - }) - ); - - return items.map((item) => { - const existingLayout = getLayoutForBreakpoint(item, 'lg'); - if (item.id === itemId && existingLayout) { - const newPosition: LayoutPosition = { ...existingLayout, y: maxY }; - - const existingResponsive = isLegacyLayout(item.layout) - ? {} - : (item.layout as ResponsiveLayouts | undefined) ?? {}; - - return { - ...item, - layout: { ...existingResponsive, lg: newPosition }, - }; - } - return item; - }); -} \ No newline at end of file From 244984e463df7e6814eeaf0c4d4e09d15fadaa69 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:42:05 +0000 Subject: [PATCH 2/3] Remove workspace card preview rendering Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../workspace-canvas/WorkspaceCard.tsx | 145 ++---------------- .../workspace-canvas/WorkspaceCardContent.tsx | 145 ++---------------- .../WorkspaceCardTypeBadge.tsx | 22 +-- src/lib/ai/workers/workspace-worker.ts | 17 +- 4 files changed, 46 insertions(+), 283 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 4952a0b2..740a4efc 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -8,7 +8,7 @@ import { } from "@/lib/workspace-state/colors"; import type { Item, DocumentData } from "@/lib/workspace-state/types"; import type { ColorResult } from "react-color"; -import { useUIStore, selectItemScrollLocked } from "@/lib/stores/ui-store"; +import { useUIStore } from "@/lib/stores/ui-store"; import { ContextMenu, ContextMenuContent, @@ -24,16 +24,14 @@ import { WorkspaceCardTypeBadge } from "./WorkspaceCardTypeBadge"; interface WorkspaceCardProps { item: Item; - allItems: Item[]; // All items for the move dialog tree + allItems: Item[]; workspaceName: string; workspaceIcon?: string | null; workspaceColor?: string | null; onUpdateItem: (itemId: string, updates: Partial) => void; onDeleteItem: (itemId: string) => void; onOpenModal: (itemId: string) => void; - // 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 + onMoveItem?: (itemId: string, folderId: string | null) => void; } /** @@ -52,55 +50,26 @@ function WorkspaceCard({ onMoveItem, }: WorkspaceCardProps) { const { resolvedTheme } = useTheme(); - const documentMarkdownRaw = - item.type === "document" - ? ((item.data as DocumentData).markdown || "").trim() - : ""; - const documentPreviewText = - item.type === "document" - ? documentMarkdownRaw || "Start writing..." - : ""; - const documentAwaitingGeneration = - item.type === "document" && - item.name === "Update me" && - documentMarkdownRaw.length === 0; - - // Subscribe directly to this card's selection state from the store - // This prevents full grid re-renders when selection changes + const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - // 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); - // Get scroll lock state from Zustand store (persists across interactions) - const isScrollLocked = useUIStore(selectItemScrollLocked(item.id)); - const toggleItemScrollLocked = useUIStore( - (state) => state.toggleItemScrollLocked, - ); - const shouldShowPreview = - item.type === "document" || - item.type === "pdf" || - item.type === "quiz" || - item.type === "audio"; - // 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); const listenersActiveRef = useRef(false); - const DRAG_THRESHOLD = 10; // pixels - movement beyond this prevents click + const DRAG_THRESHOLD = 10; - // OPTIMIZED: Store handlers in refs so they can be added/removed dynamically - // This avoids adding 240+ listeners (120 cards * 2 listeners) on every render const handlersRef = useRef<{ handleGlobalMouseMove: ((e: MouseEvent) => void) | null; handleGlobalMouseUp: (() => void) | null; }>({ handleGlobalMouseMove: null, handleGlobalMouseUp: null }); - // Cleanup listeners on unmount useEffect(() => { const handlers = handlersRef.current; return () => { @@ -113,16 +82,12 @@ function WorkspaceCard({ "mousemove", handlers.handleGlobalMouseMove, ); - document.removeEventListener( - "mouseup", - handlers.handleGlobalMouseUp, - ); + document.removeEventListener("mouseup", handlers.handleGlobalMouseUp); listenersActiveRef.current = false; } }; }, []); - // OPTIMIZED: Memoize ItemHeader callbacks to prevent inline function creation const handleNameChange = useCallback( (v: string) => { onUpdateItem(item.id, { name: v }); @@ -152,7 +117,6 @@ function WorkspaceCard({ setIsEditingTitle(false); }, []); - // Handle color change from color picker const handleColorChange = useCallback( (color: ColorResult) => { onUpdateItem(item.id, { color: color.hex as CardColor }); @@ -196,10 +160,8 @@ function WorkspaceCard({ } }, [item.type, item.data]); - // Handle mouse down - track initial position for local movement detection const handleMouseDown = useCallback( (e: React.MouseEvent) => { - // Don't track if clicking on interactive elements or text inputs const target = e.target as HTMLElement; if ( target.closest("button") || @@ -208,15 +170,12 @@ function WorkspaceCard({ target.closest('[role="menuitem"]') || target.closest('[contenteditable="true"]') ) { - // Important: Stop propagation to prevent grid drag from starting e.stopPropagation(); return; } - // Check if clicking inside a text selection area (e.g., title textarea) const selection = window.getSelection(); if (selection && selection.toString().length > 0) { - // User is selecting text, don't start drag tracking e.stopPropagation(); return; } @@ -224,21 +183,17 @@ function WorkspaceCard({ mouseDownRef.current = { x: e.clientX, y: e.clientY }; hasMovedRef.current = false; - // OPTIMIZED: Only add global listeners when mouseDown occurs, not on every render if (!listenersActiveRef.current) { const handleGlobalMouseMove = (e: MouseEvent) => { if (!mouseDownRef.current) return; - // Calculate movement delta const deltaX = Math.abs(e.clientX - mouseDownRef.current.x); const deltaY = Math.abs(e.clientY - mouseDownRef.current.y); - // If drag already detected, don't cancel it - user is dragging if (hasMovedRef.current) { return; } - // Check if user is selecting text - if so, don't treat as drag const selection = window.getSelection(); if (selection && selection.toString().length > 0) { mouseDownRef.current = null; @@ -246,7 +201,6 @@ function WorkspaceCard({ return; } - // Check if movement exceeds threshold if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { hasMovedRef.current = true; } @@ -254,7 +208,6 @@ function WorkspaceCard({ const handleGlobalMouseUp = () => { mouseDownRef.current = null; - // Clean up listeners when mouse up if ( listenersActiveRef.current && handlersRef.current.handleGlobalMouseMove && @@ -284,36 +237,24 @@ function WorkspaceCard({ [DRAG_THRESHOLD], ); - // Handle mouse move on card - detect if user moved before releasing - // Note: This is a fallback - the global listener handles most cases const handleMouseMove = useCallback((e: React.MouseEvent) => { - // The global listener handles this, but we keep this for local element-specific checks if (!mouseDownRef.current) return; - // Only check for text input/selection if drag hasn't been detected yet - // This prevents starting a drag when user is trying to select text const target = e.target as HTMLElement; if ( target.closest("textarea") || target.closest("input") || target.closest('[contenteditable="true"]') ) { - // User is interacting with text input, cancel drag tracking mouseDownRef.current = null; hasMovedRef.current = false; - return; } }, []); - // Handle mouse up - clear the mouse down tracking - // Note: The global listener also handles this, but we keep this for local cleanup - const handleMouseUp = useCallback(() => { - // Don't clear here - let the global listener handle it to ensure consistency - }, []); + const handleMouseUp = useCallback(() => {}, []); const handleCardClick = useCallback( (e: React.MouseEvent) => { - // Check if click originated from dropdown menu const target = e.target as HTMLElement; if ( target.closest('[data-slot="dropdown-menu-item"]') || @@ -330,58 +271,39 @@ function WorkspaceCard({ return; } - // For flashcard cards, check if click is on the flashcard itself - // If so, let the flashcard handle it (for flipping) if (item.type === "flashcard") { - // Check if click is on the flashcard component or its children const flashcardElement = target.closest( '.flashcard-container, .flashcard, [class*="flashcard"]', ); if (flashcardElement) { - // Click is on flashcard - let it flip, don't open modal e.stopPropagation(); return; } } - // Check if user was selecting text - if so, allow normal text selection behavior const selection = window.getSelection(); if (selection && selection.toString().length > 0) { - // User selected text, don't open modal or prevent default return; } - // Shift+click toggles card selection if (e.shiftKey) { e.stopPropagation(); onToggleSelection(item.id); return; } - // Check if user moved mouse significantly (drag detected) or is editing title - // Store the value before resetting const wasDragging = hasMovedRef.current; - - // Reset the tracking immediately after checking hasMovedRef.current = false; - // Prevent opening modal if user was dragging or is editing title if (wasDragging || isEditingTitle) { e.preventDefault(); e.stopPropagation(); return; } - // Default: open item in the left-pane overlay onOpenModal(item.id); }, - [ - isEditingTitle, - item.id, - item.type, - onOpenModal, - onToggleSelection, - ], + [isEditingTitle, item.id, item.type, onOpenModal, onToggleSelection], ); const handleMove = useCallback( @@ -394,18 +316,7 @@ function WorkspaceCard({ ); const shouldUseFramelessLayout = - item.type === "youtube" || - item.type === "image" || - (item.type === "pdf" && shouldShowPreview); - const shouldShowScrollLockButton = - item.type !== "youtube" && - item.type !== "image" && - item.type !== "quiz" && - !(item.type === "document" && (!shouldShowPreview || documentAwaitingGeneration)) && - !(item.type === "pdf" && !shouldShowPreview) && - !(item.type === "audio" && !shouldShowPreview); - const useDarkFloatingControls = - item.type === "pdf" && shouldShowPreview; + item.type === "youtube" || item.type === "image"; return ( @@ -415,7 +326,6 @@ function WorkspaceCard({ id={`item-${item.id}`} data-youtube-card data-item-type={item.type} - data-has-preview={shouldShowPreview} className={`relative rounded-md scroll-mt-4 size-full flex flex-col overflow-hidden transition-all duration-200 cursor-pointer ${ shouldUseFramelessLayout ? "p-0" @@ -460,14 +370,14 @@ function WorkspaceCard({ > toggleItemScrollLocked(item.id)} + onToggleScrollLock={() => {}} onToggleSelection={() => onToggleSelection(item.id)} onOpenRename={() => setShowRenameDialog(true)} onOpenMove={() => setShowMoveDialog(true)} @@ -476,29 +386,16 @@ function WorkspaceCard({ onDelete={handleDelete} /> - + - onUpdateItem(item.id, { - data: updater(item.data) as Item["data"], - }) - } /> @@ -539,18 +436,15 @@ function WorkspaceCard({ ); } -// Memoize to prevent unnecessary re-renders export const WorkspaceCardMemoized = memo( WorkspaceCard, (prevProps, nextProps) => { - // Compare item properties if (prevProps.item.id !== nextProps.item.id) return false; if (prevProps.item.name !== nextProps.item.name) return false; if (prevProps.item.subtitle !== nextProps.item.subtitle) return false; if (prevProps.item.color !== nextProps.item.color) return false; if (prevProps.item.type !== nextProps.item.type) return false; - // Compare item data (for PDFs, flashcards, and YouTube) if (prevProps.item.type === "pdf" && nextProps.item.type === "pdf") { const prevData = prevProps.item.data; const nextData = nextProps.item.data; @@ -596,17 +490,8 @@ export const WorkspaceCardMemoized = memo( if (JSON.stringify(prevData) !== JSON.stringify(nextData)) return false; } - - // NOTE: isSelected is now subscribed directly from the store, not a prop - - // 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 - // when parent components re-render and create new callback instances. - - return true; // Props are equal, skip re-render + return true; }, ); -// Export both the memoized version and original for backwards compatibility export { WorkspaceCardMemoized as WorkspaceCard }; diff --git a/src/components/workspace-canvas/WorkspaceCardContent.tsx b/src/components/workspace-canvas/WorkspaceCardContent.tsx index 8a037b66..51a7d1c7 100644 --- a/src/components/workspace-canvas/WorkspaceCardContent.tsx +++ b/src/components/workspace-canvas/WorkspaceCardContent.tsx @@ -1,82 +1,57 @@ "use client"; -import { Loader2 } from "lucide-react"; import { Flashcard } from "react-quizlet-flashcard"; import "react-quizlet-flashcard/dist/index.css"; import { cn } from "@/lib/utils"; import type { Item, - DocumentData, FlashcardData, - PdfData, YouTubeData, } from "@/lib/workspace-state/types"; import ItemHeader from "@/components/workspace-canvas/ItemHeader"; -import { QuizContent } from "./QuizContent"; -import { ImageCardContent } from "./ImageCardContent"; -import { AudioCardContent } from "./AudioCardContent"; -import LazyAppPdfViewer from "@/components/pdf/LazyAppPdfViewer"; import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown"; -import { Skeleton } from "@/components/ui/skeleton"; import { extractYouTubePlaylistId, extractYouTubeVideoId, } from "@/lib/utils/youtube-url"; import { YouTubeCardContent } from "./YouTubeCardContent"; -import { SourcesDisplay } from "./SourcesDisplay"; +import { ImageCardContent } from "./ImageCardContent"; interface WorkspaceCardContentProps { item: Item; - shouldShowPreview: boolean; - isScrollLocked: boolean; - documentAwaitingGeneration: boolean; - documentPreviewText: string; resolvedTheme?: string; onNameChange: (value: string) => void; onNameCommit: (value: string) => void; onSubtitleChange: (value: string) => void; onTitleFocus: () => void; onTitleBlur: () => void; - onUpdateItemData: (updater: (prev: Item["data"]) => Item["data"]) => void; } export function WorkspaceCardContent({ item, - shouldShowPreview, - isScrollLocked, - documentAwaitingGeneration, - documentPreviewText, resolvedTheme, onNameChange, onNameCommit, onSubtitleChange, onTitleFocus, onTitleBlur, - onUpdateItemData, }: WorkspaceCardContentProps) { const isCompactTextCard = - (item.type === "pdf" || - item.type === "quiz" || - item.type === "audio" || - item.type === "document") && - !shouldShowPreview; - const shouldShowHeader = - item.type !== "youtube" && - item.type !== "image" && - !(item.type === "pdf" && shouldShowPreview) && - item.name !== "Update me"; - const documentSources = - item.type === "document" ? (item.data as DocumentData).sources : undefined; + item.type === "pdf" || + item.type === "quiz" || + item.type === "audio" || + item.type === "document"; + const shouldShowHeader = item.type !== "youtube" && item.type !== "image"; return ( <> -
- {shouldShowHeader && ( -
+ {shouldShowHeader && ( +
+
- - {item.type === "document" && - shouldShowPreview && - documentSources && - documentSources.length > 0 && ( -
- -
- )}
- )} -
- - {item.type === "pdf" && - shouldShowPreview && - (() => { - const pdfData = item.data as PdfData; - const isOcrProcessing = pdfData?.ocrStatus === "processing"; - const pdfPreviewUrl = pdfData.fileUrl; - - return ( -
- - {isOcrProcessing && pdfPreviewUrl && ( -
- - Reading... -
- )} -
- ); - })()} - - {item.type === "quiz" && shouldShowPreview && ( -
-
)} @@ -172,7 +95,8 @@ export function WorkspaceCardContent({
@@ -188,7 +112,8 @@ export function WorkspaceCardContent({
@@ -247,42 +172,6 @@ export function WorkspaceCardContent({ })()} {item.type === "image" && } - - {item.type === "audio" && shouldShowPreview && ( -
- -
- )} - - {item.type === "document" && - shouldShowPreview && - (documentAwaitingGeneration ? ( -
-
-
- Generating document... -
- - - -
- ) : ( -
- - {documentPreviewText} - -
- ))} ); } diff --git a/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx b/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx index 66455531..2976370d 100644 --- a/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx +++ b/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx @@ -1,13 +1,6 @@ "use client"; -import { - Loader2, - File, - FileText, - Brain, - Mic, - Globe, -} from "lucide-react"; +import { Loader2, File, FileText, Brain, Mic, Globe } from "lucide-react"; import type { Item, PdfData, WebsiteData } from "@/lib/workspace-state/types"; import { getCardColorWithBlackMix, @@ -17,22 +10,19 @@ import { interface WorkspaceCardTypeBadgeProps { item: Item; - shouldShowPreview: boolean; resolvedTheme?: string; } export function WorkspaceCardTypeBadge({ item, - shouldShowPreview, resolvedTheme, }: WorkspaceCardTypeBadgeProps) { const showTypeBadge = - (item.type === "pdf" || - item.type === "quiz" || - item.type === "audio" || - item.type === "website" || - item.type === "document") && - !shouldShowPreview; + item.type === "pdf" || + item.type === "quiz" || + item.type === "audio" || + item.type === "website" || + item.type === "document"; if (!showTypeBadge) { return null; diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 722b971d..482e7e30 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -301,8 +301,7 @@ export async function workspaceWorker( favicon?: string; }>; folderId?: string; - /** Optional layout { x, y, w, h } for the item (lg breakpoint) */ - }, + }, ): Promise<{ success: boolean; message: string; @@ -988,13 +987,13 @@ export async function workspaceWorker( ); await deleteWorkspaceItem(params.workspaceId, params.itemId); - return { - success: true, - itemId: params.itemId, - message: existingItem - ? `Deleted "${existingItem.name}" successfully` - : "Deleted item successfully", - }; + return { + success: true, + itemId: params.itemId, + message: existingItem + ? `Deleted "${existingItem.name}" successfully` + : "Deleted item successfully", + }; } // Fallback for unhandled actions From 19feb57d813b13356cb54ab78a7f914109db725e Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:01:50 +0000 Subject: [PATCH 3/3] Remove stale workspace drag and scroll-lock code Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/app/globals.css | 389 ------------------ .../FlashcardWorkspaceCard.tsx | 170 ++------ .../workspace-canvas/FolderCard.tsx | 284 ++++++------- .../workspace-canvas/MarqueeSelector.tsx | 363 ++++++++-------- .../workspace-canvas/WorkspaceCard.tsx | 171 +------- .../workspace-canvas/WorkspaceCardActions.tsx | 44 -- .../workspace-canvas/WorkspaceSection.tsx | 2 - 7 files changed, 342 insertions(+), 1081 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index f4e0508f..ec4b8bb6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -511,400 +511,11 @@ body.marquee-selecting iframe { } } -/* React Grid Layout Overrides */ -.react-grid-layout { - position: relative; -} - -/* No transition by default — prevents fly-in animation on mount */ -.react-grid-item { - transition: none !important; -} - -/* Enable smooth transitions only after initial layout is complete */ -.workspace-grid-mounted .react-grid-item { - transition: transform 250ms ease-out, - width 250ms ease-out, - height 250ms ease-out !important; -} - -/* Flashcard flip animation isolation - prevent grid transitions from affecting flip */ -.react-grid-item .flip-card, -.react-grid-item .flip-card * { - /* Ensure flip card manages its own transitions */ - transition: none; -} - -.react-grid-item .flip-card-inner { - /* Only the inner container should have the vertical flip transition */ - transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1) !important; - transform-style: preserve-3d !important; -} - -/* Prevent any inherited transforms from affecting flip card content */ -.react-grid-item .flip-card-front, -.react-grid-item .flip-card-back { - /* Content should not have any transitions that could cause flicker */ - transition: none !important; -} - -/* Ensure flip card content is isolated from parent compositing layers */ .flip-card-content { isolation: isolate; contain: layout style; } -/* Cursor styles for draggable grid items */ -.react-grid-item.react-draggable { - cursor: grab; -} - -.react-grid-item.react-draggable-dragging { - cursor: grabbing !important; - touch-action: none !important; - /* Prevent scrolling during drag on mobile */ -} - -.react-grid-item.react-draggable-dragging * { - cursor: grabbing !important; - touch-action: none !important; -} - -/* Improve resize handles for mobile */ -@media (max-width: 768px) { - .react-grid-item>.react-resizable-handle { - width: 25px; - height: 25px; - } - - .react-grid-item>.react-resizable-handle::after { - width: 14px; - height: 14px; - } -} - -.react-grid-item.react-grid-placeholder { - transition: all 200ms ease-out; -} - -.react-grid-item img { - pointer-events: none; - user-select: none; -} - -.react-grid-item>.react-resizable-handle { - position: absolute; - width: 25px; - height: 25px; -} - -.react-grid-item>.react-resizable-handle::after { - content: ""; - position: absolute; - right: 0px; - bottom: 0px; - width: 14px; - height: 14px; - border-right: 3px solid rgba(255, 255, 255, 1); - border-bottom: 3px solid rgba(255, 255, 255, 1); - border-radius: 0 0 10px 0; - opacity: 0; - transition: opacity 0.2s ease, border-color 0.2s ease; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.dark .react-grid-item>.react-resizable-handle::after { - border-right-color: rgba(255, 255, 255, 1); - border-bottom-color: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-sw { - bottom: -3px; - left: -3px; - cursor: sw-resize; - transform: rotate(90deg); -} - -.react-grid-item>.react-resizable-handle-se { - bottom: -3px; - right: -3px; - cursor: se-resize; -} - -.react-grid-item>.react-resizable-handle-nw { - top: -3px; - left: -3px; - cursor: nw-resize; - transform: rotate(180deg); -} - -.react-grid-item>.react-resizable-handle-ne { - top: -3px; - right: -3px; - cursor: ne-resize; - transform: rotate(270deg); -} - -/* Edge handles (N, S, E, W) - span nearly full edge for large hit area */ -.react-grid-item>.react-resizable-handle-w, -.react-grid-item>.react-resizable-handle-e { - top: 30px; - height: calc(100% - 60px); - width: 15px; - cursor: ew-resize; - margin-top: 0; -} - -.react-grid-item>.react-resizable-handle-w { - left: -3px; -} - -.react-grid-item>.react-resizable-handle-e { - right: -3px; -} - -.react-grid-item>.react-resizable-handle-n, -.react-grid-item>.react-resizable-handle-s { - left: 30px; - width: calc(100% - 60px); - height: 15px; - cursor: ns-resize; - margin-left: 0; -} - -.react-grid-item>.react-resizable-handle-n { - top: -3px; -} - -.react-grid-item>.react-resizable-handle-s { - bottom: -3px; -} - -/* Make edge handles straight lines (horizontal for N/S, vertical for E/W) */ -.react-grid-item>.react-resizable-handle-n::after { - content: ""; - position: absolute; - left: 50%; - transform: translateX(-50%); - width: 20px; - height: 3px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - top: 0; - bottom: auto; - right: auto; - margin-top: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-s::after { - content: ""; - position: absolute; - left: 50%; - transform: translateX(-50%); - width: 20px; - height: 3px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - bottom: 0; - top: auto; - right: auto; - margin-top: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-e::after { - content: ""; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 20px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - right: 0; - left: auto; - bottom: auto; - margin-left: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-w::after { - content: ""; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 20px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - left: 0; - right: auto; - bottom: auto; - margin-left: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.dark .react-grid-item>.react-resizable-handle-n::after, -.dark .react-grid-item>.react-resizable-handle-s::after { - background: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); -} - -.dark .react-grid-item>.react-resizable-handle-e::after, -.dark .react-grid-item>.react-resizable-handle-w::after { - background: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); -} - -/* Show resize handles on hover - corner handles */ -.react-grid-item:hover>.react-resizable-handle-se::after, -.react-grid-item:hover>.react-resizable-handle-sw::after, -.react-grid-item:hover>.react-resizable-handle-ne::after, -.react-grid-item:hover>.react-resizable-handle-nw::after { - opacity: 1; -} - -/* Show resize handles on hover - edge handles */ -.react-grid-item:hover>.react-resizable-handle-n::after, -.react-grid-item:hover>.react-resizable-handle-s::after, -.react-grid-item:hover>.react-resizable-handle-e::after, -.react-grid-item:hover>.react-resizable-handle-w::after { - opacity: 1; -} - -/* Placeholder styling during drag */ -.react-grid-item.react-grid-placeholder { - background: rgba(59, 130, 246, 0.15); - border: 2px dashed rgba(59, 130, 246, 0.5); - border-radius: 1rem; - opacity: 0.7; - transition: all 200ms ease-out !important; - z-index: 2; - user-select: none; -} - -.react-grid-item.resizing { - opacity: 0.9; - z-index: 100; -} - -.react-grid-item.static { - background: transparent; -} - -.react-grid-item .text, -.react-grid-item button { - user-select: none; -} - -/* Disable text selection during drag */ -.react-grid-item.react-draggable-dragging { - transition: none !important; - z-index: 1000; - opacity: 0.5; -} - -.react-grid-item.react-draggable-dragging * { - user-select: none; -} - -/* Ensure YouTube and PDF preview cards show opacity during drag */ -/* The data-item-type is on the article element inside the grid item */ -.react-grid-item.react-draggable-dragging article[data-item-type="youtube"], -.react-grid-item.react-draggable-dragging article[data-item-type="pdf"][data-has-preview="true"] { - opacity: 0.5 !important; -} - -/* Ensure iframes and images in YouTube/PDF cards show opacity during drag */ -.react-grid-item.react-draggable-dragging article[data-item-type="youtube"] iframe, -.react-grid-item.react-draggable-dragging article[data-item-type="youtube"] img, -.react-grid-item.react-draggable-dragging article[data-item-type="pdf"][data-has-preview="true"] iframe, -.react-grid-item.react-draggable-dragging article[data-item-type="pdf"][data-has-preview="true"] img { - opacity: 0.5 !important; -} - -/* Disable transitions when manually resizing */ -.react-grid-item.resizing { - transition: none !important; -} - -/* Allow dragging beyond scroll boundaries */ -.react-grid-layout:has(.react-draggable-dragging) { - overflow: visible !important; -} - -/* Allow parent containers to overflow during drag */ -.layout:has(.react-draggable-dragging) { - overflow: visible !important; -} - -/* NOTE: Removed overly broad div selector that was breaking scroll containers */ -/* The grid layout itself handles overflow properly */ - -/* Reset overflow for the article content inside cards */ -/* Only apply scrolling to non-flex articles */ -.react-grid-item article:not(.flex) { - overflow-y: auto !important; - overflow-x: hidden !important; -} - -/* Card content layout with overflow handling */ -/* Flex containers handle overflow internally */ -.react-grid-item article.flex { - overflow: hidden !important; -} - -.react-grid-item article:not(.flex) { - display: block; - height: 100%; - overflow-y: auto; - overflow-x: hidden; -} - -/* Custom scrollbar for cards */ -.react-grid-item article::-webkit-scrollbar { - width: 6px; -} - -.react-grid-item article::-webkit-scrollbar-track { - background: transparent; -} - -.react-grid-item article::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: 3px; -} - -.dark .react-grid-item article::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); -} - -.react-grid-item article::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); -} - -.dark .react-grid-item article::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); -} - -/* Ensure tag containers respect boundaries */ -.react-grid-item article [class*="flex-wrap"] { - max-width: 100%; - word-break: break-word; -} - -/* Constrain textareas within cards */ -.react-grid-item article textarea { - max-height: 400px; - overflow-y: auto; -} - /* Word breaking with character fallback for workspace card item headers only */ .workspace-card-text { /* Try to break at word boundaries first */ diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx index 047d3105..53ef0d5c 100644 --- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx +++ b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx @@ -171,7 +171,6 @@ export function FlashcardWorkspaceCard({ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isFlipped, setIsFlipped] = useState(false); const [isFlipping, setIsFlipping] = useState(false); - // Get scroll lock state from Zustand store (persists across interactions) const isScrollLocked = useUIStore(selectItemScrollLocked(item.id)); const toggleItemScrollLocked = useUIStore( (state) => state.toggleItemScrollLocked, @@ -212,45 +211,9 @@ export function FlashcardWorkspaceCard({ return cards[safeIndex] ?? EMPTY_FLASHCARD_PLACEHOLDER; }, [cards, currentIndex]); - // Flashcard flip animation duration (matches FlipCard component CSS) const FLIP_ANIMATION_DURATION = 600; - - // Tracking for flip debounce const lastFlipTimeRef = useRef(0); - // Track minimal local drag detection (same pattern as WorkspaceCard) - const mouseDownRef = useRef<{ x: number; y: number } | null>(null); - const hasMovedRef = useRef(false); - const listenersActiveRef = useRef(false); - const DRAG_THRESHOLD = 10; // pixels - movement beyond this prevents flip - - // OPTIMIZED: Store handlers in refs so they can be added/removed dynamically - const handlersRef = useRef<{ - handleGlobalMouseMove: ((e: MouseEvent) => void) | null; - handleGlobalMouseUp: (() => void) | null; - }>({ handleGlobalMouseMove: null, handleGlobalMouseUp: null }); - - // Cleanup listeners on unmount - useEffect(() => { - return () => { - if ( - listenersActiveRef.current && - handlersRef.current.handleGlobalMouseMove && - handlersRef.current.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlersRef.current.handleGlobalMouseMove, - ); - document.removeEventListener( - "mouseup", - handlersRef.current.handleGlobalMouseUp, - ); - listenersActiveRef.current = false; - } - }; - }, []); - const handleDelete = useCallback(() => { setShowDeleteDialog(true); }, []); @@ -276,13 +239,11 @@ export function FlashcardWorkspaceCard({ [item.id, onUpdateItem], ); - // Helper function to hide tabs during flip animation const startFlipAnimation = useCallback(() => { setIsFlipping(true); setTimeout(() => setIsFlipping(false), FLIP_ANIMATION_DURATION); }, []); - // Debounced flip logic const handleFlip = useCallback(() => { const now = Date.now(); if (now - lastFlipTimeRef.current < 200) return; @@ -291,118 +252,47 @@ export function FlashcardWorkspaceCard({ startFlipAnimation(); }, [startFlipAnimation]); - // Handle mouse down - track initial position for drag detection - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - // Don't track if clicking on interactive elements - const target = e.target as HTMLElement; - if ( - target.closest("button") || + const isInteractiveTarget = useCallback((target: HTMLElement) => { + return Boolean( + target.closest("button") || + target.closest("input") || + target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || target.closest(".flashcard-control-button") || - target.closest('[role="menuitem"]') - ) { - return; - } + target.closest('[role="menuitem"]') || + target.closest('[contenteditable="true"]') || + target.closest('[data-slot="dropdown-menu-content"]') || + target.closest('[data-slot="dropdown-menu-trigger"]') || + target.closest('[data-slot="dialog-content"]') || + target.closest('[data-slot="dialog-close"]') || + target.closest('[data-slot="dialog-overlay"]') + ); + }, []); - // Check if clicking inside a text selection area - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { + const handleClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { e.stopPropagation(); return; } - mouseDownRef.current = { x: e.clientX, y: e.clientY }; - hasMovedRef.current = false; - - // Only add global listeners when mouseDown occurs - if (!listenersActiveRef.current) { - const handleGlobalMouseMove = (e: MouseEvent) => { - if (!mouseDownRef.current) return; - - // Calculate movement delta - const deltaX = Math.abs(e.clientX - mouseDownRef.current.x); - const deltaY = Math.abs(e.clientY - mouseDownRef.current.y); - - if (hasMovedRef.current) { - return; - } - - // Check if user is selecting text - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - mouseDownRef.current = null; - hasMovedRef.current = false; - return; - } - - // Check if movement exceeds threshold (drag detected) - if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { - hasMovedRef.current = true; - } - }; - - const handleGlobalMouseUp = () => { - mouseDownRef.current = null; - // Clean up listeners - if ( - listenersActiveRef.current && - handlersRef.current.handleGlobalMouseMove && - handlersRef.current.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlersRef.current.handleGlobalMouseMove, - ); - document.removeEventListener( - "mouseup", - handlersRef.current.handleGlobalMouseUp, - ); - listenersActiveRef.current = false; - handlersRef.current.handleGlobalMouseMove = null; - handlersRef.current.handleGlobalMouseUp = null; - } - }; - - handlersRef.current.handleGlobalMouseMove = handleGlobalMouseMove; - handlersRef.current.handleGlobalMouseUp = handleGlobalMouseUp; - document.addEventListener("mousemove", handleGlobalMouseMove); - document.addEventListener("mouseup", handleGlobalMouseUp); - listenersActiveRef.current = true; - } - }, - [DRAG_THRESHOLD], - ); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - // If unlocked, we are in "content mode" - allow text selection/scrolling, disable flip - if (!isScrollLocked) return; - - // Also prevent flip if user was selecting text (fallback check) const selection = window.getSelection(); if (selection && selection.toString().length > 0) return; - // Shift+click toggles card selection if (e.shiftKey) { e.stopPropagation(); onToggleSelection(item.id); return; } - // Prevent flipping if user was dragging - const wasDragging = hasMovedRef.current; - hasMovedRef.current = false; // Reset immediately after checking - - if (wasDragging) { - e.preventDefault(); - e.stopPropagation(); - return; - } + if (!isScrollLocked) return; - // Safe to flip - user clicked without dragging handleFlip(); }, - [handleFlip, isScrollLocked, onToggleSelection, item.id], + [handleFlip, isInteractiveTarget, isScrollLocked, onToggleSelection, item.id], ); // Navigation Handlers @@ -486,15 +376,13 @@ export function FlashcardWorkspaceCard({ id={`item-${item.id}`} className="group size-full relative rounded-md" style={{}} - onMouseDown={handleMouseDown} onClick={handleClick} > {/* Floating Controls */}
- {/* Scroll Lock/Unlock Button */} - - {/* Next Button */} - {/* Card Counter */}
{currentIndex + 1} / {cards.length}
)} - {/* Flashcard Stack Container */}
- {/* Main Flashcard - takes up space minus the tabs */}
- {/* Check for template-created items awaiting generation */} {item.name === "Update me" && (!flashcardData.cards || flashcardData.cards.length === 0) ? ( - // Generating skeleton for template-created flashcards
)} - {/* Stack Tab 1 (directly below main card) - hidden during flip */}
- {/* Stack Tab 2 (bottom-most, slightly narrower) - hidden during flip */}
void; onUpdateItem: (itemId: string, updates: Partial) => void; onDeleteItem: (itemId: string) => void; - onDeleteFolderWithContents?: (folderId: string) => void; // Callback to delete folder and all items inside - onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move folder to another location + onDeleteFolderWithContents?: (folderId: string) => void; + onMoveItem?: (itemId: string, folderId: string | null) => void; } -/** - * FolderCard - A folder-shaped card that displays in the workspace grid - * Now uses Item type with type: 'folder' instead of separate Folder type - */ function FolderCardComponent({ item, itemCount, @@ -77,120 +86,99 @@ function FolderCardComponent({ }: FolderCardProps) { const [showColorPicker, setShowColorPicker] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [deleteOption, setDeleteOption] = useState<'keep' | 'delete' | null>(null); + const [deleteOption, setDeleteOption] = useState<"keep" | "delete" | null>(null); const [showMoveDialog, setShowMoveDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); const [shouldAutoFocus, setShouldAutoFocus] = useState(false); - // Subscribe directly to this folder's selection state from the store - const isSelected = useUIStore( - (state) => state.selectedCardIds.has(item.id) - ); - const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - - // Track drag movement to prevent opening folder after drag - const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null); - const hasMovedRef = useRef(false); - const DRAG_THRESHOLD = 5; // pixels + const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); + const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - const folderColor = item.color || "#6366F1"; // Default to indigo + const folderColor = item.color || "#6366F1"; - // Auto-focus and scroll into view for newly created folders (name is "New Folder") useEffect(() => { if (item.name === "New Folder") { setShouldAutoFocus(true); - // Scroll the folder card into view const element = document.getElementById(`item-${item.id}`); if (element) { setTimeout(() => { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.scrollIntoView({ behavior: "smooth", block: "center" }); }, 100); } } }, [item.id, item.name]); - // Handle mouse down - track initial position for drag detection - const handleMouseDown = useCallback((e: React.MouseEvent) => { - // Don't track if clicking on interactive elements - const target = e.target as HTMLElement; - if ( - target.closest('button') || - target.closest('input') || - target.closest('textarea') || - target.closest('[role="menuitem"]') - ) { - return; - } - mouseDownPosRef.current = { x: e.clientX, y: e.clientY }; - hasMovedRef.current = false; + const isInteractiveTarget = useCallback((target: HTMLElement) => { + return Boolean( + target.closest("button") || + target.closest("input") || + target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || + target.closest('[role="menuitem"]') || + target.closest('[contenteditable="true"]') || + target.closest('[data-slot="dropdown-menu-content"]') || + target.closest('[data-slot="dropdown-menu-trigger"]') || + target.closest('[data-slot="dialog-content"]') || + target.closest('[data-slot="dialog-close"]') || + target.closest('[data-slot="dialog-overlay"]'), + ); }, []); - // Handle mouse move - detect if user moved beyond threshold - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (!mouseDownPosRef.current || hasMovedRef.current) return; - - const deltaX = Math.abs(e.clientX - mouseDownPosRef.current.x); - const deltaY = Math.abs(e.clientY - mouseDownPosRef.current.y); - - if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { - hasMovedRef.current = true; - } - }, []); + const handleClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { + return; + } - // Handle click - only open folder if it wasn't a drag - const handleClick = useCallback((e: React.MouseEvent) => { - // Don't open if clicking on interactive elements - const target = e.target as HTMLElement; - if ( - target.closest('button') || - target.closest('[data-slot="dropdown-menu-content"]') || - target.closest('[data-slot="dropdown-menu-trigger"]') || - target.closest('[data-slot="dialog-content"]') || - target.closest('[data-slot="dialog-close"]') || - target.closest('[data-slot="dialog-overlay"]') - ) { - return; - } + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + return; + } - // Shift+click toggles folder selection - if (e.shiftKey) { - e.stopPropagation(); - onToggleSelection(item.id); - return; - } + if (e.shiftKey) { + e.stopPropagation(); + onToggleSelection(item.id); + return; + } - // Don't open if user was dragging or is editing title - if (hasMovedRef.current || isEditingTitle) { - hasMovedRef.current = false; - mouseDownPosRef.current = null; - return; - } + if (isEditingTitle) { + return; + } - mouseDownPosRef.current = null; - onOpenFolder(item.id); - }, [item.id, onOpenFolder, onToggleSelection, isEditingTitle]); + onOpenFolder(item.id); + }, + [isEditingTitle, isInteractiveTarget, item.id, onOpenFolder, onToggleSelection], + ); const handleColorChange = useCallback( (color: ColorResult) => { onUpdateItem(item.id, { color: color.hex as CardColor }); setShowColorPicker(false); }, - [item.id, onUpdateItem] + [item.id, onUpdateItem], ); - // Handlers for inline title editing (like WorkspaceCard) - const handleNameChange = useCallback((v: string) => { - onUpdateItem(item.id, { name: v }); - }, [item.id, onUpdateItem]); + const handleNameChange = useCallback( + (v: string) => { + onUpdateItem(item.id, { name: v }); + }, + [item.id, onUpdateItem], + ); - const handleNameCommit = useCallback((v: string) => { - onUpdateItem(item.id, { name: v }); - }, [item.id, onUpdateItem]); + const handleNameCommit = useCallback( + (v: string) => { + onUpdateItem(item.id, { name: v }); + }, + [item.id, onUpdateItem], + ); const handleDelete = useCallback(() => { - if (deleteOption === 'delete' && onDeleteFolderWithContents) { + if (deleteOption === "delete" && onDeleteFolderWithContents) { onDeleteFolderWithContents(item.id); } else { onDeleteItem(item.id); @@ -199,12 +187,14 @@ function FolderCardComponent({ setDeleteOption(null); }, [item.id, onDeleteItem, onDeleteFolderWithContents, deleteOption]); - const handleRename = useCallback((newName: string) => { - onUpdateItem(item.id, { name: newName }); - toast.success("Folder renamed"); - }, [item.id, onUpdateItem]); + const handleRename = useCallback( + (newName: string) => { + onUpdateItem(item.id, { name: newName }); + toast.success("Folder renamed"); + }, + [item.id, onUpdateItem], + ); - // Reset delete option when dialog closes useEffect(() => { if (!showDeleteConfirm) { setDeleteOption(null); @@ -213,17 +203,17 @@ function FolderCardComponent({ const { resolvedTheme } = useTheme(); - // Calculate colors using the same utilities as WorkspaceCard - const bodyBgColor = getCardColorCSS(folderColor, 0.25); // Body is more transparent - const tabBgColor = getCardColorCSS(folderColor, 0.35); // Tab is slightly less transparent - const borderColor = isSelected ? 'rgba(255, 255, 255, 0.8)' : getCardAccentColor(folderColor, 0.5); - // Selection ring on outer wrapper (like normal cards) – avoids overflow-hidden clipping - // Light mode: match resize-handle style with layered dark shadow for visibility + const bodyBgColor = getCardColorCSS(folderColor, 0.25); + const tabBgColor = getCardColorCSS(folderColor, 0.35); + const borderColor = isSelected + ? "rgba(255, 255, 255, 0.8)" + : getCardAccentColor(folderColor, 0.5); const selectedRingStyle = isSelected ? { - boxShadow: resolvedTheme === 'dark' - ? '0 0 0 3px rgba(255, 255, 255, 0.8)' - : '0 0 0 3px rgba(255, 255, 255, 0.8), 0 0 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.6), 0 0 12px rgba(0, 0, 0, 0.4)', + boxShadow: + resolvedTheme === "dark" + ? "0 0 0 3px rgba(255, 255, 255, 0.8)" + : "0 0 0 3px rgba(255, 255, 255, 0.8), 0 0 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.6), 0 0 12px rgba(0, 0, 0, 0.4)", } : undefined; @@ -231,62 +221,61 @@ function FolderCardComponent({
- {/* Folder tab - top left, more transparent than body */}
- {/* Main folder body - starts where tab ends, less transparent */}
- {/* Selection Button */} - {/* Options Menu */}
- {/* Right-Click Context Menu */} setShowRenameDialog(true)}> @@ -569,7 +545,7 @@ function FolderCardComponent({ Delete Folder - + ); } diff --git a/src/components/workspace-canvas/MarqueeSelector.tsx b/src/components/workspace-canvas/MarqueeSelector.tsx index 292f5af3..3d1ae8fe 100644 --- a/src/components/workspace-canvas/MarqueeSelector.tsx +++ b/src/components/workspace-canvas/MarqueeSelector.tsx @@ -1,27 +1,24 @@ "use client"; -import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import React, { useState, useCallback, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { useUIStore } from "@/lib/stores/ui-store"; import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids"; -import { createRectFromPoints, getIntersectingCards, type Rectangle } from "@/lib/utils/marquee-utils"; +import { + createRectFromPoints, + getIntersectingCards, + type Rectangle, +} from "@/lib/utils/marquee-utils"; import { useAutoScroll } from "@/hooks/ui/use-auto-scroll"; interface MarqueeSelectorProps { scrollContainerRef: React.RefObject; cardIds: string[]; - isGridDragging: boolean; } -/** - * MarqueeSelector component for rectangular card selection - * Handles mouse events to create a selection rectangle and select intersecting cards - * Features autoscroll when selecting near container edges - */ export function MarqueeSelector({ scrollContainerRef, cardIds, - isGridDragging, }: MarqueeSelectorProps) { const [isSelecting, setIsSelecting] = useState(false); const [marqueeRect, setMarqueeRect] = useState(null); @@ -32,258 +29,236 @@ export function MarqueeSelector({ const isDraggingRef = useRef(false); const startPointRef = useRef({ x: 0, y: 0 }); - // Use autoscroll hook for smooth scrolling during selection - const { handleDragStart: startAutoScroll, handleDragStop: stopAutoScroll } = useAutoScroll( - scrollContainerRef as React.RefObject - ); - - // Cancel marquee selection if grid starts dragging - useEffect(() => { - if (isGridDragging && isSelecting) { - setIsSelecting(false); - setMarqueeRect(null); - isDraggingRef.current = false; - stopAutoScroll(); // Stop autoscroll when canceling - } - }, [isGridDragging, isSelecting, stopAutoScroll]); + const { handleDragStart: startAutoScroll, handleDragStop: stopAutoScroll } = + useAutoScroll(scrollContainerRef as React.RefObject); - // Add/remove class on body to disable pointer events on iframes during marquee selection useEffect(() => { if (isSelecting) { - document.body.classList.add('marquee-selecting'); + document.body.classList.add("marquee-selecting"); } else { - document.body.classList.remove('marquee-selecting'); + document.body.classList.remove("marquee-selecting"); } return () => { - document.body.classList.remove('marquee-selecting'); + document.body.classList.remove("marquee-selecting"); }; }, [isSelecting]); - // Helper to start marquee selection - const startMarqueeSelection = useCallback((e: MouseEvent) => { - const container = scrollContainerRef.current; - if (!container) return false; - - const containerRect = container.getBoundingClientRect(); - - // Check if click is within the container bounds - if ( - e.clientX < containerRect.left || - e.clientX > containerRect.right || - e.clientY < containerRect.top || - e.clientY > containerRect.bottom - ) { - return false; - } - - // Robust scrollbar detection: calculate actual scrollbar dimensions - // offsetWidth/Height includes scrollbar, clientWidth/Height excludes it - const scrollbarWidth = container.offsetWidth - container.clientWidth; - const scrollbarHeight = container.offsetHeight - container.clientHeight; - - // Check if clicking on scrollbar by comparing click position with client area - // For vertical scrollbar: check if click is to the right of the client area - const clickXRelativeToContainer = e.clientX - containerRect.left; - const isVerticalScrollbar = scrollbarWidth > 0 && clickXRelativeToContainer > container.clientWidth; + const startMarqueeSelection = useCallback( + (e: MouseEvent) => { + const container = scrollContainerRef.current; + if (!container) return false; + + const containerRect = container.getBoundingClientRect(); + if ( + e.clientX < containerRect.left || + e.clientX > containerRect.right || + e.clientY < containerRect.top || + e.clientY > containerRect.bottom + ) { + return false; + } - // For horizontal scrollbar: check if click is below the client area - const clickYRelativeToContainer = e.clientY - containerRect.top; - const isHorizontalScrollbar = scrollbarHeight > 0 && clickYRelativeToContainer > container.clientHeight; + const scrollbarWidth = container.offsetWidth - container.clientWidth; + const scrollbarHeight = container.offsetHeight - container.clientHeight; + const clickXRelativeToContainer = e.clientX - containerRect.left; + const clickYRelativeToContainer = e.clientY - containerRect.top; + const isVerticalScrollbar = + scrollbarWidth > 0 && clickXRelativeToContainer > container.clientWidth; + const isHorizontalScrollbar = + scrollbarHeight > 0 && clickYRelativeToContainer > container.clientHeight; + + if (isHorizontalScrollbar || isVerticalScrollbar) { + return false; + } - // Don't start marquee if clicking on scrollbar - if (isHorizontalScrollbar || isVerticalScrollbar) { - return false; - } + const x = e.clientX - containerRect.left + container.scrollLeft; + const y = e.clientY - containerRect.top + container.scrollTop; - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; - - startPointRef.current = { x, y }; - setIsSelecting(true); - isDraggingRef.current = true; - - // Start autoscroll for marquee selection - startAutoScroll(); - - // Prevent text selection and other drag behaviors during marquee - e.preventDefault(); - e.stopPropagation(); - - return true; - }, [scrollContainerRef, startAutoScroll]); - - const handleMouseDown = useCallback((e: MouseEvent) => { - // Only start marquee on left click - if (e.button !== 0) return; - - // Don't start if clicking on a card or interactive element - const target = e.target as HTMLElement; - - // Clear native text selection when clicking on workspace background - // (MarqueeSelector captures background clicks before they reach WorkspaceSection) - window.getSelection()?.removeAllRanges(); - - // Check if clicking on any interactive or grid element - if ( - target.closest('article') || // Card itself - target.closest('button') || // Any button - target.closest('[role="button"]') || // Button role elements - target.closest('.react-grid-item') || // Grid item wrapper - target.classList.contains('drag-handle') || // Drag handle - target.tagName === 'INPUT' || // Input fields - target.tagName === 'TEXTAREA' // Text areas - ) { - return; - } + startPointRef.current = { x, y }; + setIsSelecting(true); + isDraggingRef.current = true; + startAutoScroll(); - startMarqueeSelection(e); - }, [startMarqueeSelection]); + e.preventDefault(); + e.stopPropagation(); - // Global shift+drag handler - activates marquee from ANYWHERE in the app when Shift is held - // Takes precedence over card dragging and other interactions - const handleGlobalShiftMouseDown = useCallback((e: MouseEvent) => { - // Only activate on left click with Shift held - if (e.button !== 0 || !e.shiftKey) return; + return true; + }, + [scrollContainerRef, startAutoScroll], + ); - // Don't interfere with text inputs - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { - return; - } + const handleMouseDown = useCallback( + (e: MouseEvent) => { + if (e.button !== 0) return; + + const target = e.target as HTMLElement; + window.getSelection()?.removeAllRanges(); + + if ( + target.closest('[id^="item-"]') || + target.closest("button") || + target.closest('[role="button"]') || + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } - const container = scrollContainerRef.current; - if (!container) return; + startMarqueeSelection(e); + }, + [startMarqueeSelection], + ); - const containerRect = container.getBoundingClientRect(); + const handleGlobalShiftMouseDown = useCallback( + (e: MouseEvent) => { + if (e.button !== 0 || !e.shiftKey) return; + + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } - // Use raw mouse position - marquee can start from anywhere - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; + const container = scrollContainerRef.current; + if (!container) return; - startPointRef.current = { x, y }; - setIsSelecting(true); - isDraggingRef.current = true; + const containerRect = container.getBoundingClientRect(); + const x = e.clientX - containerRect.left + container.scrollLeft; + const y = e.clientY - containerRect.top + container.scrollTop; - // Start autoscroll for marquee selection - startAutoScroll(); + startPointRef.current = { x, y }; + setIsSelecting(true); + isDraggingRef.current = true; + startAutoScroll(); - // Prevent text selection and other drag behaviors during marquee - e.preventDefault(); - e.stopPropagation(); - }, [scrollContainerRef, startAutoScroll]); + e.preventDefault(); + e.stopPropagation(); + }, + [scrollContainerRef, startAutoScroll], + ); - const handleMouseMove = useCallback((e: MouseEvent) => { - if (!isDraggingRef.current || isGridDragging) return; + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDraggingRef.current) return; - const container = scrollContainerRef.current; - if (!container) return; + const container = scrollContainerRef.current; + if (!container) return; - const containerRect = container.getBoundingClientRect(); - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; + const containerRect = container.getBoundingClientRect(); + const x = e.clientX - containerRect.left + container.scrollLeft; + const y = e.clientY - containerRect.top + container.scrollTop; - // Calculate marquee rectangle - const rect = createRectFromPoints( - startPointRef.current.x, - startPointRef.current.y, - x, - y - ); - setMarqueeRect(rect); - }, [scrollContainerRef, isGridDragging]); + const rect = createRectFromPoints( + startPointRef.current.x, + startPointRef.current.y, + x, + y, + ); + setMarqueeRect(rect); + }, + [scrollContainerRef], + ); const handleMouseUp = useCallback(() => { if (!isDraggingRef.current) return; isDraggingRef.current = false; setIsSelecting(false); - - // Stop autoscroll when selection ends stopAutoScroll(); - // Get intersecting cards and add to selection if (marqueeRect && marqueeRect.width > 3 && marqueeRect.height > 3) { const intersecting = getIntersectingCards( marqueeRect, cardIds, - scrollContainerRef.current + scrollContainerRef.current, ); if (intersecting.length > 0) { - // Combine with existing selection (always add) const newSelection = [...Array.from(selectedCardIds), ...intersecting]; selectMultipleCards(newSelection); } } setMarqueeRect(null); - }, [marqueeRect, cardIds, scrollContainerRef, selectMultipleCards, selectedCardIds, stopAutoScroll]); + }, [ + cardIds, + marqueeRect, + scrollContainerRef, + selectMultipleCards, + selectedCardIds, + stopAutoScroll, + ]); - // Set up mouse event listeners on scroll container (normal marquee) useEffect(() => { const container = scrollContainerRef.current; if (!container) return; - container.addEventListener('mousedown', handleMouseDown); + container.addEventListener("mousedown", handleMouseDown); return () => { - container.removeEventListener('mousedown', handleMouseDown); + container.removeEventListener("mousedown", handleMouseDown); }; }, [handleMouseDown, scrollContainerRef]); - // Set up global shift+drag listener (takes precedence, captures phase) useEffect(() => { - // Use capture phase to intercept before other handlers (like card drag) - document.addEventListener('mousedown', handleGlobalShiftMouseDown, { capture: true }); + document.addEventListener("mousedown", handleGlobalShiftMouseDown, { + capture: true, + }); return () => { - document.removeEventListener('mousedown', handleGlobalShiftMouseDown, { capture: true }); + document.removeEventListener("mousedown", handleGlobalShiftMouseDown, { + capture: true, + }); }; }, [handleGlobalShiftMouseDown]); - // Set up global mouse event listeners for move and up useEffect(() => { - if (isSelecting) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + if (!isSelecting) return; - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - } + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; }, [isSelecting, handleMouseMove, handleMouseUp]); - // Calculate display rectangle (relative to viewport) - const displayRect = marqueeRect && scrollContainerRef.current ? { - left: marqueeRect.left - scrollContainerRef.current.scrollLeft, - top: marqueeRect.top - scrollContainerRef.current.scrollTop, - width: marqueeRect.width, - height: marqueeRect.height, - } : null; - - // Render marquee via portal to escape stacking context and appear above sidebar - const marqueeElement = isSelecting && displayRect && displayRect.width > 0 && displayRect.height > 0 ? ( -
- {displayRect.width > 120 && displayRect.height > 30 && ( - - Select items in workspace - - )} -
- ) : null; - - return typeof document !== 'undefined' && marqueeElement + const displayRect = + marqueeRect && scrollContainerRef.current + ? { + left: marqueeRect.left - scrollContainerRef.current.scrollLeft, + top: marqueeRect.top - scrollContainerRef.current.scrollTop, + width: marqueeRect.width, + height: marqueeRect.height, + } + : null; + + const marqueeElement = + isSelecting && displayRect && displayRect.width > 0 && displayRect.height > 0 ? ( +
+ {displayRect.width > 120 && displayRect.height > 30 && ( + + Select items in workspace + + )} +
+ ) : null; + + return typeof document !== "undefined" && marqueeElement ? createPortal(marqueeElement, document.body) : null; } - diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 740a4efc..4d6e8ea6 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, memo, useRef, useEffect } from "react"; +import { useCallback, useState, memo, type CSSProperties } from "react"; import { useTheme } from "next-themes"; import { toast } from "sonner"; import { @@ -34,10 +34,6 @@ interface WorkspaceCardProps { onMoveItem?: (itemId: string, folderId: string | null) => void; } -/** - * Individual workspace card component. - * Handles rendering a single card with drag handle, options menu, and content. - */ function WorkspaceCard({ item, allItems, @@ -60,34 +56,6 @@ function WorkspaceCard({ const [showRenameDialog, setShowRenameDialog] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); - const mouseDownRef = useRef<{ x: number; y: number } | null>(null); - const hasMovedRef = useRef(false); - const listenersActiveRef = useRef(false); - const DRAG_THRESHOLD = 10; - - const handlersRef = useRef<{ - handleGlobalMouseMove: ((e: MouseEvent) => void) | null; - handleGlobalMouseUp: (() => void) | null; - }>({ handleGlobalMouseMove: null, handleGlobalMouseUp: null }); - - useEffect(() => { - const handlers = handlersRef.current; - return () => { - if ( - listenersActiveRef.current && - handlers.handleGlobalMouseMove && - handlers.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlers.handleGlobalMouseMove, - ); - document.removeEventListener("mouseup", handlers.handleGlobalMouseUp); - listenersActiveRef.current = false; - } - }; - }, []); - const handleNameChange = useCallback( (v: string) => { onUpdateItem(item.id, { name: v }); @@ -160,127 +128,35 @@ function WorkspaceCard({ } }, [item.type, item.data]); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if ( - target.closest("button") || + const isInteractiveTarget = useCallback((target: HTMLElement) => { + return Boolean( + target.closest("button") || target.closest("input") || target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || target.closest('[role="menuitem"]') || - target.closest('[contenteditable="true"]') - ) { - e.stopPropagation(); - return; - } - - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - e.stopPropagation(); - return; - } - - mouseDownRef.current = { x: e.clientX, y: e.clientY }; - hasMovedRef.current = false; - - if (!listenersActiveRef.current) { - const handleGlobalMouseMove = (e: MouseEvent) => { - if (!mouseDownRef.current) return; - - const deltaX = Math.abs(e.clientX - mouseDownRef.current.x); - const deltaY = Math.abs(e.clientY - mouseDownRef.current.y); - - if (hasMovedRef.current) { - return; - } - - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - mouseDownRef.current = null; - hasMovedRef.current = false; - return; - } - - if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { - hasMovedRef.current = true; - } - }; - - const handleGlobalMouseUp = () => { - mouseDownRef.current = null; - if ( - listenersActiveRef.current && - handlersRef.current.handleGlobalMouseMove && - handlersRef.current.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlersRef.current.handleGlobalMouseMove, - ); - document.removeEventListener( - "mouseup", - handlersRef.current.handleGlobalMouseUp, - ); - listenersActiveRef.current = false; - handlersRef.current.handleGlobalMouseMove = null; - handlersRef.current.handleGlobalMouseUp = null; - } - }; - - handlersRef.current.handleGlobalMouseMove = handleGlobalMouseMove; - handlersRef.current.handleGlobalMouseUp = handleGlobalMouseUp; - document.addEventListener("mousemove", handleGlobalMouseMove); - document.addEventListener("mouseup", handleGlobalMouseUp); - listenersActiveRef.current = true; - } - }, - [DRAG_THRESHOLD], - ); - - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (!mouseDownRef.current) return; - - const target = e.target as HTMLElement; - if ( - target.closest("textarea") || - target.closest("input") || - target.closest('[contenteditable="true"]') - ) { - mouseDownRef.current = null; - hasMovedRef.current = false; - } - }, []); - - const handleMouseUp = useCallback(() => {}, []); - - const handleCardClick = useCallback( - (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if ( - target.closest('[data-slot="dropdown-menu-item"]') || + target.closest('[contenteditable="true"]') || target.closest('[data-slot="dropdown-menu-content"]') || target.closest('[data-slot="dropdown-menu-trigger"]') || target.closest('[data-slot="popover-content"]') || target.closest('[data-slot="popover"]') || target.closest('[data-slot="dialog-content"]') || target.closest('[data-slot="dialog-close"]') || - target.closest('[data-slot="dialog-overlay"]') - ) { + target.closest('[data-slot="dialog-overlay"]'), + ); + }, []); + + const handleCardClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { e.preventDefault(); e.stopPropagation(); return; } - if (item.type === "flashcard") { - const flashcardElement = target.closest( - '.flashcard-container, .flashcard, [class*="flashcard"]', - ); - if (flashcardElement) { - e.stopPropagation(); - return; - } - } - const selection = window.getSelection(); if (selection && selection.toString().length > 0) { return; @@ -292,10 +168,7 @@ function WorkspaceCard({ return; } - const wasDragging = hasMovedRef.current; - hasMovedRef.current = false; - - if (wasDragging || isEditingTitle) { + if (isEditingTitle) { e.preventDefault(); e.stopPropagation(); return; @@ -303,7 +176,7 @@ function WorkspaceCard({ onOpenModal(item.id); }, - [isEditingTitle, item.id, item.type, onOpenModal, onToggleSelection], + [isEditingTitle, isInteractiveTarget, item.id, onOpenModal, onToggleSelection], ); const handleMove = useCallback( @@ -361,23 +234,17 @@ function WorkspaceCard({ : undefined, transition: "border-color 150ms ease-out, box-shadow 150ms ease-out, background-color 150ms ease-out", - } as React.CSSProperties + } as CSSProperties } - onMouseDown={handleMouseDown} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} onClick={handleCardClick} > {}} onToggleSelection={() => onToggleSelection(item.id)} onOpenRename={() => setShowRenameDialog(true)} onOpenMove={() => setShowMoveDialog(true)} diff --git a/src/components/workspace-canvas/WorkspaceCardActions.tsx b/src/components/workspace-canvas/WorkspaceCardActions.tsx index 2f30c53c..bb2950c3 100644 --- a/src/components/workspace-canvas/WorkspaceCardActions.tsx +++ b/src/components/workspace-canvas/WorkspaceCardActions.tsx @@ -11,7 +11,6 @@ import { X, Pencil, } from "lucide-react"; -import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi"; import { cn } from "@/lib/utils"; import type { Item } from "@/lib/workspace-state/types"; import { @@ -142,14 +141,11 @@ function getFloatingControlHandlers({ interface WorkspaceCardControlsProps { itemType: Item["type"]; - showScrollLockButton: boolean; useDarkOverlay: boolean; resolvedTheme?: string; - isScrollLocked: boolean; isSelected: boolean; isEditingTitle: boolean; canMove: boolean; - onToggleScrollLock: () => void; onToggleSelection: () => void; onOpenRename: () => void; onOpenMove: () => void; @@ -160,14 +156,11 @@ interface WorkspaceCardControlsProps { export function WorkspaceCardControls({ itemType, - showScrollLockButton, useDarkOverlay, resolvedTheme, - isScrollLocked, isSelected, isEditingTitle, canMove, - onToggleScrollLock, onToggleSelection, onOpenRename, onOpenMove, @@ -195,11 +188,6 @@ export function WorkspaceCardControls({ ? "rgba(239, 68, 68, 0.6)" : "rgba(239, 68, 68, 0.5)" : defaultHoverBackgroundColor; - const scrollLockHandlers = getFloatingControlHandlers({ - defaultBackgroundColor, - hoverBackgroundColor: defaultHoverBackgroundColor, - onClick: onToggleScrollLock, - }); const selectionHandlers = getFloatingControlHandlers({ defaultBackgroundColor: selectionBackgroundColor, hoverBackgroundColor: selectionHoverBackgroundColor, @@ -217,38 +205,6 @@ export function WorkspaceCardControls({ isEditingTitle ? "" : "opacity-0 group-hover:opacity-100", )} > - {showScrollLockButton && ( - - )} -