diff --git a/packages/views/editor/content-editor.test.tsx b/packages/views/editor/content-editor.test.tsx index 302c9a968e2..888b8d04374 100644 --- a/packages/views/editor/content-editor.test.tsx +++ b/packages/views/editor/content-editor.test.tsx @@ -192,6 +192,29 @@ describe("ContentEditor", () => { expect(mockFocus).not.toHaveBeenCalled(); }); + it("focuses at the end through the lazy-handoff ref", () => { + const ref = createRef(); + render(); + + act(() => ref.current?.focusAtEnd()); + + expect(mockFocus).toHaveBeenCalledWith("end"); + }); + + it("adopts pending handoff text without emitting an update", () => { + const onUpdate = vi.fn(); + const ref = createRef(); + render(); + + act(() => ref.current?.adoptContent("我")); + + expect(mockSetContent).toHaveBeenCalledWith( + "我", + expect.objectContaining({ emitUpdate: false, contentType: "markdown" }), + ); + expect(onUpdate).not.toHaveBeenCalled(); + }); + it("syncs editor content when defaultValue changes externally and editor is unfocused", () => { editorState.markdown = "old content"; const { rerender } = render(); diff --git a/packages/views/editor/content-editor.tsx b/packages/views/editor/content-editor.tsx index a666462cced..402b0d91ceb 100644 --- a/packages/views/editor/content-editor.tsx +++ b/packages/views/editor/content-editor.tsx @@ -196,6 +196,8 @@ interface ContentEditorRef { getMarkdown: () => string; clearContent: () => void; focus: () => void; + /** Focus the editor with the caret after its current content. */ + focusAtEnd: () => void; /** * Focus and place the caret at the document position under the given * viewport coordinates. Used by readonly-first hosts so the click that @@ -724,6 +726,10 @@ const ContentEditor = forwardRef( // Editor not mounted yet — defer the focus to `onCreate`. else focusOnReadyRef.current = true; }, + focusAtEnd: () => { + if (editor) editor.commands.focus("end"); + else focusOnReadyRef.current = true; + }, focusAtCoords: (coords: { x: number; y: number }) => { if (!editor) { // Editor not mounted yet — degrade to the latched plain focus. diff --git a/packages/views/editor/use-lazy-editor.test.ts b/packages/views/editor/use-lazy-editor.test.ts index 26638b144cd..02b44a09d75 100644 --- a/packages/views/editor/use-lazy-editor.test.ts +++ b/packages/views/editor/use-lazy-editor.test.ts @@ -46,6 +46,71 @@ describe("useLazyEditor", () => { expect(handle.uploadFile).toHaveBeenCalledWith(file); }); + it("keeps an editable stand-in alive until IME composition ends", () => { + let pendingContent = "w"; + const handle = { + ...makeHandle(), + focusAtEnd: vi.fn(), + adoptContent: vi.fn(), + }; + const editorRef = { current: handle as LazyEditorHandle }; + const { result } = renderHook(() => + useLazyEditor({ + editorRef, + getPendingContent: () => pendingContent, + }), + ); + + act(() => result.current.activate()); + act(() => result.current.onCompositionStart()); + act(() => result.current.onReady()); + + expect(result.current.ready).toBe(false); + expect(handle.adoptContent).not.toHaveBeenCalled(); + + pendingContent = "我"; + act(() => result.current.onCompositionEnd()); + + expect(result.current.ready).toBe(true); + expect(handle.adoptContent).toHaveBeenCalledWith("我"); + expect(handle.focusAtEnd).toHaveBeenCalled(); + }); + + it("cancels a queued ready swap when composition starts before its commit", () => { + let pendingContent = ""; + const handle = { + ...makeHandle(), + focusAtEnd: vi.fn(), + adoptContent: vi.fn(), + }; + const editorRef = { current: handle as LazyEditorHandle }; + const { result } = renderHook(() => + useLazyEditor({ + editorRef, + getPendingContent: () => pendingContent, + }), + ); + + act(() => result.current.activate()); + act(() => { + result.current.onReady(); + result.current.onCompositionStart(); + }); + + // React may batch the ready update with the next native input event. The + // composition-start update must win that batch so the focused shell stays. + expect(result.current.ready).toBe(false); + expect(handle.adoptContent).toHaveBeenCalledWith(""); + expect(handle.focusAtEnd).not.toHaveBeenCalled(); + + pendingContent = "我"; + act(() => result.current.onCompositionEnd()); + + expect(result.current.ready).toBe(true); + expect(handle.adoptContent).toHaveBeenLastCalledWith("我"); + expect(handle.focusAtEnd).toHaveBeenCalled(); + }); + it("resets to the stand-in during the render that changes resetKey", () => { const editorRef = { current: makeHandle() as LazyEditorHandle }; const { result, rerender } = renderHook( diff --git a/packages/views/editor/use-lazy-editor.ts b/packages/views/editor/use-lazy-editor.ts index a310b267086..eb0915334e8 100644 --- a/packages/views/editor/use-lazy-editor.ts +++ b/packages/views/editor/use-lazy-editor.ts @@ -9,9 +9,12 @@ import type { TextAnchor } from "./text-anchor"; */ export interface LazyEditorHandle { focus: () => void; + focusAtEnd?: () => void; focusAtCoords?: (coords: { x: number; y: number }) => void; focusAtAnchor?: (anchor: TextAnchor) => void; uploadFile?: (file: File) => void; + /** Replace the live document with text collected by an editable stand-in. */ + adoptContent?: (markdown: string) => void; } /** @@ -47,6 +50,12 @@ export interface UseLazyEditorOptions { * Hosts that remount per subject (via `key`) don't need this. */ resetKey?: unknown; + /** + * Read text accepted by an editable stand-in while Tiptap initializes. + * When present, readiness is held across an active IME composition, then + * the collected text is adopted before the stand-in is replaced. + */ + getPendingContent?: () => string | undefined; } /** @@ -70,6 +79,10 @@ export interface UseLazyEditorOptions { * } * {!lazy.ready && lazy.activate({x: e.clientX, y: e.clientY})} />} * + * A stand-in that accepts text should pass `getPendingContent` and wire the + * returned composition handlers. The hook then delays the swap until IME + * composition ends and adopts the stand-in's complete text first. + * * Hosts that outlive a subject switch without remounting (the web issue * route) must pass `resetKey`; keyed hosts get the reset for free by * remounting. @@ -78,12 +91,15 @@ export function useLazyEditor({ initialActive = false, editorRef, resetKey, + getPendingContent, }: UseLazyEditorOptions) { const [active, setActive] = useState(initialActive); const [ready, setReady] = useState(false); const focusTargetRef = useRef(null); const focusPendingRef = useRef(false); const pendingFilesRef = useRef([]); + const editorReadyRef = useRef(false); + const composingRef = useRef(false); // Render-phase reset on subject change — see the `resetKey` option doc. const [prevResetKey, setPrevResetKey] = useState(resetKey); @@ -94,6 +110,8 @@ export function useLazyEditor({ focusTargetRef.current = null; focusPendingRef.current = false; pendingFilesRef.current = []; + editorReadyRef.current = false; + composingRef.current = false; } const focusAtTarget = useCallback( @@ -102,6 +120,7 @@ export function useLazyEditor({ if (!handle) return; if (target?.anchor && handle.focusAtAnchor) handle.focusAtAnchor(target.anchor); else if (target && handle.focusAtCoords) handle.focusAtCoords(target); + else if (handle.focusAtEnd) handle.focusAtEnd(); else handle.focus(); }, [editorRef], @@ -124,7 +143,31 @@ export function useLazyEditor({ [ready, focusAtTarget], ); - const onReady = useCallback(() => setReady(true), []); + const completeReady = useCallback(() => { + const pendingContent = getPendingContent?.(); + if (pendingContent !== undefined) { + editorRef.current?.adoptContent?.(pendingContent); + } + setReady(true); + }, [editorRef, getPendingContent]); + + const onReady = useCallback(() => { + editorReadyRef.current = true; + if (!composingRef.current) completeReady(); + }, [completeReady]); + + const onCompositionStart = useCallback(() => { + composingRef.current = true; + // `onReady` and the next native input event can land in one React batch. + // If readiness already queued the stand-in swap but has not committed it, + // make this later update win so the browser keeps its composing node. + if (editorReadyRef.current) setReady(false); + }, []); + + const onCompositionEnd = useCallback(() => { + composingRef.current = false; + if (editorReadyRef.current) completeReady(); + }, [completeReady]); // Post-swap work — runs after the commit that revealed the ready editor, // so focus targets can resolve against a laid-out, live doc and uploads @@ -155,5 +198,13 @@ export function useLazyEditor({ [ready, editorRef], ); - return { active, ready, activate, onReady, uploadOrQueue }; + return { + active, + ready, + activate, + onReady, + onCompositionStart, + onCompositionEnd, + uploadOrQueue, + }; } diff --git a/packages/views/issues/components/comment-composers.test.tsx b/packages/views/issues/components/comment-composers.test.tsx index 0bc189104d3..b9809093c06 100644 --- a/packages/views/issues/components/comment-composers.test.tsx +++ b/packages/views/issues/components/comment-composers.test.tsx @@ -9,6 +9,10 @@ import { CommentInput } from "./comment-input"; import { ReplyInput } from "./reply-input"; const uploadWithToast = vi.hoisted(() => vi.fn()); +const editorLifecycle = vi.hoisted(() => ({ + deferReady: false, + pendingReady: [] as Array<() => void>, +})); vi.mock("@multica/core/api", () => ({ api: {}, @@ -64,13 +68,18 @@ vi.mock("../../editor", async () => ({ ref: Ref, ) { const valueRef = useRef(defaultValue ?? ""); + const textareaRef = useRef(null); // Mirrors the real editor's `uploading` node attrs: the placeholder exists // from before the await until the upload settles, `hasActiveUploads` reads // it synchronously, and the host is told through onUploadingChange. const inFlightRef = useRef(0); useEffect(() => { - onReady?.(); + if (editorLifecycle.deferReady && onReady) { + editorLifecycle.pendingReady.push(onReady); + } else { + onReady?.(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -79,8 +88,18 @@ vi.mock("../../editor", async () => ({ clearContent: () => { valueRef.current = ""; }, - focus: () => {}, - focusAtCoords: () => {}, + focus: () => textareaRef.current?.focus(), + focusAtEnd: () => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + }, + focusAtCoords: () => textareaRef.current?.focus(), + adoptContent: (markdown: string) => { + valueRef.current = markdown; + if (textareaRef.current) textareaRef.current.value = markdown; + }, blur: () => {}, uploadFile: async (file: File) => { inFlightRef.current += 1; @@ -100,6 +119,7 @@ vi.mock("../../editor", async () => ({ return (