diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 5c60c56b..fd3c90a7 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -61,6 +61,7 @@ import { isEditorInstanceHydratedForMemo, reconcileEditorInstanceMemoIdentity, remapEditorInstanceMemoIdentity, + resolveEditorInsertionMemoId, } from "./editor/editor-instance-identity"; import { analyzeMarkdownModeContent, @@ -444,6 +445,8 @@ const RichEditorPane = ({ // Keep field ownership and values in one React state update. memoRef changes // synchronously during hydration, before a new title/tags render can commit. const [memoFields, setMemoFields] = useState({ memoId: null, title: "", tagsText: "" }); + const memoFieldsRef = useRef(memoFields); + memoFieldsRef.current = memoFields; const { memoId: fieldsMemoId, title, tagsText } = memoFields; const { dirtyVersion, @@ -676,6 +679,7 @@ const RichEditorPane = ({ const editSessionRef = useRef(null); const editorRef = useRef(null); const editorCanvasInteractionVersionRef = useRef(0); + const persistResourceInsertionDraftRef = useRef<() => Promise>(() => Promise.resolve()); const openAiAssistantRef = useRef<() => void>(() => undefined); const aiSpaceShortcutEnabledRef = useRef(readAiSpaceShortcutPreference()); const editorScrollContainerRef = useRef(null); @@ -809,6 +813,7 @@ const RichEditorPane = ({ memo?.id ?? null, ); const editorInstanceMemoKey = editorInstanceMemoIdentityRef.current.instanceKey; + useEffect(() => setImageUploadState("idle"), [editorInstanceMemoKey]); const editorIsHydratedForCurrentMemo = isEditorInstanceHydratedForMemo( editorInstanceMemoIdentityRef.current, hydratedEditorMemoId, @@ -835,6 +840,9 @@ const RichEditorPane = ({ mappings, ); memoRef.current = { ...currentMemo, id: nextMemoId }; + if (memoFieldsRef.current.memoId === previousMemoId) { + memoFieldsRef.current = { ...memoFieldsRef.current, memoId: nextMemoId }; + } setMemoFields((fields) => fields.memoId === previousMemoId ? { ...fields, memoId: nextMemoId } : fields); @@ -1019,7 +1027,14 @@ const RichEditorPane = ({ return false; } - const targetMemoId = currentMemo.id; + const requestedMemoId = currentMemo.id; + const requestedInstanceKey = editorInstanceMemoIdentityRef.current.instanceKey; + const resolveTargetMemoId = () => resolveEditorInsertionMemoId( + editorInstanceMemoIdentityRef.current, + requestedInstanceKey, + requestedMemoId, + memoRef.current?.id, + ); const interactionVersionAtRequest = editorCanvasInteractionVersionRef.current; const placeholderPosition = currentEditor.state.selection.from; const placeholderByFile = new Map(files.map((file) => [file, @@ -1034,143 +1049,162 @@ const RichEditorPane = ({ }); void resourceInsertionLimit(async () => { - const insertionEditor = editorRef.current; - if ( - memoRef.current?.id !== targetMemoId || - !isEditorReady(insertionEditor) || - !insertionEditor.isEditable - ) { - return; - } + let resumeDesktopSync: (() => void) | null = null; + try { + // A new desktop note can receive its durable ID while a file is being + // staged. Keep the note ID and staged resource together until the + // attachment has been inserted and its draft is durable locally. + if (isDesktopResourceRuntime()) { + resumeDesktopSync = await (await import("@/lib/desktop-sync")).pauseDesktopSyncForImport(); + } + const insertionEditor = editorRef.current; + if (!resolveTargetMemoId() || !isEditorReady(insertionEditor) || !insertionEditor.isEditable) { + throw new Error("The note changed before the attachment could be inserted"); + } - // Read the selection only after earlier resource insertions complete. - // Rapid consecutive pastes otherwise race with the same stale cursor. - const insertionTarget = getResourceInsertionTarget(insertionEditor.state.selection); - setImageUploadState("uploading"); - const imageReadiness: Promise[] = []; - - const results = await processFileUploadBatch(files, async (file) => { - const isImage = SUPPORTED_PASTE_IMAGE_TYPES.has(file.type); - const shouldCompress = isImage && imageCompressionEnabledRef.current; - const placeholder = placeholderByFile.get(file); - if (placeholder) updateImageUploadPlaceholder(editorRef.current, placeholder, - t(shouldCompress ? "editor.uploadState.imageCompressing" : "editor.uploadState.waitingToUpload")); - setImageUploadState(shouldCompress ? "compressing" : "uploading"); - const preparedFile = shouldCompress ? (await compressImageForUpload(file)).file : file; - if (placeholder) updateImageUploadPlaceholder(editorRef.current, placeholder, - t("editor.uploadState.waitingToUpload")); - return preparedFile; - }, async (uploadFile, file) => { - const isImage = SUPPORTED_PASTE_IMAGE_TYPES.has(file.type); - const placeholder = placeholderByFile.get(file); + // Read the selection only after earlier resource insertions complete. + // Rapid consecutive pastes otherwise race with the same stale cursor. + const insertionTarget = getResourceInsertionTarget(insertionEditor.state.selection); setImageUploadState("uploading"); - if (placeholder) updateImageUploadPlaceholder(editorRef.current, placeholder, - t("editor.uploadState.uploading")); - const resource = await uploadEditorResource(targetMemoId, uploadFile, isImage); - if (resource.kind === "image") { - imageReadiness.push(waitForImageSourceReady(resource.url)); + const imageReadiness: Promise[] = []; + + const results = await processFileUploadBatch(files, async (file) => { + const isImage = SUPPORTED_PASTE_IMAGE_TYPES.has(file.type); + const shouldCompress = isImage && imageCompressionEnabledRef.current; + const placeholder = placeholderByFile.get(file); + if (placeholder) updateImageUploadPlaceholder(editorRef.current, placeholder, + t(shouldCompress ? "editor.uploadState.imageCompressing" : "editor.uploadState.waitingToUpload")); + setImageUploadState(shouldCompress ? "compressing" : "uploading"); + const preparedFile = shouldCompress ? (await compressImageForUpload(file)).file : file; + if (placeholder) updateImageUploadPlaceholder(editorRef.current, placeholder, + t("editor.uploadState.waitingToUpload")); + return preparedFile; + }, async (uploadFile, file) => { + const targetMemoId = resolveTargetMemoId(); + if (!targetMemoId) throw new Error("The active note changed during attachment upload"); + const isImage = SUPPORTED_PASTE_IMAGE_TYPES.has(file.type); + const placeholder = placeholderByFile.get(file); + setImageUploadState("uploading"); + if (placeholder) updateImageUploadPlaceholder(editorRef.current, placeholder, + t("editor.uploadState.uploading")); + const resource = await uploadEditorResource(targetMemoId, uploadFile, isImage); + if (resource.kind === "image") { + imageReadiness.push(waitForImageSourceReady(resource.url)); + } + return resource; + }); + + const successfulResults = results.filter((result) => result.status === "fulfilled"); + if (successfulResults.length > 0) { + void queryClient.invalidateQueries({ queryKey: ["resources"] }); } - return resource; - }); + await Promise.all(imageReadiness); - const successfulResults = results.filter((result) => result.status === "fulfilled"); - if (successfulResults.length > 0) { - void queryClient.invalidateQueries({ queryKey: ["resources"] }); - } - await Promise.all(imageReadiness); - - const activeEditor = editorRef.current; - if (memoRef.current?.id !== targetMemoId || !isEditorReady(activeEditor)) { - setImageUploadState("idle"); - return; - } - - const content = successfulResults.map(({ file, value: resource }) => { - const filename = resource.filename || file.name; - if (resource.kind === "image") { - return { - type: "image", - attrs: { - src: resource.url, - alt: file.name, - title: file.name, - width: NEW_IMAGE_WIDTH_PERCENT, - }, - }; + const activeEditor = editorRef.current; + const activeMemoId = resolveTargetMemoId(); + if (!activeMemoId || !isEditorReady(activeEditor) || !activeEditor.isEditable) { + throw new Error("The note changed before the attachment could be inserted"); } - if (isPdfAttachment(file.type, filename)) { + + const content = successfulResults.map(({ file, value: resource }) => { + const filename = resource.filename || file.name; + if (resource.kind === "image") { + return { + type: "image", + attrs: { + src: resource.url, + alt: file.name, + title: file.name, + width: NEW_IMAGE_WIDTH_PERCENT, + }, + }; + } + if (isPdfAttachment(file.type, filename)) { + return { + type: "paragraph", + content: [{ + type: "edgeeverPdfAttachment", + attrs: { + url: resource.url, + label: t("editor.attachmentLabel", { filename }), + filename, + mimeType: resource.mimeType || file.type || "application/pdf", + byteSize: resource.byteSize, + displayMode: "compact", + }, + }], + }; + } return { type: "paragraph", content: [{ - type: "edgeeverPdfAttachment", + type: "edgeeverFileAttachment", attrs: { url: resource.url, label: t("editor.attachmentLabel", { filename }), filename, - mimeType: resource.mimeType || file.type || "application/pdf", + mimeType: file.type, byteSize: resource.byteSize, - displayMode: "compact", }, }], }; - } - return { - type: "paragraph", - content: [{ - type: "edgeeverFileAttachment", - attrs: { - url: resource.url, - label: t("editor.attachmentLabel", { filename }), - filename, - mimeType: file.type, - byteSize: resource.byteSize, - }, - }], - }; - }); + }); - if (content.length > 0) { - const safeInsertionTarget = clampResourceInsertionTarget( - insertionTarget, - activeEditor.state.doc.content.size, - ); - const updateSelection = shouldSelectInsertedResources( - interactionVersionAtRequest, - editorCanvasInteractionVersionRef.current, - ); - const insertion = activeEditor.chain(); - if (updateSelection) { - insertion.focus(); + if (content.length > 0) { + const safeInsertionTarget = clampResourceInsertionTarget( + insertionTarget, + activeEditor.state.doc.content.size, + ); + const updateSelection = shouldSelectInsertedResources( + interactionVersionAtRequest, + editorCanvasInteractionVersionRef.current, + ); + const insertion = activeEditor.chain(); + if (updateSelection) { + insertion.focus(); + } + const inserted = insertion + .command(insertUploadedResources( + safeInsertionTarget, + content, + updateSelection, + )) + .run(); + if (!inserted) throw new Error("The attachment could not be inserted into the note"); + if (!updateSelection) { + // ProseMirror can still map a cursor at the document boundary to a + // NodeSelection for the newly inserted block image. Honor the newer + // canvas click deterministically instead of trusting that mapping. + clearNodeSelectionAtDocumentEnd(activeEditor); + } + if (!getWritableEditorMemoFields( + memoFieldsRef.current, + activeMemoId, + hydratedMemoIdRef.current, + hydratingRef.current, + )) { + throw new Error("The attachment was inserted before the note finished loading"); + } + await persistResourceInsertionDraftRef.current(); + if (!hasUnsavedChangesRef.current) markDirtyStatus(); } - insertion - .command(insertUploadedResources( - safeInsertionTarget, - content, - updateSelection, - )) - .run(); - if (!updateSelection) { - // ProseMirror can still map a cursor at the document boundary to a - // NodeSelection for the newly inserted block image. Honor the newer - // canvas click deterministically instead of trusting that mapping. - clearNodeSelectionAtDocumentEnd(activeEditor); - } - } - if (results.some((result) => result.status === "rejected")) { + const failures = results.filter((result) => result.status === "rejected"); + failures.forEach((result) => console.error("[editor] Attachment upload failed", result.reason)); + setImageUploadState(failures.length > 0 ? "error" : "idle"); + } catch (error) { + console.error("[editor] Attachment insertion failed", error); setImageUploadState("error"); - window.setTimeout(() => setImageUploadState("idle"), 2200); - } else { - setImageUploadState("idle"); + } finally { + resumeDesktopSync?.(); + const placeholderEditor = editorRef.current; + placeholders.forEach((placeholder) => { + removeImageUploadPlaceholder(placeholderEditor, placeholder); + }); } - }).finally(() => { - const placeholderEditor = editorRef.current; - placeholders.forEach((placeholder) => { - removeImageUploadPlaceholder(placeholderEditor, placeholder); - }); }); return true; - }, [queryClient, resourceInsertionLimit, t, uploadEditorResource]); + }, [markDirtyStatus, queryClient, resourceInsertionLimit, t, uploadEditorResource]); const pluginEmbedExtension = useMemo(() => createPluginEmbedExtension(pluginHost), [pluginHost]); const inlineFieldExtension = useMemo(() => createInlineFieldExtension(i18n.language), [i18n.language]); @@ -1963,14 +1997,14 @@ const RichEditorPane = ({ ); const persistCurrentDraft = useCallback( - (nextTitle = title, nextTagsText = tagsText, nextMobilePlainText = getMobilePlainTextValue()) => { + (nextTitle = memoFieldsRef.current.title, nextTagsText = memoFieldsRef.current.tagsText, nextMobilePlainText = getMobilePlainTextValue()) => { const currentMemo = memoRef.current; const currentEditor = editorRef.current; if ( !currentMemo || currentMemo.isDeleted || - !getWritableEditorMemoFields(memoFields, currentMemo.id, hydratedMemoIdRef.current, hydratingRef.current) || + !getWritableEditorMemoFields(memoFieldsRef.current, currentMemo.id, hydratedMemoIdRef.current, hydratingRef.current) || (!useMobilePlainTextEditor && !isEditorReady(currentEditor)) ) { return Promise.resolve(); @@ -1992,8 +2026,9 @@ const RichEditorPane = ({ updatedAt: new Date().toISOString(), }); }, - [getMobilePlainTextValue, markdownSource, memoFields, tagsText, title, useMarkdownSourceEditor, useMobilePlainTextEditor] + [getMobilePlainTextValue, markdownSource, useMarkdownSourceEditor, useMobilePlainTextEditor] ); + persistResourceInsertionDraftRef.current = () => persistCurrentDraft(); const markDirty = useCallback(() => { const currentMemo = memoRef.current; @@ -2001,13 +2036,13 @@ const RichEditorPane = ({ hydratingRef.current || currentMemo?.isDeleted || !currentMemo || - !getWritableEditorMemoFields(memoFields, currentMemo.id, hydratedMemoIdRef.current, hydratingRef.current) + !getWritableEditorMemoFields(memoFieldsRef.current, currentMemo.id, hydratedMemoIdRef.current, hydratingRef.current) ) { return; } markDirtyStatus(); - }, [markDirtyStatus, memoFields]); + }, [markDirtyStatus]); const getCurrentMarkdownForAi = useCallback(() => { if (useMobilePlainTextEditor) return getMobilePlainTextValue(); diff --git a/apps/web/src/components/editor/editor-instance-identity.test.mjs b/apps/web/src/components/editor/editor-instance-identity.test.mjs index b66a27c6..53efe9f5 100644 --- a/apps/web/src/components/editor/editor-instance-identity.test.mjs +++ b/apps/web/src/components/editor/editor-instance-identity.test.mjs @@ -4,6 +4,7 @@ import { isEditorInstanceHydratedForMemo, reconcileEditorInstanceMemoIdentity, remapEditorInstanceMemoIdentity, + resolveEditorInsertionMemoId, } from "./editor-instance-identity.ts"; describe("editor instance memo identity", () => { @@ -69,4 +70,22 @@ describe("editor instance memo identity", () => { new Map([["local_memo_2", "memo_remote_2"]]), )).toBe(identity); }); + + test("keeps a pasted file on its note when a local ID becomes durable", () => { + const initial = createEditorInstanceMemoIdentity("memo_local_1"); + const remapped = remapEditorInstanceMemoIdentity(initial, new Map([["memo_local_1", "memo_remote_1"]])); + + expect(resolveEditorInsertionMemoId( + remapped, + initial.instanceKey, + "memo_local_1", + "memo_remote_1", + )).toBe("memo_remote_1"); + expect(resolveEditorInsertionMemoId( + createEditorInstanceMemoIdentity("memo_other"), + initial.instanceKey, + "memo_local_1", + "memo_other", + )).toBeNull(); + }); }); diff --git a/apps/web/src/components/editor/editor-instance-identity.ts b/apps/web/src/components/editor/editor-instance-identity.ts index 603ce8df..c0a36f4e 100644 --- a/apps/web/src/components/editor/editor-instance-identity.ts +++ b/apps/web/src/components/editor/editor-instance-identity.ts @@ -32,6 +32,22 @@ export const isEditorInstanceHydratedForMemo = ( && identity.aliases.has(renderedMemoId) ); +/** Keep an in-flight file insertion attached to the same editor across a local ID remap. */ +export const resolveEditorInsertionMemoId = ( + identity: EditorInstanceMemoIdentity, + requestedInstanceKey: string | null, + requestedMemoId: string, + activeMemoId: string | null | undefined, +): string | null => ( + requestedInstanceKey + && identity.instanceKey === requestedInstanceKey + && identity.aliases.has(requestedMemoId) + && activeMemoId + && identity.aliases.has(activeMemoId) + ? activeMemoId + : null +); + export const remapEditorInstanceMemoIdentity = ( identity: EditorInstanceMemoIdentity, memoIdMappings: ReadonlyMap, diff --git a/apps/web/src/lib/resource-insertion.test.mjs b/apps/web/src/lib/resource-insertion.test.mjs index 54c6dd80..e9c71c80 100644 --- a/apps/web/src/lib/resource-insertion.test.mjs +++ b/apps/web/src/lib/resource-insertion.test.mjs @@ -3,7 +3,7 @@ import { Editor } from "@tiptap/core"; import Image from "@tiptap/extension-image"; import StarterKit from "@tiptap/starter-kit"; import { closeHistory, history, undo, redo } from "@tiptap/pm/history"; -import { ImageGallery, IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared"; +import { FileAttachment, ImageGallery, IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared"; import { insertUploadedResources } from "./resource-insertion"; import { getResourceInsertionTarget } from "./resource-insertion-target"; @@ -13,7 +13,7 @@ const gallery = (layout = "3", sources = ["one", "two"]) => ({ type: IMAGE_GALLERY_NODE_TYPE, attrs: { layout }, content: sources.map(image), }); const makeEditor = (content) => new Editor({ - extensions: [StarterKit, Image, ImageGallery], content: { type: "doc", content }, + extensions: [StarterKit, Image, ImageGallery, FileAttachment], content: { type: "doc", content }, }); const insert = (editor, target, content, select = true) => { expect(editor.commands.command(insertUploadedResources(target, content, select))).toBe(true); @@ -21,6 +21,27 @@ const insert = (editor, target, content, select = true) => { }; describe("uploaded resources beside an existing gallery", () => { + test("inserts a Word attachment into an empty note", () => { + const editor = makeEditor([paragraph()]); + const attachment = { + type: "paragraph", + content: [{ + type: "edgeeverFileAttachment", + attrs: { + url: "edgeever-staged://stage_word_1", + label: "附件:软件外包合同.docx", + filename: "软件外包合同.docx", + mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + byteSize: 42_559, + }, + }], + }; + + insert(editor, { from: 1, to: 1 }, [attachment]); + expect(editor.getJSON().content[0].content[0]).toMatchObject(attachment.content[0]); + editor.destroy(); + }); + test("still extends a gallery after saving and reloading the note", () => { const original = makeEditor([gallery("2")]); const saved = JSON.parse(JSON.stringify(original.getJSON()));