From fe61e650c21d8e004fed4ae06ba653db8db15be7 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 14:05:08 -0500 Subject: [PATCH] Avoid blur-save during mention selection Co-Authored-By: Paperclip --- ui/src/components/InlineEditor.test.tsx | 84 +++++++++++++++++++++++++ ui/src/components/InlineEditor.tsx | 63 +++++++++++++++---- 2 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/InlineEditor.test.tsx diff --git a/ui/src/components/InlineEditor.test.tsx b/ui/src/components/InlineEditor.test.tsx new file mode 100644 index 00000000..ddf99936 --- /dev/null +++ b/ui/src/components/InlineEditor.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queueContainedBlurCommit } from "./InlineEditor"; + +vi.mock("./MarkdownEditor", () => ({ + MarkdownEditor: () => null, +})); + +vi.mock("../hooks/useAutosaveIndicator", () => ({ + useAutosaveIndicator: () => ({ + state: "idle", + markDirty: () => {}, + reset: () => {}, + runSave: async (save: () => Promise) => { + await save(); + }, + }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("queueContainedBlurCommit", () => { + let container: HTMLDivElement; + let inside: HTMLTextAreaElement; + let outside: HTMLButtonElement; + let originalRequestAnimationFrame: typeof window.requestAnimationFrame; + let originalCancelAnimationFrame: typeof window.cancelAnimationFrame; + + beforeEach(() => { + vi.useFakeTimers(); + originalRequestAnimationFrame = window.requestAnimationFrame; + originalCancelAnimationFrame = window.cancelAnimationFrame; + window.requestAnimationFrame = ((callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((id: number) => window.clearTimeout(id)) as typeof window.cancelAnimationFrame; + + container = document.createElement("div"); + inside = document.createElement("textarea"); + outside = document.createElement("button"); + container.appendChild(inside); + document.body.append(container, outside); + }); + + afterEach(() => { + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + container.remove(); + outside.remove(); + vi.useRealTimers(); + }); + + async function flushFrames() { + await act(async () => { + vi.runAllTimers(); + await Promise.resolve(); + }); + } + + it("commits when focus stays outside the editor container", async () => { + const onCommit = vi.fn(); + const cancel = queueContainedBlurCommit(container, onCommit); + + outside.focus(); + await flushFrames(); + + expect(onCommit).toHaveBeenCalledTimes(1); + cancel(); + }); + + it("skips the commit when focus returns inside before the delayed check completes", async () => { + const onCommit = vi.fn(); + const cancel = queueContainedBlurCommit(container, onCommit); + + outside.focus(); + inside.focus(); + await flushFrames(); + + expect(onCommit).not.toHaveBeenCalled(); + cancel(); + }); +}); diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index c05f8a41..43c214df 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -19,6 +19,23 @@ const pad = "px-1 -mx-1"; const markdownPad = "px-1"; const AUTOSAVE_DEBOUNCE_MS = 900; +export function queueContainedBlurCommit(container: HTMLDivElement, onCommit: () => void) { + let frameId = requestAnimationFrame(() => { + frameId = requestAnimationFrame(() => { + frameId = 0; + const active = document.activeElement; + if (active instanceof Node && container.contains(active)) return; + onCommit(); + }); + }); + + return () => { + if (frameId === 0) return; + cancelAnimationFrame(frameId); + frameId = 0; + }; +} + export function InlineEditor({ value, onSave, @@ -35,6 +52,7 @@ export function InlineEditor({ const inputRef = useRef(null); const markdownRef = useRef(null); const autosaveDebounceRef = useRef | null>(null); + const blurCommitFrameRef = useRef<(() => void) | null>(null); const { state: autosaveState, markDirty, @@ -52,6 +70,10 @@ export function InlineEditor({ if (autosaveDebounceRef.current) { clearTimeout(autosaveDebounceRef.current); } + if (blurCommitFrameRef.current !== null) { + blurCommitFrameRef.current(); + blurCommitFrameRef.current = null; + } }; }, []); @@ -91,6 +113,30 @@ export function InlineEditor({ } }, [draft, multiline, onSave, value]); + const cancelPendingBlurCommit = useCallback(() => { + if (blurCommitFrameRef.current === null) return; + blurCommitFrameRef.current(); + blurCommitFrameRef.current = null; + }, []); + + const scheduleBlurCommit = useCallback((container: HTMLDivElement) => { + cancelPendingBlurCommit(); + blurCommitFrameRef.current = queueContainedBlurCommit(container, () => { + blurCommitFrameRef.current = null; + if (autosaveDebounceRef.current) { + clearTimeout(autosaveDebounceRef.current); + } + setMultilineFocused(false); + const trimmed = draft.trim(); + if (!trimmed || trimmed === value) { + reset(); + void commit(); + return; + } + void runSave(() => commit()); + }); + }, [cancelPendingBlurCommit, commit, draft, reset, runSave, value]); + function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && !multiline) { e.preventDefault(); @@ -146,20 +192,13 @@ export function InlineEditor({ "rounded transition-colors", multilineFocused ? "bg-transparent" : "hover:bg-accent/20", )} - onFocusCapture={() => setMultilineFocused(true)} + onFocusCapture={() => { + cancelPendingBlurCommit(); + setMultilineFocused(true); + }} onBlurCapture={(event) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; - if (autosaveDebounceRef.current) { - clearTimeout(autosaveDebounceRef.current); - } - setMultilineFocused(false); - const trimmed = draft.trim(); - if (!trimmed || trimmed === value) { - reset(); - void commit(); - return; - } - void runSave(() => commit()); + scheduleBlurCommit(event.currentTarget); }} onKeyDown={handleKeyDown} >