fix(editor): preserve empty links and paste resources in Markdown mode
This commit is contained in:
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
+421
-415
File diff suppressed because it is too large.
Load diff
@@ -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-",
|
||||
|
||||
@@ -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})`;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user