fix(mobile): safely preserve unsupported editor nodes

This commit is contained in:
tianma-if committed 2026-08-28 00:56:41 +08:00
1 parent 2ffca8501b
commit 9f355f418a
10 files changed
+1009 -558

No files matched your search

File diff suppressed because it is too large. Load diff
+19 -14
View File
@@ -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() {
+27
View File
@@ -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),
};
};
+4 -1
View File
@@ -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, "\\$");
+2
View File
@@ -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;