主编辑区改造准备
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user