mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(web): submit latest editor content on shortcut
This commit is contained in:
parent
e651045ac5
commit
95e0d1df0b
7 changed files with 223 additions and 43 deletions
|
|
@ -5,7 +5,7 @@ import { LinkIcon } from "lucide-react"
|
|||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { TextEditor } from "../text-editor"
|
||||
import { resolveSubmittedContent, TextEditor } from "../text-editor"
|
||||
import { extractUrls } from "@/lib/url-helpers"
|
||||
|
||||
interface NoteContentProps {
|
||||
|
|
@ -31,11 +31,10 @@ export function NoteContent({
|
|||
const { urls: detectedUrls } = useMemo(() => extractUrls(content), [content])
|
||||
const showBulkOffer = detectedUrls.length >= 2 && !dismissed
|
||||
|
||||
const canSubmit = content.trim().length > 0 && !isSubmitting
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (canSubmit && onSubmit) {
|
||||
onSubmit(content)
|
||||
const handleSubmit = (submittedContent?: string) => {
|
||||
const contentToSubmit = resolveSubmittedContent(submittedContent, content)
|
||||
if (contentToSubmit.trim() && !isSubmitting && onSubmit) {
|
||||
onSubmit(contentToSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { toast } from "sonner"
|
|||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { parsePluginDocument } from "@/lib/plugin-document"
|
||||
import { useFullDocumentContent } from "@/hooks/use-full-document"
|
||||
import { resolveSubmittedContent } from "../text-editor"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -247,15 +248,23 @@ 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 = resolveSubmittedContent(
|
||||
submittedContent,
|
||||
draftContentString,
|
||||
)
|
||||
updateMutation.mutate(
|
||||
{ documentId: _document.id, content },
|
||||
{
|
||||
onSuccess: (_data, variables) =>
|
||||
setLastSavedContent(variables.content),
|
||||
},
|
||||
)
|
||||
},
|
||||
[_document?.id, draftContentString, updateMutation],
|
||||
)
|
||||
|
||||
const textEditorProps = useMemo(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { dmSansClassName } from "@/lib/fonts"
|
|||
import { Logo } from "@ui/assets/Logo"
|
||||
import { Minimize2, Plus, Loader2 } from "lucide-react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { TextEditor } from "./text-editor"
|
||||
import { resolveSubmittedContent, TextEditor } from "./text-editor"
|
||||
import { useProject } from "@/stores"
|
||||
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
|
|
@ -52,12 +52,18 @@ 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 = resolveSubmittedContent(
|
||||
submittedContent,
|
||||
contentRef.current,
|
||||
)
|
||||
if (currentContent.trim() && !isSaving) {
|
||||
onSave(currentContent)
|
||||
}
|
||||
},
|
||||
[isSaving, onSave],
|
||||
)
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(newContent: string) => {
|
||||
|
|
@ -165,7 +171,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",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { dmSansClassName } from "@/lib/fonts"
|
|||
import { Maximize2, Plus, Loader2, X } from "lucide-react"
|
||||
import { useProject } from "@/stores"
|
||||
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
|
||||
import { TextEditor } from "./text-editor"
|
||||
import { resolveSubmittedContent, TextEditor } from "./text-editor"
|
||||
|
||||
interface QuickNoteCardProps {
|
||||
onSave: (content: string) => void
|
||||
|
|
@ -85,11 +85,15 @@ export function QuickNoteCard({
|
|||
setIsExpanded(false)
|
||||
}, [])
|
||||
|
||||
const handleSaveClick = useCallback(() => {
|
||||
if (draft.trim() && !isSaving) {
|
||||
onSave(draft)
|
||||
}
|
||||
}, [draft, isSaving, onSave])
|
||||
const handleSave = useCallback(
|
||||
(submittedContent?: string) => {
|
||||
const content = resolveSubmittedContent(submittedContent, draft)
|
||||
if (content.trim() && !isSaving) {
|
||||
onSave(content)
|
||||
}
|
||||
},
|
||||
[draft, isSaving, onSave],
|
||||
)
|
||||
|
||||
const handleMaximizeClick = useCallback(() => {
|
||||
setIsExpanded(false)
|
||||
|
|
@ -199,7 +203,7 @@ export function QuickNoteCard({
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveClick}
|
||||
onClick={() => handleSave()}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
|
|
@ -322,7 +326,7 @@ export function QuickNoteCard({
|
|||
<TextEditor
|
||||
content={expandedInitialContent}
|
||||
onContentChange={handleChange}
|
||||
onSubmit={handleSaveClick}
|
||||
onSubmit={handleSave}
|
||||
debounceMs={0}
|
||||
autoFocus
|
||||
placeholder="Start writing..."
|
||||
|
|
@ -332,7 +336,7 @@ export function QuickNoteCard({
|
|||
<footer className="flex shrink-0 justify-center border-t border-[#202A36]/70 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveClick}
|
||||
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",
|
||||
|
|
|
|||
116
apps/web/components/text-editor/index.test.ts
Normal file
116
apps/web/components/text-editor/index.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
handleEditorSubmitShortcut,
|
||||
resolveSubmittedContent,
|
||||
submitEditorContent,
|
||||
} from "."
|
||||
|
||||
type EditorSnapshot = NonNullable<Parameters<typeof submitEditorContent>[0]>
|
||||
|
||||
function createEditor(readMarkdown: () => string): EditorSnapshot {
|
||||
return {
|
||||
getJSON: () => ({ type: "doc" }),
|
||||
storage: {
|
||||
markdown: {
|
||||
manager: {
|
||||
serialize: readMarkdown,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as EditorSnapshot
|
||||
}
|
||||
|
||||
describe("submitEditorContent", () => {
|
||||
it("submits the editor snapshot captured before pending updates flush", () => {
|
||||
let markdown = "latest editor content"
|
||||
const events: string[] = []
|
||||
const editor = createEditor(() => {
|
||||
events.push("serialize")
|
||||
return markdown
|
||||
})
|
||||
|
||||
submitEditorContent(
|
||||
editor,
|
||||
() => {
|
||||
events.push("flush")
|
||||
markdown = "content changed during flush"
|
||||
},
|
||||
(content) => events.push(`submit:${content}`),
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
"serialize",
|
||||
"flush",
|
||||
"submit:latest editor content",
|
||||
])
|
||||
})
|
||||
|
||||
it("does not submit when the editor is unavailable", () => {
|
||||
const events: string[] = []
|
||||
|
||||
submitEditorContent(
|
||||
null,
|
||||
() => events.push("flush"),
|
||||
(content) => events.push(`submit:${content}`),
|
||||
)
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleEditorSubmitShortcut", () => {
|
||||
it("handles both Command+Enter and Control+Enter", () => {
|
||||
for (const modifier of ["metaKey", "ctrlKey"] as const) {
|
||||
const events: string[] = []
|
||||
const editor = createEditor(() => "latest editor content")
|
||||
const event = {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
key: "Enter",
|
||||
preventDefault: () => events.push("preventDefault"),
|
||||
[modifier]: true,
|
||||
}
|
||||
|
||||
const handled = handleEditorSubmitShortcut(
|
||||
event,
|
||||
editor,
|
||||
() => events.push("flush"),
|
||||
(content) => events.push(`submit:${content}`),
|
||||
)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(events).toEqual([
|
||||
"preventDefault",
|
||||
"flush",
|
||||
"submit:latest editor content",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it("ignores unrelated key presses", () => {
|
||||
const events: string[] = []
|
||||
const handled = handleEditorSubmitShortcut(
|
||||
{
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
key: "Escape",
|
||||
preventDefault: () => events.push("preventDefault"),
|
||||
},
|
||||
createEditor(() => "latest editor content"),
|
||||
() => events.push("flush"),
|
||||
(content) => events.push(`submit:${content}`),
|
||||
)
|
||||
|
||||
expect(handled).toBe(false)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveSubmittedContent", () => {
|
||||
it("prefers the editor snapshot and falls back for button submissions", () => {
|
||||
expect(resolveSubmittedContent("latest", "stale")).toBe("latest")
|
||||
expect(resolveSubmittedContent(undefined, "button state")).toBe(
|
||||
"button state",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -11,6 +11,50 @@ import { Bold, Italic, Code } from "lucide-react"
|
|||
import { useDebouncedCallback } from "use-debounce"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
type EditorSnapshot = Pick<Editor, "getJSON" | "storage">
|
||||
type SubmitShortcutEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"ctrlKey" | "key" | "metaKey" | "preventDefault"
|
||||
>
|
||||
|
||||
function getEditorMarkdown(editor: EditorSnapshot): string {
|
||||
const json = editor.getJSON()
|
||||
return editor.storage.markdown?.manager?.serialize(json) ?? ""
|
||||
}
|
||||
|
||||
export function resolveSubmittedContent(
|
||||
submittedContent: string | undefined,
|
||||
fallbackContent: string,
|
||||
): string {
|
||||
return submittedContent ?? fallbackContent
|
||||
}
|
||||
|
||||
export function submitEditorContent(
|
||||
editor: EditorSnapshot | null,
|
||||
flushPendingUpdate: () => void,
|
||||
onSubmit: (content: string) => void,
|
||||
): void {
|
||||
if (!editor) return
|
||||
|
||||
// Parent state updates flushed below are not visible to this event's submit closure.
|
||||
const markdown = getEditorMarkdown(editor)
|
||||
flushPendingUpdate()
|
||||
onSubmit(markdown)
|
||||
}
|
||||
|
||||
export function handleEditorSubmitShortcut(
|
||||
event: SubmitShortcutEvent,
|
||||
editor: EditorSnapshot | null,
|
||||
flushPendingUpdate: () => void,
|
||||
onSubmit: (content: string) => void,
|
||||
): boolean {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") return false
|
||||
|
||||
event.preventDefault()
|
||||
submitEditorContent(editor, flushPendingUpdate, onSubmit)
|
||||
return true
|
||||
}
|
||||
|
||||
export function TextEditor({
|
||||
content: initialContent,
|
||||
onContentChange,
|
||||
|
|
@ -21,7 +65,7 @@ export function TextEditor({
|
|||
}: {
|
||||
content: string | undefined
|
||||
onContentChange: (content: string) => void
|
||||
onSubmit: () => void
|
||||
onSubmit: (content: string) => void
|
||||
debounceMs?: number
|
||||
autoFocus?: boolean
|
||||
placeholder?: string
|
||||
|
|
@ -41,8 +85,7 @@ 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)
|
||||
onContentChange?.(markdown)
|
||||
}, debounceMs)
|
||||
|
||||
|
|
@ -58,8 +101,7 @@ 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)
|
||||
onContentChange?.(markdown)
|
||||
return
|
||||
}
|
||||
|
|
@ -67,10 +109,14 @@ export function TextEditor({
|
|||
},
|
||||
editorProps: {
|
||||
handleKeyDown: (_view, event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
debouncedUpdates.flush()
|
||||
onSubmitRef.current?.()
|
||||
if (
|
||||
handleEditorSubmitShortcut(
|
||||
event,
|
||||
editorRef.current,
|
||||
() => debouncedUpdates.flush(),
|
||||
onSubmitRef.current,
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
hasUserEditedRef.current = true
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue