fix(editor): preserve IME composition during content sync
This commit is contained in:
parent
a569775120
commit
14d757ce1f
7 changed files with 137 additions and 13 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { EditorState } from "@codemirror/state";
|
||||
import { placeholder as cmPlaceholder, EditorView } from "@codemirror/view";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useTagCounts } from "@/hooks/useUserQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { EditorController } from "../types/editorController";
|
||||
|
|
@ -12,8 +12,10 @@ import { createFormattingController } from "./formatting";
|
|||
interface EditorProps {
|
||||
className: string;
|
||||
initialContent: string;
|
||||
contentIsExternal?: boolean;
|
||||
placeholder: string;
|
||||
onContentChange: (content: string) => void;
|
||||
onExternalContentApplied?: (content: string) => void;
|
||||
onFiles: (files: File[], position: number) => void;
|
||||
/** Invoked by the in-editor save shortcut (Cmd/Ctrl+Enter). */
|
||||
onSubmit: () => void;
|
||||
|
|
@ -21,12 +23,26 @@ interface EditorProps {
|
|||
}
|
||||
|
||||
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorController>) {
|
||||
const { className, initialContent, placeholder, onContentChange, onFiles, onSubmit, isFocusMode } = props;
|
||||
const {
|
||||
className,
|
||||
initialContent,
|
||||
contentIsExternal = true,
|
||||
placeholder,
|
||||
onContentChange,
|
||||
onExternalContentApplied,
|
||||
onFiles,
|
||||
onSubmit,
|
||||
isFocusMode,
|
||||
} = props;
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const controllerRef = useRef<EditorController | null>(null);
|
||||
const applyingExternalContentRef = useRef(false);
|
||||
const pendingExternalContentRef = useRef<string | null>(null);
|
||||
const onChangeRef = useRef(onContentChange);
|
||||
onChangeRef.current = onContentChange;
|
||||
const onExternalContentAppliedRef = useRef(onExternalContentApplied);
|
||||
onExternalContentAppliedRef.current = onExternalContentApplied;
|
||||
const onFilesRef = useRef(onFiles);
|
||||
onFilesRef.current = onFiles;
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
|
|
@ -40,6 +56,18 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
const tagsRef = useRef(tags);
|
||||
tagsRef.current = tags;
|
||||
|
||||
const applyExternalContent = useCallback((content: string) => {
|
||||
pendingExternalContentRef.current = null;
|
||||
const controller = controllerRef.current;
|
||||
if (!controller || controller.getMarkdown() === content) return;
|
||||
applyingExternalContentRef.current = true;
|
||||
try {
|
||||
controller.setMarkdown(content);
|
||||
} finally {
|
||||
applyingExternalContentRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// useLayoutEffect (not useEffect) so the EditorView — and its placeholder —
|
||||
// mount before the browser paints. With useEffect the first painted frame
|
||||
// shows an empty host, then the placeholder pops in (a load flicker).
|
||||
|
|
@ -50,7 +78,9 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
doc: initialContent,
|
||||
extensions: buildEditorExtensions({
|
||||
placeholder,
|
||||
onChange: (md) => onChangeRef.current(md),
|
||||
onChange: (md) => {
|
||||
if (!applyingExternalContentRef.current) onChangeRef.current(md);
|
||||
},
|
||||
onFiles: (files, position) => onFilesRef.current(files, position),
|
||||
onUpdate: () => listenersRef.current.forEach((l) => l()),
|
||||
onSubmit: () => onSubmitRef.current(),
|
||||
|
|
@ -61,7 +91,24 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
});
|
||||
viewRef.current = view;
|
||||
controllerRef.current = createController(view, createFormattingController(view, listenersRef.current));
|
||||
const handleCompositionEnd = () => {
|
||||
// CodeMirror may flush its final Firefox/Android DOM mutations in a
|
||||
// microtask after compositionend. Queue behind that flush before
|
||||
// replacing the document with a deferred external value.
|
||||
queueMicrotask(() => {
|
||||
if (viewRef.current !== view || view.compositionStarted) return;
|
||||
const pendingContent = pendingExternalContentRef.current;
|
||||
if (pendingContent === null) return;
|
||||
applyExternalContent(pendingContent);
|
||||
// The composition may have emitted a newer local value after this
|
||||
// deferred external value entered the store. Reassert the applied
|
||||
// external value there too.
|
||||
onExternalContentAppliedRef.current?.(pendingContent);
|
||||
});
|
||||
};
|
||||
view.contentDOM.addEventListener("compositionend", handleCompositionEnd);
|
||||
return () => {
|
||||
view.contentDOM.removeEventListener("compositionend", handleCompositionEnd);
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
controllerRef.current = null;
|
||||
|
|
@ -76,12 +123,16 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
|
|||
viewRef.current?.dispatch({ effects: placeholderCompartment.reconfigure(cmPlaceholder(placeholder)) });
|
||||
}, [placeholder]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!contentIsExternal) return;
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
if (view.state.doc.toString() === initialContent) return;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: initialContent } });
|
||||
}, [initialContent]);
|
||||
if (view.compositionStarted) {
|
||||
pendingExternalContentRef.current = initialContent;
|
||||
return;
|
||||
}
|
||||
applyExternalContent(initialContent);
|
||||
}, [applyExternalContent, contentIsExternal, initialContent]);
|
||||
|
||||
// The controller is created in the mount layout effect above, which runs
|
||||
// before this (also layout-phase) handle, so controllerRef.current is set.
|
||||
|
|
|
|||
|
|
@ -17,21 +17,28 @@ import type { EditorController } from "../types/editorController";
|
|||
export const EditorContent = forwardRef<EditorController, EditorContentProps>(({ placeholder, onSubmit, onFiles }, ref) => {
|
||||
const { actions, dispatch } = useEditorContext();
|
||||
const content = useEditorSelector((s) => s.content);
|
||||
const contentSource = useEditorSelector((s) => s.contentSource);
|
||||
const isFocusMode = useEditorSelector((s) => s.ui.isFocusMode);
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
dispatch(actions.updateContent(content));
|
||||
};
|
||||
|
||||
const handleExternalContentApplied = (content: string) => {
|
||||
dispatch(actions.setContent(content));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col flex-1">
|
||||
<Editor
|
||||
ref={ref}
|
||||
className="memo-editor-content"
|
||||
initialContent={content}
|
||||
contentIsExternal={contentSource === "external"}
|
||||
placeholder={placeholder || ""}
|
||||
isFocusMode={isFocusMode}
|
||||
onContentChange={handleContentChange}
|
||||
onExternalContentApplied={handleExternalContentApplied}
|
||||
onFiles={onFiles}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export const useMemoInit = ({
|
|||
} else {
|
||||
const cachedDraft = cacheService.loadDraft(key);
|
||||
if (cachedDraft.content) {
|
||||
dispatch(actions.updateContent(cachedDraft.content));
|
||||
dispatch(actions.setContent(cachedDraft.content));
|
||||
}
|
||||
if (cachedDraft.attachments.length > 0) {
|
||||
dispatch(actions.setMetadata({ attachments: cachedDraft.attachments }));
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ export const editorActions = {
|
|||
|
||||
updateContent: (content: string): EditorAction => ({
|
||||
type: "UPDATE_CONTENT",
|
||||
payload: content,
|
||||
payload: { content, source: "editor" },
|
||||
}),
|
||||
|
||||
setContent: (content: string): EditorAction => ({
|
||||
type: "UPDATE_CONTENT",
|
||||
payload: { content, source: "external" },
|
||||
}),
|
||||
|
||||
setMetadata: (metadata: Partial<EditorState["metadata"]>): EditorAction => ({
|
||||
|
|
|
|||
|
|
@ -7,15 +7,21 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
|
|||
return {
|
||||
...state,
|
||||
content: action.payload.content,
|
||||
contentSource: "external",
|
||||
metadata: action.payload.metadata,
|
||||
timestamps: action.payload.timestamps,
|
||||
};
|
||||
|
||||
case "UPDATE_CONTENT":
|
||||
case "UPDATE_CONTENT": {
|
||||
if (state.content === action.payload.content && state.contentSource === action.payload.source) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
content: action.payload,
|
||||
content: action.payload.content,
|
||||
contentSource: action.payload.source,
|
||||
};
|
||||
}
|
||||
|
||||
case "SET_METADATA":
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
|||
import type { LocalFile } from "../types/attachment";
|
||||
|
||||
export type LoadingKey = "saving" | "uploading" | "loading";
|
||||
export type ContentSource = "editor" | "external";
|
||||
|
||||
export interface EditorState {
|
||||
content: string;
|
||||
contentSource: ContentSource;
|
||||
metadata: {
|
||||
visibility: Visibility;
|
||||
attachments: Attachment[];
|
||||
|
|
@ -34,7 +36,7 @@ export interface EditorState {
|
|||
|
||||
export type EditorAction =
|
||||
| { type: "INIT_MEMO"; payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] } }
|
||||
| { type: "UPDATE_CONTENT"; payload: string }
|
||||
| { type: "UPDATE_CONTENT"; payload: { content: string; source: ContentSource } }
|
||||
| { type: "SET_METADATA"; payload: Partial<EditorState["metadata"]> }
|
||||
| { type: "ADD_LOCAL_FILE"; payload: LocalFile }
|
||||
| { type: "REMOVE_LOCAL_FILE"; payload: string }
|
||||
|
|
@ -49,6 +51,7 @@ export type EditorAction =
|
|||
// Module-private template for createInitialState.
|
||||
const defaultState: EditorState = {
|
||||
content: "",
|
||||
contentSource: "external",
|
||||
metadata: {
|
||||
visibility: Visibility.PRIVATE,
|
||||
attachments: [],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Editor from "@/components/MemoEditor/Editor";
|
||||
|
|
@ -62,6 +62,58 @@ describe("Editor", () => {
|
|||
expect(onChange).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("does not replace newer editor text with a stale local echo", () => {
|
||||
const ref = createRef<EditorController>();
|
||||
const props = {
|
||||
ref,
|
||||
className: "x",
|
||||
placeholder: "memo",
|
||||
onContentChange: vi.fn(),
|
||||
onFiles: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<Editor {...props} initialContent="" />);
|
||||
|
||||
ref.current?.setMarkdown("H");
|
||||
ref.current?.setMarkdown("Hello");
|
||||
rerender(<Editor {...props} initialContent="H" contentIsExternal={false} />);
|
||||
|
||||
expect(ref.current?.getMarkdown()).toBe("Hello");
|
||||
});
|
||||
|
||||
it("defers external content until an IME composition ends", async () => {
|
||||
const ref = createRef<EditorController>();
|
||||
const onChange = vi.fn();
|
||||
const onExternalContentApplied = vi.fn();
|
||||
const props = {
|
||||
ref,
|
||||
className: "x",
|
||||
placeholder: "memo",
|
||||
onContentChange: onChange,
|
||||
onExternalContentApplied,
|
||||
onFiles: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
contentIsExternal: true,
|
||||
};
|
||||
const { container, rerender } = render(<Editor {...props} initialContent="" />);
|
||||
const content = container.querySelector<HTMLElement>(".cm-content");
|
||||
expect(content).not.toBeNull();
|
||||
|
||||
fireEvent.compositionStart(content!);
|
||||
rerender(<Editor {...props} initialContent="server value" />);
|
||||
expect(ref.current?.getMarkdown()).toBe("");
|
||||
|
||||
// A final IME transaction can arrive after the external value entered the
|
||||
// store. The deferred external value must still win at compositionend.
|
||||
ref.current?.setMarkdown("local composition value");
|
||||
expect(onChange).toHaveBeenLastCalledWith("local composition value");
|
||||
fireEvent.compositionEnd(content!);
|
||||
|
||||
await waitFor(() => expect(ref.current?.getMarkdown()).toBe("server value"));
|
||||
expect(onExternalContentApplied).toHaveBeenLastCalledWith("server value");
|
||||
expect(onChange).not.toHaveBeenCalledWith("server value");
|
||||
});
|
||||
|
||||
it("keeps native autocorrection enabled for Windows text services", () => {
|
||||
const props = {
|
||||
className: "x",
|
||||
|
|
|
|||
Loading…
Reference in a new issue