Files
edgeever/packages/shared/src/content.ts
T
tianma-if 99e9c2820c refactor: share editor schema and drop RN iOS builds
Editors now take document TipTap nodes from a shared factory. Workspace and editor helpers move out of the large panes. Store iOS no longer builds from Expo EAS.
2026-09-15 12:56:44 +08:00

411 lines
13 KiB
TypeScript

import { MarkdownManager } from "@tiptap/markdown";
import { createEdgeEverDocumentExtensions } from "./document-extensions";
import { MergeDivider, MERGE_DIVIDER_NODE_TYPE } from "./merge-divider";
import { PdfAttachment, PDF_ATTACHMENT_NODE_TYPE, upgradeStandalonePdfLinks } from "./pdf-attachment";
import { FileAttachment, FILE_ATTACHMENT_NODE_TYPE, upgradeStandaloneFileLinks } from "./file-attachment";
import {
BLOCK_MATH_NODE_TYPE,
createEdgeEverMarkdownMathematics,
INLINE_MATH_NODE_TYPE,
} from "./mathematics-markdown";
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";
export { PluginEmbed, PLUGIN_EMBED_NODE_TYPE, pluginEmbedToMarkdown, normalizePluginEmbedAttributes } from "./plugin-embed";
export type { PluginEmbedAttributes } from "./plugin-embed";
export {
BLOCK_MATH_NODE_TYPE,
INLINE_MATH_NODE_TYPE,
} from "./mathematics-markdown";
export {
MergeDivider,
MERGE_DIVIDER_MARKDOWN_MARKER,
MERGE_DIVIDER_NODE_TYPE,
mergeMemoDocs,
createMergeDividerNode,
} from "./merge-divider";
export {
PdfAttachment,
PDF_ATTACHMENT_NODE_TYPE,
PDF_DISPLAY_MODES,
isPdfAttachment,
resolvePdfDisplayMode,
upgradeStandalonePdfLinks,
} from "./pdf-attachment";
export type { PdfDisplayMode } from "./pdf-attachment";
export {
FileAttachment,
FILE_ATTACHMENT_NODE_TYPE,
FILE_DISPLAY_MODES,
isFileAttachmentLink,
resolveFileDisplayMode,
upgradeStandaloneFileLinks,
} from "./file-attachment";
export type { FileDisplayMode } from "./file-attachment";
export type TiptapTextNode = {
type: "text";
text: string;
marks?: TiptapMark[];
};
export type TiptapMark = {
type: string;
attrs?: Record<string, unknown>;
};
export type TiptapNode = {
type: string;
attrs?: Record<string, unknown>;
content?: Array<TiptapNode | TiptapTextNode>;
};
export type TiptapDoc = {
type: "doc";
content: TiptapNode[];
};
export const DEFAULT_MEMO_TITLE = "无标题笔记";
export const resolveMergedMemoTitle = (
inputTitle: string | null | undefined,
sourceMemos: Array<{ title: string | null | undefined }>,
date = new Date(),
) => {
const explicitTitle = inputTitle?.trim();
if (explicitTitle) {
return explicitTitle;
}
const customTitle = sourceMemos
.map((memo) => memo.title?.trim())
.find((title): title is string => Boolean(title && title !== DEFAULT_MEMO_TITLE));
return customTitle ?? `合并笔记 ${date.toLocaleDateString("zh-CN")}`;
};
export const emptyDoc = (): TiptapDoc => ({
type: "doc",
content: [{ type: "paragraph" }],
});
const markdownManager = new MarkdownManager({
extensions: createEdgeEverDocumentExtensions({
mathematics: createEdgeEverMarkdownMathematics(),
markdown: true,
}),
});
export const markdownToDoc = (markdown: string): TiptapDoc => {
if (!markdown.trim()) {
return emptyDoc();
}
return markdownManager.parse(markdown.replace(/\r\n?/g, "\n")) as TiptapDoc;
};
const docContainsNodeType = (doc: TiptapDoc, nodeType: string): boolean => {
const visit = (nodes: TiptapNode[]): boolean => nodes.some((node) =>
node.type === nodeType || (node.content ? visit(node.content as TiptapNode[]) : false)
);
return visit(doc.content);
};
/**
* Recovers Markdown features that an older editor schema could not persist in
* contentJson. The stored Markdown remains the compatibility source in that
* case; otherwise the richer JSON document (for example image sizing attrs)
* keeps precedence.
*/
export const resolveMemoContentDoc = (
contentJson: TiptapDoc | null | undefined,
contentMarkdown: string | null | undefined
): TiptapDoc => {
const currentDoc = contentJson && Array.isArray(contentJson.content)
? normalizeImageGalleries(
upgradeStandaloneFileLinks(upgradeStandalonePdfLinks(upgradeLegacyAttachmentLinks(contentJson))),
)
: emptyDoc();
if (
!contentMarkdown?.trim() ||
docContainsNodeType(currentDoc, "table") ||
docContainsNodeType(currentDoc, "taskList") ||
docContainsNodeType(currentDoc, "edgeeverThemeBlock") ||
docContainsNodeType(currentDoc, IMAGE_GALLERY_NODE_TYPE) ||
docContainsNodeType(currentDoc, MERGE_DIVIDER_NODE_TYPE) ||
docContainsNodeType(currentDoc, PLUGIN_EMBED_NODE_TYPE) ||
docContainsNodeType(currentDoc, BLOCK_MATH_NODE_TYPE) ||
docContainsNodeType(currentDoc, INLINE_MATH_NODE_TYPE)
|| docContainsNodeType(currentDoc, PDF_ATTACHMENT_NODE_TYPE)
|| docContainsNodeType(currentDoc, FILE_ATTACHMENT_NODE_TYPE)
) {
return currentDoc;
}
const markdownDoc = markdownToDoc(contentMarkdown);
// Some older saves left an empty JSON document behind while retaining the
// real body in Markdown. Treat that as a compatibility case too; otherwise
// the editor can show the Markdown body while list excerpts see an empty
// JSON document. Also recover task lists and merge dividers when only Markdown
// still retains their semantics.
return docContainsNodeType(markdownDoc, "table")
|| docContainsNodeType(markdownDoc, "taskList")
|| docContainsNodeType(markdownDoc, MERGE_DIVIDER_NODE_TYPE)
|| docContainsNodeType(markdownDoc, BLOCK_MATH_NODE_TYPE)
|| docContainsNodeType(markdownDoc, INLINE_MATH_NODE_TYPE)
|| !docToText(currentDoc)
? markdownDoc
: currentDoc;
};
const LEGACY_ATTACHMENT_PATTERN = /^(附件:|Attachment:\s*)(.+?)\s+(\/api\/v1\/resources\/\S+|https?:\/\/\S+)$/;
const isTiptapTextNode = (node: TiptapNode | TiptapTextNode): node is TiptapTextNode =>
node.type === "text" && "text" in node;
/** Convert the first-generation plain-text attachment insertion into a link mark. */
const upgradeLegacyAttachmentLinks = (doc: TiptapDoc): TiptapDoc => {
let changed = false;
const visit = (node: TiptapNode | TiptapTextNode): TiptapNode | TiptapTextNode => {
if (isTiptapTextNode(node)) {
const match = node.text.match(LEGACY_ATTACHMENT_PATTERN);
if (!match) {
return node;
}
const existingMarks = node.marks ?? [];
if (existingMarks.some((mark) => mark.type === "link")) {
return node;
}
changed = true;
return {
...node,
text: `${match[1]}${match[2]}`,
marks: [
...existingMarks,
{ type: "link", attrs: { href: match[3], target: "_blank", class: "edgeever-attachment-link" } },
],
};
}
return node.content
? { ...node, content: node.content.map((child: TiptapNode | TiptapTextNode) => visit(child)) }
: node;
};
const upgradedDoc = visit(doc) as TiptapDoc;
return changed ? upgradedDoc : doc;
};
export const docToText = (doc: unknown): string => {
const pieces: string[] = [];
const walk = (node: unknown) => {
if (!node || typeof node !== "object") {
return;
}
const current = node as { type?: unknown; text?: unknown; attrs?: Record<string, unknown>; content?: unknown };
if (typeof current.text === "string") {
pieces.push(current.text);
}
if (current.type === "image") {
const label =
getStringAttr(current.attrs, "alt") ||
getStringAttr(current.attrs, "title") ||
getStringAttr(current.attrs, "filename");
if (label) {
pieces.push(label);
}
}
if (current.type === PDF_ATTACHMENT_NODE_TYPE || current.type === FILE_ATTACHMENT_NODE_TYPE) {
const label = getStringAttr(current.attrs, "label");
if (label) pieces.push(label);
}
if (current.type === BLOCK_MATH_NODE_TYPE || current.type === INLINE_MATH_NODE_TYPE) {
const latex = getStringAttr(current.attrs, "latex");
if (latex) {
pieces.push(latex);
}
}
if (Array.isArray(current.content)) {
for (const child of current.content) {
walk(child);
}
}
};
walk(doc);
return pieces.join(" ").replace(/\s+/g, " ").trim();
};
let memoCharacterSegmenter: Intl.Segmenter | null | undefined;
const getMemoCharacterSegmenter = () => {
if (memoCharacterSegmenter !== undefined) {
return memoCharacterSegmenter;
}
memoCharacterSegmenter = typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
return memoCharacterSegmenter;
};
/**
* Counts visible memo-body characters while excluding whitespace. Formatting,
* titles, tags, and image labels are intentionally not part of the count.
*/
export const countMemoCharacters = (doc: unknown): number => {
const pieces: string[] = [];
const walk = (node: unknown) => {
if (!node || typeof node !== "object") {
return;
}
const current = node as { type?: unknown; text?: unknown; attrs?: Record<string, unknown>; content?: unknown };
if (typeof current.text === "string") {
pieces.push(current.text);
}
if (current.type === PDF_ATTACHMENT_NODE_TYPE || current.type === FILE_ATTACHMENT_NODE_TYPE) {
const label = getStringAttr(current.attrs, "label");
if (label) pieces.push(label);
}
if (Array.isArray(current.content)) {
for (const child of current.content) {
walk(child);
}
}
};
walk(doc);
const text = pieces.join("");
const segmenter = getMemoCharacterSegmenter();
const characters = segmenter ? Array.from(segmenter.segment(text), ({ segment }) => segment) : Array.from(text);
return characters.reduce((count, character) => count + (/^\s+$/u.test(character) ? 0 : 1), 0);
};
export const docToMarkdown = (doc: unknown): string => {
if (!doc || typeof doc !== "object") {
return "";
}
const root = doc as { content?: unknown };
if (!Array.isArray(root.content)) {
return "";
}
const serializableDoc = protectLiteralDollarPairs(projectNativeUnknownContentForMarkdown(
stripEditorOnlyNodes(doc) as TiptapDoc
));
return markdownManager
.serialize(serializableDoc as Parameters<typeof markdownManager.serialize>[0])
.replaceAll(LITERAL_DOLLAR_PLACEHOLDER, "\\$");
};
const LITERAL_DOLLAR_PLACEHOLDER = "\uE000edgeever-dollar\uE001";
/** Preserve dollar pairs that are text rather than inline-math nodes. */
const protectLiteralDollarPairs = (value: unknown): unknown => {
if (!value || typeof value !== "object") {
return value;
}
const node = value as { type?: unknown; text?: unknown; content?: unknown };
if (node.type === "text" && typeof node.text === "string") {
const dollarCount = Array.from(node.text).filter((character) => character === "$").length;
return dollarCount >= 2
? { ...node, text: node.text.replaceAll("$", LITERAL_DOLLAR_PLACEHOLDER) }
: value;
}
return Array.isArray(node.content)
? { ...node, content: node.content.map(protectLiteralDollarPairs) }
: value;
};
/**
* Returns the best complete Markdown representation available for a memo.
* Some older rich-editor saves populated contentJson while leaving the
* Markdown compatibility copy empty, so merge/export callers must not trust
* contentMarkdown alone.
*/
export const resolveMemoContentMarkdown = (
contentJson: TiptapDoc | null | undefined,
contentMarkdown: string | null | undefined,
) => docToMarkdown(resolveMemoContentDoc(contentJson, contentMarkdown));
/**
* Theme blocks are richer editor-only nodes. Markdown has no portable equivalent,
* so exports keep their text as a quoted section instead of silently dropping it.
*/
const stripEditorOnlyNodes = (doc: unknown): unknown => {
if (!doc || typeof doc !== "object") {
return doc;
}
const node = doc as { type?: unknown; attrs?: Record<string, unknown>; content?: unknown };
if (node.type === "edgeeverThemeBlock") {
const label = getStringAttr(node.attrs, "kind");
const content = Array.isArray(node.content) ? node.content : [];
return {
type: "blockquote",
content: [
{ type: "paragraph", content: [{ type: "text", text: label ? `[${label}]` : "[主题化组件]" }] },
...content.map(stripEditorOnlyNodes),
],
};
}
if (!Array.isArray(node.content)) {
return doc;
}
return { ...node, content: node.content.map(stripEditorOnlyNodes) };
};
const getStringAttr = (attrs: Record<string, unknown> | undefined, key: string) => {
const value = attrs?.[key];
return typeof value === "string" ? value.trim() : "";
};
export const createExcerpt = (text: string, maxLength = 30): string => {
const normalized = text.replace(/\s+/g, " ").trim();
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
};
export const normalizeTags = (tags: unknown): string[] => {
if (!Array.isArray(tags)) {
return [];
}
return Array.from(
new Set(
tags
.map((tag) => (typeof tag === "string" ? tag.trim() : ""))
.filter(Boolean)
.map((tag) => tag.replace(/^#/, ""))
)
).slice(0, 24);
};