fix(desktop): keep new note editor focused during sync
This commit is contained in:
1 parent
2d703a9ed5
commit
1a1429cde0
4 files changed
+108
-12
No files matched your search
@@ -61,6 +61,7 @@ import { EditorTagPicker } from "./EditorTagPicker";
|
||||
import { useAiBubbleMenu } from "./editor/useAiBubbleMenu";
|
||||
import {
|
||||
createEditorInstanceMemoIdentity,
|
||||
isEditorInstanceHydratedForMemo,
|
||||
reconcileEditorInstanceMemoIdentity,
|
||||
remapEditorInstanceMemoIdentity,
|
||||
} from "./editor/editor-instance-identity";
|
||||
@@ -866,6 +867,11 @@ const RichEditorPane = ({
|
||||
memo?.id ?? null,
|
||||
);
|
||||
const editorInstanceMemoKey = editorInstanceMemoIdentityRef.current.instanceKey;
|
||||
const editorIsHydratedForCurrentMemo = isEditorInstanceHydratedForMemo(
|
||||
editorInstanceMemoIdentityRef.current,
|
||||
hydratedEditorMemoId,
|
||||
memo?.id ?? null,
|
||||
);
|
||||
/** Last content source applied to the editor — used to skip redundant setContent. */
|
||||
const appliedEditorSourceKeyRef = useRef<string | null>(null);
|
||||
const editingMemoIdRef = useRef<string | null>(memo?.id ?? null);
|
||||
@@ -1228,7 +1234,7 @@ const RichEditorPane = ({
|
||||
content: memo
|
||||
? resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown)
|
||||
: { type: "doc", content: [{ type: "paragraph" }] },
|
||||
editable: Boolean(memo && !effectiveReadOnly && hydratedEditorMemoId === memo.id),
|
||||
editable: Boolean(memo && !effectiveReadOnly && editorIsHydratedForCurrentMemo),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "edgeever-note-rich-editor prose prose-slate max-w-none focus:outline-none min-h-[240px] px-4 py-3 sm:px-7 lg:min-h-[180px]",
|
||||
@@ -2429,7 +2435,15 @@ const RichEditorPane = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.setEditable(Boolean(memo && !effectiveReadOnly && hydratedEditorMemoId === memo.id));
|
||||
editor.setEditable(Boolean(
|
||||
memo
|
||||
&& !effectiveReadOnly
|
||||
&& isEditorInstanceHydratedForMemo(
|
||||
editorInstanceMemoIdentityRef.current,
|
||||
hydratedEditorMemoId,
|
||||
memo.id,
|
||||
)
|
||||
));
|
||||
}
|
||||
}, [editor, effectiveReadOnly, hydratedEditorMemoId, memo]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createEditorInstanceMemoIdentity,
|
||||
isEditorInstanceHydratedForMemo,
|
||||
reconcileEditorInstanceMemoIdentity,
|
||||
remapEditorInstanceMemoIdentity,
|
||||
} from "./editor-instance-identity.ts";
|
||||
@@ -35,6 +36,31 @@ describe("editor instance memo identity", () => {
|
||||
expect(secondIdentity.instanceKey).not.toBe(firstIdentity.instanceKey);
|
||||
});
|
||||
|
||||
test("keeps the editor hydrated while desktop sync announces the durable id before the parent rerenders", () => {
|
||||
const localIdentity = createEditorInstanceMemoIdentity("local_memo_1");
|
||||
const remappedIdentity = remapEditorInstanceMemoIdentity(
|
||||
localIdentity,
|
||||
new Map([["local_memo_1", "memo_remote_1"]]),
|
||||
);
|
||||
|
||||
expect(isEditorInstanceHydratedForMemo(
|
||||
remappedIdentity,
|
||||
"memo_remote_1",
|
||||
"local_memo_1",
|
||||
)).toBe(true);
|
||||
expect(isEditorInstanceHydratedForMemo(
|
||||
remappedIdentity,
|
||||
"memo_remote_1",
|
||||
"memo_remote_1",
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
test("does not treat a genuinely different memo as hydrated", () => {
|
||||
const identity = createEditorInstanceMemoIdentity("memo_1");
|
||||
|
||||
expect(isEditorInstanceHydratedForMemo(identity, "memo_1", "memo_2")).toBe(false);
|
||||
});
|
||||
|
||||
test("ignores id mappings for a different memo", () => {
|
||||
const identity = createEditorInstanceMemoIdentity("memo_1");
|
||||
|
||||
|
||||
@@ -21,6 +21,17 @@ export const reconcileEditorInstanceMemoIdentity = (
|
||||
? identity
|
||||
: createEditorInstanceMemoIdentity(memoId);
|
||||
|
||||
export const isEditorInstanceHydratedForMemo = (
|
||||
identity: EditorInstanceMemoIdentity,
|
||||
hydratedMemoId: string | null,
|
||||
renderedMemoId: string | null,
|
||||
) => Boolean(
|
||||
hydratedMemoId
|
||||
&& renderedMemoId
|
||||
&& identity.aliases.has(hydratedMemoId)
|
||||
&& identity.aliases.has(renderedMemoId)
|
||||
);
|
||||
|
||||
export const remapEditorInstanceMemoIdentity = (
|
||||
identity: EditorInstanceMemoIdentity,
|
||||
memoIdMappings: ReadonlyMap<string, string>,
|
||||
|
||||
@@ -61,6 +61,35 @@ const holdNextMemoCreate = async (page: Page) => {
|
||||
return { createResponse, createStarted, releaseCreate };
|
||||
};
|
||||
|
||||
const holdNextMemoCreateResponse = async (page: Page) => {
|
||||
let releaseCreate!: () => void;
|
||||
let resolveCreatedMemo!: (memo: { id: string }) => void;
|
||||
const createGate = new Promise<void>((resolve) => {
|
||||
releaseCreate = resolve;
|
||||
});
|
||||
const createdMemo = new Promise<{ id: string }>((resolve) => {
|
||||
resolveCreatedMemo = resolve;
|
||||
});
|
||||
const createResponse = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/v1/memos",
|
||||
);
|
||||
|
||||
await page.route("**/api/v1/memos", async (route: Route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await route.fetch();
|
||||
const created = await response.json() as { memo: { id: string } };
|
||||
resolveCreatedMemo(created.memo);
|
||||
await createGate;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
return { createdMemo, createResponse, releaseCreate };
|
||||
};
|
||||
|
||||
const editNewMemo = async (page: Page, title: string, content: string) => {
|
||||
await page.getByRole("button", { name: "新建笔记", exact: true }).click();
|
||||
|
||||
@@ -177,14 +206,22 @@ const finishSyncAndVerifyReload = async (
|
||||
};
|
||||
|
||||
test.describe("new memo synchronization", () => {
|
||||
test("keeps the new memo editor mounted and focused while its local id is remapped", async ({ page }) => {
|
||||
const heldCreate = await holdNextMemoCreate(page);
|
||||
test("keeps the new memo editor editable and focused when desktop sync remaps its id early", async ({ page }) => {
|
||||
const heldCreate = await holdNextMemoCreateResponse(page);
|
||||
let memoId: string | null = null;
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "新建笔记", exact: true }).click();
|
||||
await heldCreate.createStarted;
|
||||
const createdMemo = await heldCreate.createdMemo;
|
||||
memoId = createdMemo.id;
|
||||
|
||||
let temporaryMemoId: string | null = null;
|
||||
await expect.poll(async () => {
|
||||
const items = await readIndexedDbStore<StoredQueueItem>(page, "syncQueue");
|
||||
temporaryMemoId = items.find((item) => item.kind === "memo.create")?.memoId ?? null;
|
||||
return temporaryMemoId;
|
||||
}).toMatch(/^local_/);
|
||||
|
||||
const editor = page.locator(".ProseMirror[contenteditable='true']");
|
||||
await expect(editor).toBeEditable();
|
||||
@@ -234,21 +271,29 @@ test.describe("new memo synchronization", () => {
|
||||
}, { once: true });
|
||||
}));
|
||||
|
||||
heldCreate.releaseCreate();
|
||||
const createResponse = await heldCreate.createResponse;
|
||||
expect(createResponse.status()).toBe(201);
|
||||
const created = await createResponse.json() as { memo: { id: string } };
|
||||
memoId = created.memo.id;
|
||||
await page.evaluate(({ localId, remoteId }) => {
|
||||
window.dispatchEvent(new CustomEvent("edgeever:memo-id-remapped", {
|
||||
detail: new Map([[localId, remoteId]]),
|
||||
}));
|
||||
}, { localId: temporaryMemoId!, remoteId: memoId });
|
||||
|
||||
const observation = await editorObservation;
|
||||
expect(observation.mapping).toContainEqual([expect.any(String), memoId]);
|
||||
expect(observation.mapping[0]?.[0]).not.toBe(memoId);
|
||||
expect(observation.mapping).toContainEqual([temporaryMemoId, memoId]);
|
||||
expect(observation).toMatchObject({
|
||||
disconnected: false,
|
||||
focusLost: false,
|
||||
replaced: false,
|
||||
stillFocused: true,
|
||||
});
|
||||
await expect(editor).toBeEditable();
|
||||
await expect(editor).toBeFocused();
|
||||
|
||||
heldCreate.releaseCreate();
|
||||
const createResponse = await heldCreate.createResponse;
|
||||
expect(createResponse.status()).toBe(201);
|
||||
const created = await createResponse.json() as { memo: { id: string } };
|
||||
expect(created.memo.id).toBe(memoId);
|
||||
await expect(editor).toBeEditable();
|
||||
await expect(editor).toBeFocused();
|
||||
} finally {
|
||||
heldCreate.releaseCreate();
|
||||
|
||||
Reference in new issue
Block a user