Files
Netcatty/components/notes/NoteTitleInput.tsx
T
陈大猫 0f535d0b80 fix(notes): protect title IME, surface persist failures, harden paste (#2803)
* fix(notes): protect title IME, surface persist failures, harden paste

Note titles rewrote controlled value during CJK composition (Sogou Wubi).
Notes localStorage writes ignored QuotaExceededError so restarts lost
content. Markdown paste intercept could preventDefault with a no-op insert
(fixes #2783).

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(notes): commit title draft on blur for IME composition

IME-only title entry never called onCommit until compositionend; blur
could flush an empty draftTitle and then adopt the stale external value.
Commit the local draft on blur before parent flushNoteDraft.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(notes): stash IME title drafts in refs without controlled rewrite

Composition updates now live-stash into draftTitleRef for teardown and
note-switch flushes, while onCommit still waits for idle IME. Blur syncs
superseded external titles before flush so stale drafts cannot overwrite.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(notes): clear stashed title when IME composition is superseded

compositionEnd now live-stashes the adopted external title so pagehide
and note-switch flushes cannot persist rejected composed text.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(notes): cancel draft flush during IME title stash

Prior idle commits could still debounce-flush mid-composition and rewrite
the controlled title. Stash now clears the timer, and external supersede
in the adopt effect live-stashes the authoritative title.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(notes): dedupe quota failure toasts during autosave

Debounced draft flushes can hit QuotaExceededError repeatedly; rate-limit
the error toast so users are not spammed every 300ms.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 10:25:54 +00:00

157 lines
4.9 KiB
TypeScript

import React, { useEffect, useRef, useState } from "react";
import {
resolveSupersededImeInputEvent,
shouldAdoptExternalImeControlledValue,
shouldCommitImeControlledChange,
} from "../../domain/imeControlledInput";
type NoteTitleInputProps = {
noteId: string;
value: string;
placeholder?: string;
className?: string;
/** Commit into parent draft state (may rewrite controlled value). Idle IME only. */
onCommit: (title: string) => void;
/**
* Stash title for crash/teardown flush without updating controlled React state.
* Called during IME composition so pagehide/note-switch can persist without
* fighting the composition buffer.
*/
onLiveDraft?: (title: string) => void;
onBlur?: () => void;
};
/**
* Controlled note-title field that does not push parent updates during CJK IME
* composition. Immediate `value={external}` writes mid-composition break Windows
* IMEs such as Sogou Wubi (candidate dismiss / no committed text).
*/
export const NoteTitleInput: React.FC<NoteTitleInputProps> = ({
noteId,
value,
placeholder,
className,
onCommit,
onLiveDraft,
onBlur,
}) => {
const [draft, setDraft] = useState(value);
const composingRef = useRef(false);
const valueAtComposeStartRef = useRef(value);
const supersededRef = useRef(false);
const noteIdRef = useRef(noteId);
const onLiveDraftRef = useRef(onLiveDraft);
onLiveDraftRef.current = onLiveDraft;
useEffect(() => {
if (noteIdRef.current !== noteId) {
noteIdRef.current = noteId;
composingRef.current = false;
supersededRef.current = false;
setDraft(value);
return;
}
let adoptedExternal: string | null = null;
setDraft((draftValue) => {
const composing = composingRef.current;
const shouldAdopt = shouldAdoptExternalImeControlledValue({
isComposingSession: composing,
draftValue,
externalValue: value,
valueAtComposeStart: composing ? valueAtComposeStartRef.current : undefined,
});
if (shouldAdopt && composing && value !== valueAtComposeStartRef.current) {
supersededRef.current = true;
adoptedExternal = value;
}
return shouldAdopt ? value : draftValue;
});
if (adoptedExternal !== null) {
onLiveDraftRef.current?.(adoptedExternal);
}
}, [noteId, value]);
const commit = (next: string) => {
composingRef.current = false;
supersededRef.current = false;
setDraft(next);
onCommit(next);
};
return (
<input
data-note-title-input="true"
className={className}
value={draft}
placeholder={placeholder}
onBlur={(event) => {
// Blur finalizes IME. Sync parent before flushNoteDraft so composition-only
// titles and superseded external adoptions both land in draftTitleRef.
composingRef.current = false;
if (supersededRef.current) {
supersededRef.current = false;
setDraft(value);
onCommit(value);
} else {
const next = event.currentTarget.value;
setDraft(next);
onCommit(next);
}
onBlur?.();
}}
onChange={(event) => {
const superseded = resolveSupersededImeInputEvent({
compositionExternallySuperseded: supersededRef.current,
isComposingSession: composingRef.current,
nativeEventIsComposing: event.nativeEvent.isComposing,
});
if (superseded.ignoreEventValue) {
if (superseded.clearSupersedeLatch) {
supersededRef.current = false;
}
setDraft(value);
return;
}
const next = event.target.value;
setDraft(next);
// Always stash for teardown/note-switch flush; do not fight IME via onCommit.
onLiveDraft?.(next);
if (
shouldCommitImeControlledChange({
isComposingSession: composingRef.current,
nativeEventIsComposing: event.nativeEvent.isComposing,
compositionExternallySuperseded: supersededRef.current,
})
) {
onCommit(next);
}
}}
onCompositionStart={() => {
composingRef.current = true;
supersededRef.current = false;
valueAtComposeStartRef.current = value;
}}
onCompositionEnd={(event) => {
composingRef.current = false;
if (value !== valueAtComposeStartRef.current || supersededRef.current) {
supersededRef.current = true;
setDraft(value);
// Drop any live-stashed composed text so teardown flush cannot persist
// the rejected IME draft over the authoritative external title.
onLiveDraft?.(value);
window.setTimeout(() => {
if (supersededRef.current && !composingRef.current) {
supersededRef.current = false;
}
}, 0);
return;
}
commit(event.currentTarget.value);
}}
/>
);
};