主编辑区改造准备

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;