Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/views/editor/content-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,29 @@ describe("ContentEditor", () => {
expect(mockFocus).not.toHaveBeenCalled();
});

it("focuses at the end through the lazy-handoff ref", () => {
const ref = createRef<ContentEditorRef>();
render(<ContentEditor ref={ref} />);

act(() => ref.current?.focusAtEnd());

expect(mockFocus).toHaveBeenCalledWith("end");
});

it("adopts pending handoff text without emitting an update", () => {
const onUpdate = vi.fn();
const ref = createRef<ContentEditorRef>();
render(<ContentEditor ref={ref} onUpdate={onUpdate} />);

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(<ContentEditor defaultValue="old content" />);
Expand Down
6 changes: 6 additions & 0 deletions packages/views/editor/content-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -724,6 +726,10 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
// 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.
Expand Down
65 changes: 65 additions & 0 deletions packages/views/editor/use-lazy-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
55 changes: 53 additions & 2 deletions packages/views/editor/use-lazy-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -70,6 +79,10 @@ export interface UseLazyEditorOptions {
* <Editor ref={editorRef} onReady={lazy.onReady} ... /></div>}
* {!lazy.ready && <StaticStandIn onClick={e => 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.
Expand All @@ -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<LazyFocusTarget | null>(null);
const focusPendingRef = useRef(false);
const pendingFilesRef = useRef<File[]>([]);
const editorReadyRef = useRef(false);
const composingRef = useRef(false);

// Render-phase reset on subject change — see the `resetKey` option doc.
const [prevResetKey, setPrevResetKey] = useState(resetKey);
Expand All @@ -94,6 +110,8 @@ export function useLazyEditor({
focusTargetRef.current = null;
focusPendingRef.current = false;
pendingFilesRef.current = [];
editorReadyRef.current = false;
composingRef.current = false;
}

const focusAtTarget = useCallback(
Expand All @@ -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],
Expand All @@ -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
Expand Down Expand Up @@ -155,5 +198,13 @@ export function useLazyEditor({
[ready, editorRef],
);

return { active, ready, activate, onReady, uploadOrQueue };
return {
active,
ready,
activate,
onReady,
onCompositionStart,
onCompositionEnd,
uploadOrQueue,
};
}
Loading
Loading