mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
0f535d0b80
* 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>
157 lines
4.9 KiB
TypeScript
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);
|
|
}}
|
|
/>
|
|
);
|
|
};
|