fix(editor): preserve empty links and paste resources in Markdown mode

This commit is contained in:
tianma-if committed 2026-09-24 13:09:38 +08:00
1 parent a801863965
commit be5b1eaeba
15 files changed
+1118 -873

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-2
View File
@@ -5,7 +5,6 @@ import { Editor } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import CodeBlock from "@tiptap/extension-code-block";
import { EdgeEverLink } from "@edgeever/shared/editor-link";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
import mermaid from "mermaid";
@@ -759,7 +758,6 @@ function buildExtensions(placeholder: string) {
table: { table: { resizable: false } },
markdown: true,
}),
EdgeEverLink,
NativeAttachmentMetadata,
CodeBlock.configure({
languageClassPrefix: "language-",
+64 -39
View File
@@ -192,6 +192,7 @@ import {
} from "@/lib/editor-external-link";
import { insertAiDraftAtTextCursor } from "@/lib/ai-draft-insertion";
import { createFileBatchQueue, processFileUploadBatch } from "@/lib/file-batch";
import { resourceToMarkdown } from "@/lib/markdown-resource-paste";
import { MEMO_ID_REMAPPED_EVENT, MEMO_SYNC_ACKNOWLEDGED_EVENT } from "@/lib/sync-events";
import { useStandaloneMobileEditor } from "@/hooks/useStandaloneMobileEditor";
import { statusSettleMotion } from "@/lib/motion";
@@ -886,6 +887,39 @@ const RichEditorPane = ({
}
}, [focusMobileInputTarget, isMobileViewport, memo?.id, mobileDefaultEditMemoId, onMobileDefaultEditConsumed, readOnly]);
const uploadEditorResource = useCallback(async (targetMemoId: string, uploadFile: File, isImage: boolean) => {
try {
const resource = (await repository.uploadMemoResource(targetMemoId, uploadFile)).resource;
return { ...resource, url: toDesktopResourceUrl(resource.url) };
} catch (error) {
if (!isDesktopResourceRuntime()) throw error;
const listed = await repository.listResources().catch(() => ({ resources: [] as Array<{ memoId?: string; url: string; filename?: string | null; kind?: string | null; mimeType?: string | null; byteSize?: number }> }));
const existing = findMatchingMemoResource(
listed.resources.filter((item) => item.memoId === targetMemoId),
uploadFile.name,
isImage ? "image" : "attachment",
);
if (existing) {
return {
kind: isImage ? "image" as const : "attachment" as const,
filename: existing.filename || uploadFile.name,
mimeType: existing.mimeType || uploadFile.type || null,
byteSize: existing.byteSize ?? uploadFile.size,
url: toDesktopResourceUrl(existing.url),
};
}
const staged = await stageDesktopResource(targetMemoId, uploadFile);
if (!staged) throw error;
return {
kind: isImage ? "image" as const : "attachment" as const,
filename: uploadFile.name,
mimeType: uploadFile.type || null,
byteSize: uploadFile.size,
url: `edgeever-staged://${staged.id}`,
};
}
}, [repository]);
const insertResourceFiles = useCallback((files: File[]) => {
const currentMemo = memoRef.current;
const currentEditor = editorRef.current;
@@ -941,44 +975,7 @@ const RichEditorPane = ({
setImageUploadState("uploading");
if (placeholder) updateImageUploadPlaceholder(editorRef.current, placeholder,
t("editor.uploadState.uploading"));
let resource: {
kind: "image" | "attachment";
filename: string | null;
mimeType: string | null;
byteSize: number;
url: string;
};
try {
const uploadedResource = (await repository.uploadMemoResource(targetMemoId, uploadFile)).resource;
resource = { ...uploadedResource, url: toDesktopResourceUrl(uploadedResource.url) };
} catch (error) {
if (!isDesktopResourceRuntime()) throw error;
const listed = await repository.listResources().catch(() => ({ resources: [] as Array<{ memoId?: string; url: string; filename?: string | null; kind?: string | null; mimeType?: string | null; byteSize?: number }> }));
const existing = findMatchingMemoResource(
listed.resources.filter((item) => item.memoId === targetMemoId),
uploadFile.name,
isImage ? "image" : "attachment",
);
if (existing) {
resource = {
kind: isImage ? "image" : "attachment",
filename: existing.filename || uploadFile.name,
mimeType: existing.mimeType || uploadFile.type || null,
byteSize: existing.byteSize ?? uploadFile.size,
url: toDesktopResourceUrl(existing.url),
};
} else {
const staged = await stageDesktopResource(targetMemoId, uploadFile);
if (!staged) throw error;
resource = {
kind: isImage ? "image" : "attachment",
filename: uploadFile.name,
mimeType: uploadFile.type || null,
byteSize: uploadFile.size,
url: `edgeever-staged://${staged.id}`,
};
}
}
const resource = await uploadEditorResource(targetMemoId, uploadFile, isImage);
if (resource.kind === "image") {
imageReadiness.push(waitForImageSourceReady(resource.url));
}
@@ -1082,7 +1079,7 @@ const RichEditorPane = ({
});
});
return true;
}, [queryClient, repository, resourceInsertionLimit, t]);
}, [queryClient, resourceInsertionLimit, t, uploadEditorResource]);
const pluginEmbedExtension = useMemo(() => createPluginEmbedExtension(pluginHost), [pluginHost]);
const inlineFieldExtension = useMemo(() => createInlineFieldExtension(i18n.language), [i18n.language]);
@@ -1282,6 +1279,32 @@ const RichEditorPane = ({
});
const useMarkdownSourceEditor = !useMobilePlainTextEditor && isMarkdownMode;
const uploadMarkdownPasteFiles = useCallback(async (files: File[]) => {
const targetMemoId = memoRef.current?.id;
if (!targetMemoId || effectiveReadOnly) return "";
setImageUploadState("uploading");
const results = await processFileUploadBatch(files, async (file) => {
const shouldCompress = SUPPORTED_PASTE_IMAGE_TYPES.has(file.type) && imageCompressionEnabledRef.current;
if (!shouldCompress) return file;
setImageUploadState("compressing");
return (await compressImageForUpload(file)).file;
}, async (uploadFile, file) => {
if (memoRef.current?.id !== targetMemoId) throw new Error("The active note changed during upload");
setImageUploadState("uploading");
return uploadEditorResource(targetMemoId, uploadFile, SUPPORTED_PASTE_IMAGE_TYPES.has(file.type));
});
if (results.some((result) => result.status === "fulfilled")) {
void queryClient.invalidateQueries({ queryKey: ["resources"] });
}
const failed = results.some((result) => result.status === "rejected");
setImageUploadState(failed ? "error" : "idle");
if (failed) window.setTimeout(() => setImageUploadState("idle"), 2200);
return results
.filter((result) => result.status === "fulfilled")
.map((result) => resourceToMarkdown(result.value, result.file.name))
.join("\n\n");
}, [effectiveReadOnly, queryClient, uploadEditorResource]);
useEffect(() => {
const syncPreference = (event?: Event) => {
const detail = event && event.type === AI_SPACE_SHORTCUT_CHANGED_EVENT
@@ -4085,8 +4108,10 @@ const RichEditorPane = ({
<Suspense fallback={<div className="h-full w-full" />}>
<MarkdownSourceEditor
ref={markdownSourceEditorRef}
memoId={memo?.id ?? ""}
value={markdownSource}
onChange={handleMarkdownSourceChange}
onPasteFiles={uploadMarkdownPasteFiles}
themeName={markdownTheme}
readOnly={effectiveReadOnly}
placeholder={`# ${t("editor.placeholder")}`}
@@ -1,4 +1,4 @@
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from "react";
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, type ClipboardEvent as ReactClipboardEvent } from "react";
import CodeMirror, {
EditorView,
type ReactCodeMirrorRef,
@@ -27,6 +27,9 @@ import type { MarkdownThemeName } from "../ThemeProvider";
import { getAiSlashCommandStart } from "@/lib/editor-shortcuts";
import { lightMarkdownHighlightStyles } from "@/lib/markdown-source-highlight";
import { cn } from "@/lib/utils";
import { getResourceFilesFromDataTransfer } from "./editor-pane-helpers";
type PendingPaste = { from: number; to: number; originalText: string };
export interface MarkdownSourceEditorRef {
getScrollContainer: () => HTMLElement | null;
@@ -38,8 +41,10 @@ export interface MarkdownSourceEditorRef {
}
export interface MarkdownSourceEditorProps {
memoId: string;
value: string;
onChange: (value: string) => void;
onPasteFiles?: (files: File[]) => Promise<string>;
themeName: MarkdownThemeName;
readOnly?: boolean;
placeholder?: string;
@@ -101,8 +106,10 @@ const baseEditorTheme = EditorView.theme({
export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, MarkdownSourceEditorProps>(
(
{
memoId,
value,
onChange,
onPasteFiles,
themeName,
readOnly = false,
placeholder,
@@ -114,6 +121,9 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
ref,
) => {
const cmRef = useRef<ReactCodeMirrorRef | null>(null);
const memoIdRef = useRef(memoId);
memoIdRef.current = memoId;
const pendingPastesRef = useRef(new Set<PendingPaste>());
useImperativeHandle(
ref,
@@ -181,9 +191,48 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
markdown(),
EditorView.lineWrapping,
baseEditorTheme,
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
for (const pending of pendingPastesRef.current) {
pending.from = update.changes.mapPos(pending.from, -1);
pending.to = update.changes.mapPos(pending.to, 1);
}
}),
];
}, []);
const handlePasteCapture = useCallback((event: ReactClipboardEvent<HTMLDivElement>) => {
if (readOnly || !onPasteFiles) return;
const files = getResourceFilesFromDataTransfer(event.clipboardData);
if (!files.length) return;
const view = cmRef.current?.view;
if (!view) return;
event.preventDefault();
event.stopPropagation();
const selection = view.state.selection.main;
const pending: PendingPaste = {
from: selection.from,
to: selection.to,
originalText: view.state.doc.sliceString(selection.from, selection.to),
};
const targetMemoId = memoId;
pendingPastesRef.current.add(pending);
void onPasteFiles(files).then((markdown) => {
pendingPastesRef.current.delete(pending);
if (!markdown || cmRef.current?.view !== view || memoIdRef.current !== targetMemoId) return;
const unchanged = view.state.doc.sliceString(pending.from, pending.to) === pending.originalText;
const from = unchanged ? pending.from : pending.to;
const shouldMoveCursor = view.hasFocus && view.state.selection.main.from === pending.to
&& view.state.selection.main.to === pending.to;
view.dispatch({
changes: { from, to: pending.to, insert: markdown },
...(shouldMoveCursor ? { selection: { anchor: from + markdown.length }, scrollIntoView: true } : {}),
});
}).catch(() => {
pendingPastesRef.current.delete(pending);
});
}, [memoId, onPasteFiles, readOnly]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent | KeyboardEvent) => {
// Link shortcut: Cmd+K / Ctrl+K
@@ -248,6 +297,7 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
className,
)}
aria-label={ariaLabel}
onPasteCapture={handlePasteCapture}
>
<CodeMirror
ref={cmRef}
@@ -0,0 +1,11 @@
import { describe, expect, test } from "bun:test";
import { resourceToMarkdown } from "./markdown-resource-paste.ts";
describe("resourceToMarkdown", () => {
test("formats uploaded images and attachments without breaking Markdown syntax", () => {
expect(resourceToMarkdown({ kind: "image", filename: "a[b].png", url: "/api/v1/resources/res_1/blob" }, "fallback.png"))
.toBe("![a\\[b\\].png](/api/v1/resources/res_1/blob)");
expect(resourceToMarkdown({ kind: "attachment", filename: null, url: "https://example.com/a(b).pdf" }, "report.pdf"))
.toBe("[report.pdf](https://example.com/a%28b%29.pdf)");
});
});
@@ -0,0 +1,9 @@
const escapeLabel = (value: string) => value
.replaceAll("\\", "\\\\")
.replace(/[\[\]\r\n]/g, (character) => character === "\r" || character === "\n" ? " " : `\\${character}`);
export const resourceToMarkdown = (resource: { kind: "image" | "attachment"; url: string; filename: string | null }, fallbackName: string) => {
const label = escapeLabel(resource.filename || fallbackName || "file");
const url = resource.url.replaceAll("(", "%28").replaceAll(")", "%29");
return `${resource.kind === "image" ? "!" : ""}[${label}](${url})`;
};
+33
View File
@@ -1189,6 +1189,39 @@ textarea {
border-radius: 2px;
}
.ProseMirror a.edgeever-empty-external-link,
.markdown-content a.edgeever-empty-external-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5em;
min-height: 1.5em;
padding: 0 0.25em;
border: 1px solid var(--brand-green-border);
border-radius: 0.35em;
background: var(--brand-green-soft);
color: var(--brand-green-text);
font-size: 0.9em;
font-weight: 700;
line-height: 1;
text-decoration: none;
vertical-align: baseline;
}
.ProseMirror a.edgeever-empty-external-link:hover,
.markdown-content a.edgeever-empty-external-link:hover {
border-color: var(--brand-green);
background: var(--brand-green-muted);
color: var(--brand-green-text);
text-decoration: none;
}
.ProseMirror a.edgeever-empty-external-link:focus-visible,
.markdown-content a.edgeever-empty-external-link:focus-visible {
outline: 2px solid var(--brand-green);
outline-offset: 2px;
}
.ProseMirror a.edgeever-note-link,
.ProseMirror a[href^="#memo="] {
display: inline-flex;
+17
View File
@@ -156,6 +156,23 @@ describe("file attachment Markdown compatibility", () => {
expect(doc.content[0]?.content?.[0]?.type).toBe("text");
});
test("preserves an empty external link through Markdown and JSON", () => {
const markdown = "Before [](https://example.com/path) and [named](https://example.com).";
const doc = markdownToDoc(markdown);
expect(doc.content[0]?.content?.[1]).toMatchObject({
type: "edgeeverEmptyExternalLink",
attrs: { href: "https://example.com/path" },
});
expect(docToMarkdown(doc)).toBe(markdown);
expect(markdownToDoc("`[](https://example.com)`").content[0]?.content?.[0]?.type).toBe("text");
});
test("recovers an empty link from older content JSON", () => {
const markdown = "Read [](https://example.com) here";
const oldDoc = markdownToDoc("Read here");
expect(resolveMemoContentDoc(oldDoc, markdown)).toEqual(markdownToDoc(markdown));
});
test("keeps video preview expanded unless compact display mode is stored", () => {
expect(resolveFileDisplayMode(undefined)).toBe("inline");
expect(resolveFileDisplayMode("inline")).toBe("inline");
+3
View File
@@ -14,6 +14,7 @@ import {
import { projectNativeUnknownContentForMarkdown } from "./mobile-content-compatibility";
import { PluginEmbed, PLUGIN_EMBED_NODE_TYPE } from "./plugin-embed";
import { ImageGallery, IMAGE_GALLERY_NODE_TYPE, normalizeImageGalleries } from "./image-gallery";
import { EMPTY_EXTERNAL_LINK_NODE_TYPE } from "./empty-external-link";
export { PluginEmbed, PLUGIN_EMBED_NODE_TYPE, pluginEmbedToMarkdown, normalizePluginEmbedAttributes } from "./plugin-embed";
export type { PluginEmbedAttributes } from "./plugin-embed";
@@ -169,6 +170,7 @@ export const resolveMemoContentDoc = (
docContainsNodeType(currentDoc, INLINE_MATH_NODE_TYPE)
|| docContainsNodeType(currentDoc, PDF_ATTACHMENT_NODE_TYPE)
|| docContainsNodeType(currentDoc, FILE_ATTACHMENT_NODE_TYPE)
|| docContainsNodeType(currentDoc, EMPTY_EXTERNAL_LINK_NODE_TYPE)
|| docContainsNodeType(currentDoc, "details")
) {
return currentDoc;
@@ -185,6 +187,7 @@ export const resolveMemoContentDoc = (
|| docContainsNodeType(markdownDoc, MERGE_DIVIDER_NODE_TYPE)
|| docContainsNodeType(markdownDoc, BLOCK_MATH_NODE_TYPE)
|| docContainsNodeType(markdownDoc, INLINE_MATH_NODE_TYPE)
|| docContainsNodeType(markdownDoc, EMPTY_EXTERNAL_LINK_NODE_TYPE)
|| docContainsNodeType(markdownDoc, "details")
|| !docToText(currentDoc)
? markdownDoc
+7 -1
View File
@@ -10,6 +10,8 @@ import { ImageGallery } from "./image-gallery";
import { MergeDivider } from "./merge-divider";
import { PdfAttachment } from "./pdf-attachment";
import { PluginEmbed } from "./plugin-embed";
import { EmptyExternalLink } from "./empty-external-link";
import { EdgeEverLink } from "./editor-link";
export type CreateEdgeEverDocumentExtensionsOptions = {
mathematics: AnyExtension[];
@@ -36,7 +38,11 @@ const withOptional = (value: AnyExtension | false | undefined, fallback: AnyExte
export const createEdgeEverDocumentExtensions = (
options: CreateEdgeEverDocumentExtensionsOptions,
): AnyExtension[] => [
options.starterKit === undefined ? StarterKit : StarterKit.configure(options.starterKit),
options.markdown
? StarterKit.configure({ ...options.starterKit, link: false })
: options.starterKit === undefined ? StarterKit : StarterKit.configure(options.starterKit),
...(options.markdown ? [EdgeEverLink] : []),
EmptyExternalLink,
TaskList,
TaskItem.configure({ nested: true }),
options.table === undefined ? TableKit : TableKit.configure(options.table),
+13
View File
@@ -1,4 +1,5 @@
import Link from "@tiptap/extension-link";
import { EMPTY_EXTERNAL_LINK_NODE_TYPE } from "./empty-external-link";
/**
* Keep text entered at the end of a link outside that link while preserving
@@ -6,4 +7,16 @@ import Link from "@tiptap/extension-link";
*/
export const EdgeEverLink = Link.extend({
inclusive: false,
parseMarkdown(token, helpers) {
if (!token.tokens?.length && token.text === "" && typeof token.href === "string" && token.href) {
return {
type: EMPTY_EXTERNAL_LINK_NODE_TYPE,
attrs: { href: token.href, title: token.title || null },
};
}
return helpers.applyMark("link", helpers.parseInline(token.tokens || []), {
href: token.href,
title: token.title || null,
});
},
});
@@ -0,0 +1,54 @@
import { mergeAttributes, Node } from "@tiptap/core";
export const EMPTY_EXTERNAL_LINK_NODE_TYPE = "edgeeverEmptyExternalLink" as const;
const safeHref = (value: unknown) => {
if (typeof value !== "string") return "";
const href = value.trim();
return /^(?:https?:\/\/|\/|#)/i.test(href) ? href : "";
};
/** A visible, round-trippable target for Markdown links with no label. */
export const EmptyExternalLink = Node.create({
name: EMPTY_EXTERNAL_LINK_NODE_TYPE,
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
href: { default: "" },
title: { default: null },
};
},
parseHTML() {
return [{ tag: "a[data-edgeever-empty-link]" }];
},
renderHTML({ HTMLAttributes }) {
const href = safeHref(HTMLAttributes.href);
return [
"a",
mergeAttributes(HTMLAttributes, {
href: href || undefined,
target: "_blank",
rel: "noopener noreferrer",
"data-edgeever-empty-link": "true",
"aria-label": href ? `Open link: ${href}` : "Open link",
class: "edgeever-empty-external-link",
}),
"↗",
];
},
renderMarkdown(node) {
const href = safeHref(node.attrs?.href);
if (!href) return "";
const title = typeof node.attrs?.title === "string" && node.attrs.title
? ` "${node.attrs.title.replaceAll('"', '\\"')}"`
: "";
return `[](${href}${title})`;
},
});
@@ -86,6 +86,18 @@ describe("native editor unknown-content compatibility", () => {
.toEqual(galleryDocument);
});
test("keeps empty links visible and serializable in native editors", () => {
const document = {
type: "doc",
content: [{
type: "paragraph",
content: [{ type: "edgeeverEmptyExternalLink", attrs: { href: "https://example.com", title: null } }],
}],
};
expect(prepareNativeEditorContent(document)).toEqual(document);
expect(docToMarkdown(document)).toBe("[](https://example.com)");
});
test("keeps Markdown generation alive by projecting unknown content to text", () => {
const markdown = docToMarkdown(futureDocument);
expect(markdown).toContain("保留主题块正文");
@@ -3,6 +3,7 @@ import type { TiptapDoc, TiptapMark, TiptapNode, TiptapTextNode } from "./conten
import { FILE_ATTACHMENT_NODE_TYPE } from "./file-attachment";
import { PDF_ATTACHMENT_NODE_TYPE } from "./pdf-attachment";
import { PLUGIN_EMBED_NODE_TYPE } from "./plugin-embed";
import { EMPTY_EXTERNAL_LINK_NODE_TYPE } from "./empty-external-link";
export const UNSUPPORTED_BLOCK_NODE_TYPE = "edgeeverUnsupportedBlock" as const;
export const UNSUPPORTED_INLINE_NODE_TYPE = "edgeeverUnsupportedInline" as const;
@@ -38,6 +39,7 @@ export const NATIVE_EDITOR_NODE_TYPES = new Set<string>([
"tableHeader",
"tableCell",
"edgeeverMergeDivider",
EMPTY_EXTERNAL_LINK_NODE_TYPE,
"inlineMath",
"blockMath",
...FALLBACK_NODE_TYPES,