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/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,142 +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 [isDragHover, setIsDragHover] = useState(false); - const [selectedCount, setSelectedCount] = useState(null); 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]); - // 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 - 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); @@ -221,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); @@ -235,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; @@ -253,64 +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)}> @@ -602,9 +545,8 @@ function FolderCardComponent({ Delete Folder - + ); } export const FolderCard = memo(FolderCardComponent); - 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/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..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 { @@ -8,8 +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 { getLayoutForBreakpoint } from "@/lib/workspace-state/grid-layout-helpers"; +import { useUIStore } from "@/lib/stores/ui-store"; import { ContextMenu, ContextMenuContent, @@ -25,22 +24,16 @@ 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; } -/** - * Individual workspace card component. - * Handles rendering a single card with drag handle, options menu, and content. - */ function WorkspaceCard({ item, allItems, @@ -53,76 +46,16 @@ 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 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; - - // 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 - - // 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 () => { - if ( - listenersActiveRef.current && - handlers.handleGlobalMouseMove && - handlers.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlers.handleGlobalMouseMove, - ); - 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 +85,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,192 +128,55 @@ 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") || + 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"]') - ) { - // 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; - } - - 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; - hasMovedRef.current = false; - return; - } - - // Check if movement exceeds threshold - if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { - hasMovedRef.current = true; - } - }; - - const handleGlobalMouseUp = () => { - mouseDownRef.current = null; - // Clean up listeners when mouse up - 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], - ); - - // 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 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"]') || + 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; } - // 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) { + if (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, isInteractiveTarget, item.id, onOpenModal, onToggleSelection], ); const handleMove = useCallback( @@ -394,18 +189,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 +199,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" @@ -451,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} > toggleItemScrollLocked(item.id)} onToggleSelection={() => onToggleSelection(item.id)} onOpenRename={() => setShowRenameDialog(true)} onOpenMove={() => setShowMoveDialog(true)} @@ -476,29 +253,16 @@ function WorkspaceCard({ onDelete={handleDelete} /> - + - onUpdateItem(item.id, { - data: updater(item.data) as Item["data"], - }) - } /> @@ -539,18 +303,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,24 +357,8 @@ 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 - - // 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/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 && ( - - )} -
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..482e7e30 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 }), }; } @@ -303,8 +301,6 @@ export async function workspaceWorker( favicon?: string; }>; 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; @@ -995,7 +991,7 @@ export async function workspaceWorker( success: true, itemId: params.itemId, message: existingItem - ? `Deleted \"${existingItem.name}\" successfully` + ? `Deleted "${existingItem.name}" successfully` : "Deleted item successfully", }; } 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