主编辑区改造准备

This commit is contained in:
lix-2026
2026-04-21 06:26:35 +08:00
parent 1e686bfa3c
commit 5d1c94eb9e
49 changed files with 6730 additions and 2565 deletions
@@ -2,12 +2,24 @@ import { describe, expect, it } from "vitest";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
describe("buildDocumentSavePayload", () => {
it("统一规范 documents.save 的共享 payload", () => {
it("优先规范 editorDocument 并从它派生 legacy content", () => {
const payload = buildDocumentSavePayload({
documentId: " doc_1 ",
workspaceId: " ws_1 ",
revision: 3,
content: [{ id: "block_1", type: "paragraph", content: [] }],
editorDocument: {
documentId: "other_doc",
rootBlockIds: ["block_1"],
blocks: [
{
blockId: "block_1",
blockType: "paragraph",
contentNodes: [{ type: "text", text: "新正文" }],
childBlockIds: [],
},
],
},
content: [{ id: "legacy_1", type: "paragraph", content: "旧正文" }],
conflictDetectionKey: " conflict_1 ",
});
@@ -15,27 +27,103 @@ describe("buildDocumentSavePayload", () => {
documentId: "doc_1",
workspaceId: "ws_1",
revision: 3,
content: [{ id: "block_1", type: "paragraph", content: [] }],
editorDocument: {
documentId: "doc_1",
rootBlockIds: ["block_1"],
blocks: [
{
blockId: "block_1",
blockType: "paragraph",
props: {},
contentNodes: [{ type: "text", text: "新正文", marks: [] }],
childBlockIds: [],
},
],
},
content: [{ id: "block_1", type: "paragraph", props: undefined, content: "新正文" }],
tiptapDocument: null,
conflictDetectionKey: "conflict_1",
snapshotCapturedAt: null,
blockCount: null,
});
});
it("非法 revision/conflictDetectionKey 会回退到 null", () => {
it("兼容仅 content 的旧 payload,并补齐 editorDocument", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "",
revision: -1,
content: [],
conflictDetectionKey: " ",
content: [{ id: "heading_1", type: "heading", props: { level: 2 }, content: "章节标题" }],
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: null,
revision: null,
content: [],
editorDocument: {
documentId: "doc_1",
rootBlockIds: ["heading_1"],
blocks: [
{
blockId: "heading_1",
blockType: "heading",
props: { headingLevel: 2 },
contentNodes: [{ type: "text", text: "章节标题", marks: [] }],
childBlockIds: [],
},
],
},
content: [{ id: "heading_1", type: "heading", props: { level: 2 }, content: "章节标题" }],
tiptapDocument: null,
conflictDetectionKey: null,
snapshotCapturedAt: null,
blockCount: null,
});
});
it("支持将 tiptapDocument 适配为 editorDocument 与兼容 content", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
tiptapDocument: {
type: "doc",
content: [
{
type: "paragraph",
attrs: { blockId: "p_1" },
content: [{ type: "text", text: "来自 tiptap" }],
},
],
},
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
revision: null,
editorDocument: {
documentId: "doc_1",
rootBlockIds: ["p_1"],
blocks: [
{
blockId: "p_1",
blockType: "paragraph",
props: {},
contentNodes: [{ type: "text", text: "来自 tiptap", marks: [] }],
childBlockIds: [],
},
],
},
content: [{ id: "p_1", type: "paragraph", props: undefined, content: "来自 tiptap" }],
tiptapDocument: {
type: "doc",
content: [
{
type: "paragraph",
attrs: { blockId: "p_1" },
content: [{ type: "text", text: "来自 tiptap" }],
},
],
},
conflictDetectionKey: null,
snapshotCapturedAt: null,
blockCount: null,
@@ -53,14 +141,9 @@ describe("buildDocumentSavePayload", () => {
blockCount: 1,
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 4,
content: [{ id: "block_1" }],
conflictDetectionKey: "doc_1:4",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
expect(payload.revision).toBe(4);
expect(payload.conflictDetectionKey).toBe("doc_1:4");
expect(payload.snapshotCapturedAt).toBe("2026-04-14T14:30:00.000Z");
expect(payload.blockCount).toBe(1);
});
});
@@ -1,24 +1,78 @@
import type { Json } from "@/types/supabase";
import {
editorBlockDocumentFromContent,
editorBlockDocumentFromTiptapDoc,
legacyBlocksFromEditorBlockDocument,
normalizeEditorBlockDocument,
type EditorBlockDocument,
type TiptapDoc,
} from "@/lib/documents/tiptap-content-converter";
export type DocumentSavePayload = {
documentId: string;
workspaceId: string | null;
revision: number | null;
editorDocument: EditorBlockDocument;
content: Json;
tiptapDocument: TiptapDoc | null;
conflictDetectionKey: string | null;
snapshotCapturedAt: string | null;
blockCount: number | null;
};
export function buildDocumentSavePayload(input: {
type BuildDocumentSavePayloadInput = {
documentId: string;
workspaceId?: string | null;
revision?: number | null;
content: Json;
editorDocument?: unknown;
content?: Json;
tiptapDocument?: unknown;
conflictDetectionKey?: string | null;
snapshotCapturedAt?: string | null;
blockCount?: number | null;
}): DocumentSavePayload {
};
function resolveEditorDocument(
input: BuildDocumentSavePayloadInput,
normalizedDocumentId: string,
): EditorBlockDocument {
if (input.editorDocument != null) {
return normalizeEditorBlockDocument(input.editorDocument, normalizedDocumentId);
}
if (typeof input.content !== "undefined") {
return normalizeEditorBlockDocument(
editorBlockDocumentFromContent(input.content),
normalizedDocumentId,
);
}
if (input.tiptapDocument != null) {
return normalizeEditorBlockDocument(
editorBlockDocumentFromTiptapDoc(input.tiptapDocument, normalizedDocumentId),
normalizedDocumentId,
);
}
return normalizeEditorBlockDocument(
{
documentId: normalizedDocumentId,
rootBlockIds: [],
blocks: [],
},
normalizedDocumentId,
);
}
export function buildDocumentSavePayload(
input: BuildDocumentSavePayloadInput,
): DocumentSavePayload {
const documentId = input.documentId.trim();
const editorDocument = resolveEditorDocument(input, documentId);
const shouldDeriveLegacyContent =
input.editorDocument != null ||
input.tiptapDocument != null ||
typeof input.content === "undefined";
const content: Json = shouldDeriveLegacyContent
? (legacyBlocksFromEditorBlockDocument(editorDocument) as Json)
: (input.content as Json);
const revision =
typeof input.revision === "number" && Number.isInteger(input.revision) && input.revision >= 0
? input.revision
@@ -35,12 +89,18 @@ export function buildDocumentSavePayload(input: {
typeof input.blockCount === "number" && Number.isInteger(input.blockCount) && input.blockCount >= 0
? input.blockCount
: null;
const tiptapDocument =
input.tiptapDocument && typeof input.tiptapDocument === "object"
? (input.tiptapDocument as TiptapDoc)
: null;
return {
documentId: input.documentId.trim(),
documentId,
workspaceId: input.workspaceId?.trim() || null,
revision,
content: input.content,
editorDocument,
content,
tiptapDocument,
conflictDetectionKey,
snapshotCapturedAt,
blockCount,
@@ -0,0 +1,25 @@
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { createDocumentCommand } from "@/lib/documents/tree-command-client";
export function SidebarCreateDocumentEntry() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const edit = searchParams.get("edit");
if (edit !== "1") return;
}, [searchParams]);
return null;
}
export async function createDocumentAndOpenEdit(router: ReturnType<typeof useRouter>, parentId: string | null) {
const payload = await createDocumentCommand(parentId);
const query = new URLSearchParams();
query.set("edit", "1");
const workspaceId = payload.workspace_id ?? null;
if (workspaceId) query.set("workspaceId", workspaceId);
router.push(`/documents/${payload.id}?${query.toString()}`);
}
@@ -0,0 +1,54 @@
import {
blocksFromTiptapDoc,
editorBlockDocumentFromContent,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
tiptapDocFromEditorBlockDocument,
} from "@/lib/documents/tiptap-content-converter";
import { describe, expect, it } from "vitest";
describe("tiptap-content-converter", () => {
it("keeps empty content editable", () => {
expect(tiptapDocFromBlocks([])).toEqual({
type: "doc",
content: [{ type: "paragraph", attrs: {}, content: [] }],
});
expect(blocksFromTiptapDoc({ type: "doc", content: [] })).toEqual([]);
});
it("preserves stable block ids through paragraph round-trip", () => {
const blocks = [{ id: "p1", type: "paragraph", content: "你好,世界" }];
expect(blocksFromTiptapDoc(tiptapDocFromBlocks(blocks as never))).toEqual([
{ id: "p1", type: "paragraph", props: undefined, content: "你好,世界" },
]);
});
it("round-trips p0.5 block families through editor block document", () => {
const document = editorBlockDocumentFromContent([
{ id: "p1", type: "paragraph", content: "段落" },
{ id: "h1", type: "heading", props: { level: 2 }, content: "标题" },
{ id: "b1", type: "bullet_list_item", content: "项目" },
{ id: "n1", type: "numbered_list_item", content: "序号" },
{ id: "t1", type: "todo", props: { checked: true }, content: "待办" },
{ id: "q1", type: "quote", content: "引用" },
{ id: "c1", type: "code_block", props: { language: "ts" }, content: "const x = 1" },
] as never);
const tiptapDoc = tiptapDocFromEditorBlockDocument(document);
const roundTrip = editorBlockDocumentFromTiptapDoc(tiptapDoc, "doc-1");
expect(roundTrip.rootBlockIds).toEqual(["p1", "h1", "b1", "n1", "t1", "q1", "c1"]);
expect(roundTrip.blocks.map((block) => [block.blockId, block.blockType])).toEqual([
["p1", "paragraph"],
["h1", "heading"],
["b1", "bullet_list_item"],
["n1", "numbered_list_item"],
["t1", "todo"],
["q1", "quote"],
["c1", "code_block"],
]);
expect(roundTrip.blocks[1].props?.headingLevel).toBe(2);
expect(roundTrip.blocks[4].props?.checked).toBe(true);
expect(roundTrip.blocks[6].props?.language).toBe("ts");
});
});
@@ -0,0 +1,506 @@
/**
* TipTap 与 EditorBlockDocument 的适配层。
* 约定:EditorBlockDocument 是正式语义边界,legacy blocks 仅用于兼容存储与旧链路。
*/
export type TiptapMark = {
type: string;
attrs?: Record<string, unknown>;
};
export type TiptapNode = {
type: string;
attrs?: Record<string, unknown>;
content?: TiptapNode[];
text?: string;
marks?: TiptapMark[];
};
export type TiptapDoc = {
type: "doc";
content: TiptapNode[];
};
export type EditorTextMark = "bold" | "italic" | "underline" | "strike" | "code";
export type EditorBlockType =
| "paragraph"
| "heading"
| "bullet_list_item"
| "numbered_list_item"
| "todo"
| "quote"
| "code_block";
export type EditorContentNode = {
type: "text";
text: string;
marks?: EditorTextMark[];
};
export type EditorBlock = {
blockId: string;
blockType: EditorBlockType;
props?: {
headingLevel?: number | null;
checked?: boolean | null;
language?: string | null;
};
contentNodes?: EditorContentNode[];
childBlockIds?: string[];
};
export type EditorBlockDocument = {
documentId: string;
rootBlockIds: string[];
blocks: EditorBlock[];
};
type Json = unknown;
type LegacyBlockLike = {
id?: string;
blockId?: string;
type?: string;
blockType?: string;
content?: unknown;
contentNodes?: unknown;
props?: Record<string, unknown>;
children?: unknown;
};
const EMPTY_DOC: TiptapDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: {}, content: [] }],
};
const MARK_NAME_MAP: Record<string, EditorTextMark> = {
bold: "bold",
italic: "italic",
underline: "underline",
strike: "strike",
code: "code",
};
const BLOCK_ID_ATTR = "blockId";
function coerceTextMark(value: unknown): EditorTextMark | null {
const normalized = String(value ?? "").trim().toLowerCase();
return MARK_NAME_MAP[normalized] ?? null;
}
function readMarks(marks: unknown): EditorTextMark[] {
if (!Array.isArray(marks)) return [];
const normalized = marks
.map((mark) => {
if (typeof mark === "string") return coerceTextMark(mark);
if (mark && typeof mark === "object") return coerceTextMark((mark as { type?: unknown }).type);
return null;
})
.filter((mark): mark is EditorTextMark => Boolean(mark));
return Array.from(new Set(normalized));
}
function toText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(toText).join("");
if (value && typeof value === "object") {
const obj = value as { text?: unknown; content?: unknown; value?: unknown; contentNodes?: unknown };
return `${toText(obj.text)}${toText(obj.value)}${toText(obj.content)}${toText(obj.contentNodes)}`;
}
return "";
}
function clampHeadingLevel(value: unknown): number {
const level = Number(value);
return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;
}
function normalizeBlockId(value: unknown, fallback: string): string {
const raw = typeof value === "string" ? value.trim() : "";
return raw || fallback;
}
function normalizeEditorContentNodes(input: unknown): EditorContentNode[] {
if (Array.isArray(input)) {
const nodes = input.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const record = item as { type?: unknown; text?: unknown; marks?: unknown; payload?: unknown };
if (record.type === "text") {
const text = toText(record.text);
return text ? [{ type: "text" as const, text, marks: readMarks(record.marks) }] : [];
}
if (record.payload && typeof record.payload === "object") {
const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown };
if (payload.type === "text") {
const text = toText(payload.text);
return text ? [{ type: "text" as const, text, marks: readMarks(payload.marks) }] : [];
}
}
const text = toText(item);
return text ? [{ type: "text" as const, text, marks: [] }] : [];
});
return nodes;
}
const text = toText(input);
return text ? [{ type: "text", text, marks: [] }] : [];
}
function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBlock {
const blockType = String(block.blockType ?? block.type ?? "paragraph").trim().toLowerCase();
const blockId = normalizeBlockId(block.blockId ?? block.id, `block-${index + 1}`);
const props = block.props ?? {};
const contentNodes = normalizeEditorContentNodes(block.contentNodes ?? block.content ?? props.content ?? props.text ?? props.title);
switch (blockType) {
case "heading":
return {
blockId,
blockType: "heading",
props: { headingLevel: clampHeadingLevel(props.level ?? props.headingLevel) },
contentNodes,
childBlockIds: [],
};
case "bullet_list_item":
case "bullet_list":
case "bullet-list":
return { blockId, blockType: "bullet_list_item", props: {}, contentNodes, childBlockIds: [] };
case "numbered_list_item":
case "ordered_list":
case "ordered-list":
return { blockId, blockType: "numbered_list_item", props: {}, contentNodes, childBlockIds: [] };
case "todo":
case "task":
return {
blockId,
blockType: "todo",
props: { checked: Boolean(props.checked) },
contentNodes,
childBlockIds: [],
};
case "quote":
case "blockquote":
return { blockId, blockType: "quote", props: {}, contentNodes, childBlockIds: [] };
case "code":
case "code_block":
case "code-block":
return {
blockId,
blockType: "code_block",
props: { language: typeof props.language === "string" ? props.language : null },
contentNodes,
childBlockIds: [],
};
case "paragraph":
case "text":
default:
return { blockId, blockType: "paragraph", props: {}, contentNodes, childBlockIds: [] };
}
}
function normalizeInputToEditorDocument(value: Json): EditorBlockDocument {
if (value && typeof value === "object" && !Array.isArray(value)) {
const maybe = value as Partial<EditorBlockDocument>;
if (typeof maybe.documentId === "string" && Array.isArray(maybe.blocks)) {
const normalizedBlocks = maybe.blocks.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index));
const rootBlockIds = Array.isArray(maybe.rootBlockIds) && maybe.rootBlockIds.length > 0
? maybe.rootBlockIds.map((id, index) => normalizeBlockId(id, normalizedBlocks[index]?.blockId ?? `block-${index + 1}`))
: normalizedBlocks.map((block) => block.blockId);
return {
documentId: maybe.documentId,
rootBlockIds,
blocks: normalizedBlocks,
};
}
if (Array.isArray((value as { blocks?: unknown }).blocks)) {
const blocks = ((value as { blocks: unknown[] }).blocks ?? []).map((block, index) =>
normalizeLegacyBlock(block as LegacyBlockLike, index),
);
return {
documentId: typeof (value as { documentId?: unknown }).documentId === "string"
? String((value as { documentId?: unknown }).documentId)
: "document",
rootBlockIds: blocks.map((block) => block.blockId),
blocks,
};
}
}
const blocks = Array.isArray(value)
? value.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index))
: [];
return {
documentId: "document",
rootBlockIds: blocks.map((block) => block.blockId),
blocks,
};
}
function textNodesToInline(contentNodes: EditorContentNode[] | undefined): TiptapNode[] {
return (contentNodes ?? []).flatMap((node) => {
const text = toText(node.text);
if (!text) return [];
const marks = (node.marks ?? [])
.map((mark) => ({ type: mark }))
.filter((mark) => Boolean(mark.type));
return [{ type: "text", text, marks: marks.length > 0 ? marks : undefined }];
});
}
function blockToTiptapNode(block: EditorBlock): TiptapNode {
const commonAttrs: Record<string, unknown> = { [BLOCK_ID_ATTR]: block.blockId };
switch (block.blockType) {
case "heading":
return {
type: "heading",
attrs: { ...commonAttrs, level: clampHeadingLevel(block.props?.headingLevel) },
content: textNodesToInline(block.contentNodes),
};
case "bullet_list_item":
return {
type: "bulletList",
attrs: commonAttrs,
content: [{ type: "listItem", attrs: commonAttrs, content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],
};
case "numbered_list_item":
return {
type: "orderedList",
attrs: commonAttrs,
content: [{ type: "listItem", attrs: commonAttrs, content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],
};
case "todo":
return {
type: "taskList",
attrs: commonAttrs,
content: [{ type: "taskItem", attrs: { ...commonAttrs, checked: Boolean(block.props?.checked) }, content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],
};
case "quote":
return {
type: "blockquote",
attrs: commonAttrs,
content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }],
};
case "code_block":
return {
type: "codeBlock",
attrs: { ...commonAttrs, language: block.props?.language ?? null },
content: textNodesToInline(block.contentNodes),
};
case "paragraph":
default:
return {
type: "paragraph",
attrs: commonAttrs,
content: textNodesToInline(block.contentNodes),
};
}
}
function extractInlineContent(node: TiptapNode | undefined): EditorContentNode[] {
if (!node) return [];
if (Array.isArray(node.content)) {
return node.content.flatMap((child) => {
if (child.type === "text") {
const text = toText(child.text);
if (!text) return [];
return [{ type: "text" as const, text, marks: readMarks(child.marks) }];
}
if (child.type === "hardBreak") {
return [{ type: "text" as const, text: "\n", marks: [] }];
}
return extractInlineContent(child);
});
}
if (node.type === "text") {
const text = toText(node.text);
return text ? [{ type: "text", text, marks: readMarks(node.marks) }] : [];
}
return [];
}
function firstChild(node: TiptapNode | undefined): TiptapNode | undefined {
return Array.isArray(node?.content) ? node?.content?.[0] : undefined;
}
function normalizeDocumentId(value: unknown, fallback = "document"): string {
const raw = typeof value === "string" ? value.trim() : "";
return raw || fallback;
}
function nodeBlockId(node: TiptapNode, index: number): string {
return normalizeBlockId(node.attrs?.[BLOCK_ID_ATTR], `block-${index + 1}`);
}
function tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null {
const blockId = nodeBlockId(node, index);
switch (node.type) {
case "paragraph":
return { blockId, blockType: "paragraph", props: {}, contentNodes: extractInlineContent(node), childBlockIds: [] };
case "heading":
return {
blockId,
blockType: "heading",
props: { headingLevel: clampHeadingLevel(node.attrs?.level) },
contentNodes: extractInlineContent(node),
childBlockIds: [],
};
case "bulletList": {
const paragraph = firstChild(firstChild(node));
return {
blockId,
blockType: "bullet_list_item",
props: {},
contentNodes: extractInlineContent(paragraph),
childBlockIds: [],
};
}
case "orderedList": {
const paragraph = firstChild(firstChild(node));
return {
blockId,
blockType: "numbered_list_item",
props: {},
contentNodes: extractInlineContent(paragraph),
childBlockIds: [],
};
}
case "taskList": {
const taskItem = firstChild(node);
const paragraph = firstChild(taskItem);
return {
blockId,
blockType: "todo",
props: { checked: Boolean(taskItem?.attrs?.checked) },
contentNodes: extractInlineContent(paragraph),
childBlockIds: [],
};
}
case "blockquote":
return { blockId, blockType: "quote", props: {}, contentNodes: extractInlineContent(firstChild(node)), childBlockIds: [] };
case "codeBlock":
return {
blockId,
blockType: "code_block",
props: { language: typeof node.attrs?.language === "string" ? String(node.attrs.language) : null },
contentNodes: extractInlineContent(node),
childBlockIds: [],
};
default:
return null;
}
}
export function editorBlockDocumentFromContent(content: Json): EditorBlockDocument {
return normalizeInputToEditorDocument(content);
}
export function normalizeEditorBlockDocument(
value: unknown,
fallbackDocumentId?: string,
): EditorBlockDocument {
const normalized = normalizeInputToEditorDocument(value as Json);
const documentId = normalizeDocumentId(
fallbackDocumentId ?? normalized.documentId,
normalized.documentId,
);
const blockIdSet = new Set(normalized.blocks.map((block) => block.blockId));
const rootBlockIds = Array.from(
new Set(
normalized.rootBlockIds
.map((id, index) => normalizeBlockId(id, normalized.blocks[index]?.blockId ?? `block-${index + 1}`))
.filter((id) => blockIdSet.has(id)),
),
);
return {
...normalized,
documentId,
rootBlockIds: rootBlockIds.length > 0 ? rootBlockIds : normalized.blocks.map((block) => block.blockId),
};
}
export function tiptapDocFromEditorBlockDocument(document: EditorBlockDocument): TiptapDoc {
const blockById = new Map(document.blocks.map((block) => [block.blockId, block]));
const nodes = document.rootBlockIds
.map((blockId) => blockById.get(blockId))
.filter((block): block is EditorBlock => Boolean(block))
.map(blockToTiptapNode);
return {
type: "doc",
content: nodes.length > 0 ? nodes : EMPTY_DOC.content,
};
}
export function editorBlockDocumentFromTiptapDoc(doc: unknown, documentId = "document"): EditorBlockDocument {
if (!doc || typeof doc !== "object") {
return { documentId, rootBlockIds: [], blocks: [] };
}
const root = doc as { type?: unknown; content?: unknown };
if (root.type !== "doc" || !Array.isArray(root.content)) {
return { documentId, rootBlockIds: [], blocks: [] };
}
const blocks = root.content.flatMap((node, index) => {
if (!node || typeof node !== "object") return [];
const block = tiptapNodeToBlock(node as TiptapNode, index);
return block ? [block] : [];
});
return {
documentId,
rootBlockIds: blocks.map((block) => block.blockId),
blocks,
};
}
function orderedBlocksForLegacy(document: EditorBlockDocument): EditorBlock[] {
const blockById = new Map(document.blocks.map((block) => [block.blockId, block]));
const visited = new Set<string>();
const ordered: EditorBlock[] = [];
const visit = (blockId: string) => {
if (visited.has(blockId)) return;
visited.add(blockId);
const block = blockById.get(blockId);
if (!block) return;
ordered.push(block);
for (const childId of block.childBlockIds ?? []) {
visit(childId);
}
};
for (const rootId of document.rootBlockIds) {
visit(rootId);
}
for (const block of document.blocks) {
visit(block.blockId);
}
return ordered;
}
export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocument): Json {
return orderedBlocksForLegacy(document).map((block) => ({
id: block.blockId,
type: block.blockType,
props:
block.blockType === "heading"
? { level: block.props?.headingLevel ?? 1 }
: block.blockType === "todo"
? { checked: Boolean(block.props?.checked) }
: block.blockType === "code_block"
? { language: block.props?.language ?? null }
: undefined,
content: (block.contentNodes ?? []).map((node) => node.text).join(""),
}));
}
export function tiptapDocFromBlocks(blocks: Json): TiptapDoc {
return tiptapDocFromEditorBlockDocument(editorBlockDocumentFromContent(blocks));
}
export function blocksFromTiptapDoc(doc: unknown): Json {
return legacyBlocksFromEditorBlockDocument(editorBlockDocumentFromTiptapDoc(doc));
}
export const blocksToTiptapDoc = tiptapDocFromBlocks;
export const tiptapDocToBlocks = blocksFromTiptapDoc;
export const tiptapDocumentToEditorBlockDocumentAdapter = editorBlockDocumentFromTiptapDoc;
export const editorBlockDocumentToLegacyBlocksAdapter = legacyBlocksFromEditorBlockDocument;
+17 -37
View File
@@ -32,6 +32,14 @@ export type MnoteRuntimeConfig = {
* 说明:实验壳必须显式开启,禁止仅因配置了 mnoteWebBaseUrl 就自动进入主界面链路。
*/
mnoteWebTreeShellEnabled?: boolean;
/**
* 编辑器 host 选择。
* 说明:这里只允许显式选择实验 host,默认主链仍由 BlockNote 承担。
*/
documentEditorHost?:
| "blocknote"
| "leptos_tiptap_inline"
| "leptos_tiptap_iframe_debug";
/**
* 是否为桌面端(Electron)运行。
*/
@@ -140,7 +148,7 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
// 说明:桌面端与网页端共用 public/mnote-env.json 作为“公共环境文件”。
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
// 说明:这里不能直接静态引入 `node:fs` / `node:path`
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖。
// Node 22 优先走 `process.getBuiltinModule`,其余环境再兜底到 runtime require。
@@ -184,13 +192,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
cfg.isDesktop ??
(typeof window === "undefined" ? process.env.MNOTE_DESKTOP === "1" : false);
// 说明:`onlyofficeBaseUrl` / `onlyofficeStorageHostOverride` 属于“通用默认值”。
// 但我们在 `public/mnote-env.json` 里同时提供了 Web/Desktop 两套配置,
// 因此这里应当优先选择与平台匹配的字段,避免被环境变量中的默认值覆盖。
//
// 典型场景:开发机环境变量仍是 `http://localhost:8081`,但网页端需要走
// `https://onlyoffice.<域名>`。若不调整优先级,远程浏览器会尝试访问它自己
// 的 localhost,从而导致 docx 打不开。
const onlyofficeBaseUrl = isDesktop
? cfg.onlyofficeBaseUrlDesktop ||
cfg.onlyofficeBaseUrl ||
@@ -201,9 +202,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
cfg.onlyofficeBaseUrlDesktop ||
"";
// 说明:这里需要用 `??` 而不是 `||`,允许通过配置显式传入空字符串来“关闭 override”。
// 否则 Web 端配置为 "" 时,会被环境变量里的默认值(例如 host.docker.internal)误覆盖,
// 进而导致 ONLYOFFICE 文档服务器无法访问真实的存储地址。
const onlyofficeStorageHostOverride = isDesktop
? (cfg.onlyofficeStorageHostOverrideDesktop ??
cfg.onlyofficeStorageHostOverride ??
@@ -214,26 +212,13 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
cfg.onlyofficeStorageHostOverrideDesktop ??
"");
// 说明:`onlyofficeProxyOrigin` 也属于“通用默认值”。在 Web 端需要优先使用
// onlyofficeProxyOriginWeb,避免被旧的通用值(例如 Cloudflare 域名)覆盖,
// 否则会导致 ONLYOFFICE 文档服务器回源到错误的公网入口。
const onlyofficeProxyOrigin = isDesktop
? cfg.onlyofficeProxyOrigin || ""
: cfg.onlyofficeProxyOriginWeb ||
cfg.onlyofficeProxyOrigin ||
"";
? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeProxyOriginWeb)
: (cfg.onlyofficeProxyOriginWeb ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeCallbackOriginDesktop);
// 说明:ONLYOFFICE 回调(保存)必须是“文档服务器可访问”的地址。
// Web 端优先用 onlyofficeCallbackOriginWeb(通常是 http://host.docker.internal:3000)。
const onlyofficeCallbackOrigin = isDesktop
? cfg.onlyofficeCallbackOriginDesktop ||
cfg.onlyofficeCallbackOrigin ||
cfg.onlyofficeCallbackOriginWeb ||
""
: cfg.onlyofficeCallbackOriginWeb ||
cfg.onlyofficeCallbackOrigin ||
cfg.onlyofficeCallbackOriginDesktop ||
"";
? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginWeb)
: (cfg.onlyofficeCallbackOriginWeb ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginDesktop);
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled =
@@ -251,7 +236,7 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
};
};
export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
if (typeof window !== "undefined") {
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
}
@@ -272,13 +257,8 @@ export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
...envRuntime,
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
...(publicRuntime.mnoteWebBaseUrl !== undefined
? { mnoteWebBaseUrl: publicRuntime.mnoteWebBaseUrl }
: {}),
...(publicRuntime.mnoteWebTreeShellEnabled !== undefined
? { mnoteWebTreeShellEnabled: publicRuntime.mnoteWebTreeShellEnabled }
: {}),
isDesktop,
mnoteWebTreeShellEnabled:
publicRuntime.mnoteWebTreeShellEnabled ?? envRuntime.mnoteWebTreeShellEnabled,
};
return normalizeRuntimeConfig(merged);
};
}