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 (