diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx
index a4e7f1ba..29d8b0e8 100644
--- a/apps/web/components/memories-grid.tsx
+++ b/apps/web/components/memories-grid.tsx
@@ -394,11 +394,14 @@ export function MemoriesGrid({
return items
}, [documents, isMobile, hasQuickNote, isSelectionMode, selectedDocumentIds])
- // Stable key for Masonry based on document IDs, not item values
+ // Reset Masonry when the actual rendered item set changes. Masonic caches
+ // positions by index, so mobile removing the quick note must remount it.
const masonryKey = useMemo(() => {
- const docIds = documents.map((d) => d.id).join(",")
- return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}`
- }, [documents, isChatOpen, hasQuickNote])
+ const itemIds = masonryItems.map((item) => item.id).join(",")
+ return `masonry-${isMobile ? "mobile" : "desktop"}-${masonryItems.length}-${itemIds}-${isChatOpen}`
+ }, [masonryItems, isChatOpen, isMobile])
+
+ const getMasonryItemKey = useCallback((item: MasonryItem) => item.id, [])
const isLoadingMore = isFetchingNextPage
@@ -542,9 +545,9 @@ export function MemoriesGrid({
{!isEmpty && !isSelectionMode && (
-
+
-
+
{/* View mode toggle — segmented control */}
void
@@ -13,33 +16,74 @@ interface QuickNoteCardProps {
isSaving?: boolean
}
+type NoteRect = {
+ left: number
+ top: number
+ width: number
+ height: number
+}
+
+function getExpandedRect(source: NoteRect): NoteRect {
+ const viewportWidth = window.innerWidth
+ const viewportHeight = window.innerHeight
+ const margin = viewportWidth < 768 ? 16 : 32
+ const availableWidth = viewportWidth - source.left - margin
+ const availableHeight = viewportHeight - source.top - margin
+ const width = Math.min(1008, Math.max(source.width, availableWidth))
+ const height = Math.min(720, Math.max(source.height, availableHeight))
+
+ return {
+ left: source.left,
+ top: source.top,
+ width,
+ height,
+ }
+}
+
export function QuickNoteCard({
onSave,
onMaximize,
isSaving = false,
}: QuickNoteCardProps) {
- const textareaRef = useRef
(null)
+ const cardRef = useRef(null)
+ const wasSavingRef = useRef(isSaving)
const { selectedProject } = useProject()
const { draft, setDraft } = useQuickNoteDraft(selectedProject)
+ const [isExpanded, setIsExpanded] = useState(false)
+ const [sourceRect, setSourceRect] = useState(null)
+ const [targetRect, setTargetRect] = useState(null)
+ const [expandedInitialContent, setExpandedInitialContent] = useState<
+ string | undefined
+ >(undefined)
+ const [isMounted, setIsMounted] = useState(false)
const handleChange = useCallback(
- (e: React.ChangeEvent) => {
- setDraft(e.target.value)
+ (content: string) => {
+ setDraft(content)
},
[setDraft],
)
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
- e.preventDefault()
- if (draft.trim() && !isSaving) {
- onSave(draft)
- }
- }
- },
- [draft, isSaving, onSave],
- )
+ const handleExpand = useCallback(() => {
+ const rect = cardRef.current?.getBoundingClientRect()
+ if (!rect) return
+
+ const nextSourceRect = {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ }
+
+ setSourceRect(nextSourceRect)
+ setTargetRect(getExpandedRect(nextSourceRect))
+ setExpandedInitialContent(draft || undefined)
+ setIsExpanded(true)
+ }, [draft])
+
+ const handleClose = useCallback(() => {
+ setIsExpanded(false)
+ }, [])
const handleSaveClick = useCallback(() => {
if (draft.trim() && !isSaving) {
@@ -48,112 +92,278 @@ export function QuickNoteCard({
}, [draft, isSaving, onSave])
const handleMaximizeClick = useCallback(() => {
+ setIsExpanded(false)
onMaximize(draft)
}, [draft, onMaximize])
const canSave = draft.trim().length > 0 && !isSaving
+ const previewText = useMemo(() => {
+ const trimmed = draft.trim()
+ if (!trimmed) return null
+ return trimmed.replace(/\s+/g, " ")
+ }, [draft])
+
+ useEffect(() => {
+ setIsMounted(true)
+ }, [])
+
+ useEffect(() => {
+ if (!isExpanded || !sourceRect) return
+
+ const handleResize = () => setTargetRect(getExpandedRect(sourceRect))
+ window.addEventListener("resize", handleResize)
+ return () => window.removeEventListener("resize", handleResize)
+ }, [isExpanded, sourceRect])
+
+ useEffect(() => {
+ if (!isExpanded) return
+
+ const previousOverflow = document.body.style.overflow
+ document.body.style.overflow = "hidden"
+
+ const handleGlobalKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault()
+ handleClose()
+ }
+ }
+
+ document.addEventListener("keydown", handleGlobalKeyDown)
+
+ return () => {
+ document.body.style.overflow = previousOverflow
+ document.removeEventListener("keydown", handleGlobalKeyDown)
+ }
+ }, [isExpanded, handleClose])
+
+ useEffect(() => {
+ if (wasSavingRef.current && !isSaving && draft.trim().length === 0) {
+ setIsExpanded(false)
+ }
+ wasSavingRef.current = isSaving
+ }, [draft, isSaving])
return (
-
+ <>
-
-
-
-
-
-
+
+
+
+
+
-
+
+ {isMounted &&
+ createPortal(
+
+ {isExpanded && sourceRect && targetRect && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ ,
+ document.body,
+ )}
+ >
)
}
diff --git a/apps/web/components/text-editor/extensions.tsx b/apps/web/components/text-editor/extensions.tsx
index 9db26c2b..cefbac18 100644
--- a/apps/web/components/text-editor/extensions.tsx
+++ b/apps/web/components/text-editor/extensions.tsx
@@ -6,9 +6,13 @@ import TaskList from "@tiptap/extension-task-list"
import TaskItem from "@tiptap/extension-task-item"
import { cx } from "class-variance-authority"
-const placeholder = Placeholder.configure({
- placeholder: 'Write, paste anything or type "/" for commands...',
-})
+const DEFAULT_PLACEHOLDER = 'Write, paste anything or type "/" for commands...'
+
+function createPlaceholder(placeholderText = DEFAULT_PLACEHOLDER) {
+ return Placeholder.configure({
+ placeholder: placeholderText,
+ })
+}
const taskList = TaskList.configure({
HTMLAttributes: {
@@ -82,11 +86,15 @@ const starterKit = StarterKit.configure({
gapcursor: false,
})
-export const defaultExtensions = [
- starterKit,
- placeholder,
- link,
- image,
- taskList,
- taskItem,
-]
+export function createDefaultExtensions(placeholderText?: string) {
+ return [
+ starterKit,
+ createPlaceholder(placeholderText),
+ link,
+ image,
+ taskList,
+ taskItem,
+ ]
+}
+
+export const defaultExtensions = createDefaultExtensions()
diff --git a/apps/web/components/text-editor/index.tsx b/apps/web/components/text-editor/index.tsx
index 687c8065..58bd375a 100644
--- a/apps/web/components/text-editor/index.tsx
+++ b/apps/web/components/text-editor/index.tsx
@@ -4,30 +4,36 @@ import { useEditor, EditorContent } from "@tiptap/react"
import { BubbleMenu } from "@tiptap/react/menus"
import type { Editor } from "@tiptap/core"
import { Markdown } from "@tiptap/markdown"
-import { useRef, useEffect, useCallback } from "react"
-import { defaultExtensions } from "./extensions"
+import { useRef, useEffect, useCallback, useMemo } from "react"
+import { createDefaultExtensions } from "./extensions"
import { slashCommand } from "./suggestions"
import { Bold, Italic, Code } from "lucide-react"
import { useDebouncedCallback } from "use-debounce"
import { cn } from "@lib/utils"
-const extensions = [...defaultExtensions, slashCommand, Markdown]
-
export function TextEditor({
content: initialContent,
onContentChange,
onSubmit,
debounceMs = 500,
+ autoFocus = false,
+ placeholder,
}: {
content: string | undefined
onContentChange: (content: string) => void
onSubmit: () => void
debounceMs?: number
+ autoFocus?: boolean
+ placeholder?: string
}) {
const containerRef = useRef(null)
const editorRef = useRef(null)
const onSubmitRef = useRef(onSubmit)
const hasUserEditedRef = useRef(false)
+ const extensions = useMemo(
+ () => [...createDefaultExtensions(placeholder), slashCommand, Markdown],
+ [placeholder],
+ )
useEffect(() => {
onSubmitRef.current = onSubmit
@@ -92,6 +98,16 @@ export function TextEditor({
}
}, [editor, initialContent])
+ useEffect(() => {
+ if (!editor || !autoFocus) return
+
+ const id = window.setTimeout(() => {
+ editor.commands.focus("end")
+ }, 0)
+
+ return () => window.clearTimeout(id)
+ }, [editor, autoFocus])
+
const handleClick = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (target.closest(".ProseMirror")) {
diff --git a/apps/web/components/text-editor/slash-command.tsx b/apps/web/components/text-editor/slash-command.tsx
index 3b6f3a33..846b8792 100644
--- a/apps/web/components/text-editor/slash-command.tsx
+++ b/apps/web/components/text-editor/slash-command.tsx
@@ -111,7 +111,7 @@ function CommandMenu({
if (!mounted) return null
return createPortal(
-