Avoid blur-save during mention selection

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-04 14:05:08 -05:00
parent c89349687f
commit fe61e650c2
2 changed files with 135 additions and 12 deletions

View file

@ -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<void>) => {
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();
});
});

View file

@ -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<HTMLTextAreaElement>(null);
const markdownRef = useRef<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | 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}
>