Improve quick note editor interactions & fix filter chips responsiveness (#964)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Vedant Mahajan <vedant.04.mahajan@gmail.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Ishaan Gupta 2026-05-21 21:29:33 +05:30 committed by GitHub
parent 7a9b155db3
commit 7f6b28ab83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 225 additions and 130 deletions

View file

@ -20,11 +20,9 @@ export function NoteContent({
const [content, setContent] = useState(initialContent ?? "")
const [seededContent] = useState(initialContent || undefined)
const canSubmit = content.trim().length > 0 && !isSubmitting
const handleSubmit = () => {
if (canSubmit && onSubmit) {
onSubmit(content)
const handleSubmit = (submittedContent = content) => {
if (submittedContent.trim() && !isSubmitting && onSubmit) {
onSubmit(submittedContent)
}
}

View file

@ -14,7 +14,7 @@ export interface TextEditorProps {
hasUnsavedChanges: boolean
isSaving: boolean
onContentChange: (content: string) => void
onSave: () => void
onSave: (content?: string) => void
onReset: () => void
}
@ -60,7 +60,7 @@ export function TextEditorContent({
<Button
variant="insideOut"
size="sm"
onClick={onSave}
onClick={() => onSave()}
disabled={isSaving}
className="hover:text-white rounded-full px-4"
>

View file

@ -236,15 +236,20 @@ export function DocumentModal({
draftContentString !== initialEditorString &&
draftContentString !== lastSavedContent
const handleSave = useCallback(() => {
if (!_document?.id) return
updateMutation.mutate(
{ documentId: _document.id, content: draftContentString },
{
onSuccess: (_data, variables) => setLastSavedContent(variables.content),
},
)
}, [_document?.id, draftContentString, updateMutation])
const handleSave = useCallback(
(submittedContent?: string) => {
if (!_document?.id) return
const content = submittedContent ?? draftContentString
updateMutation.mutate(
{ documentId: _document.id, content },
{
onSuccess: (_data, variables) =>
setLastSavedContent(variables.content),
},
)
},
[_document?.id, draftContentString, updateMutation],
)
const textEditorProps = useMemo(
() => ({

View file

@ -52,12 +52,15 @@ export function FullscreenNoteModal({
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
const handleSave = useCallback(() => {
const currentContent = contentRef.current
if (currentContent.trim() && !isSaving) {
onSave(currentContent)
}
}, [isSaving, onSave])
const handleSave = useCallback(
(submittedContent?: string) => {
const currentContent = submittedContent ?? contentRef.current
if (currentContent.trim() && !isSaving) {
onSave(currentContent)
}
},
[isSaving, onSave],
)
const handleContentChange = useCallback(
(newContent: string) => {
@ -165,7 +168,7 @@ export function FullscreenNoteModal({
>
<button
type="button"
onClick={handleSave}
onClick={() => handleSave()}
disabled={!canSave}
className={cn(
"bg-[#1B1F24] rounded-[8px] px-4 py-2.5 flex items-center justify-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",

View file

@ -542,13 +542,13 @@ export function MemoriesGrid({
{!isEmpty && !isSelectionMode && (
<div
id="filter-pills"
className="flex items-center justify-between gap-4 mb-3 pr-2"
className="mb-3 flex flex-col gap-2 pr-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<div className="flex flex-wrap items-center gap-1.5">
<div className="scrollbar-none -mx-1 flex items-center gap-1.5 overflow-x-auto px-1 pb-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0">
<Button
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
"h-auto shrink-0 rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategories.length === 0 &&
"bg-[#00173C] border-[#2261CA33]",
)}
@ -566,7 +566,7 @@ export function MemoriesGrid({
key={facet.category}
className={cn(
dmSansClassName(),
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
"h-auto shrink-0 rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategoriesSet.has(facet.category) &&
"bg-[#00173C] border-[#2261CA33]",
)}
@ -577,7 +577,7 @@ export function MemoriesGrid({
</Button>
))}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex shrink-0 items-center justify-end gap-2">
{/* View mode toggle — segmented control */}
<div
role="tablist"

View file

@ -1,11 +1,12 @@
"use client"
import { useRef, useCallback } from "react"
import { useCallback, useEffect, useState } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Maximize2, Plus, Loader2 } from "lucide-react"
import { useProject } from "@/stores"
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
import { TextEditor } from "./text-editor"
interface QuickNoteCardProps {
onSave: (content: string) => void
@ -18,27 +19,22 @@ export function QuickNoteCard({
onMaximize,
isSaving = false,
}: QuickNoteCardProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [isExpanded, setIsExpanded] = useState(false)
const { selectedProject } = useProject()
const { draft, setDraft } = useQuickNoteDraft(selectedProject)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraft(e.target.value)
},
const handleContentChange = useCallback(
(content: string) => setDraft(content),
[setDraft],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault()
if (draft.trim() && !isSaving) {
onSave(draft)
}
const handleEditorSubmit = useCallback(
(content: string) => {
if (content.trim() && !isSaving) {
onSave(content)
}
},
[draft, isSaving, onSave],
[isSaving, onSave],
)
const handleSaveClick = useCallback(() => {
@ -51,109 +47,172 @@ export function QuickNoteCard({
onMaximize(draft)
}, [draft, onMaximize])
const handleBlurCapture = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return
setIsExpanded(false)
},
[],
)
const handleBackdropPointerDown = useCallback(() => {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement) {
activeElement.blur()
}
setIsExpanded(false)
}, [])
useEffect(() => {
if (!isExpanded) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement) {
activeElement.blur()
}
setIsExpanded(false)
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isExpanded])
const canSave = draft.trim().length > 0 && !isSaving
const hasDraft = draft.trim().length > 0
const editorHeight = isExpanded
? "min-h-[min(58dvh,520px)] sm:min-h-[min(54vh,560px)]"
: hasDraft
? "min-h-[188px]"
: "min-h-[120px]"
return (
<div
className="bg-[#1B1F24] rounded-[22px] p-1"
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<>
{isExpanded && (
<div
className="fixed inset-0 z-[60] bg-[#05080D]/45 backdrop-blur-[10px]"
onPointerDown={handleBackdropPointerDown}
aria-hidden
/>
)}
<div
id="quick-note-inner"
className="bg-[#0B1017] rounded-[18px] p-3 relative"
className={cn(
"relative w-full rounded-[22px] bg-[#1B1F24] p-1 transition-[box-shadow,transform,width] duration-200",
isExpanded &&
"z-[70] w-[min(calc(100vw-1.5rem),640px)] sm:w-full scale-[1.01]",
)}
style={{
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
boxShadow: isExpanded
? "0 24px 80px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255,255,255,0.08), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset"
: "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
>
<button
type="button"
onClick={handleMaximizeClick}
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
aria-label="Expand to full screen"
>
<Maximize2 className="size-[14px]" />
</button>
<textarea
ref={textareaRef}
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Start writing..."
disabled={isSaving}
className={cn(
dmSansClassName(),
"w-full h-[120px] bg-transparent resize-none outline-none text-[12px] leading-normal text-white placeholder:text-[#737373] pr-5 disabled:opacity-50",
)}
/>
<div
id="quick-note-action-bar"
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
id="quick-note-inner"
className={cn(
"relative flex flex-col rounded-[18px] bg-[#0B1017] p-3",
isExpanded &&
"fixed inset-x-3 top-[calc(env(safe-area-inset-top)+72px)] z-[71] max-h-[calc(100dvh-96px)] sm:static sm:inset-auto",
)}
onFocusCapture={() => setIsExpanded(true)}
onBlurCapture={handleBlurCapture}
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
}}
>
<button
type="button"
onClick={handleSaveClick}
disabled={!canSave}
className={cn(
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
onClick={handleMaximizeClick}
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
aria-label="Expand to full screen"
>
<span className="flex items-center gap-1.5">
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
</span>
<Maximize2 className="size-[14px]" />
</button>
<span
<div
className={cn(
dmSansClassName(),
"w-full flex-1 overflow-y-auto pr-5 text-white disabled:opacity-50",
"[&_.ProseMirror]:text-[12px] [&_.ProseMirror]:leading-normal [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-[#737373]",
editorHeight,
)}
aria-disabled={isSaving}
>
<TextEditor
content={draft}
onContentChange={handleContentChange}
onSubmit={handleEditorSubmit}
debounceMs={0}
editable={!isSaving}
/>
</div>
<div
id="quick-note-action-bar"
className="mt-3 flex w-full items-center justify-center gap-8 rounded-[8px] bg-[#1B1F24] px-2 py-1.5"
style={{
boxShadow:
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
}}
>
<button
type="button"
onClick={handleSaveClick}
disabled={!canSave}
className={cn(
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<svg
className="size-[10px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Command Key</title>
<path
d="M6.67 0.42C6.34 0.42 6.02 0.55 5.78 0.78C5.55 1.02 5.42 1.34 5.42 1.67V6.67C5.42 7 5.55 7.32 5.78 7.55C6.02 7.78 6.34 7.92 6.67 7.92C7 7.92 7.32 7.78 7.55 7.55C7.78 7.32 7.92 7 7.92 6.67C7.92 6.34 7.78 6.02 7.55 5.78C7.32 5.55 7 5.42 6.67 5.42H1.67C1.34 5.42 1.02 5.55 0.78 5.78C0.55 6.02 0.42 6.34 0.42 6.67C0.42 7 0.55 7.32 0.78 7.55C1.02 7.78 1.34 7.92 1.67 7.92C2 7.92 2.32 7.78 2.55 7.55C2.78 7.32 2.92 7 2.92 6.67V1.67C2.92 1.34 2.78 1.02 2.55 0.78C2.32 0.55 2 0.42 1.67 0.42C1.34 0.42 1.02 0.55 0.78 0.78C0.55 1.02 0.42 1.34 0.42 1.67C0.42 2 0.55 2.32 0.78 2.55C1.02 2.78 1.34 2.92 1.67 2.92H6.67C7 2.92 7.32 2.78 7.55 2.55C7.78 2.32 7.92 2 7.92 1.67C7.92 1.34 7.78 1.02 7.55 0.78C7.32 0.55 7 0.42 6.67 0.42Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="flex items-center gap-1.5">
{isSaving ? (
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
) : (
<Plus className="size-2 text-[#fafafa]" />
)}
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#fafafa]",
)}
>
{isSaving ? "Saving..." : "Save note"}
</span>
</span>
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#737373]",
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
)}
>
Enter
<svg
className="size-[10px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Command Key</title>
<path
d="M6.67 0.42C6.34 0.42 6.02 0.55 5.78 0.78C5.55 1.02 5.42 1.34 5.42 1.67V6.67C5.42 7 5.55 7.32 5.78 7.55C6.02 7.78 6.34 7.92 6.67 7.92C7 7.92 7.32 7.78 7.55 7.55C7.78 7.32 7.92 7 7.92 6.67C7.92 6.34 7.78 6.02 7.55 5.78C7.32 5.55 7 5.42 6.67 5.42H1.67C1.34 5.42 1.02 5.55 0.78 5.78C0.55 6.02 0.42 6.34 0.42 6.67C0.42 7 0.55 7.32 0.78 7.55C1.02 7.78 1.34 7.92 1.67 7.92C2 7.92 2.32 7.78 2.55 7.55C2.78 7.32 2.92 7 2.92 6.67V1.67C2.92 1.34 2.78 1.02 2.55 0.78C2.32 0.55 2 0.42 1.67 0.42C1.34 0.42 1.02 0.55 0.78 0.78C0.55 1.02 0.42 1.34 0.42 1.67C0.42 2 0.55 2.32 0.78 2.55C1.02 2.78 1.34 2.92 1.67 2.92H6.67C7 2.92 7.32 2.78 7.55 2.55C7.78 2.32 7.92 2 7.92 1.67C7.92 1.34 7.78 1.02 7.55 0.78C7.32 0.55 7 0.42 6.67 0.42Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span
className={cn(
dmSansClassName(),
"text-[10px] font-medium text-[#737373]",
)}
>
Enter
</span>
</span>
</span>
</button>
</button>
</div>
</div>
</div>
</div>
</>
)
}

View file

@ -13,21 +13,30 @@ import { cn } from "@lib/utils"
const extensions = [...defaultExtensions, slashCommand, Markdown]
function getEditorMarkdown(editor: Editor) {
const json = editor.getJSON()
return editor.storage.markdown?.manager?.serialize(json) ?? ""
}
export function TextEditor({
content: initialContent,
onContentChange,
onSubmit,
debounceMs = 500,
editable = true,
}: {
content: string | undefined
onContentChange: (content: string) => void
onSubmit: () => void
onSubmit: (content: string) => void
debounceMs?: number
editable?: boolean
}) {
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Editor | null>(null)
const onSubmitRef = useRef(onSubmit)
const hasUserEditedRef = useRef(false)
const lastAppliedContentRef = useRef(initialContent)
const lastEmittedContentRef = useRef<string | undefined>(undefined)
useEffect(() => {
onSubmitRef.current = onSubmit
@ -35,8 +44,8 @@ export function TextEditor({
const debouncedUpdates = useDebouncedCallback((editor: Editor) => {
if (!hasUserEditedRef.current) return
const json = editor.getJSON()
const markdown = editor.storage.markdown?.manager?.serialize(json) ?? ""
const markdown = getEditorMarkdown(editor)
lastEmittedContentRef.current = markdown
onContentChange?.(markdown)
}, debounceMs)
@ -44,6 +53,7 @@ export function TextEditor({
extensions,
content: initialContent,
contentType: "markdown",
editable,
immediatelyRender: true,
onCreate: ({ editor }) => {
editorRef.current = editor
@ -52,8 +62,8 @@ export function TextEditor({
editorRef.current = editor
if (!hasUserEditedRef.current) return
if (debounceMs === 0) {
const json = editor.getJSON()
const markdown = editor.storage.markdown?.manager?.serialize(json) ?? ""
const markdown = getEditorMarkdown(editor)
lastEmittedContentRef.current = markdown
onContentChange?.(markdown)
return
}
@ -64,7 +74,10 @@ export function TextEditor({
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault()
debouncedUpdates.flush()
onSubmitRef.current?.()
const currentEditor = editorRef.current
const markdown = currentEditor ? getEditorMarkdown(currentEditor) : ""
lastEmittedContentRef.current = markdown
onSubmitRef.current?.(markdown)
return true
}
hasUserEditedRef.current = true
@ -86,12 +99,29 @@ export function TextEditor({
})
useEffect(() => {
if (editor && initialContent) {
hasUserEditedRef.current = false
editor.commands.setContent(initialContent, { contentType: "markdown" })
if (!editor || initialContent === undefined) return
if (initialContent === lastAppliedContentRef.current) return
if (initialContent === lastEmittedContentRef.current) {
lastAppliedContentRef.current = initialContent
return
}
const currentContent = getEditorMarkdown(editor)
if (currentContent === initialContent) {
lastAppliedContentRef.current = initialContent
return
}
hasUserEditedRef.current = false
lastAppliedContentRef.current = initialContent
editor.commands.setContent(initialContent, { contentType: "markdown" })
}, [editor, initialContent])
useEffect(() => {
editor?.setEditable(editable)
}, [editor, editable])
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
if (target.closest(".ProseMirror")) {