mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
Avoid blur-save during mention selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c89349687f
commit
fe61e650c2
2 changed files with 135 additions and 12 deletions
84
ui/src/components/InlineEditor.test.tsx
Normal file
84
ui/src/components/InlineEditor.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -19,6 +19,23 @@ const pad = "px-1 -mx-1";
|
||||||
const markdownPad = "px-1";
|
const markdownPad = "px-1";
|
||||||
const AUTOSAVE_DEBOUNCE_MS = 900;
|
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({
|
export function InlineEditor({
|
||||||
value,
|
value,
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -35,6 +52,7 @@ export function InlineEditor({
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const blurCommitFrameRef = useRef<(() => void) | null>(null);
|
||||||
const {
|
const {
|
||||||
state: autosaveState,
|
state: autosaveState,
|
||||||
markDirty,
|
markDirty,
|
||||||
|
|
@ -52,6 +70,10 @@ export function InlineEditor({
|
||||||
if (autosaveDebounceRef.current) {
|
if (autosaveDebounceRef.current) {
|
||||||
clearTimeout(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]);
|
}, [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) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
if (e.key === "Enter" && !multiline) {
|
if (e.key === "Enter" && !multiline) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -146,20 +192,13 @@ export function InlineEditor({
|
||||||
"rounded transition-colors",
|
"rounded transition-colors",
|
||||||
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
||||||
)}
|
)}
|
||||||
onFocusCapture={() => setMultilineFocused(true)}
|
onFocusCapture={() => {
|
||||||
|
cancelPendingBlurCommit();
|
||||||
|
setMultilineFocused(true);
|
||||||
|
}}
|
||||||
onBlurCapture={(event) => {
|
onBlurCapture={(event) => {
|
||||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||||
if (autosaveDebounceRef.current) {
|
scheduleBlurCommit(event.currentTarget);
|
||||||
clearTimeout(autosaveDebounceRef.current);
|
|
||||||
}
|
|
||||||
setMultilineFocused(false);
|
|
||||||
const trimmed = draft.trim();
|
|
||||||
if (!trimmed || trimmed === value) {
|
|
||||||
reset();
|
|
||||||
void commit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void runSave(() => commit());
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue