fix(mobile): safely preserve unsupported editor nodes
This commit is contained in:
1 parent
2ffca8501b
commit
9f355f418a
10 files changed
+1009
-558
No files matched your search
+418
-420
File diff suppressed because it is too large.
Load diff
@@ -12,7 +12,15 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import mermaid from "mermaid";
|
||||
import { toCanvas } from "html-to-image";
|
||||
import { resolveAttachmentKind } from "@edgeever/shared";
|
||||
import {
|
||||
createNativeUnsupportedContentExtensions,
|
||||
docToMarkdown,
|
||||
prepareNativeEditorContent,
|
||||
resolveAttachmentKind,
|
||||
resolveNativeAttachmentContent,
|
||||
restoreNativeEditorContent,
|
||||
type TiptapDoc,
|
||||
} from "@edgeever/shared";
|
||||
import {
|
||||
type NoteImageTheme,
|
||||
type NoteImageFontStyle,
|
||||
@@ -198,7 +206,7 @@ type ImageExportRequest = {
|
||||
branding?: boolean;
|
||||
};
|
||||
let mode: "viewer" | "editor" = "viewer";
|
||||
let locale = "zh-CN";
|
||||
let locale: "zh-CN" | "en-US" = "zh-CN";
|
||||
let currentPlaceholder = "开始输入…";
|
||||
let suppressChange = false;
|
||||
const resourceResolvers = new Map<string, (dataUrl: string | null) => void>();
|
||||
@@ -708,6 +716,7 @@ function buildExtensions(placeholder: string) {
|
||||
TableKit.configure({
|
||||
table: { resizable: false },
|
||||
}),
|
||||
...createNativeUnsupportedContentExtensions(),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
@@ -886,14 +895,7 @@ const protectLiteralDollarPairs = (value: unknown): unknown => {
|
||||
};
|
||||
|
||||
const serializeEditorMarkdown = (ed: Editor) => {
|
||||
const manager = (ed.storage as { markdown?: { manager?: { serialize?: (doc: unknown) => string } } })
|
||||
.markdown?.manager;
|
||||
if (manager?.serialize) {
|
||||
return manager.serialize(protectLiteralDollarPairs(ed.getJSON())).replaceAll(LITERAL_DOLLAR_PLACEHOLDER, "\\$");
|
||||
}
|
||||
return typeof ed.getMarkdown === "function"
|
||||
? ed.getMarkdown()
|
||||
: ed.getText({ blockSeparator: "\n\n" });
|
||||
return docToMarkdown(restoreNativeEditorContent(ed.getJSON() as TiptapDoc));
|
||||
};
|
||||
|
||||
let pendingAiSelection: { from: number; to: number; isInline: boolean; documentFingerprint: string } | null = null;
|
||||
@@ -1007,7 +1009,7 @@ const parseAiSelectionReplacement = (ed: Editor, draft: string, isInline: boolea
|
||||
|
||||
function emitChange(ed: Editor) {
|
||||
try {
|
||||
const contentJson = JSON.stringify(ed.getJSON());
|
||||
const contentJson = JSON.stringify(restoreNativeEditorContent(ed.getJSON() as TiptapDoc));
|
||||
const contentMarkdown = serializeEditorMarkdown(ed);
|
||||
post({ type: "change", contentMarkdown, contentJson });
|
||||
} catch (error) {
|
||||
@@ -1328,8 +1330,11 @@ const api: EdgeEverEditorAPI = {
|
||||
setDocumentFromJSON(json) {
|
||||
suppressChange = true;
|
||||
try {
|
||||
const doc = JSON.parse(json);
|
||||
editor.commands.setContent(doc);
|
||||
const doc = JSON.parse(json) as TiptapDoc;
|
||||
editor.commands.setContent(prepareNativeEditorContent(
|
||||
resolveNativeAttachmentContent(doc),
|
||||
locale,
|
||||
));
|
||||
} catch {
|
||||
editor.commands.setContent({ type: "doc", content: [{ type: "paragraph" }] });
|
||||
}
|
||||
@@ -1351,7 +1356,7 @@ const api: EdgeEverEditorAPI = {
|
||||
},
|
||||
|
||||
getDocument() {
|
||||
return JSON.stringify(editor.getJSON());
|
||||
return JSON.stringify(restoreNativeEditorContent(editor.getJSON() as TiptapDoc));
|
||||
},
|
||||
|
||||
captureSelection() {
|
||||
|
||||
@@ -596,6 +596,33 @@ html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-code::before { b
|
||||
html[data-theme="dark"] .edgeever-prose a.edgeever-attachment-kind-text::before,
|
||||
html[data-theme="dark"] .ProseMirror a.edgeever-attachment-kind-text::before { background: #334155; color: #cbd5e1; }
|
||||
|
||||
.edgeever-prose .edgeever-unsupported-content,
|
||||
.ProseMirror .edgeever-unsupported-content {
|
||||
border: 1px dashed #94a3b8;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edgeever-prose .edgeever-unsupported-content--block,
|
||||
.ProseMirror .edgeever-unsupported-content--block { display: block; margin: 8px 0; padding: 12px; }
|
||||
.edgeever-prose .edgeever-unsupported-content--inline,
|
||||
.ProseMirror .edgeever-unsupported-content--inline { display: inline-block; margin: 0 2px; padding: 2px 6px; }
|
||||
.edgeever-prose .edgeever-unsupported-mark,
|
||||
.ProseMirror .edgeever-unsupported-mark { border-bottom: 1px dashed #64748b; }
|
||||
|
||||
html[data-theme="dark"] .edgeever-prose .edgeever-unsupported-content,
|
||||
html[data-theme="dark"] .ProseMirror .edgeever-unsupported-content {
|
||||
border-color: #64748b;
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .edgeever-prose .edgeever-unsupported-mark,
|
||||
html[data-theme="dark"] .ProseMirror .edgeever-unsupported-mark { border-bottom-color: #94a3b8; }
|
||||
|
||||
.ProseMirror hr {
|
||||
margin: 1.5rem 0;
|
||||
border: 0;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AI_TONES,
|
||||
AI_WHOLE_NOTE_ACTIONS,
|
||||
canReplaceAiSource,
|
||||
createNativeUnsupportedContentExtensions,
|
||||
docToMarkdown,
|
||||
getDefaultAiTargetLanguage,
|
||||
getAiDocumentFingerprint,
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
MEMO_CONTENT_STYLE,
|
||||
MergeDivider,
|
||||
normalizeAiSelectionReplacement,
|
||||
prepareNativeEditorContent,
|
||||
restoreNativeEditorContent,
|
||||
getImageReferrerPolicy,
|
||||
getResourceIdFromUrl,
|
||||
type AiAction,
|
||||
@@ -693,13 +696,17 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
TableKit.configure({
|
||||
table: { renderWrapper: true },
|
||||
}),
|
||||
...createNativeUnsupportedContentExtensions(),
|
||||
...(isViewer
|
||||
? []
|
||||
: [Placeholder.configure({
|
||||
placeholder: getMobileEditorPlaceholder(props.locale),
|
||||
})]),
|
||||
],
|
||||
content: resolveImageSources(resolveMobileAttachmentContent(props.content), props.baseUrl),
|
||||
content: prepareNativeEditorContent(
|
||||
resolveImageSources(resolveMobileAttachmentContent(props.content), props.baseUrl),
|
||||
props.locale,
|
||||
),
|
||||
editorProps: {
|
||||
attributes: getMobileEditorInputAttributes(
|
||||
isViewer ? "edgeever-editor-content edgeever-viewer-content" : "edgeever-editor-content"
|
||||
@@ -751,7 +758,10 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(contentJsonSerialized) as EditorDoc;
|
||||
const next = resolveImageSources(resolveMobileAttachmentContent(parsed), props.baseUrl);
|
||||
const next = prepareNativeEditorContent(
|
||||
resolveImageSources(resolveMobileAttachmentContent(parsed), props.baseUrl),
|
||||
props.locale,
|
||||
);
|
||||
// Do not focus while replacing content. Callers decide when the editor should
|
||||
// take focus and place the caret via focusEnd().
|
||||
// This command synchronizes native-owned state (draft restore/template/new
|
||||
@@ -761,7 +771,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
} catch {
|
||||
// Ignore malformed payloads from the native bridge.
|
||||
}
|
||||
}, [editor, props.baseUrl]);
|
||||
}, [editor, props.baseUrl, props.locale]);
|
||||
|
||||
const search = useCallback((query: DOMValue, requestedIndex: DOMValue) => {
|
||||
const normalizedQuery = typeof query === "string" ? query : "";
|
||||
@@ -1300,13 +1310,16 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
|
||||
if (!editor || editor.isDestroyed || !isViewer) {
|
||||
return;
|
||||
}
|
||||
const next = resolveImageSources(resolveMobileAttachmentContent(props.content), props.baseUrl);
|
||||
const next = prepareNativeEditorContent(
|
||||
resolveImageSources(resolveMobileAttachmentContent(props.content), props.baseUrl),
|
||||
props.locale,
|
||||
);
|
||||
const current = JSON.stringify(editor.getJSON());
|
||||
const incoming = JSON.stringify(next);
|
||||
if (current !== incoming) {
|
||||
editor.commands.setContent(next, { emitUpdate: false });
|
||||
}
|
||||
}, [editor, isViewer, props.baseUrl, props.content]);
|
||||
}, [editor, isViewer, props.baseUrl, props.content, props.locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed || isViewer) {
|
||||
@@ -1983,7 +1996,10 @@ const normalizeImageSources = (doc: EditorDoc, baseUrl: string) => {
|
||||
};
|
||||
|
||||
const getPersistableEditorDoc = (doc: EditorDoc, baseUrl: string) =>
|
||||
normalizeImageSources(stripMobileImageUploadPlaceholders(doc), baseUrl);
|
||||
normalizeImageSources(
|
||||
restoreNativeEditorContent(stripMobileImageUploadPlaceholders(doc)),
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
const normalizeProtectedResourceSource = (source: string, baseUrl: string) =>
|
||||
// Shared normalizer adds `/blob` so editor loads hit the API blob route even when
|
||||
@@ -3008,6 +3024,17 @@ const getEditorStyles = (theme: "light" | "dark", options?: { viewer?: boolean }
|
||||
.edgeever-editor-content a.edgeever-attachment-kind-archive::before { background: ${theme === "dark" ? "#713f12" : "#fffbeb"}; color: ${theme === "dark" ? "#fde68a" : "#d97706"}; content: "ZIP"; }
|
||||
.edgeever-editor-content a.edgeever-attachment-kind-code::before { background: ${theme === "dark" ? "#581c87" : "#faf5ff"}; color: ${theme === "dark" ? "#d8b4fe" : "#8b5cf6"}; content: "</>"; }
|
||||
.edgeever-editor-content a.edgeever-attachment-kind-text::before { background: ${theme === "dark" ? "#334155" : "#f1f5f9"}; color: ${theme === "dark" ? "#cbd5e1" : "#64748b"}; content: "TXT"; }
|
||||
.edgeever-editor-content .edgeever-unsupported-content {
|
||||
border: 1px dashed ${theme === "dark" ? "#64748b" : "#94a3b8"};
|
||||
border-radius: 8px;
|
||||
background: ${theme === "dark" ? "#1e293b" : "#f8fafc"};
|
||||
color: ${theme === "dark" ? "#cbd5e1" : "#475569"};
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.edgeever-editor-content .edgeever-unsupported-content--block { display: block; margin: 8px 0; padding: 12px; }
|
||||
.edgeever-editor-content .edgeever-unsupported-content--inline { display: inline-block; margin: 0 2px; padding: 2px 6px; }
|
||||
.edgeever-editor-content .edgeever-unsupported-mark { border-bottom: 1px dashed ${theme === "dark" ? "#94a3b8" : "#64748b"}; }
|
||||
.edgeever-editor-content a.edgeever-attachment-link::after, .edgeever-editor-content a[href*="/api/v1/resources/"]::after {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -1,118 +1,4 @@
|
||||
import {
|
||||
FILE_ATTACHMENT_NODE_TYPE,
|
||||
PDF_ATTACHMENT_NODE_TYPE,
|
||||
resolveAttachmentKind,
|
||||
type TiptapDoc,
|
||||
type TiptapMark,
|
||||
type TiptapNode,
|
||||
type TiptapTextNode,
|
||||
export {
|
||||
getMobileAttachmentLinkClass,
|
||||
resolveMobileAttachmentContent,
|
||||
} from "@edgeever/shared";
|
||||
|
||||
const ATTACHMENT_NODE_TYPES = new Set<string>([
|
||||
FILE_ATTACHMENT_NODE_TYPE,
|
||||
PDF_ATTACHMENT_NODE_TYPE,
|
||||
]);
|
||||
|
||||
const getStringAttr = (node: TiptapNode, key: string) => {
|
||||
const value = node.attrs?.[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
};
|
||||
|
||||
const ATTACHMENT_KIND_CLASS_PREFIX = "edgeever-attachment-kind-";
|
||||
|
||||
const normalizeAttachmentFilename = (label: string) =>
|
||||
label.replace(/^\s*(?:附件[::]|Attachment:)\s*/i, "").trim();
|
||||
|
||||
export const getMobileAttachmentLinkClass = (
|
||||
filename: string,
|
||||
mimeType?: string | null,
|
||||
existingClass?: unknown,
|
||||
) => {
|
||||
const preservedClasses = typeof existingClass === "string"
|
||||
? existingClass.split(/\s+/).filter((className) =>
|
||||
className &&
|
||||
className !== "edgeever-attachment-link" &&
|
||||
!className.startsWith(ATTACHMENT_KIND_CLASS_PREFIX)
|
||||
)
|
||||
: [];
|
||||
const kind = resolveAttachmentKind(mimeType, normalizeAttachmentFilename(filename));
|
||||
return [
|
||||
...preservedClasses,
|
||||
"edgeever-attachment-link",
|
||||
`${ATTACHMENT_KIND_CLASS_PREFIX}${kind}`,
|
||||
].join(" ");
|
||||
};
|
||||
|
||||
const isAttachmentMark = (mark: TiptapMark) => {
|
||||
if (mark.type !== "link") return false;
|
||||
const href = typeof mark.attrs?.href === "string" ? mark.attrs.href : "";
|
||||
const className = typeof mark.attrs?.class === "string" ? mark.attrs.class : "";
|
||||
return className.split(/\s+/).includes("edgeever-attachment-link") || href.includes("/api/v1/resources/");
|
||||
};
|
||||
|
||||
const withAttachmentKindClass = (node: TiptapTextNode): TiptapTextNode => {
|
||||
if (!node.marks?.some(isAttachmentMark)) return node;
|
||||
return {
|
||||
...node,
|
||||
marks: node.marks.map((mark) => isAttachmentMark(mark)
|
||||
? {
|
||||
...mark,
|
||||
attrs: {
|
||||
...mark.attrs,
|
||||
class: getMobileAttachmentLinkClass(node.text, null, mark.attrs?.class),
|
||||
},
|
||||
}
|
||||
: mark),
|
||||
};
|
||||
};
|
||||
|
||||
const toLegacyAttachmentLink = (node: TiptapNode): TiptapTextNode => {
|
||||
const url = getStringAttr(node, "url");
|
||||
const label = getStringAttr(node, "label") || getStringAttr(node, "filename") || "Attachment";
|
||||
const mimeType = getStringAttr(node, "mimeType");
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: label,
|
||||
...(url
|
||||
? {
|
||||
marks: [{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: url,
|
||||
target: "_blank",
|
||||
class: getMobileAttachmentLinkClass(label, mimeType),
|
||||
},
|
||||
}],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The native WebView editor intentionally keeps the stable link-based attachment
|
||||
* schema used by its resource action bridge. Convert richer cross-client nodes at
|
||||
* the boundary so Tiptap never rejects an otherwise valid note as unknown content.
|
||||
*/
|
||||
export const resolveMobileAttachmentContent = (doc: TiptapDoc): TiptapDoc => {
|
||||
const visit = (node: TiptapNode | TiptapTextNode): TiptapNode | TiptapTextNode => {
|
||||
if (ATTACHMENT_NODE_TYPES.has(node.type)) {
|
||||
return toLegacyAttachmentLink(node as TiptapNode);
|
||||
}
|
||||
|
||||
if ("content" in node && node.content) {
|
||||
return { ...node, content: node.content.map(visit) };
|
||||
}
|
||||
|
||||
if (node.type === "text") {
|
||||
return withAttachmentKindClass(node as TiptapTextNode);
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return {
|
||||
...doc,
|
||||
content: doc.content.map((node) => visit(node) as TiptapNode),
|
||||
};
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createEdgeEverMarkdownMathematics,
|
||||
INLINE_MATH_NODE_TYPE,
|
||||
} from "./mathematics-markdown";
|
||||
import { projectNativeUnknownContentForMarkdown } from "./mobile-content-compatibility";
|
||||
|
||||
export {
|
||||
BLOCK_MATH_NODE_TYPE,
|
||||
@@ -315,7 +316,9 @@ export const docToMarkdown = (doc: unknown): string => {
|
||||
return "";
|
||||
}
|
||||
|
||||
const serializableDoc = protectLiteralDollarPairs(stripEditorOnlyNodes(doc));
|
||||
const serializableDoc = protectLiteralDollarPairs(projectNativeUnknownContentForMarkdown(
|
||||
stripEditorOnlyNodes(doc) as TiptapDoc
|
||||
));
|
||||
return markdownManager
|
||||
.serialize(serializableDoc as Parameters<typeof markdownManager.serialize>[0])
|
||||
.replaceAll(LITERAL_DOLLAR_PLACEHOLDER, "\\$");
|
||||
|
||||
@@ -5,6 +5,8 @@ export * from "./ai-prompt-seeds";
|
||||
export * from "./ai-tag-suggestions";
|
||||
export * from "./ai-selection";
|
||||
export * from "./attachment-kind";
|
||||
export * from "./mobile-content-compatibility";
|
||||
export * from "./native-attachment-content";
|
||||
export * from "./backup";
|
||||
export * from "./github-feedback";
|
||||
export * from "./image-display";
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
UNSUPPORTED_BLOCK_NODE_TYPE,
|
||||
UNSUPPORTED_INLINE_NODE_TYPE,
|
||||
UNSUPPORTED_MARK_TYPE,
|
||||
prepareNativeEditorContent,
|
||||
restoreNativeEditorContent,
|
||||
} from "./mobile-content-compatibility.ts";
|
||||
import { docToMarkdown } from "./content.ts";
|
||||
|
||||
const futureDocument = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "edgeeverThemeBlock",
|
||||
attrs: { kind: "warning", futureOption: true },
|
||||
content: [{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "保留主题块正文" }],
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "带格式文本",
|
||||
marks: [
|
||||
{ type: "futureComment", attrs: { threadId: "thread-1" } },
|
||||
{ type: "bold" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "futureMention",
|
||||
attrs: { userId: "user-1", label: "小明" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("native editor unknown-content compatibility", () => {
|
||||
test("replaces unknown block, inline node, and mark with schema-safe placeholders", () => {
|
||||
const prepared = prepareNativeEditorContent(futureDocument, "zh-CN");
|
||||
|
||||
expect(prepared.content[0]).toMatchObject({
|
||||
type: UNSUPPORTED_BLOCK_NODE_TYPE,
|
||||
attrs: {
|
||||
originalType: "edgeeverThemeBlock",
|
||||
displayLabel: "暂不支持的内容:edgeeverThemeBlock",
|
||||
},
|
||||
});
|
||||
expect(prepared.content[1]?.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
marks: [
|
||||
{ type: "bold" },
|
||||
{ type: UNSUPPORTED_MARK_TYPE },
|
||||
],
|
||||
});
|
||||
expect(prepared.content[1]?.content?.[1]).toMatchObject({
|
||||
type: UNSUPPORTED_INLINE_NODE_TYPE,
|
||||
attrs: { originalType: "futureMention" },
|
||||
});
|
||||
});
|
||||
|
||||
test("restores the original JSON exactly before persistence", () => {
|
||||
expect(restoreNativeEditorContent(prepareNativeEditorContent(futureDocument)))
|
||||
.toEqual(futureDocument);
|
||||
});
|
||||
|
||||
test("keeps Markdown generation alive by projecting unknown content to text", () => {
|
||||
const markdown = docToMarkdown(futureDocument);
|
||||
expect(markdown).toContain("保留主题块正文");
|
||||
expect(markdown).toContain("带格式文本");
|
||||
expect(markdown).toContain("Unsupported content: futureMention");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { Mark, Node } from "@tiptap/core";
|
||||
import type { TiptapDoc, TiptapMark, TiptapNode, TiptapTextNode } from "./content";
|
||||
import { FILE_ATTACHMENT_NODE_TYPE } from "./file-attachment";
|
||||
import { PDF_ATTACHMENT_NODE_TYPE } from "./pdf-attachment";
|
||||
|
||||
export const UNSUPPORTED_BLOCK_NODE_TYPE = "edgeeverUnsupportedBlock" as const;
|
||||
export const UNSUPPORTED_INLINE_NODE_TYPE = "edgeeverUnsupportedInline" as const;
|
||||
export const UNSUPPORTED_MARK_TYPE = "edgeeverUnsupportedMark" as const;
|
||||
|
||||
const FALLBACK_NODE_TYPES = new Set<string>([
|
||||
UNSUPPORTED_BLOCK_NODE_TYPE,
|
||||
UNSUPPORTED_INLINE_NODE_TYPE,
|
||||
]);
|
||||
|
||||
/** Nodes intentionally understood by both native WebView editor schemas. */
|
||||
export const NATIVE_EDITOR_NODE_TYPES = new Set<string>([
|
||||
"doc",
|
||||
"text",
|
||||
"paragraph",
|
||||
"heading",
|
||||
"blockquote",
|
||||
"bulletList",
|
||||
"orderedList",
|
||||
"listItem",
|
||||
"taskList",
|
||||
"taskItem",
|
||||
"codeBlock",
|
||||
"hardBreak",
|
||||
"horizontalRule",
|
||||
"image",
|
||||
"table",
|
||||
"tableRow",
|
||||
"tableHeader",
|
||||
"tableCell",
|
||||
"edgeeverMergeDivider",
|
||||
"inlineMath",
|
||||
"blockMath",
|
||||
...FALLBACK_NODE_TYPES,
|
||||
]);
|
||||
|
||||
export const NATIVE_EDITOR_MARK_TYPES = new Set<string>([
|
||||
"bold",
|
||||
"italic",
|
||||
"strike",
|
||||
"underline",
|
||||
"code",
|
||||
"link",
|
||||
UNSUPPORTED_MARK_TYPE,
|
||||
]);
|
||||
|
||||
const MARKDOWN_EDITOR_NODE_TYPES = new Set<string>([
|
||||
...NATIVE_EDITOR_NODE_TYPES,
|
||||
FILE_ATTACHMENT_NODE_TYPE,
|
||||
PDF_ATTACHMENT_NODE_TYPE,
|
||||
]);
|
||||
|
||||
const INLINE_PARENT_TYPES = new Set<string>(["paragraph", "heading"]);
|
||||
|
||||
const isTextNode = (node: TiptapNode | TiptapTextNode): node is TiptapTextNode =>
|
||||
node.type === "text" && "text" in node;
|
||||
|
||||
const getOriginalType = (attrs: Record<string, unknown> | undefined) =>
|
||||
typeof attrs?.originalType === "string" ? attrs.originalType : "unknown";
|
||||
|
||||
const getFallbackLabel = (type: string, locale?: "zh-CN" | "en-US") =>
|
||||
locale === "zh-CN" ? `暂不支持的内容:${type}` : `Unsupported content: ${type}`;
|
||||
|
||||
const fallbackAttrs = (
|
||||
node: TiptapNode | TiptapTextNode,
|
||||
locale?: "zh-CN" | "en-US",
|
||||
) => ({
|
||||
originalType: node.type,
|
||||
originalJson: JSON.stringify(node),
|
||||
displayLabel: getFallbackLabel(node.type, locale),
|
||||
});
|
||||
|
||||
const parseOriginalNode = (attrs: Record<string, unknown> | undefined) => {
|
||||
if (typeof attrs?.originalJson !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(attrs.originalJson) as TiptapNode | TiptapTextNode;
|
||||
return parsed && typeof parsed.type === "string" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseOriginalMarks = (attrs: Record<string, unknown> | undefined): TiptapMark[] => {
|
||||
if (typeof attrs?.originalMarksJson !== "string") return [];
|
||||
try {
|
||||
const parsed = JSON.parse(attrs.originalMarksJson) as unknown;
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((mark): mark is TiptapMark => Boolean(mark && typeof mark === "object" && typeof (mark as TiptapMark).type === "string"))
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const UnsupportedBlock = Node.create({
|
||||
name: UNSUPPORTED_BLOCK_NODE_TYPE,
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
originalType: { default: "unknown" },
|
||||
originalJson: { default: "" },
|
||||
displayLabel: { default: "" },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-edgeever-unsupported-block]" }];
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
const type = getOriginalType(node.attrs);
|
||||
return [
|
||||
"div",
|
||||
{
|
||||
"data-edgeever-unsupported-block": "true",
|
||||
"data-original-type": type,
|
||||
class: "edgeever-unsupported-content edgeever-unsupported-content--block",
|
||||
contenteditable: "false",
|
||||
},
|
||||
typeof node.attrs.displayLabel === "string" && node.attrs.displayLabel
|
||||
? node.attrs.displayLabel
|
||||
: getFallbackLabel(type),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const UnsupportedInline = Node.create({
|
||||
name: UNSUPPORTED_INLINE_NODE_TYPE,
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
originalType: { default: "unknown" },
|
||||
originalJson: { default: "" },
|
||||
displayLabel: { default: "" },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-edgeever-unsupported-inline]" }];
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
const type = getOriginalType(node.attrs);
|
||||
return [
|
||||
"span",
|
||||
{
|
||||
"data-edgeever-unsupported-inline": "true",
|
||||
"data-original-type": type,
|
||||
class: "edgeever-unsupported-content edgeever-unsupported-content--inline",
|
||||
contenteditable: "false",
|
||||
},
|
||||
typeof node.attrs.displayLabel === "string" && node.attrs.displayLabel
|
||||
? node.attrs.displayLabel
|
||||
: getFallbackLabel(type),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const UnsupportedMark = Mark.create({
|
||||
name: UNSUPPORTED_MARK_TYPE,
|
||||
|
||||
addAttributes() {
|
||||
return { originalMarksJson: { default: "[]" } };
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-edgeever-unsupported-mark]" }];
|
||||
},
|
||||
|
||||
renderHTML() {
|
||||
return ["span", {
|
||||
"data-edgeever-unsupported-mark": "true",
|
||||
class: "edgeever-unsupported-mark",
|
||||
}, 0];
|
||||
},
|
||||
});
|
||||
|
||||
export const createNativeUnsupportedContentExtensions = () => [
|
||||
UnsupportedBlock,
|
||||
UnsupportedInline,
|
||||
UnsupportedMark,
|
||||
];
|
||||
|
||||
const prepareTextNode = (node: TiptapTextNode): TiptapTextNode => {
|
||||
const marks = node.marks ?? [];
|
||||
const supportedMarks = marks.filter((mark) => NATIVE_EDITOR_MARK_TYPES.has(mark.type));
|
||||
const unsupportedMarks = marks.filter((mark) => !NATIVE_EDITOR_MARK_TYPES.has(mark.type));
|
||||
if (!unsupportedMarks.length) return node;
|
||||
return {
|
||||
...node,
|
||||
marks: [
|
||||
...supportedMarks,
|
||||
{
|
||||
type: UNSUPPORTED_MARK_TYPE,
|
||||
attrs: { originalMarksJson: JSON.stringify(marks) },
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts content unknown to native editor schemas into lossless atom placeholders.
|
||||
* The raw subtree/marks stay in attrs and are restored before persistence.
|
||||
*/
|
||||
export const prepareNativeEditorContent = (
|
||||
doc: TiptapDoc,
|
||||
locale?: "zh-CN" | "en-US",
|
||||
): TiptapDoc => {
|
||||
const visit = (
|
||||
node: TiptapNode | TiptapTextNode,
|
||||
parentType: string,
|
||||
): TiptapNode | TiptapTextNode => {
|
||||
if (isTextNode(node)) return prepareTextNode(node);
|
||||
if (!NATIVE_EDITOR_NODE_TYPES.has(node.type)) {
|
||||
return {
|
||||
type: INLINE_PARENT_TYPES.has(parentType)
|
||||
? UNSUPPORTED_INLINE_NODE_TYPE
|
||||
: UNSUPPORTED_BLOCK_NODE_TYPE,
|
||||
attrs: fallbackAttrs(node, locale),
|
||||
};
|
||||
}
|
||||
const structuralNode = node as TiptapNode;
|
||||
if (FALLBACK_NODE_TYPES.has(structuralNode.type)) return structuralNode;
|
||||
return structuralNode.content
|
||||
? { ...structuralNode, content: structuralNode.content.map((child) => visit(child, structuralNode.type)) }
|
||||
: structuralNode;
|
||||
};
|
||||
|
||||
return {
|
||||
...doc,
|
||||
content: doc.content.map((node) => visit(node, "doc") as TiptapNode),
|
||||
};
|
||||
};
|
||||
|
||||
/** Restores the exact unsupported JSON subtrees/marks before save or sync. */
|
||||
export const restoreNativeEditorContent = (doc: TiptapDoc): TiptapDoc => {
|
||||
const visit = (node: TiptapNode | TiptapTextNode): TiptapNode | TiptapTextNode => {
|
||||
if (FALLBACK_NODE_TYPES.has(node.type)) {
|
||||
const fallbackNode = node as TiptapNode;
|
||||
return parseOriginalNode(fallbackNode.attrs) ?? {
|
||||
type: node.type === UNSUPPORTED_INLINE_NODE_TYPE ? "text" : "paragraph",
|
||||
...(node.type === UNSUPPORTED_INLINE_NODE_TYPE
|
||||
? { text: getFallbackLabel(getOriginalType(fallbackNode.attrs)) }
|
||||
: { content: [{ type: "text", text: getFallbackLabel(getOriginalType(fallbackNode.attrs)) }] }),
|
||||
} as TiptapNode | TiptapTextNode;
|
||||
}
|
||||
if (isTextNode(node)) {
|
||||
const textNode = node;
|
||||
const fallbackMark = textNode.marks?.find((mark) => mark.type === UNSUPPORTED_MARK_TYPE);
|
||||
const marks = fallbackMark
|
||||
? parseOriginalMarks(fallbackMark.attrs)
|
||||
: textNode.marks ?? [];
|
||||
return marks.length ? { ...textNode, marks } : { type: "text", text: textNode.text };
|
||||
}
|
||||
const structuralNode = node as TiptapNode;
|
||||
return structuralNode.content
|
||||
? { ...structuralNode, content: structuralNode.content.map(visit) }
|
||||
: structuralNode;
|
||||
};
|
||||
|
||||
return {
|
||||
...doc,
|
||||
content: doc.content.map((node) => visit(node) as TiptapNode),
|
||||
};
|
||||
};
|
||||
|
||||
const collectText = (node: TiptapNode | TiptapTextNode): string => {
|
||||
if (isTextNode(node)) return node.text;
|
||||
return (node as TiptapNode).content?.map(collectText).filter(Boolean).join(" ") ?? "";
|
||||
};
|
||||
|
||||
/** Produces a schema-safe secondary Markdown projection without mutating saved JSON. */
|
||||
export const projectNativeUnknownContentForMarkdown = (doc: TiptapDoc): TiptapDoc => {
|
||||
const visit = (node: TiptapNode | TiptapTextNode, parentType: string): TiptapNode | TiptapTextNode => {
|
||||
if (isTextNode(node)) {
|
||||
const textNode = node;
|
||||
return {
|
||||
...textNode,
|
||||
marks: textNode.marks?.filter((mark) => NATIVE_EDITOR_MARK_TYPES.has(mark.type) && mark.type !== UNSUPPORTED_MARK_TYPE),
|
||||
};
|
||||
}
|
||||
if (!MARKDOWN_EDITOR_NODE_TYPES.has(node.type) || FALLBACK_NODE_TYPES.has(node.type)) {
|
||||
const structuralNode = node as TiptapNode;
|
||||
const original = FALLBACK_NODE_TYPES.has(node.type) ? parseOriginalNode(structuralNode.attrs) : structuralNode;
|
||||
const type = original?.type ?? getOriginalType(structuralNode.attrs);
|
||||
const text = original ? collectText(original) : "";
|
||||
const label = text || getFallbackLabel(type);
|
||||
return INLINE_PARENT_TYPES.has(parentType)
|
||||
? { type: "text", text: label }
|
||||
: { type: "paragraph", content: [{ type: "text", text: label }] };
|
||||
}
|
||||
const structuralNode = node as TiptapNode;
|
||||
return structuralNode.content
|
||||
? { ...structuralNode, content: structuralNode.content.map((child) => visit(child, structuralNode.type)) }
|
||||
: structuralNode;
|
||||
};
|
||||
|
||||
return {
|
||||
...doc,
|
||||
content: doc.content.map((node) => visit(node, "doc") as TiptapNode),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { resolveAttachmentKind } from "./attachment-kind";
|
||||
import { FILE_ATTACHMENT_NODE_TYPE } from "./file-attachment";
|
||||
import { PDF_ATTACHMENT_NODE_TYPE } from "./pdf-attachment";
|
||||
import type { TiptapDoc, TiptapMark, TiptapNode, TiptapTextNode } from "./content";
|
||||
|
||||
const ATTACHMENT_NODE_TYPES = new Set<string>([
|
||||
FILE_ATTACHMENT_NODE_TYPE,
|
||||
PDF_ATTACHMENT_NODE_TYPE,
|
||||
]);
|
||||
|
||||
const getStringAttr = (node: TiptapNode, key: string) => {
|
||||
const value = node.attrs?.[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
};
|
||||
|
||||
const ATTACHMENT_KIND_CLASS_PREFIX = "edgeever-attachment-kind-";
|
||||
|
||||
const normalizeAttachmentFilename = (label: string) =>
|
||||
label.replace(/^\s*(?:附件[::]|Attachment:)\s*/i, "").trim();
|
||||
|
||||
export const getNativeAttachmentLinkClass = (
|
||||
filename: string,
|
||||
mimeType?: string | null,
|
||||
existingClass?: unknown,
|
||||
) => {
|
||||
const preservedClasses = typeof existingClass === "string"
|
||||
? existingClass.split(/\s+/).filter((className) =>
|
||||
className &&
|
||||
className !== "edgeever-attachment-link" &&
|
||||
!className.startsWith(ATTACHMENT_KIND_CLASS_PREFIX)
|
||||
)
|
||||
: [];
|
||||
const kind = resolveAttachmentKind(mimeType, normalizeAttachmentFilename(filename));
|
||||
return [
|
||||
...preservedClasses,
|
||||
"edgeever-attachment-link",
|
||||
`${ATTACHMENT_KIND_CLASS_PREFIX}${kind}`,
|
||||
].join(" ");
|
||||
};
|
||||
|
||||
export const getMobileAttachmentLinkClass = getNativeAttachmentLinkClass;
|
||||
|
||||
const isAttachmentMark = (mark: TiptapMark) => {
|
||||
if (mark.type !== "link") return false;
|
||||
const href = typeof mark.attrs?.href === "string" ? mark.attrs.href : "";
|
||||
const className = typeof mark.attrs?.class === "string" ? mark.attrs.class : "";
|
||||
return className.split(/\s+/).includes("edgeever-attachment-link") || href.includes("/api/v1/resources/");
|
||||
};
|
||||
|
||||
const withAttachmentKindClass = (node: TiptapTextNode): TiptapTextNode => {
|
||||
if (!node.marks?.some(isAttachmentMark)) return node;
|
||||
return {
|
||||
...node,
|
||||
marks: node.marks.map((mark) => isAttachmentMark(mark)
|
||||
? {
|
||||
...mark,
|
||||
attrs: {
|
||||
...mark.attrs,
|
||||
class: getNativeAttachmentLinkClass(node.text, null, mark.attrs?.class),
|
||||
},
|
||||
}
|
||||
: mark),
|
||||
};
|
||||
};
|
||||
|
||||
const toLegacyAttachmentLink = (node: TiptapNode): TiptapTextNode => {
|
||||
const url = getStringAttr(node, "url");
|
||||
const label = getStringAttr(node, "label") || getStringAttr(node, "filename") || "Attachment";
|
||||
const mimeType = getStringAttr(node, "mimeType");
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: label,
|
||||
...(url
|
||||
? {
|
||||
marks: [{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: url,
|
||||
target: "_blank",
|
||||
class: getNativeAttachmentLinkClass(label, mimeType),
|
||||
},
|
||||
}],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/** Converts rich attachment nodes into the stable native resource-link schema. */
|
||||
export const resolveNativeAttachmentContent = (doc: TiptapDoc): TiptapDoc => {
|
||||
const visit = (node: TiptapNode | TiptapTextNode): TiptapNode | TiptapTextNode => {
|
||||
if (ATTACHMENT_NODE_TYPES.has(node.type)) {
|
||||
return toLegacyAttachmentLink(node as TiptapNode);
|
||||
}
|
||||
|
||||
if ("content" in node && node.content) {
|
||||
return { ...node, content: node.content.map(visit) };
|
||||
}
|
||||
|
||||
if (node.type === "text") {
|
||||
return withAttachmentKindClass(node as TiptapTextNode);
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return {
|
||||
...doc,
|
||||
content: doc.content.map((node) => visit(node) as TiptapNode),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveMobileAttachmentContent = resolveNativeAttachmentContent;
|
||||
Reference in new issue
Block a user