fix(web): keep pasted attachments in new desktop notes
This commit is contained in:
1 parent
dc8f51460f
commit
82f69a50e1
4 files changed
+213
-122
No files matched your search
@@ -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<EditorMemoFields>({ 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<MemoEditSession | null>(null);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const editorCanvasInteractionVersionRef = useRef(0);
|
||||
const persistResourceInsertionDraftRef = useRef<() => Promise<unknown>>(() => Promise.resolve());
|
||||
const openAiAssistantRef = useRef<() => void>(() => undefined);
|
||||
const aiSpaceShortcutEnabledRef = useRef(readAiSpaceShortcutPreference());
|
||||
const editorScrollContainerRef = useRef<HTMLDivElement | null>(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<void>[] = [];
|
||||
|
||||
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<void>[] = [];
|
||||
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>,
|
||||
|
||||
@@ -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()));
|
||||
|
||||
Reference in new issue
Block a user