diff --git a/server/hocuspocus/src/extractPlainTextFromYXml.test.ts b/server/hocuspocus/src/extractPlainTextFromYXml.test.ts index edac4134..80dbf313 100644 --- a/server/hocuspocus/src/extractPlainTextFromYXml.test.ts +++ b/server/hocuspocus/src/extractPlainTextFromYXml.test.ts @@ -3,6 +3,25 @@ import * as Y from "yjs"; import { buildContentPreview, extractTextFromYXml } from "./extractPlainTextFromYXml.js"; describe("extractTextFromYXml", () => { + it("returns empty string for an empty fragment / 空の fragment では空文字を返す", () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("default"); + expect(extractTextFromYXml(fragment)).toBe(""); + }); + + it("extracts plain text from a simple paragraph / 単純なパラグラフからテキストを抽出する", () => { + const doc = new Y.Doc(); + doc.transact(() => { + const fragment = doc.getXmlFragment("default"); + const p = new Y.XmlElement("paragraph"); + fragment.push([p]); + const t = new Y.XmlText(); + t.insert(0, "Hello world"); + p.push([t]); + }); + expect(extractTextFromYXml(doc.getXmlFragment("default")).trim()).toBe("Hello world"); + }); + it("does not insert a newline inside a paragraph between inline bold and following text", () => { const doc = new Y.Doc(); doc.transact(() => { @@ -49,6 +68,70 @@ describe("extractTextFromYXml", () => { expect(plain.includes("First") && plain.includes("Second")).toBe(true); expect(/\n/.test(plain)).toBe(true); }); + + it("strips formatting attributes from XmlText delta (Tiptap marks) / XmlText の書式属性を除去する", () => { + const doc = new Y.Doc(); + doc.transact(() => { + const fragment = doc.getXmlFragment("default"); + const p = new Y.XmlElement("paragraph"); + fragment.push([p]); + const t = new Y.XmlText(); + t.insert(0, "Hello "); + t.insert(6, "world", { bold: true }); + t.insert(11, "!"); + p.push([t]); + }); + const plain = extractTextFromYXml(doc.getXmlFragment("default")).trim(); + expect(plain).toBe("Hello world!"); + expect(plain).not.toContain(""); + expect(plain).not.toContain(""); + }); + + it("strips italic and other mark attributes / italic 等の書式属性も除去する", () => { + const doc = new Y.Doc(); + doc.transact(() => { + const fragment = doc.getXmlFragment("default"); + const p = new Y.XmlElement("paragraph"); + fragment.push([p]); + const t = new Y.XmlText(); + t.insert(0, "normal "); + t.insert(7, "italic", { italic: true }); + t.insert(13, " "); + t.insert(14, "bold-italic", { bold: true, italic: true }); + p.push([t]); + }); + const plain = extractTextFromYXml(doc.getXmlFragment("default")).trim(); + expect(plain).toBe("normal italic bold-italic"); + expect(plain).not.toContain(""); + expect(plain).not.toContain(""); + }); + + it("handles nested elements (e.g. list items) / ネストされた要素を処理する", () => { + const doc = new Y.Doc(); + doc.transact(() => { + const fragment = doc.getXmlFragment("default"); + const list = new Y.XmlElement("bulletList"); + const item1 = new Y.XmlElement("listItem"); + const p1 = new Y.XmlElement("paragraph"); + const t1 = new Y.XmlText(); + t1.insert(0, "Item 1"); + p1.push([t1]); + item1.push([p1]); + + const item2 = new Y.XmlElement("listItem"); + const p2 = new Y.XmlElement("paragraph"); + const t2 = new Y.XmlText(); + t2.insert(0, "Item 2"); + p2.push([t2]); + item2.push([p2]); + + list.push([item1, item2]); + fragment.push([list]); + }); + const plain = extractTextFromYXml(doc.getXmlFragment("default")); + expect(plain).toContain("Item 1"); + expect(plain).toContain("Item 2"); + }); }); describe("buildContentPreview", () => { diff --git a/server/hocuspocus/src/extractPlainTextFromYXml.ts b/server/hocuspocus/src/extractPlainTextFromYXml.ts index 06ae89ca..8ac6fd31 100644 --- a/server/hocuspocus/src/extractPlainTextFromYXml.ts +++ b/server/hocuspocus/src/extractPlainTextFromYXml.ts @@ -30,7 +30,12 @@ function isInlineXmlElement(node: Y.XmlElement): boolean { /** * Y.Doc の XmlFragment(または XmlElement 根)からプレーンテキストを再帰的に抽出する。 + * `Y.XmlText.toString()` / `toJSON()` は `` 等の HTML タグを返すため、 + * `toDelta()` を使い `insert` 文字列のみを連結する。 + * * Recursively extract plain text from a Y.XmlFragment or Y.XmlElement subtree. + * Uses `toDelta()` instead of `toString()` / `toJSON()` because the latter + * returns HTML-like tags (``, ``, etc.) for formatted text. */ export function extractTextFromYXml(node: Y.XmlFragment | Y.XmlElement): string { let text = ""; @@ -38,7 +43,13 @@ export function extractTextFromYXml(node: Y.XmlFragment | Y.XmlElement): string for (let i = 0; i < node.length; i++) { const child = node.get(i); if (child instanceof Y.XmlText) { - text += child.toString(); + // toDelta() を使い書式属性なしの純粋なテキストのみを抽出する。 + // Use toDelta() to extract pure text without formatting attributes. + for (const op of child.toDelta()) { + if (typeof op.insert === "string") { + text += op.insert; + } + } } else if (child instanceof Y.XmlElement) { const inner = extractTextFromYXml(child); const suffix = isInlineXmlElement(child) ? " " : "\n"; diff --git a/server/hocuspocus/src/index.ts b/server/hocuspocus/src/index.ts index 03c8e6ac..a635ce5c 100644 --- a/server/hocuspocus/src/index.ts +++ b/server/hocuspocus/src/index.ts @@ -196,6 +196,8 @@ async function loadDocumentFromDb(pageId: string): Promise { async function saveDocumentToDb(pageId: string, document: Y.Doc): Promise { const encodedState = Buffer.from(Y.encodeStateAsUpdate(document)); + // Y.Doc からプレーンテキストを抽出(HTML タグなし・toDelta() ベース) + // Extract plain text from Y.Doc (without HTML tags, using toDelta()) const contentText = extractTextFromYXml(document.getXmlFragment("default")); const contentPreview = buildContentPreview(contentText); const client = await getPool().connect(); @@ -207,8 +209,8 @@ async function saveDocumentToDb(pageId: string, document: Y.Doc): Promise VALUES ($1, $2, 1, $3, NOW()) ON CONFLICT (page_id) DO UPDATE SET ydoc_state = EXCLUDED.ydoc_state, - version = page_contents.version + 1, content_text = EXCLUDED.content_text, + version = page_contents.version + 1, updated_at = NOW() `, [pageId, encodedState, contentText], diff --git a/src/lib/collaboration/CollaborationManager.ts b/src/lib/collaboration/CollaborationManager.ts index 7b8d51ef..b7f7dcc7 100644 --- a/src/lib/collaboration/CollaborationManager.ts +++ b/src/lib/collaboration/CollaborationManager.ts @@ -296,7 +296,15 @@ export class CollaborationManager { const parts: string[] = []; const walk = (node: Y.XmlFragment | Y.XmlElement | Y.XmlText) => { if (node instanceof Y.XmlText) { - parts.push(node.toJSON()); + // toDelta() を使い書式属性なしの純粋なテキストのみを抽出する。 + // toString() / toJSON() は 等の HTML タグを返すため使用しない。 + // Use toDelta() to extract pure text without formatting attributes. + // toString() / toJSON() return HTML-like tags (, etc.) so we avoid them. + for (const op of node.toDelta()) { + if (typeof op.insert === "string") { + parts.push(op.insert); + } + } } else { for (const child of node.toArray()) { if (