From 6307f99266323bf200330eb1dd9c893e2bb7aee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 06:23:34 +0000 Subject: [PATCH 1/3] refactor(#892): retire local Y.js mode and /pages/:id route (#889 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #889 段階的リファクタの Phase 3。Phase 1 (#888) でメタデータ専用 `PUT /api/pages/:id` + 読み取り専用 GET ルートを、Phase 2 (#893) で `NotePagePublicView` を整備した上で、残った大本命のクリーンアップとして 以下を完全に廃止する。 - `CollaborationManager` の `local` モード(IndexedDB 同期と並行に `GET/PUT /api/pages/:id/content` を debounce で叩いて Y.Doc を REST 保存する経路)を撤去。全ページは所属ノートを持つので、Hocuspocus WebSocket 同期に統一する。 - `/pages/:id` および `/page/:id` ルートを撤去。ノートネイティブ経路 `/notes/:noteId/:pageId` に統合し、`useCreatePage` の戻り値が常に `noteId` を持つことを前提に 16 箇所の `navigate(...)` を書き換えた。 - `useCollaboration` API から `mode` / `flushSave` / `setPageTitle` を撤去 し、`PageEditor/` 配下の重複コンポーネント・フック 22 ファイルを削除。 - Web Clipper / AI チャット / PromoteToWiki / WikiLink dialog などの 作成フローは `navigate("/notes/:noteId/:pageId", { state: { initialContent } })` 経由で `NotePageView` に seed を渡し、Hocuspocus `synced` 後に Y.Doc に 反映する形式に切り替えた。 - サーバ側は `POST /api/pages` レスポンスに `note_id` を追加。PDF 派生 ページ・ハイライト一覧・グローバル検索の各レスポンスに `note_id` を 同梱して、クライアントが `/notes/:noteId/:pageId` を組み立てられるように した。`GET/PUT /api/pages/:id/content` 本体や `snapshotService.ts` の 削除は Phase 4 に温存(移行期セーフネット)。 - Phase 3 で `/pages/:id` 専用の `e2e/page-editor.spec.ts` と `e2e/wikilink-create-dialog.spec.ts` を削除し、`e2e/auth-mock.ts` の `createNewPage` helper を `/notes/:noteId/:pageId` URL を待つように更新。 Issue #889 phase 3 — retires the legacy `local` Y.js REST path and the top-level `/pages/:id` route. Every page now syncs through Hocuspocus and navigates under its owning note. `useCollaboration` loses `mode` / `flushSave` / `setPageTitle`; create flows pass an `initialContent` seed via React Router state and the editor applies it after the initial sync. The server's `POST /api/pages` response, the derive-page handler, the highlight list, and the global search rows now all carry the derived page's `note_id` so the client never has to ask twice. Phase 4 will delete the now-orphaned `/api/pages/:id/content` endpoints. Test plan: - `bun run lint` — 0 errors (621 pre-existing warnings) - `bun run format:check` — clean - `bunx vitest run` (main) — 229 files / 2327 tests pass - `cd server/api && bunx vitest run` — 95 files / 1280 tests pass Refs: Issue #892, Phase 1 (#888), Phase 2 (#893) https://claude.ai/code/session_01CVtupQrUS23UEerQJgPgLH --- e2e/auth-mock.ts | 29 +- e2e/page-editor.spec.ts | 355 ------- e2e/wikilink-create-dialog.spec.ts | 208 ---- server/api/src/__tests__/routes/pages.test.ts | 11 +- server/api/src/routes/pages.ts | 1 + server/api/src/routes/pdfSources.ts | 41 +- server/api/src/routes/search.ts | 8 + src/App.tsx | 15 - .../ai-chat/PromoteToWikiDialog.tsx | 5 +- .../editor/PageEditor/PageEditorAlerts.tsx | 117 --- .../editor/PageEditor/PageEditorDialogs.tsx | 106 --- .../PageEditor/PageEditorLayout.test.tsx | 173 ---- .../editor/PageEditor/PageEditorLayout.tsx | 243 ----- src/components/editor/PageEditor/types.ts | 94 -- .../PageEditor/useEditorAutoSave.test.ts | 522 ----------- .../editor/PageEditor/useEditorAutoSave.ts | 222 ----- .../editor/PageEditor/usePageDeletion.test.ts | 343 ------- .../editor/PageEditor/usePageDeletion.ts | 244 ----- .../editor/PageEditor/usePageEditor.ts | 131 --- .../PageEditor/usePageEditorAIEffects.ts | 21 - .../usePageEditorAutoSaveWithMutation.ts | 72 -- .../PageEditor/usePageEditorEffects.test.tsx | 887 ------------------ .../editor/PageEditor/usePageEditorEffects.ts | 202 ---- .../PageEditor/usePageEditorHandlers.ts | 91 -- .../PageEditor/usePageEditorKeyboard.ts | 26 - .../editor/PageEditor/usePageEditorState.ts | 128 --- .../PageEditor/usePageEditorStateAndSync.ts | 243 ----- .../usePageEditorStateAndSyncReturnSlices.ts | 94 -- .../PageEditor/usePageEditorWikiCollab.ts | 29 - .../usePendingChatPageGeneration.test.tsx | 391 -------- .../usePendingChatPageGeneration.ts | 188 ---- src/components/editor/PageEditorView.tsx | 21 - .../useWikiLinkNavigation.test.ts | 30 +- .../TiptapEditor/useWikiLinkNavigation.ts | 87 +- .../layout/useFloatingActionButtonHandlers.ts | 41 +- src/components/page/LinkGroupRow.tsx | 14 +- src/components/page/LinkSection.tsx | 14 +- .../page/LinkedPagesSection.test.tsx | 26 +- src/components/page/LinkedPagesSection.tsx | 11 +- src/components/page/PageCard.tsx | 25 +- src/components/page/PageLinkCard.test.tsx | 1 + .../pdf-reader/HighlightLayer.test.tsx | 1 + .../pdf-reader/HighlightSidebar.test.tsx | 13 +- .../pdf-reader/HighlightSidebar.tsx | 18 +- src/components/pdf-reader/PdfReader.tsx | 5 +- .../pdf-reader/saveAndDeriveFlow.test.ts | 16 +- .../pdf-reader/saveAndDeriveFlow.ts | 15 +- src/components/search/SearchResultCard.tsx | 14 +- src/contexts/GlobalSearchContext.test.ts | 25 +- src/contexts/GlobalSearchContext.tsx | 23 +- src/hooks/runAIChatAction.test.ts | 16 +- src/hooks/runAIChatAction.ts | 20 +- src/hooks/useAIChatActions.test.ts | 4 +- src/hooks/useAIChatActions.ts | 5 +- src/hooks/useCollaboration.ts | 59 +- src/hooks/useCreateNewPage.ts | 29 +- src/hooks/useGlobalSearch.test.ts | 5 +- src/hooks/useGlobalSearch.ts | 37 +- src/hooks/useLinkedPages.test.ts | 4 +- src/hooks/useLinkedPages.ts | 11 + src/lib/api/types.ts | 18 + .../CollaborationManager.test.ts | 47 +- src/lib/collaboration/CollaborationManager.ts | 306 +----- src/lib/collaboration/types.ts | 22 +- src/lib/pdfKnowledge/highlightsApi.ts | 12 + src/pages/NotePageView.tsx | 50 +- src/pages/PageEditor.tsx | 12 - src/pages/SearchResults.tsx | 9 +- 68 files changed, 558 insertions(+), 5748 deletions(-) delete mode 100644 e2e/page-editor.spec.ts delete mode 100644 e2e/wikilink-create-dialog.spec.ts delete mode 100644 src/components/editor/PageEditor/PageEditorAlerts.tsx delete mode 100644 src/components/editor/PageEditor/PageEditorDialogs.tsx delete mode 100644 src/components/editor/PageEditor/PageEditorLayout.test.tsx delete mode 100644 src/components/editor/PageEditor/PageEditorLayout.tsx delete mode 100644 src/components/editor/PageEditor/types.ts delete mode 100644 src/components/editor/PageEditor/useEditorAutoSave.test.ts delete mode 100644 src/components/editor/PageEditor/useEditorAutoSave.ts delete mode 100644 src/components/editor/PageEditor/usePageDeletion.test.ts delete mode 100644 src/components/editor/PageEditor/usePageDeletion.ts delete mode 100644 src/components/editor/PageEditor/usePageEditor.ts delete mode 100644 src/components/editor/PageEditor/usePageEditorAIEffects.ts delete mode 100644 src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts delete mode 100644 src/components/editor/PageEditor/usePageEditorEffects.test.tsx delete mode 100644 src/components/editor/PageEditor/usePageEditorEffects.ts delete mode 100644 src/components/editor/PageEditor/usePageEditorHandlers.ts delete mode 100644 src/components/editor/PageEditor/usePageEditorKeyboard.ts delete mode 100644 src/components/editor/PageEditor/usePageEditorState.ts delete mode 100644 src/components/editor/PageEditor/usePageEditorStateAndSync.ts delete mode 100644 src/components/editor/PageEditor/usePageEditorStateAndSyncReturnSlices.ts delete mode 100644 src/components/editor/PageEditor/usePageEditorWikiCollab.ts delete mode 100644 src/components/editor/PageEditor/usePendingChatPageGeneration.test.tsx delete mode 100644 src/components/editor/PageEditor/usePendingChatPageGeneration.ts delete mode 100644 src/components/editor/PageEditorView.tsx delete mode 100644 src/pages/PageEditor.tsx diff --git a/e2e/auth-mock.ts b/e2e/auth-mock.ts index e0040f59..3800bdeb 100644 --- a/e2e/auth-mock.ts +++ b/e2e/auth-mock.ts @@ -28,10 +28,16 @@ const helpers = { }, /** - * Create a new page and return its ID. - * Uses the home FAB (「新規作成」): `/pages/new` is no longer a creation entry (editor redirects to /home). + * Create a new page from the home FAB and return its note/page id pair. + * + * Issue #889 Phase 3 で `/pages/:id` ルートを撤去したため、作成後は必ず + * `/notes/:noteId/:pageId` に遷移する。テスト側もこの URL を待つように更新する。 + * + * Issue #889 Phase 3 retired `/pages/:id`, so pages always land on + * `/notes/:noteId/:pageId` after creation. This helper waits on that URL and + * returns both ids; callers that only care about the page id can destructure. */ - async createNewPage(page: Page): Promise { + async createNewPage(page: Page): Promise<{ noteId: string; pageId: string }> { await page.goto("/home"); await page.waitForLoadState("networkidle"); @@ -40,21 +46,22 @@ const helpers = { await page.locator('[data-testid="home-fab"]').click(); await page.getByRole("button", { name: "新規作成" }).click(); - // `^/pages/$` のみを許容し、`/notes/.../pages/` のようなノート配下ルートに - // 誤遷移した場合はリグレッションとして検知できるように pathname 完全一致で判定する。 - // Match only the top-level `/pages/:id` route; a regression that accidentally - // creates a note-scoped page should fail this helper instead of silently passing. - await page.waitForURL((url) => /^\/pages\/(?!new$)[^/]+$/.test(url.pathname), { + // 作成後の遷移先は常に `/notes/:noteId/:pageId`(Issue #889 Phase 3)。 + // 旧 `/pages/:id` に着地した場合はリグレッションとして失敗させる。 + // After Issue #889 Phase 3 the post-create URL is always + // `/notes/:noteId/:pageId`. Reject the legacy `/pages/:id` shape so a + // regression surfaces immediately instead of silently passing. + await page.waitForURL((url) => /^\/notes\/[^/]+\/[^/]+$/.test(url.pathname), { timeout: 15000, }); const { pathname } = new URL(page.url()); - const match = pathname.match(/^\/pages\/([^/]+)$/); + const match = pathname.match(/^\/notes\/([^/]+)\/([^/]+)$/); if (!match) { - throw new Error(`Failed to extract page ID from URL: ${page.url()}`); + throw new Error(`Failed to extract note/page IDs from URL: ${page.url()}`); } - return match[1]; + return { noteId: match[1], pageId: match[2] }; }, /** diff --git a/e2e/page-editor.spec.ts b/e2e/page-editor.spec.ts deleted file mode 100644 index 086e0b4d..00000000 --- a/e2e/page-editor.spec.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { test, expect } from "./auth-mock"; - -test.describe("Page Editor", () => { - test.setTimeout(60000); - - test.beforeEach(async ({ page, helpers }) => { - await helpers.goToHome(page); - }); - - test.describe("Page Creation", () => { - test("should create a new page and redirect to page ID", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - // Verify editor is visible - await expect(page.locator(".tiptap")).toBeVisible({ timeout: 10000 }); - await expect(page.getByPlaceholder("タイトル")).toBeVisible(); - }); - - test("redirects /pages/new to the default note (direct /pages/new is not a creation entry)", async ({ - page, - }) => { - // `/pages/new` 直接アクセスは /notes/me に飛ばし、NoteMeRedirect 経由で - // 既定の `/notes/:noteId` に着地する (issue #884)。`/home` 経路は廃止。 - // `/pages/new` redirects to `/notes/me`, which `NoteMeRedirect` then - // resolves into `/notes/:noteId` — the legacy `/home` hop is gone (#884). - await page.goto("/pages/new"); - await expect(page).toHaveURL(/\/notes\/(me|[^/]+)/, { timeout: 10000 }); - }); - }); - - test.describe("Title Editing", () => { - test("should save title changes with auto-save", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - const titleInput = page.getByPlaceholder("タイトル"); - await titleInput.fill("Test Title"); - - // Wait for debounced save (500ms + buffer) - await page.waitForTimeout(1500); - - // Verify title persists after reload - await page.reload(); - await page.waitForLoadState("networkidle"); - - await expect(titleInput).toHaveValue("Test Title"); - }); - - test("should show duplicate title warning", async ({ page, helpers }) => { - // Create first page with title - await helpers.createNewPage(page); - - await page.getByPlaceholder("タイトル").fill("Unique Title"); - await page.waitForTimeout(1500); - - // Create second page with same title - await helpers.createNewPage(page); - - await page.getByPlaceholder("タイトル").fill("Unique Title"); - // Debounced duplicate check (useTitleValidation) + save - await page.waitForTimeout(2500); - - // Duplicate message: toast + inline title (two role=alert with same copy) - await expect( - page - .getByRole("alert") - .filter({ hasText: /既に存在します/ }) - .first(), - ).toBeVisible({ timeout: 15000 }); - }); - }); - - test.describe("Content Editing", () => { - test("should type in editor and auto-generate title", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type("Auto generated title from first line"); - - // Wait for debounced save - await page.waitForTimeout(1500); - - // Title should be auto-generated from content - const titleInput = page.getByPlaceholder("タイトル"); - await expect(titleInput).toHaveValue("Auto generated title from first line"); - }); - - test("should persist content after reload", async ({ page, helpers }) => { - await helpers.createNewPage(page); - const currentUrl = page.url(); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Content Test"); - await page.waitForTimeout(500); - - // Enter content - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type("This content should persist"); - - // Wait for save - await page.waitForTimeout(2000); - - // Reload and verify - await page.goto(currentUrl); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(1000); - - await expect(editor).toContainText("This content should persist"); - }); - - test("should show content error warning for invalid content", async ({ page, helpers }) => { - // This test would require inserting invalid content directly into the database - // For now, we just verify the error UI exists in the DOM structure - await helpers.createNewPage(page); - - // The content error banner should not be visible for normal content - await expect(page.locator(".bg-amber-500\\/10")).not.toBeVisible(); - }); - - test("should apply bold via bubble menu when text is selected", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type("Bold test"); - await page.keyboard.press("Mod+a"); - - await expect(page.getByRole("button", { name: "太字" })).toBeVisible({ timeout: 3000 }); - await page.getByRole("button", { name: "太字" }).click(); - - await expect(editor.locator("strong")).toContainText("Bold test"); - }); - }); - - test.describe("Wiki Generator", () => { - test("should show Wiki生成 button when title exists and content is empty", async ({ - page, - helpers, - }) => { - await helpers.createNewPage(page); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Test Topic"); - await page.waitForTimeout(500); - - // Wiki生成 button should be visible - await expect(page.getByText("Wiki生成")).toBeVisible(); - }); - - test("should hide Wiki生成 button when content exists", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Test Topic"); - await page.waitForTimeout(500); - - // Enter content - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type("Some content here"); - await page.waitForTimeout(500); - - // Wiki生成 button should not be visible - await expect(page.getByText("Wiki生成")).not.toBeVisible(); - }); - }); - - test.describe("Navigation", () => { - // `/home` は #884 で廃止予定。back ボタン / 失敗時の遷移先は /notes/me に集約され、 - // `NoteMeRedirect` 経由で `/notes/:noteId` に着地する。 - // `/home` is being retired in #884: back-button navigation now lands on - // `/notes/me` which `NoteMeRedirect` resolves to `/notes/:noteId`. - test("should navigate back to the default note on back button click", async ({ - page, - helpers, - }) => { - await helpers.createNewPage(page); - - // Enter title to avoid delete warning - await page.getByPlaceholder("タイトル").fill("Navigation Test"); - await page.waitForTimeout(1500); - - // Click back button - await page.locator('button:has(svg[class*="lucide-arrow-left"])').click(); - - // Should land on the default note (via /notes/me redirect) - await expect(page).toHaveURL(/\/notes\/(me|[^/]+)/); - }); - - test("should delete page on back if title is empty", async ({ page, helpers }) => { - await helpers.createNewPage(page); - const pageUrl = page.url(); - - // Don't enter title, just wait - await page.waitForTimeout(500); - - // Click back button - await page.locator('button:has(svg[class*="lucide-arrow-left"])').click(); - - // Should land on the default note (via /notes/me redirect) - await expect(page).toHaveURL(/\/notes\/(me|[^/]+)/); - - // Page should not exist anymore - await page.goto(pageUrl); - await page.waitForTimeout(1000); - - // Should redirect to the default note (page not found) - await expect(page).toHaveURL(/\/notes\/(me|[^/]+)/); - }); - }); - - test.describe("Page Actions Menu", () => { - test("should show dropdown menu with actions", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Actions Test"); - await page.waitForTimeout(500); - - // Click more options button - await page.locator('button:has(svg[class*="lucide-more-horizontal"])').click(); - - // Should see menu items - await expect(page.getByText("URLから取り込み")).toBeVisible(); - await expect(page.getByText("Markdownでエクスポート")).toBeVisible(); - await expect(page.getByText("Markdownをコピー")).toBeVisible(); - await expect(page.getByText("削除")).toBeVisible(); - }); - - test("should delete page via menu", async ({ page, helpers }) => { - await helpers.createNewPage(page); - const pageUrl = page.url(); - - // Enter title - await page.getByPlaceholder("タイトル").fill("Delete Test"); - await page.waitForTimeout(1500); - - // Click more options and delete - await page.locator('button:has(svg[class*="lucide-more-horizontal"])').click(); - await page.getByText("削除").click(); - - // Should redirect to the default note (via /notes/me) - await expect(page).toHaveURL(/\/notes\/(me|[^/]+)/); - - // Page should not exist - await page.goto(pageUrl); - await page.waitForTimeout(1000); - await expect(page).toHaveURL(/\/notes\/(me|[^/]+)/); - }); - }); - - test.describe("Keyboard Shortcuts", () => { - test("should navigate home with Cmd+H", async ({ page, helpers }) => { - await helpers.createNewPage(page); - - // Enter title to avoid delete warning - await page.getByPlaceholder("タイトル").fill("Shortcut Test"); - await page.waitForTimeout(1500); - - // Press Cmd+H (or Ctrl+H on Windows/Linux) - await page.keyboard.press("Meta+h"); - - // Should land on the default note (via /notes/me redirect) - await expect(page).toHaveURL(/\/notes\/(me|[^/]+)/); - }); - }); - - test.describe("Linked Pages Section", () => { - test("should show linked pages section when page has WikiLinks", async ({ page, helpers }) => { - // Create target page first - await helpers.createNewPage(page); - await page.getByPlaceholder("タイトル").fill("Target Page for Links"); - await page.locator(".tiptap").click(); - await page.keyboard.type("Content of target page"); - await page.waitForTimeout(2000); - - // Create source page with WikiLink - await helpers.createNewPage(page); - const sourceUrl = page.url(); - - await page.getByPlaceholder("タイトル").fill("Source Page with Links"); - await page.locator(".tiptap").click(); - - // Type WikiLink - await page.keyboard.type("[[Target Page for Links"); - await page.waitForTimeout(500); - await page.keyboard.press("Enter"); - await page.waitForTimeout(3000); - - // Reload source page - await page.goto(sourceUrl); - await page.waitForLoadState("networkidle"); - await page.waitForTimeout(2000); - - // Should see linked pages section - const linkSection = page.getByText("リンク"); - const isVisible = await linkSection.isVisible().catch(() => false); - - if (isVisible) { - await expect(linkSection).toBeVisible(); - } - }); - }); - - test.describe("Home page context menu delete", () => { - test("should delete page via context menu and keep UI interactive (fix #313)", async ({ - page, - helpers, - }) => { - await helpers.createNewPage(page); - const syncPromise = page.waitForResponse( - (res) => { - const req = res.request(); - return req.method() === "POST" && res.url().includes("/api/sync/pages") && res.ok(); - }, - { timeout: 10000 }, - ); - await page.getByPlaceholder("タイトル").fill("Context Menu Delete Test"); - await syncPromise; - - await page.goto("/home"); - await page.waitForLoadState("networkidle"); - - const card = page.locator(".page-card", { hasText: "Context Menu Delete Test" }); - await expect(card).toBeVisible({ timeout: 10000 }); - - await card.click({ button: "right" }); - await page.getByRole("menuitem", { name: "削除" }).click(); - - await expect(page.getByRole("alertdialog")).toBeVisible({ timeout: 5000 }); - const deletePromise = page.waitForResponse( - (res) => { - const req = res.request(); - return req.method() === "DELETE" && res.url().includes("/api/pages") && res.ok(); - }, - { timeout: 10000 }, - ); - await page.getByRole("alertdialog").getByRole("button", { name: "削除" }).click(); - await deletePromise; - - await expect(page.getByRole("alertdialog")).not.toBeVisible({ timeout: 5000 }); - // /home は /notes/me に redirect され、その後 NoteMeRedirect が /notes/:noteId - // に着地させるため、最終 URL は note detail になる (#884)。 - // The /home hop redirects through /notes/me into /notes/:noteId, so the - // final URL after the delete settles on the note detail (#884). - await expect(page).toHaveURL(/\/notes\/(me|[^/]+)/); - await expect(card).toHaveCount(0); - - const fab = page.locator('[data-testid="home-fab"]'); - await fab.click(); - await expect(page.getByRole("button", { name: /新規作成/ })).toBeVisible({ timeout: 3000 }); - }); - }); -}); diff --git a/e2e/wikilink-create-dialog.spec.ts b/e2e/wikilink-create-dialog.spec.ts deleted file mode 100644 index aa02e813..00000000 --- a/e2e/wikilink-create-dialog.spec.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { Page } from "@playwright/test"; -import { test, expect } from "./auth-mock"; -import { MOCK_USER_ID } from "../src/components/auth/MockAuthProvider"; - -const GHOST_TITLE = "生産手段"; - -async function seedBlankPage(page: Page, title: string) { - const pageId = crypto.randomUUID(); - const now = Date.now(); - - await page.evaluate( - async ({ userId, pageId, title, now }) => { - const request = indexedDB.open(`zedi-storage-${userId}`, 1); - - await new Promise((resolve, reject) => { - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains("my_pages")) { - const pages = db.createObjectStore("my_pages", { keyPath: "id" }); - pages.createIndex("updated_at", "updatedAt", { unique: false }); - pages.createIndex("created_at", "createdAt", { unique: false }); - } - if (!db.objectStoreNames.contains("my_links")) { - const links = db.createObjectStore("my_links", { keyPath: ["sourceId", "targetId"] }); - links.createIndex("by_source", "sourceId", { unique: false }); - links.createIndex("by_target", "targetId", { unique: false }); - } - if (!db.objectStoreNames.contains("my_ghost_links")) { - const ghost = db.createObjectStore("my_ghost_links", { - keyPath: ["linkText", "sourcePageId"], - }); - ghost.createIndex("by_source", "sourcePageId", { unique: false }); - } - if (!db.objectStoreNames.contains("search_index")) { - db.createObjectStore("search_index", { keyPath: "pageId" }); - } - if (!db.objectStoreNames.contains("meta")) { - db.createObjectStore("meta", { keyPath: "key" }); - } - if (!db.objectStoreNames.contains("ydoc_versions")) { - db.createObjectStore("ydoc_versions", { keyPath: "pageId" }); - } - }; - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - - const db = request.result; - await new Promise((resolve, reject) => { - const tx = db.transaction(["my_pages", "search_index", "ydoc_versions"], "readwrite"); - tx.objectStore("my_pages").put({ - id: pageId, - ownerId: userId, - sourcePageId: null, - title, - contentPreview: null, - thumbnailUrl: null, - sourceUrl: null, - createdAt: now, - updatedAt: now, - isDeleted: false, - }); - tx.objectStore("search_index").put({ pageId, text: "" }); - tx.objectStore("ydoc_versions").put({ pageId, version: 1 }); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(tx.error); - }); - - db.close(); - }, - { userId: MOCK_USER_ID, pageId, title, now }, - ); - - return pageId; -} - -async function createPageWithGhostWikiLink(page: Page, sourceTitle: string) { - const pageId = await seedBlankPage(page, sourceTitle); - await page.goto(`/pages/${pageId}`); - await page.waitForLoadState("networkidle"); - await expect(page.getByRole("textbox", { name: "タイトル" })).toHaveValue(sourceTitle); - - const sourceUrl = page.url(); - - await page.getByRole("textbox", { name: "タイトル" }).fill(sourceTitle); - await page.waitForTimeout(500); - - const editor = page.locator(".tiptap"); - await editor.click(); - await page.keyboard.type(`[[${GHOST_TITLE}`); - await page.waitForTimeout(300); - await page.keyboard.press("Enter"); - - await expect(editor.locator(`[data-wiki-link][data-title="${GHOST_TITLE}"]`)).toBeVisible({ - timeout: 5000, - }); - - // Wait for autosave (PUT /api/pages/:id/content), then reload to test against persisted content. - await page.waitForResponse( - (res) => - res.url().includes("/api/pages/") && - res.url().includes("/content") && - res.request().method() === "PUT", - { timeout: 10000 }, - ); - await page.goto(sourceUrl); - await page.waitForLoadState("networkidle"); - - return { sourceUrl }; -} - -test.describe("WikiLink create-page dialog", () => { - test.setTimeout(60000); - - test.beforeEach(async ({ page, helpers }) => { - // auth-mock の page fixture が既に同じ per-user onboarding cache を seed - // しているため、ここでは goToHome のみ。重複 seed があるとキー変更時に - // ずれる可能性があるので単一責務にまとめる。 - // The auth-mock page fixture already seeds the same per-user onboarding - // cache; keep seeding centralized there to avoid drift when the key - // changes. - await helpers.goToHome(page); - }); - - test("shows create-page dialog and cancels without crashing", async ({ page }) => { - const pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); - - const { sourceUrl } = await createPageWithGhostWikiLink(page, "Ghost Link Cancel Test"); - - const ghostLink = page.locator(`.tiptap [data-wiki-link][data-title="${GHOST_TITLE}"]`); - await ghostLink.click(); - - await expect(page.getByRole("alertdialog")).toBeVisible(); - await expect(page.getByRole("heading", { name: "ページを作成しますか?" })).toBeVisible(); - - await page.getByRole("button", { name: "キャンセル" }).click(); - - await expect(page.getByRole("alertdialog")).not.toBeVisible(); - await expect(page).toHaveURL(sourceUrl); - await expect(page.locator(".tiptap")).toBeVisible(); - await expect(ghostLink).toBeVisible(); - - expect( - pageErrors.some((error) => error.message.includes("Maximum update depth exceeded")), - ).toBeFalsy(); - }); - - test("creates a page from an unconfigured wiki link", async ({ page }) => { - const pageErrors: Error[] = []; - page.on("pageerror", (error) => pageErrors.push(error)); - - const { sourceUrl } = await createPageWithGhostWikiLink(page, "Ghost Link Create Test"); - - await page.route("**/api/pages", async (route) => { - if (route.request().method() !== "POST") { - await route.fallback(); - return; - } - let requestBody: { - title?: string; - content_preview?: string | null; - source_page_id?: string | null; - thumbnail_url?: string | null; - source_url?: string | null; - }; - try { - requestBody = route.request().postDataJSON() ?? {}; - } catch { - await route.fallback(); - return; - } - const now = new Date().toISOString(); - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - id: crypto.randomUUID(), - owner_id: MOCK_USER_ID, - source_page_id: requestBody.source_page_id ?? null, - title: requestBody.title ?? null, - content_preview: requestBody.content_preview ?? null, - thumbnail_url: requestBody.thumbnail_url ?? null, - source_url: requestBody.source_url ?? null, - created_at: now, - updated_at: now, - is_deleted: false, - }), - }); - }); - - const ghostLink = page.locator(`.tiptap [data-wiki-link][data-title="${GHOST_TITLE}"]`); - await ghostLink.click(); - - await expect(page.getByRole("alertdialog")).toBeVisible(); - await page.getByRole("button", { name: "作成する" }).click(); - - await expect(page).not.toHaveURL(sourceUrl, { timeout: 15000 }); - await expect(page.getByRole("textbox", { name: "タイトル" })).toHaveValue(GHOST_TITLE); - await expect(page.locator(".tiptap")).toBeVisible(); - - expect( - pageErrors.some((error) => error.message.includes("Maximum update depth exceeded")), - ).toBeFalsy(); - }); -}); diff --git a/server/api/src/__tests__/routes/pages.test.ts b/server/api/src/__tests__/routes/pages.test.ts index e5335a16..9f86492d 100644 --- a/server/api/src/__tests__/routes/pages.test.ts +++ b/server/api/src/__tests__/routes/pages.test.ts @@ -454,9 +454,18 @@ describe("POST /api/pages", () => { expect(res.status).toBe(201); expect(ensureDefaultNote).toHaveBeenCalledTimes(1); - const body = (await res.json()) as { id: string; owner_id: string }; + const body = (await res.json()) as { + id: string; + owner_id: string; + note_id: string; + }; expect(body.id).toBe("new-page-id"); expect(body.owner_id).toBe(TEST_USER_ID); + // Issue #889 Phase 3: クライアントが `/notes/:noteId/:pageId` へ遷移するため + // POST レスポンスに `note_id` が含まれる必要がある。 + // Issue #889 Phase 3: clients navigate to `/notes/:noteId/:pageId`, so the + // POST response must carry `note_id`. + expect(body.note_id).toBe("default-note-mock"); }); it("returns 403 when note_id points to a note the caller cannot edit", async () => { diff --git a/server/api/src/routes/pages.ts b/server/api/src/routes/pages.ts index 7d5d1063..ff9e0456 100644 --- a/server/api/src/routes/pages.ts +++ b/server/api/src/routes/pages.ts @@ -728,6 +728,7 @@ app.post("/", authRequired, async (c) => { { id: row.id, owner_id: row.ownerId, + note_id: row.noteId, source_page_id: row.sourcePageId ?? null, title: row.title ?? null, content_preview: row.contentPreview ?? null, diff --git a/server/api/src/routes/pdfSources.ts b/server/api/src/routes/pdfSources.ts index 9d295f26..b19927e2 100644 --- a/server/api/src/routes/pdfSources.ts +++ b/server/api/src/routes/pdfSources.ts @@ -244,9 +244,30 @@ app.get("/pdf/:sourceId/highlights", authRequired, async (c) => { const sourceId = c.req.param("sourceId"); await loadOwnedPdfSourceOrThrow(db, sourceId, userId); + // Issue #889 Phase 3: `/pages/:id` 廃止に伴い、「派生ページを開く」リンクは + // `/notes/:noteId/:pageId` を組み立てる必要があるため、ハイライト行に派生先 + // ページの `noteId` を `derivedPageNoteId` として同梱して返す(外部 join で + // 個別に取得する N+1 を避ける)。 + // Issue #889 Phase 3: include `derivedPageNoteId` from a left join with + // `pages` so the client can build `/notes/:noteId/:pageId` for the "Open + // derived page" link without a follow-up request. const rows = await db - .select() + .select({ + id: pdfHighlights.id, + sourceId: pdfHighlights.sourceId, + ownerId: pdfHighlights.ownerId, + derivedPageId: pdfHighlights.derivedPageId, + derivedPageNoteId: pages.noteId, + pdfPage: pdfHighlights.pdfPage, + rects: pdfHighlights.rects, + text: pdfHighlights.text, + color: pdfHighlights.color, + note: pdfHighlights.note, + createdAt: pdfHighlights.createdAt, + updatedAt: pdfHighlights.updatedAt, + }) .from(pdfHighlights) + .leftJoin(pages, eq(pages.id, pdfHighlights.derivedPageId)) .where(eq(pdfHighlights.sourceId, sourceId)) .orderBy(pdfHighlights.pdfPage, pdfHighlights.createdAt); @@ -414,9 +435,21 @@ app.post( .limit(1); if (!highlightRow) throw new HTTPException(404, { message: "Highlight not found" }); if (highlightRow.derivedPageId) { - // 既に派生ページがあるので冪等に既存 ID を返す。 - // Idempotent: a derived page already exists. - return c.json({ pageId: highlightRow.derivedPageId, alreadyDerived: true }); + // 既に派生ページがあるので冪等に既存 ID を返す。Issue #889 Phase 3 で + // `/pages/:id` が廃止されたため、クライアントが `/notes/:noteId/:pageId` + // へ遷移できるよう `noteId` も含めて返す(page row から引く)。 + // Idempotent: include `noteId` so the client can build the + // `/notes/:noteId/:pageId` URL (Issue #889 Phase 3 retired `/pages/:id`). + const [existing] = await db + .select({ noteId: pages.noteId }) + .from(pages) + .where(eq(pages.id, highlightRow.derivedPageId)) + .limit(1); + return c.json({ + pageId: highlightRow.derivedPageId, + alreadyDerived: true, + noteId: existing?.noteId ?? null, + }); } let body: DerivePageBody = {}; diff --git a/server/api/src/routes/search.ts b/server/api/src/routes/search.ts index 2de3630c..d419a60b 100644 --- a/server/api/src/routes/search.ts +++ b/server/api/src/routes/search.ts @@ -258,6 +258,12 @@ async function runPdfHighlightSearch( ): Promise>> { if (isPdfHighlightSearchDisabled()) return []; + // Issue #889 Phase 3: 派生ページの所属ノート ID もまとめて返す。 + // クライアント側 (`resolveSearchResultUrl`) が `/notes/:noteId/:pageId` を + // 組み立てるため、`/pages/:id` 廃止後は `derived_page_note_id` が必須。 + // Issue #889 Phase 3: include the derived page's `note_id` so the client + // can build `/notes/:noteId/:pageId` after the `/pages/:id` route was + // retired. const result = await db.execute(sql` SELECT h.id AS highlight_id, @@ -266,11 +272,13 @@ async function runPdfHighlightSearch( h.pdf_page AS pdf_page, h.text AS text, h.derived_page_id AS derived_page_id, + p.note_id AS derived_page_note_id, h.updated_at AS updated_at, s.display_name AS source_display_name, s.title AS source_title FROM pdf_highlights h INNER JOIN sources s ON s.id = h.source_id + LEFT JOIN pages p ON p.id = h.derived_page_id WHERE h.owner_id = ${userId} AND s.kind = 'pdf_local' AND h.text ILIKE ${pattern} diff --git a/src/App.tsx b/src/App.tsx index 00522ea4..5118dcaa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,6 @@ import AuthCallback from "./pages/AuthCallback"; import ExtensionAuth from "./pages/ExtensionAuth"; import ExtensionAuthCallback from "./pages/ExtensionAuthCallback"; import McpAuthorize from "./pages/McpAuthorize"; -import PageEditorPage from "./pages/PageEditor"; import PdfReaderPage from "./pages/pdfKnowledge/PdfReaderPage"; import Settings from "./pages/Settings"; import WikiSchemaPage from "./pages/WikiSchemaPage"; @@ -65,16 +64,6 @@ function LegacyAIChatConversationRedirect() { return ; } -/** - * Redirect singular `/page/:id` to plural `/pages/:id` while preserving search/hash. - * 旧パス `/page/:id` を複数形 `/pages/:id` にリダイレクト(search/hash は保持)。 - */ -function LegacyPageRedirect() { - const { id } = useParams<{ id: string }>(); - const { search, hash } = useLocation(); - return ; -} - /** * Redirect legacy `/home` to `/notes/me`, preserving search and hash. * Chrome 拡張(`/home?clipUrl=...&from=chrome-extension`)など、`/home` を直接 @@ -258,10 +247,6 @@ const App = () => ( ページのリンクが 404 にならないよう URL を確保する。 */} } /> } /> - } /> - {/* Legacy singular path — redirect to plural. - 旧単数形パス — 複数形にリダイレクト。 */} - } /> {/* PDF 知識化ビューア (issue otomatty/zedi#389). Web から開いた場合は `PdfReaderUnsupported` を表示。 PDF knowledge viewer; web falls back to the diff --git a/src/components/ai-chat/PromoteToWikiDialog.tsx b/src/components/ai-chat/PromoteToWikiDialog.tsx index 48aa77b5..14cad122 100644 --- a/src/components/ai-chat/PromoteToWikiDialog.tsx +++ b/src/components/ai-chat/PromoteToWikiDialog.tsx @@ -257,7 +257,10 @@ function PromoteToWikiDialogBody({ }; toast({ title: t("aiChat.notifications.promoteSuccess") }); onClose(); - navigate(`/pages/${firstCreated.id}`, { + // Issue #889 Phase 3: `/pages/:id` 撤去のため `/notes/:noteId/:pageId` に遷移。 + // Issue #889 Phase 3: route to `/notes/:noteId/:pageId` after `/pages/:id` + // was retired. + navigate(`/notes/${firstCreated.noteId}/${firstCreated.id}`, { state: { pendingChatPageGeneration: pending }, }); } catch { diff --git a/src/components/editor/PageEditor/PageEditorAlerts.tsx b/src/components/editor/PageEditor/PageEditorAlerts.tsx deleted file mode 100644 index 15ee0982..00000000 --- a/src/components/editor/PageEditor/PageEditorAlerts.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from "react"; -import { AlertTriangle, ExternalLink, AlertCircle, Loader2, X } from "lucide-react"; -import { Button } from "@zedi/ui"; -import { Alert, AlertDescription } from "@zedi/ui"; -import Container from "@/components/layout/Container"; -import type { Page } from "@/types/page"; -import type { ContentError } from "../TiptapEditor/useContentSanitizer"; - -interface PageEditorAlertsProps { - // Title validation - duplicatePage: Page | null; - errorMessage: string | null; - title: string; - onOpenDuplicatePage: () => void; - - // Wiki generating - isWikiGenerating: boolean; - onCancelWiki: () => void; - - // Content error - contentError: ContentError | null; -} - -/** - * Alert banners for PageEditor - * Shows warnings for duplicate titles, empty titles, wiki generation status, and content errors - */ -export const PageEditorAlerts: React.FC = ({ - duplicatePage, - errorMessage, - title, - onOpenDuplicatePage, - isWikiGenerating, - onCancelWiki, - contentError, -}) => { - const showTitleAlerts = !!duplicatePage; - - return ( - <> - {/* タイトル警告エリア */} - {showTitleAlerts && ( -
- - {/* タイトル重複警告 */} - {duplicatePage && ( - - - - {errorMessage} - - - - )} - -
- )} - - {/* Wiki生成中バナー */} - {isWikiGenerating && ( -
- -
-
- - 「{title}」について解説を生成しています... -
- -
-
-
- )} - - {/* コンテンツエラー警告 */} - {contentError && ( -
- - - - -
- {contentError.message} - {contentError.removedNodeTypes.length > 0 && ( - - 削除されたノード: {contentError.removedNodeTypes.join(", ")} - - )} - {contentError.removedMarkTypes.length > 0 && ( - - 削除されたマーク: {contentError.removedMarkTypes.join(", ")} - - )} - {contentError.wasSanitized && ( - - ※ コンテンツは自動的に修正されました。保存すると修正後のデータが保存されます。 - - )} -
-
-
-
-
- )} - - ); -}; diff --git a/src/components/editor/PageEditor/PageEditorDialogs.tsx b/src/components/editor/PageEditor/PageEditorDialogs.tsx deleted file mode 100644 index 818cf69a..00000000 --- a/src/components/editor/PageEditor/PageEditorDialogs.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React from "react"; -import { AlertCircle } from "lucide-react"; -import { Button } from "@zedi/ui"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@zedi/ui"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@zedi/ui"; - -interface PageEditorDialogsProps { - // Delete confirmation dialog - deleteConfirmOpen: boolean; - deleteReason: string; - onDeleteConfirmOpenChange: (open: boolean) => void; - onConfirmDelete: () => void; - onCancelDelete: () => void; - - // Wiki generator error dialog - wikiStatus: string; - wikiErrorMessage: string | null; - onResetWiki: () => void; - onGoToAISettings: () => void; -} - -/** - * All dialogs used in PageEditor - * - Delete confirmation dialog - * - Wiki generator error dialog - * - Web clipper dialog - */ -export const PageEditorDialogs: React.FC = ({ - deleteConfirmOpen, - deleteReason, - onDeleteConfirmOpenChange, - onConfirmDelete, - onCancelDelete, - wikiStatus, - wikiErrorMessage, - onResetWiki, - onGoToAISettings, -}) => { - return ( - <> - {/* 削除確認ダイアログ */} - - - - ページを削除しますか? - - {deleteReason} - は保存できません。このページにはコンテンツが含まれています。削除してもよろしいですか? - - - - キャンセル - - 削除 - - - - - - {/* Wiki生成エラーダイアログ */} - onResetWiki()}> - - - - - 生成エラー - - - {wikiErrorMessage === "AI_NOT_CONFIGURED" - ? "AI設定が必要です。設定画面でAPIキーを入力してください。" - : wikiErrorMessage || "生成中にエラーが発生しました。"} - - - - {wikiErrorMessage === "AI_NOT_CONFIGURED" ? ( - - ) : ( - - )} - - - - - ); -}; diff --git a/src/components/editor/PageEditor/PageEditorLayout.test.tsx b/src/components/editor/PageEditor/PageEditorLayout.test.tsx deleted file mode 100644 index 2fcd28c5..00000000 --- a/src/components/editor/PageEditor/PageEditorLayout.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -/** - * PageEditorLayout コンポーネントのテスト(履歴モーダル関連) - * Tests for PageEditorLayout (history modal integration) - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { PageEditorLayout } from "./PageEditorLayout"; -import type { PageEditorLayoutProps } from "./PageEditorLayout"; - -// ── Mocks ────────────────────────────────────────────────────────────────── - -vi.mock("./PageEditorHeader", () => ({ - PageEditorHeader: ({ - menuItems, - }: { - menuItems?: Array<{ id: string; label: string; onClick: () => void }>; - }) => { - // PageEditorLayout は履歴メニュー項目を `menuItems` 配列で渡すように変更された - // ため、テストでも id ベースで該当項目を引いて click をディスパッチする。 - // PageEditorLayout now passes the history action via the `menuItems` array, - // so the mock surfaces buttons keyed by id (matching the production - // toolbar item ids) instead of the old per-prop callbacks. - const historyItem = menuItems?.find((item) => item.id === "history"); - return ( -
- {historyItem && ( - - )} -
- ); - }, -})); - -vi.mock("./PageEditorAlerts", () => ({ - PageEditorAlerts: () =>
, -})); - -vi.mock("@/components/note/PageEditorContent", () => ({ - PageEditorContent: () =>
, -})); - -vi.mock("./PageEditorDialogs", () => ({ - PageEditorDialogs: () =>
, -})); - -vi.mock("../../ai-chat/ContentWithAIChat", () => ({ - ContentWithAIChat: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})); - -vi.mock("../pageHistory/PageHistoryModal", () => ({ - PageHistoryModal: ({ - open, - onRestored, - onOpenChange, - }: { - open: boolean; - currentYdoc: unknown; - onRestored?: () => void; - onOpenChange: (open: boolean) => void; - }) => - open ? ( -
- - -
- ) : null, -})); - -const defaultProps: PageEditorLayoutProps = { - title: "Test Page", - content: "", - sourceUrl: undefined, - currentPageId: "page-1", - pageId: "page-1", - isNewPage: false, - displayLastSaved: null, - wikiStatus: "idle", - isWikiGenerating: false, - isSyncingLinks: false, - isLocalDocEnabled: false, - collaboration: undefined, - duplicatePage: null, - errorMessage: null, - contentError: null, - pendingInitialContent: null, - onBack: vi.fn(), - onDelete: vi.fn(), - onExportMarkdown: vi.fn(), - onCopyMarkdown: vi.fn(), - onGenerateWiki: vi.fn(), - onOpenDuplicatePage: vi.fn(), - onCancelWiki: vi.fn(), - onContentChange: vi.fn(), - onContentError: vi.fn(), - onTitleChange: vi.fn(), - onPendingInitialContentClear: vi.fn(), - deleteConfirmOpen: false, - deleteReason: "", - onDeleteConfirmOpenChange: vi.fn(), - onConfirmDelete: vi.fn(), - onCancelDelete: vi.fn(), - wikiErrorMessage: null, - onResetWiki: vi.fn(), - onGoToAISettings: vi.fn(), - wikiContentForCollab: null, - onWikiContentApplied: vi.fn(), -}; - -describe("PageEditorLayout", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("主要な子コンポーネントをレンダリングする / renders main child components", () => { - render(); - - expect(screen.getByTestId("editor-header")).toBeInTheDocument(); - expect(screen.getByTestId("editor-alerts")).toBeInTheDocument(); - expect(screen.getByTestId("editor-content")).toBeInTheDocument(); - expect(screen.getByTestId("editor-dialogs")).toBeInTheDocument(); - }); - - it("初期状態では履歴モーダルが表示されない / history modal is hidden by default", () => { - render(); - - expect(screen.queryByTestId("history-modal")).not.toBeInTheDocument(); - }); - - it("履歴ボタンをクリックすると履歴モーダルが表示される / shows history modal after clicking open history", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTestId("open-history-btn")); - - expect(screen.getByTestId("history-modal")).toBeInTheDocument(); - }); - - it("モーダルを閉じると非表示になる / hides modal on close", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTestId("open-history-btn")); - expect(screen.getByTestId("history-modal")).toBeInTheDocument(); - - await user.click(screen.getByTestId("close-modal-btn")); - expect(screen.queryByTestId("history-modal")).not.toBeInTheDocument(); - }); - - it("復元後に window.location.reload が呼ばれる / calls reload on restore", async () => { - const user = userEvent.setup(); - const reloadMock = vi.fn(); - Object.defineProperty(window, "location", { - value: { ...window.location, reload: reloadMock }, - writable: true, - }); - - render(); - - await user.click(screen.getByTestId("open-history-btn")); - await user.click(screen.getByTestId("restore-btn")); - - expect(reloadMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/editor/PageEditor/PageEditorLayout.tsx b/src/components/editor/PageEditor/PageEditorLayout.tsx deleted file mode 100644 index 6b529dbb..00000000 --- a/src/components/editor/PageEditor/PageEditorLayout.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import React, { useMemo, useState, useCallback } from "react"; -import { Copy, Download, History, Trash2 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { PageEditorHeader, type PageDetailToolbarAction } from "./PageEditorHeader"; -import { PageEditorAlerts } from "./PageEditorAlerts"; -import { PageEditorContent } from "@/components/note/PageEditorContent"; -import { PageEditorDialogs } from "./PageEditorDialogs"; -import { ContentWithAIChat } from "../../ai-chat/ContentWithAIChat"; -import { PageHistoryModal } from "../pageHistory/PageHistoryModal"; -import type { ContentError } from "../TiptapEditor/useContentSanitizer"; -import type { Page } from "@/types/page"; -import type { UseCollaborationReturn } from "@/lib/collaboration/types"; -import type { WikiGeneratorStatus } from "./types"; - -/** - * PageEditorLayout コンポーネントの Props。 - * Props for the PageEditorLayout component. - */ -export interface PageEditorLayoutProps { - title: string; - content: string; - sourceUrl: string | undefined; - currentPageId: string | null; - pageId: string; - isNewPage: boolean; - displayLastSaved: number | null; - wikiStatus: WikiGeneratorStatus; - isWikiGenerating: boolean; - isSyncingLinks: boolean; - isLocalDocEnabled: boolean; - collaboration: UseCollaborationReturn | undefined; - duplicatePage: Page | null; - errorMessage: string | null; - contentError: ContentError | null; - pendingInitialContent: string | null; - onBack: () => void; - onDelete: () => void; - onExportMarkdown: () => void; - onCopyMarkdown: () => void; - onGenerateWiki: () => void; - onOpenDuplicatePage: () => void; - onCancelWiki: () => void; - onContentChange: (content: string) => void; - onContentError: (error: ContentError | null) => void; - onTitleChange: (title: string) => void; - onPendingInitialContentClear: () => void; - deleteConfirmOpen: boolean; - deleteReason: string; - onDeleteConfirmOpenChange: (open: boolean) => void; - onConfirmDelete: () => void; - onCancelDelete: () => void; - wikiErrorMessage: string | null; - onResetWiki: () => void; - onGoToAISettings: () => void; - /** コラボモード時、Wiki生成内容を Y.Doc に反映する用。反映後に onWikiContentApplied でクリア */ - wikiContentForCollab: string | null; - onWikiContentApplied: () => void; -} - -/** - * ページエディタのレイアウトコンポーネント(ヘッダー・アラート・エディタ・ダイアログを統合)。 - * Page editor layout component integrating header, alerts, editor content, and dialogs. - */ -export const PageEditorLayout: React.FC = (props) => { - const { - title, - content, - sourceUrl, - currentPageId, - pageId, - isNewPage, - displayLastSaved, - wikiStatus, - isWikiGenerating, - isSyncingLinks, - isLocalDocEnabled, - collaboration, - duplicatePage, - errorMessage, - contentError, - pendingInitialContent, - onBack, - onDelete, - onExportMarkdown, - onCopyMarkdown, - onGenerateWiki, - onOpenDuplicatePage, - onCancelWiki, - onContentChange, - onContentError, - onTitleChange, - onPendingInitialContentClear, - deleteConfirmOpen, - deleteReason, - onDeleteConfirmOpenChange, - onConfirmDelete, - onCancelDelete, - wikiErrorMessage, - onResetWiki, - onGoToAISettings, - wikiContentForCollab, - onWikiContentApplied, - } = props; - - const { t } = useTranslation(); - const [historyOpen, setHistoryOpen] = useState(false); - - const handleOpenHistory = useCallback(() => { - setHistoryOpen(true); - }, []); - - const handleRestored = useCallback(() => { - // 復元後にページをリロードして最新状態を反映する - // Reload the page after restore to reflect the latest state - window.location.reload(); - }, []); - - // 個人ページ詳細のアクションメニュー項目。共通ツールバー (`PageEditorHeader`) - // へ渡せる形にまとめる。並び順は従来の: 履歴 → エクスポート → コピー → 区切り → 削除。 - // Menu items for the personal page detail toolbar. Order preserves the - // previous layout: history → export → copy → separator → delete. - const menuItems = useMemo( - () => [ - { - id: "history", - label: t("editor.pageHistory.menuButton"), - icon: History, - onClick: handleOpenHistory, - }, - { - id: "export-markdown", - label: t("editor.pageMenu.exportMarkdown"), - icon: Download, - onClick: onExportMarkdown, - }, - { - id: "copy-markdown", - label: t("editor.pageMenu.copyMarkdown"), - icon: Copy, - onClick: onCopyMarkdown, - }, - { - id: "delete", - label: t("editor.pageMenu.deletePage"), - icon: Trash2, - onClick: onDelete, - destructive: true, - separatorBefore: true, - }, - ], - [t, handleOpenHistory, onExportMarkdown, onCopyMarkdown, onDelete], - ); - - // React Compiler が optional chain の依存を保持できないため先に抽出する - // Extract ydoc to avoid React Compiler memoization issue with optional chaining - const ydoc = collaboration?.ydoc ?? null; - - return ( -
- - {/* スクロールコンテナの最上部にヘッダーを配置し、`sticky` で - エディタ領域の上端に貼り付ける。スクロール方向に応じて - PageEditorHeader 側でスライド表示/非表示を切り替える。 - Place the header at the top of the scroll container so that - `sticky` pins it to the top of the editor area. The header - handles slide-in/out based on scroll direction. */} - - - - - { - collaboration?.flushSave?.(); - onPendingInitialContentClear(); - }} - wikiContentForCollab={wikiContentForCollab} - onWikiContentApplied={onWikiContentApplied} - /* - * `/pages/:id` は個人ページ専用のルート(IndexedDB には - * `note_id IS NULL` のページしか入らない)。そのため WikiLink の - * スコープは常に個人 (`null`)。Issue #713 Phase 4 を参照。 - * - * `/pages/:id` only serves personal pages (IndexedDB only stores - * rows with `note_id IS NULL`), so the WikiLink scope is always - * personal (`null`). See issue #713 Phase 4. - */ - pageNoteId={null} - /> - - - - - {historyOpen && ( - - )} -
- ); -}; diff --git a/src/components/editor/PageEditor/types.ts b/src/components/editor/PageEditor/types.ts deleted file mode 100644 index 40eb878e..00000000 --- a/src/components/editor/PageEditor/types.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { ContentError } from "../TiptapEditor/useContentSanitizer"; -import type { Page } from "@/types/page"; - -/** - * Re-export ContentError for convenience - */ -export type { ContentError }; - -/** - * Page data for the editor - */ -export interface PageEditorData { - title: string; - content: string; - sourceUrl?: string; - currentPageId: string | null; - lastSaved: number | null; - isInitialized: boolean; - originalTitle: string; -} - -/** - * Title validation state - */ -export interface TitleValidationState { - duplicatePage: Page | null; - isValidating: boolean; - isEmpty: boolean; - errorMessage: string | null; - shouldBlockSave: boolean; -} - -/** - * Wiki generator status - */ -export type WikiGeneratorStatus = "idle" | "generating" | "completed" | "error" | "cancelled"; - -/** - * Props for PageEditorHeader component - */ -export interface PageEditorHeaderProps { - title: string; - onTitleChange: (title: string) => void; - lastSaved: number | null; - sourceUrl?: string; - isWikiGenerating: boolean; - isValidating: boolean; - duplicatePage: Page | null; - onBack: () => void; - onDelete: () => void; - onDownloadMarkdown: () => void; - onCopyMarkdown: () => void; - onWebClipper: () => void; - onGenerateWiki: () => void; - onCancelWiki: () => void; - wikiError: string | null; -} - -/** - * Props for PageEditorAlerts component - */ -export interface PageEditorAlertsProps { - duplicatePage: Page | null; - title: string; - contentError: ContentError | null; - onDismissContentError: () => void; - onNavigateToDuplicate: (pageId: string) => void; -} - -/** - * Props for PageEditorDialogs component - */ -export interface PageEditorDialogsProps { - // Delete confirmation dialog - deleteConfirmOpen: boolean; - deleteReason: string; - onDeleteReasonChange: (reason: string) => void; - onConfirmDelete: () => void; - onCancelDelete: () => void; - - // Wiki generator error dialog - wikiError: string | null; - onDismissWikiError: () => void; - - // Web clipper dialog - webClipperOpen: boolean; - onWebClipperOpenChange: (open: boolean) => void; - onWebClipperImport: ( - title: string, - content: string, - sourceUrl: string, - thumbnailUrl?: string | null, - ) => void; -} diff --git a/src/components/editor/PageEditor/useEditorAutoSave.test.ts b/src/components/editor/PageEditor/useEditorAutoSave.test.ts deleted file mode 100644 index 738b5579..00000000 --- a/src/components/editor/PageEditor/useEditorAutoSave.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import React from "react"; -import { renderHook, act } from "@testing-library/react"; -import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query"; -import { useEditorAutoSave } from "./useEditorAutoSave"; -import { extractWikiLinksFromContent } from "@/lib/wikiLinkUtils"; -import { createWikiLinkContent } from "@/test/testDatabase"; -import { pageKeys } from "@/hooks/usePageQueries"; - -describe("useEditorAutoSave", () => { - const pageId = "page-1"; - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe("syncWikiLinks 連携", () => { - it("保存成功後に syncWikiLinks が1回呼ばれ、引数が [pageId, extractWikiLinksFromContent(content)] と一致する", async () => { - const contentWithLinks = createWikiLinkContent(["Page A", "Page B"]); - const syncWikiLinks = vi.fn().mockResolvedValue(undefined); - const onSave = vi.fn().mockResolvedValue(true); - const onSaveContentOnly = vi.fn().mockResolvedValue(true); - - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 0, - onSave, - onSaveContentOnly, - syncWikiLinks, - }), - ); - - act(() => { - result.current.saveChanges("My Title", contentWithLinks); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(onSave).toHaveBeenCalledTimes(1); - expect(onSave).toHaveBeenCalledWith({ - title: "My Title", - content: contentWithLinks, - }); - - const expectedWikiLinks = extractWikiLinksFromContent(contentWithLinks); - expect(expectedWikiLinks.length).toBeGreaterThan(0); - expect(syncWikiLinks).toHaveBeenCalledTimes(1); - expect(syncWikiLinks).toHaveBeenCalledWith(pageId, expectedWikiLinks); - }); - - it("WikiLink が含まれない content でも syncWikiLinks は空配列で呼ばれる(stale cleanup のため、issue #725 Phase 1 レビュー指摘)", async () => { - const plainContent = JSON.stringify({ - type: "doc", - content: [{ type: "paragraph", content: [{ type: "text", text: "No links" }] }], - }); - const syncWikiLinks = vi.fn().mockResolvedValue(undefined); - const onSave = vi.fn().mockResolvedValue(true); - const onSaveContentOnly = vi.fn().mockResolvedValue(true); - - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 0, - onSave, - onSaveContentOnly, - syncWikiLinks, - }), - ); - - act(() => { - result.current.saveChanges("Title", plainContent); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(onSave).toHaveBeenCalledTimes(1); - expect(extractWikiLinksFromContent(plainContent)).toHaveLength(0); - // issue #725 Phase 1 レビュー指摘: Mark が無くても同期呼び出しは走らせて - // サーバ側の stale エッジを空配列 delta で削除させる。 - // Always call sync with an empty array so stale edges cleared on save. - expect(syncWikiLinks).toHaveBeenCalledTimes(1); - expect(syncWikiLinks).toHaveBeenCalledWith(pageId, []); - }); - - it("tag marks あり + syncTags 指定時は syncTags が呼ばれ、重複タグは getUniqueTagNames で畳まれる (issue #725 Phase 1)", async () => { - // 同じタグ名 `#tech` を 2 回出しても `getUniqueTagNames` で 1 件に畳まれて - // `syncTags` が呼ばれることを検証(CodeRabbit のレビュー指摘)。 - // Duplicate `#tech` marks must collapse via `getUniqueTagNames` so - // `syncTags` is called once with a single entry (CodeRabbit review). - const tagContent = JSON.stringify({ - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - marks: [{ type: "tag", attrs: { name: "tech", exists: false, referenced: false } }], - text: "#tech", - }, - { type: "text", text: " " }, - { - type: "text", - marks: [{ type: "tag", attrs: { name: "tech", exists: false, referenced: false } }], - text: "#tech", - }, - ], - }, - ], - }); - const syncWikiLinks = vi.fn().mockResolvedValue(undefined); - const syncTags = vi.fn().mockResolvedValue(undefined); - const onSave = vi.fn().mockResolvedValue(true); - const onSaveContentOnly = vi.fn().mockResolvedValue(true); - - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 0, - onSave, - onSaveContentOnly, - syncWikiLinks, - syncTags, - }), - ); - - act(() => { - result.current.saveChanges("Title", tagContent); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - // dedupe 後の 1 件だけが渡ることを確認。 - // Only the deduped single entry should reach `syncTags`. - expect(syncTags).toHaveBeenCalledTimes(1); - expect(syncTags).toHaveBeenCalledWith(pageId, [{ name: "tech" }]); - // Wiki マークは無いが、stale cleanup のため空配列で 1 回呼ぶ契約。 - // No wiki marks, but we still call syncWikiLinks with `[]` so stale - // wiki edges get delta-deleted (issue #725 Phase 1 review feedback). - expect(syncWikiLinks).toHaveBeenCalledTimes(1); - expect(syncWikiLinks).toHaveBeenCalledWith(pageId, []); - }); - - it("syncTags 未指定ならタグがあっても呼ばれない (backward compat)", async () => { - const tagContent = JSON.stringify({ - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - marks: [{ type: "tag", attrs: { name: "tech", exists: false, referenced: false } }], - text: "#tech", - }, - ], - }, - ], - }); - const syncWikiLinks = vi.fn().mockResolvedValue(undefined); - const onSave = vi.fn().mockResolvedValue(true); - const onSaveContentOnly = vi.fn().mockResolvedValue(true); - - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 0, - onSave, - onSaveContentOnly, - syncWikiLinks, - }), - ); - - act(() => { - result.current.saveChanges("Title", tagContent); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - // syncTags prop が無ければタグ同期はスキップ。一方 syncWikiLinks は - // 空配列(wiki マーク無し)でも呼んで stale cleanup を走らせる。 - // Without a `syncTags` prop, tag sync is skipped; meanwhile - // `syncWikiLinks` is still called (with an empty array when no wiki - // marks exist) so stale wiki edges get delta-cleaned. - expect(syncWikiLinks).toHaveBeenCalledTimes(1); - expect(syncWikiLinks).toHaveBeenCalledWith(pageId, []); - }); - - it("保存がスキップ(didSave false)でも syncWikiLinks は呼ばれる", async () => { - const contentWithLinks = createWikiLinkContent(["Page A"]); - const syncWikiLinks = vi.fn().mockResolvedValue(undefined); - const onSave = vi.fn().mockResolvedValue(false); // skipped - - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 0, - onSave, - onSaveContentOnly: vi.fn().mockResolvedValue(false), - syncWikiLinks, - }), - ); - - act(() => { - result.current.saveChanges("Title", contentWithLinks); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - const expectedWikiLinks = extractWikiLinksFromContent(contentWithLinks); - expect(syncWikiLinks).toHaveBeenCalledTimes(1); - expect(syncWikiLinks).toHaveBeenCalledWith(pageId, expectedWikiLinks); - }); - - it("保存がスキップ(didSave false)でも syncTags は呼ばれる (issue #725 Phase 1)", async () => { - // Mirror of the WikiLink didSave=false test, covering the tag-sync - // contract: save may fail but the link-graph sync still runs so the - // server's stale edges get delta-updated. - // WikiLink 版と対になる契約テスト。保存が skipped でも tag 同期は - // 走らせる。 - const tagContent = JSON.stringify({ - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - marks: [{ type: "tag", attrs: { name: "tech", exists: false, referenced: false } }], - text: "#tech", - }, - ], - }, - ], - }); - const syncWikiLinks = vi.fn().mockResolvedValue(undefined); - const syncTags = vi.fn().mockResolvedValue(undefined); - const onSave = vi.fn().mockResolvedValue(false); // skipped - const onSaveContentOnly = vi.fn().mockResolvedValue(false); - - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 0, - onSave, - onSaveContentOnly, - syncWikiLinks, - syncTags, - }), - ); - - act(() => { - result.current.saveChanges("Title", tagContent); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(syncTags).toHaveBeenCalledTimes(1); - expect(syncTags).toHaveBeenCalledWith(pageId, [{ name: "tech" }]); - // 並列で WikiLink 側も空配列で 1 回呼ばれる(stale cleanup 契約)。 - // `syncWikiLinks` is still called once with an empty array to clear - // any stale wiki edges. - expect(syncWikiLinks).toHaveBeenCalledTimes(1); - expect(syncWikiLinks).toHaveBeenCalledWith(pageId, []); - }); - }); - - describe("onSaveSuccess", () => { - it("保存成功時に onSaveSuccess が1回呼ばれる", async () => { - const onSaveSuccess = vi.fn(); - const onSave = vi.fn().mockResolvedValue(true); - - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 0, - onSave, - onSaveContentOnly: vi.fn().mockResolvedValue(true), - syncWikiLinks: vi.fn().mockResolvedValue(undefined), - onSaveSuccess, - }), - ); - - act(() => { - result.current.saveChanges("Title", "{}"); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(onSaveSuccess).toHaveBeenCalledTimes(1); - }); - - it("保存が false のときは onSaveSuccess は呼ばれない", async () => { - const onSaveSuccess = vi.fn(); - const onSave = vi.fn().mockResolvedValue(false); - - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 0, - onSave, - onSaveContentOnly: vi.fn().mockResolvedValue(false), - syncWikiLinks: vi.fn().mockResolvedValue(undefined), - onSaveSuccess, - }), - ); - - act(() => { - result.current.saveChanges("Title", "{}"); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(onSaveSuccess).not.toHaveBeenCalled(); - }); - }); - - describe("cancelPendingSave (issue #768)", () => { - it("デバウンス中に cancelPendingSave を呼ぶと onSave / syncWikiLinks がもう走らない / pending debounce is cleared so onSave never fires", async () => { - const syncWikiLinks = vi.fn().mockResolvedValue(undefined); - const onSave = vi.fn().mockResolvedValue(true); - const onSaveContentOnly = vi.fn().mockResolvedValue(true); - - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 500, - onSave, - onSaveContentOnly, - syncWikiLinks, - }), - ); - - act(() => { - result.current.saveChanges("My Title", "{}"); - }); - - act(() => { - result.current.cancelPendingSave(); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(onSave).not.toHaveBeenCalled(); - expect(onSaveContentOnly).not.toHaveBeenCalled(); - expect(syncWikiLinks).not.toHaveBeenCalled(); - }); - - it("cancelPendingSave 後にアンマウントしても unmount flush で onSave / syncWikiLinks が呼ばれない / unmount flush is suppressed after cancelPendingSave", async () => { - const syncWikiLinks = vi.fn().mockResolvedValue(undefined); - const onSave = vi.fn().mockResolvedValue(true); - const onSaveContentOnly = vi.fn().mockResolvedValue(true); - - const { result, unmount } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 500, - onSave, - onSaveContentOnly, - syncWikiLinks, - }), - ); - - act(() => { - result.current.saveChanges("My Title", "{}"); - }); - - act(() => { - result.current.cancelPendingSave(); - }); - - unmount(); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(onSave).not.toHaveBeenCalled(); - expect(onSaveContentOnly).not.toHaveBeenCalled(); - expect(syncWikiLinks).not.toHaveBeenCalled(); - }); - - it("shouldBlockSave で content-only 経路が保留中でも cancelPendingSave で抑止される / content-only debounce branch is also cleared (CodeRabbit feedback)", async () => { - // CodeRabbit のレビュー指摘: 既存のキャンセル系テストは shouldBlockSave=false の - // フル保存経路しか走らせていなかったため、`onSaveContentOnly` を expect しても - // vacuous(そもそも呼ばれない)。`shouldBlockSave=true` を立てて content-only - // 経路の `pendingRef` も同じ `cancelPendingSave` で確実に消えることを検証する。 - // - // CodeRabbit: the prior cancellation tests only exercised the full-save - // branch, so asserting `onSaveContentOnly` was vacuous. This case enables - // `shouldBlockSave` to schedule the content-only debounce path and asserts - // `cancelPendingSave` clears that pendingRef too. - const syncWikiLinks = vi.fn().mockResolvedValue(undefined); - const onSave = vi.fn().mockResolvedValue(true); - const onSaveContentOnly = vi.fn().mockResolvedValue(true); - - const { result, unmount } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 500, - shouldBlockSave: true, - onSave, - onSaveContentOnly, - syncWikiLinks, - }), - ); - - act(() => { - result.current.saveChanges("My Title", "{}"); - }); - - act(() => { - result.current.cancelPendingSave(); - }); - - // 単に時間を進めても content-only 経路の onSaveContentOnly は呼ばれない。 - // Advancing timers must not flush the content-only branch. - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(onSaveContentOnly).not.toHaveBeenCalled(); - expect(onSave).not.toHaveBeenCalled(); - expect(syncWikiLinks).not.toHaveBeenCalled(); - - // unmount flush も走らない(pendingRef が null のため)。 - // The unmount flush must also stay silent (pendingRef is null). - unmount(); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(onSaveContentOnly).not.toHaveBeenCalled(); - expect(onSave).not.toHaveBeenCalled(); - expect(syncWikiLinks).not.toHaveBeenCalled(); - }); - - it("保留中の保存が無いときに cancelPendingSave を呼んでも安全(no-op) / cancelPendingSave is safe to call when nothing is pending", () => { - const onSave = vi.fn().mockResolvedValue(true); - const { result } = renderHook(() => - useEditorAutoSave({ - pageId, - debounceMs: 500, - onSave, - onSaveContentOnly: vi.fn().mockResolvedValue(true), - syncWikiLinks: vi.fn().mockResolvedValue(undefined), - }), - ); - - expect(() => { - act(() => { - result.current.cancelPendingSave(); - }); - }).not.toThrow(); - expect(onSave).not.toHaveBeenCalled(); - }); - }); - - describe("保存成功時の linkedPages 無効化(3.5)", () => { - it("onSaveSuccess で queryClient.invalidateQueries が linkedPages の queryKey で1回呼ばれる", async () => { - const queryClient = new QueryClient(); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - const userId = "user-1"; - const currentPageId = "page-1"; - const linkedPagesKey = [...pageKeys.all, "linkedPages", userId, currentPageId]; - - const useAutoSaveWithInvalidate = () => { - const client = useQueryClient(); - return useEditorAutoSave({ - pageId: currentPageId, - debounceMs: 0, - onSave: vi.fn().mockResolvedValue(true), - onSaveContentOnly: vi.fn().mockResolvedValue(true), - syncWikiLinks: vi.fn().mockResolvedValue(undefined), - onSaveSuccess: () => { - client.invalidateQueries({ queryKey: linkedPagesKey }); - }, - }); - }; - - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); - - const { result } = renderHook(useAutoSaveWithInvalidate, { wrapper }); - - act(() => { - result.current.saveChanges("Title", "{}"); - }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(invalidateSpy).toHaveBeenCalledTimes(1); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: linkedPagesKey }); - }); - }); -}); diff --git a/src/components/editor/PageEditor/useEditorAutoSave.ts b/src/components/editor/PageEditor/useEditorAutoSave.ts deleted file mode 100644 index b463af60..00000000 --- a/src/components/editor/PageEditor/useEditorAutoSave.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { useCallback, useRef, useEffect, useState } from "react"; -import { extractWikiLinksFromContent } from "@/lib/wikiLinkUtils"; -import { extractTagsFromContent, getUniqueTagNames } from "@/lib/tagUtils"; - -interface UseEditorAutoSaveOptions { - pageId: string | null; - debounceMs?: number; - shouldBlockSave?: boolean; - onSave: (updates: { title?: string; content: string }) => boolean | Promise; - onSaveContentOnly: (content: string) => boolean | Promise; - syncWikiLinks: (pageId: string, wikiLinks: Array<{ title: string }>) => Promise; - /** - * オプショナル: タグ (`#name`) マークを `link_type='tag'` バケットに同期する - * コールバック (issue #725 Phase 1)。未指定ならタグ同期はスキップする(旧コード - * パス互換)。呼び出し側は `useSyncWikiLinks().syncTags` を渡す想定。 - * - * Optional callback to sync tag marks into the `link_type='tag'` bucket - * (issue #725 Phase 1). Omit to skip tag sync (legacy behavior). Callers - * typically pass `useSyncWikiLinks().syncTags`. - */ - syncTags?: (pageId: string, tags: Array<{ name: string }>) => Promise; - onSaveSuccess?: () => void; -} - -interface UseEditorAutoSaveReturn { - saveChanges: (title: string, content: string, forceBlockTitle?: boolean) => void; - /** - * 保留中の debounce 保存とアンマウント時 flush をキャンセルする。 - * `usePageDeletion` がページ削除を発火する直前に呼ぶことで、 - * unmount flush の `updatePage` が論理削除を上書きして「無題のページ」が - * 復活するレース (issue #768) を防ぐ。 - * - * Cancel any pending debounced save and suppress the unmount flush. - * Called by `usePageDeletion` immediately before firing - * `deletePageMutation.mutate`, so the unmount flush's `updatePage` cannot - * race the soft delete and resurrect the row (issue #768). - */ - cancelPendingSave: () => void; - lastSaved: number | null; - isSaving: boolean; - isSyncingLinks: boolean; -} - -/** - * Hook to handle auto-save with debouncing and WikiLink synchronization - */ -export function useEditorAutoSave({ - pageId, - debounceMs = 500, - shouldBlockSave = false, - onSave, - onSaveContentOnly, - syncWikiLinks, - syncTags, - onSaveSuccess, -}: UseEditorAutoSaveOptions): UseEditorAutoSaveReturn { - const saveTimeoutRef = useRef(null); - const pendingRef = useRef<{ - title: string; - content: string; - contentOnly: boolean; - } | null>(null); - const [lastSaved, setLastSaved] = useState(null); - const [isSaving, setIsSaving] = useState(false); - const [isSyncingLinks, setIsSyncingLinks] = useState(false); - - // アンマウント時に未実行の保存があれば即実行(/home 戻りでタイトルが消えるのを防ぐ) - useEffect(() => { - return () => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - saveTimeoutRef.current = null; - const pending = pendingRef.current; - if (pending && pageId) { - const syncGraphFromContent = async (contentToSync: string) => { - // unmount フラッシュでも `saveChanges` と同じ契約で同期する。 - // 空配列で呼ぶことで「最後の Mark を消して /home に戻った」ケースの - // stale cleanup がサーバ側まで届く。失敗は下の catch で握りつぶす。 - // Use the same contract as `saveChanges` here: call each bucket - // with an empty array when no marks exist so removing the last - // mark and navigating away still clears stale edges. Errors bubble - // to the best-effort try/catch below. - const wikiLinks = extractWikiLinksFromContent(contentToSync); - await syncWikiLinks(pageId, wikiLinks); - if (syncTags) { - const tags = extractTagsFromContent(contentToSync); - const uniqueNames = getUniqueTagNames(tags); - await syncTags( - pageId, - uniqueNames.map((name) => ({ name })), - ); - } - }; - const saveAction = pending.contentOnly - ? () => onSaveContentOnly(pending.content) - : () => onSave({ title: pending.title, content: pending.content }); - void (async () => { - try { - await saveAction(); - } catch (e) { - console.error("Auto-save flush on unmount failed:", e); - } - try { - await syncGraphFromContent(pending.content); - } catch { - // Ignore sync errors during unmount flush - } - })(); - } - } - }; - }, [pageId, onSave, onSaveContentOnly, syncWikiLinks, syncTags]); - - const saveChanges = useCallback( - (newTitle: string, newContent: string, forceBlockTitle = false) => { - if (!pageId) return; - - /** - * Extract WikiLinks + tags from the editor content and sync each to its - * dedicated `link_type` bucket. Tag sync is only wired when `syncTags` - * is provided (issue #725 Phase 1). **Both buckets are synced on every - * save**, including with empty arrays — `syncLinksWithRepo` relies on - * the empty-input delta to clear stale links/ghosts, so skipping the - * call when the editor has no marks of that type would leave orphaned - * edges in the DB after the user removes the last mark. - * - * WikiLink とタグを Tiptap コンテンツから抽出し、それぞれ独立の - * `link_type` バケットへ同期する(issue #725 Phase 1)。**Mark が - * 空でも毎回呼ぶ**: `syncLinksWithRepo` は空配列を delta として受け - * 取って既存エッジを削除する設計のため、ガードで弾くと「最後の Mark - * を消しても DB に残る」挙動になる。`syncTags` が渡らない旧呼び出し - * 元ではタグ同期のみスキップする。 - */ - const syncGraphFromContent = async (contentToSync: string) => { - const wikiLinks = extractWikiLinksFromContent(contentToSync); - await syncWikiLinks(pageId, wikiLinks); - if (syncTags) { - const tags = extractTagsFromContent(contentToSync); - const uniqueNames = getUniqueTagNames(tags); - await syncTags( - pageId, - uniqueNames.map((name) => ({ name })), - ); - } - }; - - const runSave = async (saveAction: () => boolean | Promise) => { - setIsSaving(true); - setIsSyncingLinks(true); - try { - const didSave = await saveAction(); - try { - await syncGraphFromContent(newContent); - } finally { - setIsSyncingLinks(false); - } - if (didSave) { - setLastSaved(Date.now()); - onSaveSuccess?.(); - } - } catch (error) { - console.error("Auto-save failed:", error); - setIsSyncingLinks(false); - } finally { - setIsSaving(false); - } - }; - - // タイトル重複時は保存をブロック - if (forceBlockTitle || shouldBlockSave) { - // コンテンツのみ保存(タイトルは元のまま) - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - pendingRef.current = { title: newTitle, content: newContent, contentOnly: true }; - saveTimeoutRef.current = setTimeout(() => { - saveTimeoutRef.current = null; - pendingRef.current = null; - void runSave(() => onSaveContentOnly(newContent)); - }, debounceMs); - return; - } - - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - - pendingRef.current = { title: newTitle, content: newContent, contentOnly: false }; - saveTimeoutRef.current = setTimeout(() => { - saveTimeoutRef.current = null; - pendingRef.current = null; - void runSave(() => onSave({ title: newTitle, content: newContent })); - }, debounceMs); - }, - [ - pageId, - debounceMs, - shouldBlockSave, - onSave, - onSaveContentOnly, - syncWikiLinks, - syncTags, - onSaveSuccess, - ], - ); - - const cancelPendingSave = useCallback(() => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - saveTimeoutRef.current = null; - } - pendingRef.current = null; - }, []); - - return { - saveChanges, - cancelPendingSave, - lastSaved, - isSaving, - isSyncingLinks, - }; -} diff --git a/src/components/editor/PageEditor/usePageDeletion.test.ts b/src/components/editor/PageEditor/usePageDeletion.test.ts deleted file mode 100644 index 6d3165d9..00000000 --- a/src/components/editor/PageEditor/usePageDeletion.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { usePageDeletion } from "./usePageDeletion"; - -/** - * `usePageDeletion` の振る舞い、特に重複タイトル時の「開く」ボタンハンドラ - * (`handleOpenDuplicatePage`) をカバーするテスト。 - * - * Tests for `usePageDeletion`, focused on the "Open" button handler - * (`handleOpenDuplicatePage`) used by the duplicate-title warning. - */ - -const { mockNavigate, mockMutate, mockToast, mockCancelPendingSave } = vi.hoisted(() => ({ - mockNavigate: vi.fn(), - mockMutate: vi.fn(), - mockToast: vi.fn(), - mockCancelPendingSave: vi.fn(), -})); - -vi.mock("react-router-dom", () => ({ - useNavigate: () => mockNavigate, -})); - -vi.mock("@/hooks/usePageQueries", () => ({ - useDeletePage: () => ({ mutate: mockMutate }), -})); - -vi.mock("@zedi/ui", () => ({ - useToast: () => ({ toast: mockToast }), -})); - -// 空ではないコンテンツを表す JSON / JSON representing non-empty Tiptap content. -const NON_EMPTY_CONTENT = JSON.stringify({ - type: "doc", - content: [{ type: "paragraph", content: [{ type: "text", text: "hello" }] }], -}); - -// 空の Tiptap ドキュメント / Empty Tiptap document. -const EMPTY_CONTENT = JSON.stringify({ - type: "doc", - content: [{ type: "paragraph" }], -}); - -describe("usePageDeletion.handleOpenDuplicatePage", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("現在のページが未作成なら削除せず遷移する / navigates without deleting when currentPageId is null", () => { - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: null, - title: "foo", - content: NON_EMPTY_CONTENT, - shouldBlockSave: true, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleOpenDuplicatePage("target-id")); - - expect(mockMutate).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith("/pages/target-id"); - expect(result.current.deleteConfirmOpen).toBe(false); - }); - - it("コンテンツが空なら即削除して遷移する / deletes immediately and navigates when content is empty", () => { - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "dup-id", - title: "foo", - content: EMPTY_CONTENT, - shouldBlockSave: true, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleOpenDuplicatePage("target-id")); - - expect(mockMutate).toHaveBeenCalledWith("dup-id"); - expect(mockNavigate).toHaveBeenCalledWith("/pages/target-id"); - expect(mockToast).toHaveBeenCalledWith({ - title: "重複するタイトルのため、ページを削除しました", - }); - expect(result.current.deleteConfirmOpen).toBe(false); - }); - - it("コンテンツがあれば確認ダイアログを開き、削除・遷移はまだ行わない / opens confirm dialog without deleting or navigating when content exists", () => { - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "dup-id", - title: "foo", - content: NON_EMPTY_CONTENT, - shouldBlockSave: true, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleOpenDuplicatePage("target-id")); - - expect(mockMutate).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - expect(result.current.deleteConfirmOpen).toBe(true); - expect(result.current.deleteReason).toBe("重複するタイトルのページ"); - }); - - it("確認後は削除して既存ページへ遷移する / after confirm, deletes and navigates to the target page", () => { - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "dup-id", - title: "foo", - content: NON_EMPTY_CONTENT, - shouldBlockSave: true, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleOpenDuplicatePage("target-id")); - act(() => result.current.handleConfirmDelete()); - - expect(mockMutate).toHaveBeenCalledWith("dup-id"); - expect(mockToast).toHaveBeenCalledWith({ - title: "重複するタイトルのページを削除しました", - }); - expect(mockNavigate).toHaveBeenCalledWith("/pages/target-id"); - expect(result.current.deleteConfirmOpen).toBe(false); - }); - - it("キャンセルすると削除も遷移も行わない / cancel leaves page intact and does not navigate", () => { - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "dup-id", - title: "foo", - content: NON_EMPTY_CONTENT, - shouldBlockSave: true, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleOpenDuplicatePage("target-id")); - act(() => result.current.handleCancelDelete()); - - expect(mockMutate).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - expect(result.current.deleteConfirmOpen).toBe(false); - }); - - it("キャンセル後に handleBack を使うと /notes/me へ戻る / pendingNavTarget resets so handleBack goes back to /notes/me", () => { - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "dup-id", - title: "foo", - content: NON_EMPTY_CONTENT, - shouldBlockSave: true, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleOpenDuplicatePage("target-id")); - act(() => result.current.handleCancelDelete()); - act(() => result.current.handleBack()); - // handleBack は hasContent のため確認ダイアログを開くだけ - act(() => result.current.handleConfirmDelete()); - - // 最終 navigate は /notes/me であるべき - expect(mockNavigate).toHaveBeenLastCalledWith("/notes/me"); - }); -}); - -/** - * Issue #768: 削除前に保留中の autosave をキャンセルすることで、 - * unmount flush の `updatePage` が論理削除を上書きして「無題のページ」が - * 復活するレースを防ぐ。各削除パスで `cancelPendingSave` が - * `deletePageMutation.mutate` よりも先に呼ばれることを順序検証する。 - * - * Issue #768: cancelling the pending autosave before deletion prevents the - * unmount flush's `updatePage` from racing the soft delete and resurrecting - * an "untitled page" row. These tests assert that for each deletion path - * `cancelPendingSave` runs *before* `deletePageMutation.mutate`. - */ -describe("usePageDeletion - cancelPendingSave 順序 (issue #768)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("handleOpenDuplicatePage (空コンテンツ) は mutate より先に cancelPendingSave を呼ぶ", () => { - const callOrder: string[] = []; - mockCancelPendingSave.mockImplementation(() => { - callOrder.push("cancel"); - }); - mockMutate.mockImplementation(() => { - callOrder.push("mutate"); - }); - - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "dup-id", - title: "foo", - content: EMPTY_CONTENT, - shouldBlockSave: true, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleOpenDuplicatePage("target-id")); - - expect(callOrder).toEqual(["cancel", "mutate"]); - }); - - it("handleBack (タイトル空・コンテンツ無し) は mutate より先に cancelPendingSave を呼ぶ", () => { - const callOrder: string[] = []; - mockCancelPendingSave.mockImplementation(() => { - callOrder.push("cancel"); - }); - mockMutate.mockImplementation(() => { - callOrder.push("mutate"); - }); - - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "page-1", - title: "", - content: EMPTY_CONTENT, - shouldBlockSave: false, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleBack()); - - expect(callOrder).toEqual(["cancel", "mutate"]); - expect(mockToast).toHaveBeenCalledWith({ - title: "タイトルが未入力のため、ページを削除しました", - }); - }); - - it("handleConfirmDelete は mutate より先に cancelPendingSave を呼ぶ", () => { - const callOrder: string[] = []; - mockCancelPendingSave.mockImplementation(() => { - callOrder.push("cancel"); - }); - mockMutate.mockImplementation(() => { - callOrder.push("mutate"); - }); - - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "page-1", - title: "", - content: NON_EMPTY_CONTENT, - shouldBlockSave: false, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - // 確認ダイアログを開いてから confirm - act(() => result.current.handleBack()); - act(() => result.current.handleConfirmDelete()); - - expect(callOrder).toEqual(["cancel", "mutate"]); - }); - - it("handleDelete (明示削除) は mutate → onSuccess の順で cancelPendingSave を呼ぶ (Codex P2: 失敗時の保留保存を保護)", () => { - // Codex P2 レビューの観点: `handleDelete` は他のハンドラと違い `onError` - // でエディタに残るため、mutate 前に cancelPendingSave すると失敗時に - // 保留中の編集が落ちる。`onSuccess` の中でだけキャンセルする実装を - // 順序検証する。 - // - // Codex P2: unlike other deletion paths, `handleDelete`'s `onError` - // keeps the user on the editor, so cancelling before the mutation - // would silently drop their queued autosave. Verify cancellation - // only happens inside `onSuccess`. - const callOrder: string[] = []; - mockCancelPendingSave.mockImplementation(() => { - callOrder.push("cancel"); - }); - mockMutate.mockImplementation((_id: string, opts?: { onSuccess?: () => void }) => { - callOrder.push("mutate"); - opts?.onSuccess?.(); - }); - - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "page-1", - title: "foo", - content: NON_EMPTY_CONTENT, - shouldBlockSave: false, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleDelete()); - - expect(callOrder).toEqual(["mutate", "cancel"]); - expect(mockNavigate).toHaveBeenCalledWith("/notes/me"); - }); - - it("handleDelete: 削除失敗時は cancelPendingSave を呼ばず保留保存を保持する (Codex P2)", () => { - // 失敗ブランチではエディタに残るので、保留中の autosave は触らない。 - // On the failure branch the user stays on the editor, so the pending - // autosave must remain intact. - mockMutate.mockImplementation((_id: string, opts?: { onError?: (e: Error) => void }) => { - opts?.onError?.(new Error("network down")); - }); - - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "page-1", - title: "foo", - content: NON_EMPTY_CONTENT, - shouldBlockSave: false, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleDelete()); - - expect(mockCancelPendingSave).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - expect(mockToast).toHaveBeenCalledWith({ - title: "削除に失敗しました", - variant: "destructive", - }); - }); - - it("削除に至らないキャンセルパス (handleCancelDelete) では cancelPendingSave を呼ばない", () => { - const { result } = renderHook(() => - usePageDeletion({ - currentPageId: "page-1", - title: "", - content: NON_EMPTY_CONTENT, - shouldBlockSave: false, - cancelPendingSave: mockCancelPendingSave, - }), - ); - - act(() => result.current.handleBack()); // opens dialog only - act(() => result.current.handleCancelDelete()); - - expect(mockCancelPendingSave).not.toHaveBeenCalled(); - expect(mockMutate).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/editor/PageEditor/usePageDeletion.ts b/src/components/editor/PageEditor/usePageDeletion.ts deleted file mode 100644 index 26889fa2..00000000 --- a/src/components/editor/PageEditor/usePageDeletion.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { useState, useCallback } from "react"; -import { useNavigate } from "react-router-dom"; -import { useDeletePage } from "@/hooks/usePageQueries"; -import { useToast } from "@zedi/ui"; -import { isContentNotEmpty } from "@/lib/contentUtils"; - -interface UsePageDeletionOptions { - currentPageId: string | null; - title: string; - content: string; - shouldBlockSave: boolean; - /** - * 削除発火直前に呼ぶ、保留中 autosave のキャンセル関数。 - * `useEditorAutoSave.cancelPendingSave` を渡す想定。issue #768 のレース - * (unmount flush の `updatePage` が論理削除を上書きして「無題のページ」 - * が復活する)を防ぐために必須。 - * - * Cancel any pending autosave before firing a delete. Pass - * `useEditorAutoSave.cancelPendingSave`. Required to prevent the issue - * #768 race where the unmount flush's `updatePage` overwrites the soft - * delete and resurrects an "untitled page" row. - */ - cancelPendingSave: () => void; -} - -interface UsePageDeletionReturn { - deleteConfirmOpen: boolean; - deleteReason: string; - setDeleteConfirmOpen: (open: boolean) => void; - handleDelete: () => void; - handleBack: () => void; - handleConfirmDelete: () => void; - handleCancelDelete: () => void; - /** - * 重複警告の「開く」ボタン押下時のハンドラ。 - * 現在編集中のページ(重複側)を削除してから既存ページへ遷移する。 - * コンテンツがある場合は確認ダイアログを表示する。 - * - * Handler for the "Open" button on the duplicate-title warning. - * Deletes the currently editing (duplicate) page before navigating to the existing one. - * Shows a confirmation dialog when the page has content. - */ - handleOpenDuplicatePage: (targetPageId: string) => void; -} - -/** - * ページ削除フロー(明示削除・戻る・タイトル空・タイトル重複)の状態と - * ハンドラを束ねるフック。確認ダイアログの開閉、削除理由の保持、削除後の - * 遷移先決定、トースト表示までを管理する。 - * - * issue #768: 削除を発火する前に呼び出し側 (`useEditorAutoSave`) の - * `cancelPendingSave` を必ず実行し、保留中の autosave debounce と unmount - * flush を抑止する。これにより `updatePage` が論理削除を上書きして - * 「無題のページ」が `/notes/me` に復活するレースを防ぐ。`handleDelete` だけは - * `onError` でユーザーがエディタに残るため、保留中の編集を落とさないよう - * `onSuccess` 内(navigate 直前)でのみキャンセルする。 - * - * Hook bundling page deletion flows (explicit delete, back navigation, - * empty-title cleanup, duplicate-title cleanup): manages confirmation - * dialog state, the deletion reason, post-delete navigation target, and - * toasts. - * - * issue #768: each deletion path invokes the caller-supplied - * `cancelPendingSave` (from `useEditorAutoSave`) to clear pending autosave - * debounces and suppress the unmount flush, preventing the race where - * `updatePage` overwrites the soft delete and an "untitled" row reappears - * on `/notes/me`. `handleDelete` is the exception: its `onError` keeps the user - * on the editor, so cancellation is deferred to `onSuccess` (just before - * `navigate`) to avoid silently dropping queued edits on a failed delete. - */ -export function usePageDeletion({ - currentPageId, - title, - content, - shouldBlockSave, - cancelPendingSave, -}: UsePageDeletionOptions): UsePageDeletionReturn { - const navigate = useNavigate(); - const { toast } = useToast(); - const deletePageMutation = useDeletePage(); - - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [deleteReason, setDeleteReason] = useState(""); - // 確認ダイアログ確定後の遷移先。デフォルトは /notes/me。 - // Navigation target to use after the confirmation dialog resolves. Defaults to /notes/me. - const [pendingNavTarget, setPendingNavTarget] = useState("/notes/me"); - - const handleDelete = useCallback(() => { - if (currentPageId) { - // issue #768 + Codex P2: 他のハンドラと違い、`handleDelete` の `onError` - // ではユーザーがエディタに残るため、削除失敗時に保留中の autosave を - // 落とすと最近の編集が失われる。そのため `cancelPendingSave` は削除が - // 成功して `/notes/me` に遷移する直前(`onSuccess` 内)でのみ呼ぶ。 - // - // issue #768 + Codex P2: unlike the other handlers, `handleDelete`'s - // `onError` keeps the user on the editor, so cancelling the pending - // autosave before the mutation would silently drop their queued edits - // on a failed delete. Cancel only inside `onSuccess`, just before - // navigating away (and the unmount flush). - deletePageMutation.mutate(currentPageId, { - onSuccess: () => { - cancelPendingSave(); - toast({ - title: "ページを削除しました", - }); - navigate("/notes/me"); - }, - onError: () => { - toast({ - title: "削除に失敗しました", - variant: "destructive", - }); - }, - }); - } - }, [currentPageId, deletePageMutation, navigate, toast, cancelPendingSave]); - - const handleBack = useCallback(() => { - const hasContent = isContentNotEmpty(content); - const isTitleEmptyOrUntitled = !title.trim(); - - // 削除が必要なケースを判定 - // 1. タイトル重複警告がある場合 - // 2. タイトルが空(無題)の場合 - const shouldDeleteForDuplicate = currentPageId && shouldBlockSave; - const shouldDeleteForEmptyTitle = currentPageId && isTitleEmptyOrUntitled; - - if (shouldDeleteForDuplicate || shouldDeleteForEmptyTitle) { - // コンテンツがある場合は確認ダイアログを表示 - if (hasContent) { - if (shouldDeleteForDuplicate) { - setDeleteReason("重複するタイトルのページ"); - } else { - setDeleteReason("タイトルが未入力のページ"); - } - // 戻る経由なので遷移先はホーム - setPendingNavTarget("/notes/me"); - setDeleteConfirmOpen(true); - return; - } - - // issue #768: 削除発火前に保留中の autosave をキャンセル。 - // issue #768: cancel any pending autosave before firing the delete. - cancelPendingSave(); - // コンテンツがない場合はそのまま削除 - deletePageMutation.mutate(currentPageId); - if (shouldDeleteForDuplicate) { - toast({ - title: "重複するタイトルのため、ページを削除しました", - }); - } else { - toast({ - title: "タイトルが未入力のため、ページを削除しました", - }); - } - } - navigate("/notes/me"); - }, [ - navigate, - currentPageId, - title, - content, - deletePageMutation, - shouldBlockSave, - toast, - cancelPendingSave, - ]); - - const handleConfirmDelete = useCallback(() => { - if (currentPageId) { - // issue #768: 削除発火前に保留中の autosave をキャンセル。 - // issue #768: cancel any pending autosave before firing the delete. - cancelPendingSave(); - deletePageMutation.mutate(currentPageId); - toast({ - title: `${deleteReason}を削除しました`, - }); - } - setDeleteConfirmOpen(false); - navigate(pendingNavTarget); - // 次回に備えてデフォルトに戻す / reset to default for next invocation - setPendingNavTarget("/notes/me"); - }, [ - currentPageId, - deletePageMutation, - deleteReason, - navigate, - pendingNavTarget, - toast, - cancelPendingSave, - ]); - - const handleCancelDelete = useCallback(() => { - setDeleteConfirmOpen(false); - setPendingNavTarget("/notes/me"); - }, []); - - const handleOpenDuplicatePage = useCallback( - (targetPageId: string) => { - const targetPath = `/pages/${targetPageId}`; - - // 現在のページがまだ作成されていない場合は削除不要でそのまま遷移 - // No current page persisted yet — just navigate. - if (!currentPageId) { - navigate(targetPath); - return; - } - - const hasContent = isContentNotEmpty(content); - - // コンテンツがある場合は確認ダイアログを表示 - // Ask for confirmation when the duplicate page has content. - if (hasContent) { - setDeleteReason("重複するタイトルのページ"); - setPendingNavTarget(targetPath); - setDeleteConfirmOpen(true); - return; - } - - // issue #768: 削除発火前に保留中の autosave をキャンセル。 - // issue #768: cancel any pending autosave before firing the delete. - cancelPendingSave(); - // コンテンツがない場合はそのまま削除して遷移 - // Otherwise delete immediately and navigate to the existing page. - deletePageMutation.mutate(currentPageId); - toast({ - title: "重複するタイトルのため、ページを削除しました", - }); - navigate(targetPath); - }, - [currentPageId, content, deletePageMutation, navigate, toast, cancelPendingSave], - ); - - return { - deleteConfirmOpen, - deleteReason, - setDeleteConfirmOpen, - handleDelete, - handleBack, - handleConfirmDelete, - handleCancelDelete, - handleOpenDuplicatePage, - }; -} diff --git a/src/components/editor/PageEditor/usePageEditor.ts b/src/components/editor/PageEditor/usePageEditor.ts deleted file mode 100644 index 25428573..00000000 --- a/src/components/editor/PageEditor/usePageEditor.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { usePageEditorStateAndSync } from "./usePageEditorStateAndSync"; -import { usePageEditorHandlers } from "./usePageEditorHandlers"; -import type { PageEditorLayoutProps } from "./PageEditorLayout"; -import type { WikiGeneratorStatus } from "./types"; -import type { ContentError } from "../TiptapEditor/useContentSanitizer"; - -function buildLayoutProps( - state: { - title: string; - content: string; - sourceUrl: string | undefined; - currentPageId: string | null; - pageId: string; - isNewPage: boolean; - displayLastSaved: number | null; - wikiStatus: WikiGeneratorStatus; - isWikiGenerating: boolean; - isSyncingLinks: boolean; - isLocalDocEnabled: boolean; - collaboration: ReturnType; - duplicatePage: ReturnType< - typeof import("@/hooks/useTitleValidation").useTitleValidation - >["duplicatePage"]; - errorMessage: string | null; - contentError: ContentError | null; - pendingInitialContent: string | null; - deleteConfirmOpen: boolean; - deleteReason: string; - wikiErrorMessage: string | null; - wikiContentForCollab: string | null; - }, - handlers: { - onBack: () => void; - onDelete: () => void; - onExportMarkdown: () => void; - onCopyMarkdown: () => void; - onGenerateWiki: () => void; - onOpenDuplicatePage: () => void; - onCancelWiki: () => void; - onContentChange: (content: string) => void; - onContentError: (error: ContentError | null) => void; - onTitleChange: (title: string) => void; - onPendingInitialContentClear: () => void; - onDeleteConfirmOpenChange: (open: boolean) => void; - onConfirmDelete: () => void; - onCancelDelete: () => void; - onResetWiki: () => void; - onGoToAISettings: () => void; - onWikiContentApplied: () => void; - }, -): PageEditorLayoutProps { - return { - ...state, - duplicatePage: state.duplicatePage ?? null, - ...handlers, - }; -} - -/** - * ページエディタのトップレベルフック。状態・ハンドラ・レイアウト props を統合する。 - * Top-level page editor hook combining state, handlers, and layout props. - */ -export function usePageEditor() { - const state = usePageEditorStateAndSync(); - const handlers = usePageEditorHandlers({ - title: state.title, - content: state.content, - enableAutoTitle: state.isNewPage, - setTitle: state.setTitle, - setContent: state.setContent, - setContentError: state.setContentError, - validateTitle: state.validateTitle, - saveChanges: state.saveChanges, - generateWiki: state.generateWiki, - resetWiki: state.resetWiki, - location: state.location, - }); - - const showLoading = - (!state.isNewPage && state.isLoading) || (state.isNewPage && !state.isInitialized); - - const layoutProps = buildLayoutProps( - { - title: state.title, - content: state.content, - sourceUrl: state.sourceUrl, - currentPageId: state.currentPageId, - pageId: state.pageId, - isNewPage: state.isNewPage, - displayLastSaved: state.displayLastSaved, - wikiStatus: state.wikiStatus, - isWikiGenerating: state.isWikiGenerating, - isSyncingLinks: state.isSyncingLinks, - isLocalDocEnabled: state.isLocalDocEnabled, - collaboration: state.collaboration, - duplicatePage: state.duplicatePage, - errorMessage: state.errorMessage, - contentError: state.contentError, - pendingInitialContent: state.pendingInitialContent, - deleteConfirmOpen: state.deleteConfirmOpen, - deleteReason: state.deleteReason, - wikiErrorMessage: state.wikiError?.message || null, - wikiContentForCollab: state.wikiContentForCollab, - }, - { - onBack: state.handleBack, - onDelete: state.handleDelete, - onExportMarkdown: state.handleExportMarkdown, - onCopyMarkdown: state.handleCopyMarkdown, - onGenerateWiki: handlers.handleGenerateWiki, - onOpenDuplicatePage: () => { - if (state.duplicatePage) { - state.handleOpenDuplicatePage(state.duplicatePage.id); - } - }, - onCancelWiki: state.cancelWiki, - onContentChange: handlers.handleContentChange, - onContentError: handlers.handleContentError, - onTitleChange: handlers.handleTitleChange, - onPendingInitialContentClear: () => state.setPendingInitialContent(null), - onDeleteConfirmOpenChange: state.setDeleteConfirmOpen, - onConfirmDelete: state.handleConfirmDelete, - onCancelDelete: state.handleCancelDelete, - onResetWiki: state.resetWiki, - onGoToAISettings: handlers.handleGoToAISettings, - onWikiContentApplied: state.onWikiContentApplied, - }, - ); - - return { showLoading, layoutProps }; -} diff --git a/src/components/editor/PageEditor/usePageEditorAIEffects.ts b/src/components/editor/PageEditor/usePageEditorAIEffects.ts deleted file mode 100644 index c79a6ada..00000000 --- a/src/components/editor/PageEditor/usePageEditorAIEffects.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { usePageEditorEffects } from "./usePageEditorEffects"; -import type { UsePageEditorEffectsOptions } from "./usePageEditorEffects"; -import { usePendingChatPageGeneration } from "./usePendingChatPageGeneration"; - -/** - * Editor side effects for AI: router/url handling, chat context, wiki sync, - * and streaming full page body after create-from-chat navigation. - * AI 向けエディタ副作用: ルータ、チャットコンテキスト、Wiki 同期、チャット経由作成後の本文ストリーム。 - */ -export function usePageEditorAIEffects(options: UsePageEditorEffectsOptions): void { - usePageEditorEffects(options); - usePendingChatPageGeneration({ - currentPageId: options.currentPageId, - isInitialized: options.isInitialized, - title: options.title, - setContent: options.setContent, - setWikiContentForCollab: options.setWikiContentForCollab, - saveChanges: options.saveChanges, - toast: options.toast, - }); -} diff --git a/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts b/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts deleted file mode 100644 index 3a07bd9c..00000000 --- a/src/components/editor/PageEditor/usePageEditorAutoSaveWithMutation.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useEditorAutoSave } from "./useEditorAutoSave"; -import { useUpdatePage, useSyncWikiLinks, useRepository, pageKeys } from "@/hooks/usePageQueries"; -import { extractFirstImage } from "@/lib/contentUtils"; - -interface UsePageEditorAutoSaveWithMutationOptions { - currentPageId: string | null; - shouldBlockSave: boolean; - updateLastSaved: (timestamp: number) => void; -} - -/** - * ページエディタの autosave を `useUpdatePage` mutation と `useSyncWikiLinks` - * の WikiLink / タグ同期に配線するフック。保存成功時にサムネイル抽出と - * `linkedPages` クエリの invalidate も行う(issue #725 Phase 1 でタグ同期を追加)。 - * - * Hook that wires the page editor's autosave pipeline to the `useUpdatePage` - * mutation and `useSyncWikiLinks` (WikiLink + tag sync). It also extracts the - * first image for thumbnail updates and invalidates the `linkedPages` cache on - * save. Tag sync was added by issue #725 Phase 1. - */ -export function usePageEditorAutoSaveWithMutation({ - currentPageId, - shouldBlockSave, - updateLastSaved, -}: UsePageEditorAutoSaveWithMutationOptions) { - const queryClient = useQueryClient(); - const { userId } = useRepository(); - const updatePageMutation = useUpdatePage(); - const { syncLinks, syncTags } = useSyncWikiLinks(); - - const { - saveChanges, - cancelPendingSave, - lastSaved: autoSaveLastSaved, - isSyncingLinks, - } = useEditorAutoSave({ - pageId: currentPageId, - debounceMs: 500, - shouldBlockSave, - onSave: async (updates) => { - if (!currentPageId) return false; - const thumbnailUrl = extractFirstImage(updates.content) || undefined; - const result = await updatePageMutation.mutateAsync({ - pageId: currentPageId, - updates: { ...updates, thumbnailUrl }, - }); - return !result.skipped; - }, - onSaveContentOnly: async (content) => { - if (!currentPageId) return false; - const thumbnailUrl = extractFirstImage(content) || undefined; - const result = await updatePageMutation.mutateAsync({ - pageId: currentPageId, - updates: { content, thumbnailUrl }, - }); - return !result.skipped; - }, - syncWikiLinks: syncLinks, - syncTags, - onSaveSuccess: () => { - updateLastSaved(Date.now()); - if (currentPageId && userId) { - queryClient.invalidateQueries({ - queryKey: [...pageKeys.all, "linkedPages", userId, currentPageId], - }); - } - }, - }); - - return { saveChanges, cancelPendingSave, lastSaved: autoSaveLastSaved, isSyncingLinks }; -} diff --git a/src/components/editor/PageEditor/usePageEditorEffects.test.tsx b/src/components/editor/PageEditor/usePageEditorEffects.test.tsx deleted file mode 100644 index 767f9868..00000000 --- a/src/components/editor/PageEditor/usePageEditorEffects.test.tsx +++ /dev/null @@ -1,887 +0,0 @@ -/* eslint-disable max-lines-per-function -- Shared mocks; behavior is grouped in nested describe blocks. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook } from "@testing-library/react"; -import React from "react"; -import { usePageEditorEffects, type UsePageEditorEffectsOptions } from "./usePageEditorEffects"; -import { AIChatProvider, useAIChatContext } from "@/contexts/AIChatContext"; -import type { Page } from "@/types/page"; - -const createMockPage = (overrides: Partial = {}): Page => - ({ - id: "page-1", - title: "Test Page", - content: JSON.stringify({ - type: "doc", - content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }], - }), - ownerUserId: "user-1", - noteId: null, - createdAt: "", - updatedAt: "", - isDeleted: false, - ...overrides, - }) as Page; - -function wrapper({ children }: { children: React.ReactNode }) { - return {children}; -} - -/** Minimal `location` shape used by the hook (pathname + state). */ -function mockLocation( - pathname: string, - state: UsePageEditorEffectsOptions["location"]["state"], -): UsePageEditorEffectsOptions["location"] { - return { pathname, state } as UsePageEditorEffectsOptions["location"]; -} - -/** - * Returns whether `navigate` was invoked with `path` as the first argument (any number of args). - * `navigate` の第1引数が `path` か(引数の個数は問わない)。 - */ -function wasNavigateCalledWithPath( - navigate: { mock: { calls: unknown[][] } }, - path: string, -): boolean { - return navigate.mock.calls.some((c) => c[0] === path); -} - -function createBaseOptions( - overrides: Partial = {}, -): UsePageEditorEffectsOptions { - return { - isNewPage: false, - currentPageId: "page-1", - isInitialized: true, - isError: false, - page: createMockPage(), - title: "Test", - content: "", - isWikiGenerating: false, - wikiStatus: "idle", - throttledTiptapContent: null, - navigate: vi.fn(), - location: mockLocation("/pages/page-1", null), - initialize: vi.fn(), - setContent: vi.fn(), - setWikiContentForCollab: vi.fn(), - setSourceUrl: vi.fn(), - setPendingInitialContent: vi.fn(), - getTiptapContent: () => null, - saveChanges: vi.fn(), - resetWikiBase: vi.fn(), - updatePageMutation: { mutate: vi.fn(), mutateAsync: vi.fn() } as never, - toast: vi.fn(), - ...overrides, - }; -} - -describe("usePageEditorEffects", () => { - const mockNavigate = vi.fn(); - const mockToast = vi.fn(); - const mockSetContent = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("navigation and initialization", () => { - it("navigates to /notes/me when isNewPage is true", () => { - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isNewPage: true, - currentPageId: null, - isInitialized: false, - page: null, - title: "", - navigate: mockNavigate, - location: mockLocation("/pages/new", null), - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(mockNavigate).toHaveBeenCalledWith("/notes/me", { replace: true }); - }); - - it("does not redirect to /notes/me when isNewPage is false", () => { - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isNewPage: false, - navigate: mockNavigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(wasNavigateCalledWithPath(mockNavigate, "/notes/me")).toBe(false); - }); - - it("calls initialize when page is set and not yet initialized", () => { - const initialize = vi.fn(); - const page = createMockPage(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isInitialized: false, - page, - title: "Test", - location: mockLocation("/pages/1", null), - initialize, - navigate: mockNavigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(initialize).toHaveBeenCalledWith(page); - }); - - it("does not initialize when already initialized", () => { - const initialize = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isInitialized: true, - page: createMockPage(), - initialize, - navigate: mockNavigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(initialize).not.toHaveBeenCalled(); - }); - - it("does not initialize when page is still null", () => { - const initialize = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isNewPage: false, - isInitialized: false, - page: null, - initialize, - navigate: mockNavigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(initialize).not.toHaveBeenCalled(); - }); - - it("initializes after page data becomes available (dependency update)", () => { - const initialize = vi.fn(); - const page = createMockPage(); - - const { rerender } = renderHook( - (props: { page: Page | null }) => - usePageEditorEffects( - createBaseOptions({ - isNewPage: false, - isInitialized: false, - page: props.page, - initialize, - navigate: mockNavigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper, initialProps: { page: null as Page | null } }, - ); - - expect(initialize).not.toHaveBeenCalled(); - - rerender({ page }); - - expect(initialize).toHaveBeenCalledTimes(1); - expect(initialize).toHaveBeenCalledWith(page); - }); - - it("redirects to /notes/me when isNewPage flips to true after mount", () => { - const { rerender } = renderHook( - (props: { isNewPage: boolean }) => - usePageEditorEffects( - createBaseOptions({ - isNewPage: props.isNewPage, - navigate: mockNavigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper, initialProps: { isNewPage: false } }, - ); - - expect(wasNavigateCalledWithPath(mockNavigate, "/notes/me")).toBe(false); - - rerender({ isNewPage: true }); - - expect(mockNavigate).toHaveBeenCalledWith("/notes/me", { replace: true }); - }); - }); - - describe("location.state (create from URL)", () => { - it("applies initialContent from location.state and clears router state", () => { - const setPendingInitialContent = vi.fn(); - const navigate = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - currentPageId: "page-1", - isInitialized: true, - location: mockLocation("/pages/page-1", { initialContent: "

from-url

" }), - setPendingInitialContent, - navigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(setPendingInitialContent).toHaveBeenCalledWith("

from-url

"); - expect(navigate).toHaveBeenCalledWith("/pages/page-1", { replace: true, state: null }); - }); - - it("does not apply location.state when not initialized", () => { - const setPendingInitialContent = vi.fn(); - const navigate = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isInitialized: false, - location: mockLocation("/pages/page-1", { initialContent: "x" }), - setPendingInitialContent, - navigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(setPendingInitialContent).not.toHaveBeenCalled(); - expect(navigate).not.toHaveBeenCalled(); - }); - - it("does not apply location.state when currentPageId is missing", () => { - const setPendingInitialContent = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - currentPageId: null, - isInitialized: true, - location: mockLocation("/pages/new", { initialContent: "x" }), - setPendingInitialContent, - navigate: mockNavigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(setPendingInitialContent).not.toHaveBeenCalled(); - }); - - it("persists sourceUrl and thumbnailUrl from location.state via updatePageMutation", () => { - const setSourceUrl = vi.fn(); - const mutate = vi.fn(); - const navigate = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isInitialized: true, - location: mockLocation("/pages/page-1", { - sourceUrl: "https://example.com", - thumbnailUrl: "https://cdn.example.com/t.png", - }), - setSourceUrl, - navigate, - updatePageMutation: { mutate, mutateAsync: vi.fn() } as never, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(setSourceUrl).toHaveBeenCalledWith("https://example.com"); - expect(mutate).toHaveBeenCalledWith({ - pageId: "page-1", - updates: { - sourceUrl: "https://example.com", - thumbnailUrl: "https://cdn.example.com/t.png", - }, - }); - expect(navigate).toHaveBeenCalledWith("/pages/page-1", { replace: true, state: null }); - }); - - it("handles thumbnail-only state with empty sourceUrl passed to setSourceUrl", () => { - const setSourceUrl = vi.fn(); - const mutate = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isInitialized: true, - location: mockLocation("/pages/page-1", { thumbnailUrl: "https://img/t.png" }), - setSourceUrl, - navigate: mockNavigate, - updatePageMutation: { mutate, mutateAsync: vi.fn() } as never, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(setSourceUrl).toHaveBeenCalledWith(""); - expect(mutate).toHaveBeenCalledWith({ - pageId: "page-1", - updates: { - sourceUrl: undefined, - thumbnailUrl: "https://img/t.png", - }, - }); - }); - - it("does not persist when location.state has no initialContent or media fields", () => { - const setSourceUrl = vi.fn(); - const mutate = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isInitialized: true, - location: mockLocation("/pages/page-1", {}), - setSourceUrl, - navigate: mockNavigate, - updatePageMutation: { mutate, mutateAsync: vi.fn() } as never, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(setSourceUrl).not.toHaveBeenCalled(); - expect(mutate).not.toHaveBeenCalled(); - }); - - it("applies initialContent when location.state becomes available after mount (dependency update)", () => { - const setPendingInitialContent = vi.fn(); - const navigate = vi.fn(); - - const { rerender } = renderHook( - (props: { loc: UsePageEditorEffectsOptions["location"] }) => - usePageEditorEffects( - createBaseOptions({ - currentPageId: "page-1", - isInitialized: true, - location: props.loc, - setPendingInitialContent, - navigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { - wrapper, - initialProps: { loc: mockLocation("/pages/page-1", null) }, - }, - ); - - expect(setPendingInitialContent).not.toHaveBeenCalled(); - - rerender({ - loc: mockLocation("/pages/page-1", { initialContent: "

deferred

" }), - }); - - expect(setPendingInitialContent).toHaveBeenCalledWith("

deferred

"); - expect(navigate).toHaveBeenCalledWith("/pages/page-1", { replace: true, state: null }); - }); - }); - - describe("load errors", () => { - it("navigates home and toasts when page load errors", () => { - const navigate = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isNewPage: false, - isError: true, - navigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(navigate).toHaveBeenCalledWith("/"); - expect(mockToast).toHaveBeenCalledWith({ - title: "ページが見つかりません", - variant: "destructive", - }); - }); - - it("does not treat load error as not-found when creating a new page", () => { - const navigate = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isNewPage: true, - isError: true, - navigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper }, - ); - - expect(wasNavigateCalledWithPath(navigate, "/")).toBe(false); - expect(mockToast).not.toHaveBeenCalledWith( - expect.objectContaining({ title: "ページが見つかりません" }), - ); - }); - - it("navigates away when isError becomes true after load (dependency update)", () => { - const navigate = vi.fn(); - - const { rerender } = renderHook( - (props: { isError: boolean }) => - usePageEditorEffects( - createBaseOptions({ - isError: props.isError, - navigate, - setContent: mockSetContent, - toast: mockToast, - }), - ), - { wrapper, initialProps: { isError: false } }, - ); - - expect(wasNavigateCalledWithPath(navigate, "/")).toBe(false); - - rerender({ isError: true }); - - expect(navigate).toHaveBeenCalledWith("/"); - expect(mockToast).toHaveBeenCalledWith({ - title: "ページが見つかりません", - variant: "destructive", - }); - }); - }); - - describe("wiki generation stream", () => { - it("mirrors throttled wiki stream into editor and collab when wiki is generating", () => { - const setWikiContentForCollab = vi.fn(); - const html = "

stream

"; - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isWikiGenerating: true, - throttledTiptapContent: html, - setContent: mockSetContent, - setWikiContentForCollab, - toast: mockToast, - navigate: mockNavigate, - }), - ), - { wrapper }, - ); - - expect(mockSetContent).toHaveBeenCalledWith(html); - expect(setWikiContentForCollab).toHaveBeenCalledWith(html); - }); - - it("does not mirror wiki stream when throttled content is empty", () => { - const setWikiContentForCollab = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - isWikiGenerating: true, - throttledTiptapContent: null, - setContent: mockSetContent, - setWikiContentForCollab, - toast: mockToast, - navigate: mockNavigate, - }), - ), - { wrapper }, - ); - - expect(mockSetContent).not.toHaveBeenCalled(); - expect(setWikiContentForCollab).not.toHaveBeenCalled(); - }); - - it("starts mirroring when generation and throttled content turn on (dependency update)", () => { - const setWikiContentForCollab = vi.fn(); - const html = "

later

"; - - const { rerender } = renderHook( - (props: { gen: boolean; throttle: string | null }) => - usePageEditorEffects( - createBaseOptions({ - isWikiGenerating: props.gen, - throttledTiptapContent: props.throttle, - setContent: mockSetContent, - setWikiContentForCollab, - toast: mockToast, - navigate: mockNavigate, - }), - ), - { wrapper, initialProps: { gen: false, throttle: null as string | null } }, - ); - - expect(mockSetContent).not.toHaveBeenCalledWith(html); - - rerender({ gen: true, throttle: html }); - - expect(mockSetContent).toHaveBeenCalledWith(html); - expect(setWikiContentForCollab).toHaveBeenCalledWith(html); - }); - }); - - describe("wiki completion", () => { - it("does not run completion pipeline while wikiStatus is idle", () => { - const resetWikiBase = vi.fn(); - const saveChanges = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - wikiStatus: "idle", - getTiptapContent: () => "should-not-run", - resetWikiBase, - saveChanges, - setContent: mockSetContent, - toast: mockToast, - navigate: mockNavigate, - }), - ), - { wrapper }, - ); - - expect(resetWikiBase).not.toHaveBeenCalled(); - expect(saveChanges).not.toHaveBeenCalled(); - expect(mockToast).not.toHaveBeenCalledWith({ title: "Wiki記事を生成しました" }); - }); - - it("runs completion when wikiStatus becomes completed (dependency update)", () => { - const saveChanges = vi.fn(); - const resetWikiBase = vi.fn(); - const setWikiContentForCollab = vi.fn(); - const body = '{"type":"doc","content":[]}'; - - const { rerender } = renderHook( - (props: { status: string }) => - usePageEditorEffects( - createBaseOptions({ - wikiStatus: props.status, - title: "T", - getTiptapContent: () => body, - saveChanges, - resetWikiBase, - setContent: mockSetContent, - setWikiContentForCollab, - toast: mockToast, - navigate: mockNavigate, - }), - ), - { wrapper, initialProps: { status: "idle" } }, - ); - - expect(resetWikiBase).not.toHaveBeenCalled(); - - rerender({ status: "completed" }); - - expect(mockSetContent).toHaveBeenCalledWith(body); - expect(saveChanges).toHaveBeenCalledWith("T", body); - expect(resetWikiBase).toHaveBeenCalled(); - }); - - it("on wiki completed with content: saves, toasts, updates collab, and resets wiki base", () => { - const saveChanges = vi.fn(); - const resetWikiBase = vi.fn(); - const setWikiContentForCollab = vi.fn(); - const body = '{"type":"doc","content":[]}'; - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - wikiStatus: "completed", - title: "Wiki Title", - getTiptapContent: () => body, - saveChanges, - resetWikiBase, - setContent: mockSetContent, - setWikiContentForCollab, - toast: mockToast, - navigate: mockNavigate, - }), - ), - { wrapper }, - ); - - expect(mockSetContent).toHaveBeenCalledWith(body); - expect(setWikiContentForCollab).toHaveBeenCalledWith(body); - expect(saveChanges).toHaveBeenCalledWith("Wiki Title", body); - expect(mockToast).toHaveBeenCalledWith({ title: "Wiki記事を生成しました" }); - expect(resetWikiBase).toHaveBeenCalled(); - }); - - it("on wiki completed with empty editor: skips save but still resets wiki base", () => { - const saveChanges = vi.fn(); - const resetWikiBase = vi.fn(); - - renderHook( - () => - usePageEditorEffects( - createBaseOptions({ - wikiStatus: "completed", - getTiptapContent: () => "", - saveChanges, - resetWikiBase, - setContent: mockSetContent, - toast: mockToast, - navigate: mockNavigate, - }), - ), - { wrapper }, - ); - - expect(saveChanges).not.toHaveBeenCalled(); - expect(mockToast).not.toHaveBeenCalledWith({ title: "Wiki記事を生成しました" }); - expect(resetWikiBase).toHaveBeenCalled(); - }); - }); - - describe("AI chat context and append handler", () => { - it("sets AI page context with truncated preview and full content", () => { - const long = "a".repeat(4000); - - const { result } = renderHook( - () => { - usePageEditorEffects( - createBaseOptions({ - title: "My title", - currentPageId: "pid-9", - content: long, - setContent: mockSetContent, - toast: mockToast, - navigate: mockNavigate, - }), - ); - return useAIChatContext(); - }, - { wrapper }, - ); - - expect(result.current.pageContext).toEqual({ - type: "editor", - pageId: "pid-9", - pageTitle: "My title", - pageContent: long.substring(0, 3000), - pageFullContent: long, - }); - }); - - it("sets AI page context when only title is set (no page id)", () => { - const { result } = renderHook( - () => { - usePageEditorEffects( - createBaseOptions({ - title: "Title only", - currentPageId: null, - content: "c", - setContent: mockSetContent, - toast: mockToast, - navigate: mockNavigate, - }), - ); - return useAIChatContext(); - }, - { wrapper }, - ); - - expect(result.current.pageContext).toEqual({ - type: "editor", - pageId: undefined, - pageTitle: "Title only", - pageContent: "c".substring(0, 3000), - pageFullContent: "c", - }); - }); - - it("sets AI page context when only currentPageId is set (empty title)", () => { - const { result } = renderHook( - () => { - usePageEditorEffects( - createBaseOptions({ - title: "", - currentPageId: "only-id", - content: "", - setContent: mockSetContent, - toast: mockToast, - navigate: mockNavigate, - }), - ); - return useAIChatContext(); - }, - { wrapper }, - ); - - expect(result.current.pageContext).toEqual({ - type: "editor", - pageId: "only-id", - pageTitle: "", - pageContent: undefined, - pageFullContent: undefined, - }); - }); - - it("clears page context when title and page id become empty", () => { - const { result, rerender } = renderHook( - (props: { title: string; pageId: string | null; content: string }) => { - usePageEditorEffects( - createBaseOptions({ - title: props.title, - currentPageId: props.pageId, - content: props.content, - setContent: mockSetContent, - toast: mockToast, - navigate: mockNavigate, - }), - ); - return useAIChatContext(); - }, - { - wrapper, - initialProps: { title: "T", pageId: "p1", content: "body" }, - }, - ); - - expect(result.current.pageContext).toMatchObject({ - type: "editor", - pageId: "p1", - pageTitle: "T", - }); - - rerender({ title: "", pageId: null, content: "" }); - - expect(result.current.pageContext).toBeNull(); - }); - - it("registers setContent on contentAppendHandlerRef while page id is set and clears on unmount", () => { - const setContentFn = vi.fn(); - const { result, unmount } = renderHook( - () => { - usePageEditorEffects( - createBaseOptions({ - currentPageId: "page-1", - setContent: setContentFn, - toast: mockToast, - navigate: mockNavigate, - }), - ); - return useAIChatContext().contentAppendHandlerRef; - }, - { wrapper }, - ); - - expect(result.current.current).toBe(setContentFn); - - unmount(); - - expect(result.current.current).toBeNull(); - }); - - it("does not assign append handler when currentPageId is null", () => { - const { result } = renderHook( - () => { - usePageEditorEffects( - createBaseOptions({ - currentPageId: null, - setContent: mockSetContent, - toast: mockToast, - navigate: mockNavigate, - }), - ); - return useAIChatContext().contentAppendHandlerRef; - }, - { wrapper }, - ); - - expect(result.current.current).toBeNull(); - }); - - it("registers append handler when currentPageId becomes non-null (dependency update)", () => { - const setContentFn = vi.fn(); - - const { result, rerender } = renderHook( - (props: { pageId: string | null }) => { - usePageEditorEffects( - createBaseOptions({ - currentPageId: props.pageId, - setContent: setContentFn, - toast: mockToast, - navigate: mockNavigate, - }), - ); - return useAIChatContext().contentAppendHandlerRef; - }, - { wrapper, initialProps: { pageId: null as string | null } }, - ); - - expect(result.current.current).toBeNull(); - - rerender({ pageId: "page-1" }); - - expect(result.current.current).toBe(setContentFn); - }); - }); -}); diff --git a/src/components/editor/PageEditor/usePageEditorEffects.ts b/src/components/editor/PageEditor/usePageEditorEffects.ts deleted file mode 100644 index 79e7586f..00000000 --- a/src/components/editor/PageEditor/usePageEditorEffects.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { useEffect } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import type { UseMutationResult } from "@tanstack/react-query"; -import type { Page } from "@/types/page"; -import { useAIChatContext } from "@/contexts/AIChatContext"; - -type UpdatePageMutation = UseMutationResult< - { skipped: boolean }, - Error, - { pageId: string; updates: Partial & { content?: string; thumbnailUrl?: string | null } }, - unknown ->; - -/** - * Dependencies and state passed into `usePageEditorEffects` (navigation, page data, mutations, editor callbacks). - * `usePageEditorEffects` に渡す依存関係と状態(ナビゲーション、ページデータ、ミューテーション、エディタコールバック)。 - */ -export interface UsePageEditorEffectsOptions { - isNewPage: boolean; - currentPageId: string | null; - isInitialized: boolean; - isError: boolean; - page: Page | null | undefined; - title: string; - content: string; - isWikiGenerating: boolean; - wikiStatus: string; - throttledTiptapContent: string | null; - navigate: ReturnType; - location: ReturnType; - initialize: (page: Page) => void; - setContent: (content: string) => void; - setWikiContentForCollab: (content: string | null) => void; - setSourceUrl: (url: string | undefined) => void; - setPendingInitialContent: (content: string | null) => void; - getTiptapContent: () => string | null; - saveChanges: (title: string, content: string) => void; - /** - * Wiki 完了 effect 用。setWikiContentForCollab を null にしないため resetWiki ではなくこちらを呼ぶ。 - * For the wiki completion effect. Call this instead of resetWiki to avoid nullifying setWikiContentForCollab. - */ - resetWikiBase: () => void; - updatePageMutation: UpdatePageMutation; - toast: (opts: { title: string; variant?: "destructive" }) => void; -} - -/** - * ページエディタの副作用(ナビゲーション・初期化・Wiki生成反映・AIチャットコンテキスト設定)。 - * Page editor side effects: navigation, initialization, wiki content sync, and AI chat context setup. - */ -export function usePageEditorEffects(options: UsePageEditorEffectsOptions) { - const { - isNewPage, - currentPageId, - isInitialized, - isError, - page, - title, - content, - isWikiGenerating, - wikiStatus, - throttledTiptapContent, - navigate, - location, - initialize, - setContent, - setWikiContentForCollab, - setSourceUrl, - setPendingInitialContent, - getTiptapContent, - saveChanges, - resetWikiBase, - updatePageMutation, - toast, - } = options; - - const { setPageContext, contentAppendHandlerRef } = useAIChatContext(); - - // /pages/new への直接アクセスはデフォルトノート (/notes/me) へリダイレクト。 - // /home は #884 で廃止予定のため /notes/me に統一する。 - // Direct visits to /pages/new redirect to the caller's default note - // (/notes/me). /home is being retired in #884 so we route to /notes/me. - useEffect(() => { - if (isNewPage) { - navigate("/notes/me", { replace: true }); - } - }, [isNewPage, navigate]); - - // Load existing page - useEffect(() => { - if (!isNewPage && page && !isInitialized) { - initialize(page); - } - }, [isNewPage, page, isInitialized, initialize]); - - // URL から作成時: state で渡された initialContent をエディタに渡す - useEffect(() => { - const state = location.state as { - sourceUrl?: string; - thumbnailUrl?: string | null; - initialContent?: string; - } | null; - - if (!state || !currentPageId || !isInitialized) return; - - if (typeof state.initialContent === "string") { - setPendingInitialContent(state.initialContent); - navigate(location.pathname, { replace: true, state: null }); - return; - } - - const { sourceUrl: stateSourceUrl, thumbnailUrl: stateThumbnailUrl } = state; - if (stateSourceUrl || stateThumbnailUrl) { - setSourceUrl(stateSourceUrl || ""); - updatePageMutation.mutate({ - pageId: currentPageId, - updates: { - sourceUrl: stateSourceUrl || undefined, - thumbnailUrl: stateThumbnailUrl || undefined, - }, - }); - navigate(location.pathname, { replace: true, state: null }); - } - }, [ - location.state, - currentPageId, - isInitialized, - setSourceUrl, - updatePageMutation, - navigate, - location.pathname, - setPendingInitialContent, - ]); - - // Handle page not found - useEffect(() => { - if (!isNewPage && isError) { - navigate("/"); - toast({ title: "ページが見つかりません", variant: "destructive" }); - } - }, [isNewPage, isError, navigate, toast]); - - // Wiki生成中のコンテンツをエディターに反映(React state + コラボ時は Y.Doc 用に別途渡す) - useEffect(() => { - if (isWikiGenerating && throttledTiptapContent) { - setContent(throttledTiptapContent); - setWikiContentForCollab(throttledTiptapContent); - } - }, [isWikiGenerating, throttledTiptapContent, setContent, setWikiContentForCollab]); - - // Wiki生成完了時に保存(React state + コラボ時は Y.Doc 用に別途渡す) - // resetWiki ではなく resetWikiBase を呼ぶ: resetWiki は setWikiContentForCollab(null) も行うため、 - // 同 effect 内で setWikiContentForCollab(tiptapContent) と同バッチになりコラボに内容が渡らない。 - // Use resetWikiBase instead of resetWiki: resetWiki also calls setWikiContentForCollab(null), - // which would batch with setWikiContentForCollab(tiptapContent) in the same effect, preventing content from reaching collab. - useEffect(() => { - if (wikiStatus === "completed") { - const tiptapContent = getTiptapContent(); - if (tiptapContent) { - setContent(tiptapContent); - setWikiContentForCollab(tiptapContent); - saveChanges(title, tiptapContent); - toast({ title: "Wiki記事を生成しました" }); - } - resetWikiBase(); - } - }, [ - wikiStatus, - getTiptapContent, - title, - saveChanges, - resetWikiBase, - toast, - setContent, - setWikiContentForCollab, - ]); - - // AI Chat context: ページコンテキストを設定 - useEffect(() => { - if (title || currentPageId) { - setPageContext({ - type: "editor", - pageId: currentPageId || undefined, - pageTitle: title, - pageContent: content ? content.substring(0, 3000) : undefined, - pageFullContent: content || undefined, - }); - } - return () => setPageContext(null); - }, [title, currentPageId, content, setPageContext]); - - // AI追記時にエディタ内容を同期するハンドラを登録 - useEffect(() => { - if (currentPageId) { - contentAppendHandlerRef.current = setContent; - return () => { - contentAppendHandlerRef.current = null; - }; - } - return undefined; - }, [currentPageId, setContent, contentAppendHandlerRef]); -} diff --git a/src/components/editor/PageEditor/usePageEditorHandlers.ts b/src/components/editor/PageEditor/usePageEditorHandlers.ts deleted file mode 100644 index 6aa12d64..00000000 --- a/src/components/editor/PageEditor/usePageEditorHandlers.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useCallback } from "react"; -import { useNavigate } from "react-router-dom"; -import type { ContentError } from "../TiptapEditor/useContentSanitizer"; -import { generateAutoTitle } from "@/lib/contentUtils"; - -interface UsePageEditorHandlersOptions { - title: string; - content: string; - /** true のときだけオートタイトル(コンテンツ先頭行からの自動生成)を有効にする */ - enableAutoTitle: boolean; - setTitle: (title: string) => void; - setContent: (content: string) => void; - setContentError: (error: ContentError | null) => void; - validateTitle: (title: string) => void; - saveChanges: (title: string, content: string) => void; - generateWiki: (title: string) => void; - resetWiki: () => void; - location: { pathname: string; search: string; hash?: string }; -} - -/** Page editor event handlers (title, content, wiki, navigation). */ -export function usePageEditorHandlers(options: UsePageEditorHandlersOptions) { - const navigate = useNavigate(); - const { - title, - content, - enableAutoTitle, - setTitle, - setContent, - setContentError, - validateTitle, - saveChanges, - generateWiki, - resetWiki, - location, - } = options; - - const handleContentChange = useCallback( - (newContent: string) => { - setContent(newContent); - if (enableAutoTitle && !title) { - const autoTitle = generateAutoTitle(newContent); - if (autoTitle !== "無題のページ") { - setTitle(autoTitle); - validateTitle(autoTitle); - saveChanges(autoTitle, newContent); - return; - } - saveChanges("無題のページ", newContent); - return; - } - saveChanges(title, newContent); - }, - [title, enableAutoTitle, saveChanges, validateTitle, setContent, setTitle], - ); - - const handleTitleChange = useCallback( - (newTitle: string) => { - setTitle(newTitle); - validateTitle(newTitle); - saveChanges(newTitle, content); - }, - [content, saveChanges, validateTitle, setTitle], - ); - - const handleContentError = useCallback( - (error: ContentError | null) => { - setContentError(error); - }, - [setContentError], - ); - - const handleGenerateWiki = useCallback(() => { - generateWiki(title); - }, [generateWiki, title]); - - const handleGoToAISettings = useCallback(() => { - resetWiki(); - const returnTo = `${location.pathname}${location.search}${location.hash ?? ""}`; - const search = new URLSearchParams({ section: "ai", returnTo }).toString(); - navigate(`/settings?${search}`); - }, [resetWiki, navigate, location.pathname, location.search, location.hash]); - - return { - handleContentChange, - handleTitleChange, - handleContentError, - handleGenerateWiki, - handleGoToAISettings, - }; -} diff --git a/src/components/editor/PageEditor/usePageEditorKeyboard.ts b/src/components/editor/PageEditor/usePageEditorKeyboard.ts deleted file mode 100644 index 88ce55db..00000000 --- a/src/components/editor/PageEditor/usePageEditorKeyboard.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect } from "react"; - -interface UsePageEditorKeyboardOptions { - onBack: () => void; -} - -/** - * Hook for page editor keyboard shortcuts - * Intercepts Cmd+H / Ctrl+H to go back with proper cleanup - */ -export function usePageEditorKeyboard({ onBack }: UsePageEditorKeyboardOptions): void { - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Cmd+H / Ctrl+H - ホームに戻る(handleBackを通す) - if ((e.metaKey || e.ctrlKey) && e.key === "h") { - e.preventDefault(); - e.stopPropagation(); - onBack(); - } - }; - - // captureフェーズでイベントをキャッチ(GlobalShortcutsProviderより先に処理) - document.addEventListener("keydown", handleKeyDown, true); - return () => document.removeEventListener("keydown", handleKeyDown, true); - }, [onBack]); -} diff --git a/src/components/editor/PageEditor/usePageEditorState.ts b/src/components/editor/PageEditor/usePageEditorState.ts deleted file mode 100644 index 2c3c9193..00000000 --- a/src/components/editor/PageEditor/usePageEditorState.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import type { Page } from "@/types/page"; - -interface UsePageEditorStateReturn { - // 状態 - title: string; - content: string; - sourceUrl: string | undefined; - currentPageId: string | null; - lastSaved: number | null; - isInitialized: boolean; - originalTitle: string; - contentError: ContentError | null; - - // アクション - setTitle: (title: string) => void; - setContent: (content: string) => void; - setSourceUrl: (sourceUrl: string | undefined) => void; - setContentError: (error: ContentError | null) => void; - initialize: (page: Page) => void; - reset: () => void; - updateLastSaved: (timestamp: number) => void; -} - -export interface ContentError { - message: string; - removedNodeTypes: string[]; - removedMarkTypes: string[]; - wasSanitized: boolean; -} - -interface UsePageEditorStateOptions { - pageId: string; - isNewPage: boolean; - onInitialized?: (page: Page) => void; -} - -/** - * Hook to manage page editor state - * Handles page data initialization, state management, and lifecycle - * - * NOTE: PageEditorViewはkey={pageId}でマウントされるため、 - * ページ遷移時は完全に再マウントされる。 - * このリセットロジックは安全のために残しているが、 - * 通常はkey変更によるコンポーネント再マウントで状態がリセットされる。 - */ -export function usePageEditorState({ - pageId, - isNewPage, - onInitialized, -}: UsePageEditorStateOptions): UsePageEditorStateReturn { - const [title, setTitle] = useState(""); - const [content, setContent] = useState(""); - const [sourceUrl, setSourceUrl] = useState(undefined); - const [currentPageId, setCurrentPageId] = useState(null); - const [lastSaved, setLastSaved] = useState(null); - const [isInitialized, setIsInitialized] = useState(false); - const [originalTitle, setOriginalTitle] = useState(""); - const [contentError, setContentError] = useState(null); - const prevPageIdRef = useRef(pageId ?? ""); - - // ページIDが変わった時に即座に状態をリセット(リンクから作成後の遷移で前ページの内容が残る問題を防ぐ) - useEffect(() => { - if (prevPageIdRef.current !== pageId && !isNewPage) { - prevPageIdRef.current = pageId; - queueMicrotask(() => { - setIsInitialized(false); - setCurrentPageId(null); - setTitle(""); - setContent(""); - setSourceUrl(undefined); - setLastSaved(null); - setOriginalTitle(""); - setContentError(null); - }); - } - }, [pageId, isNewPage]); - - const initialize = useCallback( - (page: Page) => { - setCurrentPageId(page.id); - setTitle(page.title); - setOriginalTitle(page.title); - setContent(page.content); - setSourceUrl(page.sourceUrl); - setLastSaved(page.updatedAt); - setIsInitialized(true); - onInitialized?.(page); - }, - [onInitialized], - ); - - const reset = useCallback(() => { - setIsInitialized(false); - setCurrentPageId(null); - setTitle(""); - setContent(""); - setSourceUrl(undefined); - setLastSaved(null); - setOriginalTitle(""); - setContentError(null); - }, []); - - const updateLastSaved = useCallback((timestamp: number) => { - setLastSaved(timestamp); - }, []); - - return { - // 状態 - title, - content, - sourceUrl, - currentPageId, - lastSaved, - isInitialized, - originalTitle, - contentError, - - // アクション - setTitle, - setContent, - setSourceUrl, - setContentError, - initialize, - reset, - updateLastSaved, - }; -} diff --git a/src/components/editor/PageEditor/usePageEditorStateAndSync.ts b/src/components/editor/PageEditor/usePageEditorStateAndSync.ts deleted file mode 100644 index 461bd298..00000000 --- a/src/components/editor/PageEditor/usePageEditorStateAndSync.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { useState, useEffect } from "react"; -import { useLocation, useNavigate, useParams } from "react-router-dom"; -import { usePage, useUpdatePage } from "@/hooks/usePageQueries"; -import { useTitleValidation } from "@/hooks/useTitleValidation"; -import { useToast } from "@zedi/ui"; -import { useWikiGenerator } from "@/hooks/useWikiGenerator"; -import { useCollaboration } from "@/hooks/useCollaboration"; -import { usePageEditorState } from "./usePageEditorState"; -import { usePageEditorAutoSaveWithMutation } from "./usePageEditorAutoSaveWithMutation"; -import { usePageEditorAIEffects } from "./usePageEditorAIEffects"; -import { usePageEditorWikiCollab } from "./usePageEditorWikiCollab"; -import { usePageDeletion } from "./usePageDeletion"; -import { useMarkdownExport } from "./useMarkdownExport"; -import { usePageEditorKeyboard } from "./usePageEditorKeyboard"; -import { - pageEditorActionsReturnSlice, - pageEditorCoreReturnSlice, - pageEditorWikiReturnSlice, -} from "./usePageEditorStateAndSyncReturnSlices"; - -function useDisplayLastSavedAndPending( - autoSaveLastSaved: number | null | undefined, - lastSaved: number | null, -) { - const [pendingInitialContent, setPendingInitialContent] = useState(null); - const displayLastSaved = autoSaveLastSaved ?? lastSaved; - return { displayLastSaved, pendingInitialContent, setPendingInitialContent }; -} - -function usePageEditorDeletionAndNav( - currentPageId: string | null, - title: string, - content: string, - sourceUrl: string, - shouldBlockSave: boolean, - cancelPendingSave: () => void, -) { - const deletion = usePageDeletion({ - currentPageId, - title, - content, - shouldBlockSave, - cancelPendingSave, - }); - const { handleExportMarkdown, handleCopyMarkdown } = useMarkdownExport(title, content, sourceUrl); - usePageEditorKeyboard({ onBack: deletion.handleBack }); - return { ...deletion, handleExportMarkdown, handleCopyMarkdown }; -} - -/** - * Pushes current page title into CollaborationManager so PUT /content includes it. - * タイトルを CollaborationManager に渡し、Y.Doc 保存時にサーバーへ同期する。 - */ -function useSyncCollaborationPageTitle( - isLocalDocEnabled: boolean, - title: string, - collaboration: ReturnType, -): void { - const { setPageTitle } = collaboration; - useEffect(() => { - if (isLocalDocEnabled) { - setPageTitle(title); - } - }, [isLocalDocEnabled, title, setPageTitle]); -} - -/** - * ページエディタの状態管理・自動保存・副作用を統合するフック。 - * Integrates page editor state management, auto-save, and side effects. - */ -export function usePageEditorStateAndSync() { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const location = useLocation(); - const { toast } = useToast(); - const isNewPage = id === "new"; - const pageId = isNewPage ? "" : id || ""; - - const { data: page, isLoading, isError } = usePage(pageId); - const updatePageMutation = useUpdatePage(); - - const { - title, - content, - sourceUrl, - currentPageId, - lastSaved, - isInitialized, - contentError, - setTitle, - setContent, - setSourceUrl, - setContentError, - initialize, - updateLastSaved, - } = usePageEditorState({ - pageId, - isNewPage, - onInitialized: (page) => { - initializeWithTitle(page.title); - }, - }); - - const isLocalDocEnabled = Boolean(currentPageId && !isNewPage); - const collaboration = useCollaboration({ - pageId: currentPageId ?? "", - enabled: isLocalDocEnabled, - mode: "local", - }); - - const { duplicatePage, errorMessage, validateTitle, initializeWithTitle, shouldBlockSave } = - useTitleValidation({ - currentPageId: currentPageId || undefined, - isNewPage, - debounceMs: 300, - }); - - const { - status: wikiStatus, - error: wikiError, - generate: generateWiki, - cancel: cancelWiki, - reset: resetWikiBase, - throttledTiptapContent, - getTiptapContent, - } = useWikiGenerator(); - - const isWikiGenerating = wikiStatus === "generating"; - - const { wikiContentForCollab, setWikiContentForCollab, resetWiki, onWikiContentApplied } = - usePageEditorWikiCollab(resetWikiBase, collaboration); - - const { - saveChanges, - cancelPendingSave, - lastSaved: autoSaveLastSaved, - isSyncingLinks, - } = usePageEditorAutoSaveWithMutation({ - currentPageId, - shouldBlockSave, - updateLastSaved, - }); - - const { - deleteConfirmOpen, - deleteReason, - setDeleteConfirmOpen, - handleDelete, - handleBack, - handleConfirmDelete, - handleCancelDelete, - handleOpenDuplicatePage, - handleExportMarkdown, - handleCopyMarkdown, - } = usePageEditorDeletionAndNav( - currentPageId, - title, - content, - sourceUrl, - shouldBlockSave, - cancelPendingSave, - ); - - const { displayLastSaved, pendingInitialContent, setPendingInitialContent } = - useDisplayLastSavedAndPending(autoSaveLastSaved, lastSaved); - - useSyncCollaborationPageTitle(isLocalDocEnabled, title, collaboration); - - usePageEditorAIEffects({ - isNewPage, - currentPageId, - isInitialized, - isError, - page, - title, - content, - isWikiGenerating, - wikiStatus, - throttledTiptapContent, - navigate, - location, - initialize, - setContent, - setWikiContentForCollab, - setSourceUrl, - setPendingInitialContent, - getTiptapContent, - saveChanges, - resetWikiBase, - updatePageMutation, - toast, - }); - - return { - ...pageEditorCoreReturnSlice({ - isLoading, - isInitialized, - isNewPage, - pageId, - title, - content, - sourceUrl, - currentPageId, - displayLastSaved, - pendingInitialContent, - setPendingInitialContent, - contentError, - location, - }), - ...pageEditorWikiReturnSlice({ - wikiStatus, - isWikiGenerating, - isSyncingLinks, - isLocalDocEnabled, - collaboration, - wikiError, - cancelWiki, - resetWiki, - generateWiki, - wikiContentForCollab, - onWikiContentApplied, - }), - ...pageEditorActionsReturnSlice({ - duplicatePage, - errorMessage, - deleteConfirmOpen, - deleteReason, - setDeleteConfirmOpen, - handleDelete, - handleBack, - handleConfirmDelete, - handleCancelDelete, - handleOpenDuplicatePage, - setTitle, - setContent, - setContentError, - validateTitle, - saveChanges, - handleExportMarkdown, - handleCopyMarkdown, - }), - }; -} diff --git a/src/components/editor/PageEditor/usePageEditorStateAndSyncReturnSlices.ts b/src/components/editor/PageEditor/usePageEditorStateAndSyncReturnSlices.ts deleted file mode 100644 index a5515a2d..00000000 --- a/src/components/editor/PageEditor/usePageEditorStateAndSyncReturnSlices.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Location } from "react-router-dom"; -import type { Page } from "@/types/page"; -import type { UseCollaborationReturn } from "@/lib/collaboration/types"; - -/** - * Core editor + routing fields returned by the page editor state hook. - * ページエディタ状態フックが返すコア欄(ルーティング・表示用フィールド)。 - */ -export interface PageEditorCoreReturnSlice { - isLoading: boolean; - isInitialized: boolean; - isNewPage: boolean; - pageId: string; - title: string; - content: string; - sourceUrl: string; - currentPageId: string | null; - displayLastSaved: number | null; - pendingInitialContent: string | null; - setPendingInitialContent: (v: string | null) => void; - contentError: string | null; - location: Location; -} - -/** - * Identity helper that narrows / preserves the core slice type for consumers. - * コアスライスの型をそのまま返すアイデンティティヘルパー(呼び出し側の型推論用)。 - * - * @param p - Core slice from the page editor hook / ページエディタフックのコア戻り値 - * @returns Same object / 同一オブジェクト - */ -export function pageEditorCoreReturnSlice(p: PageEditorCoreReturnSlice): PageEditorCoreReturnSlice { - return p; -} - -/** Wiki generator + collaboration slice of the page editor public API. */ -export interface PageEditorWikiReturnSlice { - wikiStatus: string; - isWikiGenerating: boolean; - isSyncingLinks: boolean; - isLocalDocEnabled: boolean; - collaboration: UseCollaborationReturn; - wikiError: Error | null; - cancelWiki: () => void; - resetWiki: () => void; - generateWiki: () => void; - wikiContentForCollab: string | null; - onWikiContentApplied: () => void; -} - -/** - * Identity helper that narrows / preserves the wiki slice type for consumers. - * Wiki コラボ・生成まわりスライスの型をそのまま返すアイデンティティヘルパー。 - * - * @param p - Wiki slice from the page editor hook / ページエディタフックの Wiki 戻り値 - * @returns Same object / 同一オブジェクト - */ -export function pageEditorWikiReturnSlice(p: PageEditorWikiReturnSlice): PageEditorWikiReturnSlice { - return p; -} - -/** Title validation, deletion, and export handlers exposed by the page editor. */ -export interface PageEditorActionsReturnSlice { - duplicatePage: Page | null; - errorMessage: string | null; - deleteConfirmOpen: boolean; - deleteReason: string; - setDeleteConfirmOpen: (v: boolean) => void; - handleDelete: () => void; - handleBack: () => void; - handleConfirmDelete: () => void; - handleCancelDelete: () => void; - handleOpenDuplicatePage: (targetPageId: string) => void; - setTitle: (t: string) => void; - setContent: (c: string) => void; - setContentError: (e: string | null) => void; - validateTitle: (title: string) => Promise; - saveChanges: (title: string, content: string) => void; - handleExportMarkdown: () => void; - handleCopyMarkdown: () => void; -} - -/** - * Identity helper that narrows / preserves the actions slice type for consumers. - * 保存・削除・エクスポート等のアクションスライスの型をそのまま返すアイデンティティヘルパー。 - * - * @param p - Actions slice from the page editor hook / ページエディタフックのアクション戻り値 - * @returns Same object / 同一オブジェクト - */ -export function pageEditorActionsReturnSlice( - p: PageEditorActionsReturnSlice, -): PageEditorActionsReturnSlice { - return p; -} diff --git a/src/components/editor/PageEditor/usePageEditorWikiCollab.ts b/src/components/editor/PageEditor/usePageEditorWikiCollab.ts deleted file mode 100644 index 6694f555..00000000 --- a/src/components/editor/PageEditor/usePageEditorWikiCollab.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useCallback, useState } from "react"; -import type { UseCollaborationReturn } from "@/lib/collaboration/types"; - -/** - * コラボモード時、Wiki 生成内容を Y.Doc に渡すための state とハンドラを提供する。 - * usePageEditorStateAndSync の関数行数削減のため切り出し。 - * - * Provides state and handlers for passing wiki-generated content to Y.Doc in collab mode. - * Extracted from usePageEditorStateAndSync to reduce function length. - */ -export function usePageEditorWikiCollab( - resetWikiBase: () => void, - collaboration: UseCollaborationReturn | undefined, -) { - const [wikiContentForCollab, setWikiContentForCollab] = useState(null); - - const resetWiki = useCallback(() => { - setWikiContentForCollab(null); - resetWikiBase(); - }, [resetWikiBase]); - - const flushSave = collaboration?.flushSave; - const onWikiContentApplied = useCallback(() => { - setWikiContentForCollab(null); - flushSave?.(); - }, [flushSave]); - - return { wikiContentForCollab, setWikiContentForCollab, resetWiki, onWikiContentApplied }; -} diff --git a/src/components/editor/PageEditor/usePendingChatPageGeneration.test.tsx b/src/components/editor/PageEditor/usePendingChatPageGeneration.test.tsx deleted file mode 100644 index 67d1530d..00000000 --- a/src/components/editor/PageEditor/usePendingChatPageGeneration.test.tsx +++ /dev/null @@ -1,391 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { act, render, renderHook, waitFor, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import React, { useEffect } from "react"; -import type { Location } from "react-router-dom"; -import { MemoryRouter, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom"; -import { usePendingChatPageGeneration } from "./usePendingChatPageGeneration"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -const generateWikiContentFromChatOutlineStream = vi.fn(); - -vi.mock("@/lib/wikiGenerator", () => ({ - generateWikiContentFromChatOutlineStream: ( - title: string, - outline: string, - conversationText: string, - handlers: { - onChunk: (chunk: string) => void; - onComplete: (result: { content: string }) => void; - onError: (err: Error) => void; - }, - signal: AbortSignal, - ) => generateWikiContentFromChatOutlineStream(title, outline, conversationText, handlers, signal), -})); - -const defaultPending = { outline: "- a", conversationText: "User: hi" }; - -function buildHookOptions( - overrides: Partial[0]> = {}, -) { - return { - currentPageId: "page-1" as string | null, - isInitialized: true, - title: "Page title", - setContent: vi.fn(), - setWikiContentForCollab: vi.fn(), - saveChanges: vi.fn(), - toast: vi.fn(), - ...overrides, - }; -} - -describe("usePendingChatPageGeneration", () => { - beforeEach(() => { - vi.clearAllMocks(); - generateWikiContentFromChatOutlineStream.mockImplementation( - async ( - _title: string, - _outline: string, - _conversationText: string, - handlers: { - onChunk: (chunk: string) => void; - onComplete: (result: { content: string }) => void; - onError: (err: Error) => void; - }, - ) => { - await Promise.resolve(); - handlers.onComplete({ content: "# Generated" }); - }, - ); - }); - - it("replaces location state to clear pendingChatPageGeneration after capture", async () => { - const seen: Location[] = []; - function LocationRecorder() { - const loc = useLocation(); - useEffect(() => { - seen.push(loc); - }, [loc]); - return null; - } - - renderHook(() => usePendingChatPageGeneration(buildHookOptions()), { - wrapper: ({ children }) => ( - - - {children} - - ), - }); - - await waitFor(() => { - expect(seen.some((l) => l.state === null)).toBe(true); - }); - }); - - it("calls generateWikiContentFromChatOutlineStream once for the same page and pending payload", async () => { - renderHook(() => usePendingChatPageGeneration(buildHookOptions()), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await waitFor(() => { - expect(generateWikiContentFromChatOutlineStream).toHaveBeenCalledTimes(1); - }); - }); - - it("does not start a second stream when pathname changes away from capture route", async () => { - function Subject() { - const { id } = useParams(); - const navigate = useNavigate(); - usePendingChatPageGeneration( - buildHookOptions({ - currentPageId: id ?? null, - }), - ); - return ( - - ); - } - - const user = userEvent.setup(); - - render( - - - } /> - - , - ); - - await waitFor(() => { - expect(generateWikiContentFromChatOutlineStream).toHaveBeenCalledTimes(1); - }); - - await user.click(screen.getByTestId("go-p2")); - - await waitFor(() => { - expect(generateWikiContentFromChatOutlineStream).toHaveBeenCalledTimes(1); - }); - }); - - it("on completion calls saveChanges, setContent, and success toast", async () => { - const setContent = vi.fn(); - const setWikiContentForCollab = vi.fn(); - const saveChanges = vi.fn(); - const toast = vi.fn(); - - renderHook( - () => - usePendingChatPageGeneration( - buildHookOptions({ - setContent, - setWikiContentForCollab, - saveChanges, - toast, - }), - ), - { - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - - await waitFor(() => { - expect(saveChanges).toHaveBeenCalledWith("Page title", expect.any(String)); - expect(setContent).toHaveBeenCalled(); - expect(setWikiContentForCollab).toHaveBeenCalled(); - expect(toast).toHaveBeenCalledWith({ title: "aiChat.notifications.pageBodyGenerated" }); - }); - }); - - it("shows pageBodyGenerateFailed toast when stream reports a non-abort error", async () => { - generateWikiContentFromChatOutlineStream.mockImplementation( - async ( - _title: string, - _outline: string, - _conversationText: string, - handlers: { - onChunk: (chunk: string) => void; - onComplete: (result: { content: string }) => void; - onError: (err: Error) => void; - }, - ) => { - await Promise.resolve(); - handlers.onError(new Error("stream failed")); - }, - ); - - const toast = vi.fn(); - - renderHook(() => usePendingChatPageGeneration(buildHookOptions({ toast })), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await waitFor(() => { - expect(toast).toHaveBeenCalledWith({ - title: "aiChat.notifications.pageBodyGenerateFailed", - variant: "destructive", - }); - }); - }); - - it("does not show a destructive toast when error message is ABORTED", async () => { - generateWikiContentFromChatOutlineStream.mockImplementation( - async ( - _title: string, - _outline: string, - _conversationText: string, - handlers: { - onChunk: (chunk: string) => void; - onComplete: (result: { content: string }) => void; - onError: (err: Error) => void; - }, - ) => { - await Promise.resolve(); - handlers.onError(new Error("ABORTED")); - }, - ); - - const toast = vi.fn(); - - renderHook(() => usePendingChatPageGeneration(buildHookOptions({ toast })), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await waitFor(() => { - expect(generateWikiContentFromChatOutlineStream).toHaveBeenCalled(); - }); - - expect(toast).not.toHaveBeenCalledWith(expect.objectContaining({ variant: "destructive" })); - }); - - it("does not start generation until the editor is initialized", async () => { - const { rerender } = renderHook( - ({ initialized }: { initialized: boolean }) => - usePendingChatPageGeneration(buildHookOptions({ isInitialized: initialized })), - { - initialProps: { initialized: false }, - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - - await waitFor(() => { - expect(generateWikiContentFromChatOutlineStream).not.toHaveBeenCalled(); - }); - - rerender({ initialized: true }); - - await waitFor(() => { - expect(generateWikiContentFromChatOutlineStream).toHaveBeenCalledTimes(1); - }); - }); - - it("does not start generation when title is blank", async () => { - const blankTitleOptions = buildHookOptions({ title: " " }); - renderHook(() => usePendingChatPageGeneration(blankTitleOptions), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await act(async () => { - await Promise.resolve(); - }); - - expect(generateWikiContentFromChatOutlineStream).not.toHaveBeenCalled(); - }); - - it("invokes setContent when the stream emits chunks (throttled path) before completion", async () => { - vi.useFakeTimers(); - try { - const setContent = vi.fn(); - generateWikiContentFromChatOutlineStream.mockImplementation( - async ( - _title: string, - _outline: string, - _conversationText: string, - handlers: { - onChunk: (chunk: string) => void; - onComplete: (result: { content: string }) => void; - onError: (err: Error) => void; - }, - ) => { - await Promise.resolve(); - handlers.onChunk("## "); - handlers.onChunk("Hi"); - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - handlers.onComplete({ content: "## Hi" }); - }, - ); - - renderHook(() => usePendingChatPageGeneration(buildHookOptions({ setContent })), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await act(async () => { - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(150); - }); - - expect(setContent).toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/src/components/editor/PageEditor/usePendingChatPageGeneration.ts b/src/components/editor/PageEditor/usePendingChatPageGeneration.ts deleted file mode 100644 index 1dde80e2..00000000 --- a/src/components/editor/PageEditor/usePendingChatPageGeneration.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { useEffect, useLayoutEffect, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { useLocation, useNavigate } from "react-router-dom"; -import type { PendingChatPageGenerationState } from "@/types/chatPageGeneration"; -import { convertMarkdownToTiptapContent } from "@/lib/markdownToTiptap"; -import { generateWikiContentFromChatOutlineStream } from "@/lib/wikiGenerator"; - -/** Throttle interval (ms) for streaming Markdown → Tiptap JSON in the editor. / ストリーム更新のスロットル */ -const PENDING_CHAT_PAGE_STREAM_THROTTLE_MS = 150; - -/** Inputs for {@link usePendingChatPageGeneration}. */ -export interface UsePendingChatPageGenerationOptions { - /** Active page id in the editor. */ - currentPageId: string | null; - /** Editor state has loaded page metadata. */ - isInitialized: boolean; - /** Page title for the generation prompt. */ - title: string; - setContent: (content: string) => void; - setWikiContentForCollab: (content: string | null) => void; - saveChanges: (title: string, content: string) => void; - toast: (opts: { title: string; variant?: "destructive" }) => void; -} - -/** - * When navigating from AI chat after creating a page, streams full Markdown into the editor - * from outline + conversation (second-stage generation). - * AIチャットからの新規ページ作成後、本文をストリーミング生成してエディタに反映する。 - */ -export function usePendingChatPageGeneration({ - currentPageId, - isInitialized, - title, - setContent, - setWikiContentForCollab, - saveChanges, - toast, -}: UsePendingChatPageGenerationOptions): void { - const { t } = useTranslation(); - const navigate = useNavigate(); - const location = useLocation(); - - /** Latest title / i18n for stream callbacks (effect deps omit `title` to avoid abort on edits). / タイトル編集で effect が再実行されないよう参照で渡す */ - const titleRef = useRef(title); - const tRef = useRef(t); - - useLayoutEffect(() => { - titleRef.current = title; - tRef.current = t; - }, [title, t]); - - /** Payload copied from router before state is cleared. / navigate で消す前に退避 */ - const pendingPayloadRef = useRef(null); - /** `pathname+search+hash` when that payload was captured (ignore if user navigates away). / 取り込み時の URL(別ルートへ移動したら破棄) */ - const pendingCaptureLocationKeyRef = useRef(null); - /** Prevents duplicate runs for the same outline + conversation. / 同一内容の二重生成を防ぐ */ - const startedKeyRef = useRef(null); - const lastPageIdRef = useRef(null); - - useLayoutEffect(() => { - const raw = location.state as { - pendingChatPageGeneration?: PendingChatPageGenerationState; - } | null; - const p = raw?.pendingChatPageGeneration; - if (p) { - pendingPayloadRef.current = p; - pendingCaptureLocationKeyRef.current = `${location.pathname}${location.search}${location.hash}`; - navigate( - { pathname: location.pathname, search: location.search, hash: location.hash }, - { replace: true, state: null }, - ); - } - }, [location.state, location.pathname, location.search, location.hash, navigate]); - - /** - * True once the page has a non-empty title — generation effect depends on this instead of `title` - * so keystrokes do not re-run the effect. / タイトルが非空になったら真。キー入力で effect が再実行されないよう `title` 本体は依存に含めない。 - */ - const titleReady = Boolean(title?.trim()); - - useEffect(() => { - if (currentPageId && currentPageId !== lastPageIdRef.current) { - startedKeyRef.current = null; - lastPageIdRef.current = currentPageId; - } - - const locationKey = `${location.pathname}${location.search}${location.hash}`; - if ( - pendingCaptureLocationKeyRef.current !== null && - locationKey !== pendingCaptureLocationKeyRef.current - ) { - pendingPayloadRef.current = null; - pendingCaptureLocationKeyRef.current = null; - } - - const pending = pendingPayloadRef.current; - if (!pending || !currentPageId || !isInitialized || !titleReady) { - return; - } - - const dedupeKey = `${currentPageId}:${pending.outline}\n${pending.conversationText}`; - if (startedKeyRef.current === dedupeKey) { - return; - } - startedKeyRef.current = dedupeKey; - - let markdown = ""; - let throttleId: ReturnType | null = null; - const ac = new AbortController(); - - const pathnameAtStart = location.pathname; - const searchAtStart = location.search; - const hashAtStart = location.hash; - - const clearRouterPendingState = () => { - navigate( - { pathname: pathnameAtStart, search: searchAtStart, hash: hashAtStart }, - { replace: true, state: null }, - ); - }; - - const flushThrottled = () => { - if (throttleId) return; - throttleId = setTimeout(() => { - throttleId = null; - // AI 出力経路。先頭の `# Title` 行はタイトル input と重複するため落とす(issue #784)。 - // AI path: drop a stray leading `# Title` line that duplicates the title input (issue #784). - const tiptap = convertMarkdownToTiptapContent(markdown, { dropLeadingH1: true }); - setContent(tiptap); - setWikiContentForCollab(tiptap); - }, PENDING_CHAT_PAGE_STREAM_THROTTLE_MS); - }; - - void generateWikiContentFromChatOutlineStream( - titleRef.current, - pending.outline, - pending.conversationText, - { - onChunk: (chunk) => { - markdown += chunk; - flushThrottled(); - }, - onComplete: (result) => { - if (throttleId) clearTimeout(throttleId); - throttleId = null; - // 同上、AI 出力なので先頭 `# Title` を落とす(issue #784)。 - // Same as above: AI output, drop a leading `# Title` (issue #784). - const tiptap = convertMarkdownToTiptapContent(result.content, { dropLeadingH1: true }); - setContent(tiptap); - setWikiContentForCollab(tiptap); - saveChanges(titleRef.current, tiptap); - clearRouterPendingState(); - toast({ title: tRef.current("aiChat.notifications.pageBodyGenerated") }); - }, - onError: (err) => { - if (throttleId) clearTimeout(throttleId); - throttleId = null; - clearRouterPendingState(); - if (err.message !== "ABORTED") { - toast({ - title: tRef.current("aiChat.notifications.pageBodyGenerateFailed"), - variant: "destructive", - }); - } - }, - }, - ac.signal, - pending.userSchema, - ); - - return () => { - ac.abort(); - if (throttleId) clearTimeout(throttleId); - }; - }, [ - location.pathname, - location.search, - location.hash, - currentPageId, - isInitialized, - titleReady, - navigate, - setContent, - setWikiContentForCollab, - saveChanges, - toast, - ]); -} diff --git a/src/components/editor/PageEditorView.tsx b/src/components/editor/PageEditorView.tsx deleted file mode 100644 index 3019f7b8..00000000 --- a/src/components/editor/PageEditorView.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import { Loader2 } from "lucide-react"; -import { usePageEditor } from "./PageEditor/usePageEditor"; -import { PageEditorLayout } from "./PageEditor/PageEditorLayout"; - -const LoadingSpinner = () => ( -
- -
-); - -/** - * - */ -const PageEditor: React.FC = () => { - const { showLoading, layoutProps } = usePageEditor(); - if (showLoading) return ; - return ; -}; - -export default PageEditor; diff --git a/src/components/editor/TiptapEditor/useWikiLinkNavigation.test.ts b/src/components/editor/TiptapEditor/useWikiLinkNavigation.test.ts index a5ac4cd2..6ec297b1 100644 --- a/src/components/editor/TiptapEditor/useWikiLinkNavigation.test.ts +++ b/src/components/editor/TiptapEditor/useWikiLinkNavigation.test.ts @@ -34,6 +34,12 @@ vi.mock("@/hooks/useNoteQueries", () => ({ import { usePageByTitle } from "@/hooks/usePageQueries"; import { useNoteTitleIndex } from "@/hooks/useNoteQueries"; +// Issue #889 Phase 3: `/pages/:id` 廃止に伴い、個人スコープのナビゲーションも +// `/notes/:noteId/:pageId` に統合された。テストの期待値も note-scoped に揃える。 +// Issue #889 Phase 3: personal-scope navigation now also targets +// `/notes/:noteId/:pageId` since `/pages/:id` has been retired. +const DEFAULT_NOTE_ID = "default-note"; + describe("useWikiLinkNavigation", () => { beforeEach(() => { vi.clearAllMocks(); @@ -78,7 +84,10 @@ describe("useWikiLinkNavigation", () => { vi.mocked(usePageByTitle).mockImplementation( (title: string) => ({ - data: title === "Existing Page" ? { id: "existing-id" } : undefined, + data: + title === "Existing Page" + ? { id: "existing-id", title: "Existing Page", noteId: DEFAULT_NOTE_ID } + : undefined, isFetched: title !== "", }) as ReturnType, ); @@ -92,7 +101,7 @@ describe("useWikiLinkNavigation", () => { }); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith("/pages/existing-id", { + expect(mockNavigate).toHaveBeenCalledWith(`/notes/${DEFAULT_NOTE_ID}/existing-id`, { replace: false, flushSync: true, }); @@ -130,7 +139,7 @@ describe("useWikiLinkNavigation", () => { }); it("handleConfirmCreate calls mutateAsync and navigates on success", async () => { - mockMutateAsync.mockResolvedValue({ id: "new-page-id" }); + mockMutateAsync.mockResolvedValue({ id: "new-page-id", noteId: DEFAULT_NOTE_ID }); vi.mocked(usePageByTitle).mockImplementation( (title: string) => @@ -161,7 +170,7 @@ describe("useWikiLinkNavigation", () => { title: "New Page Title", content: "", }); - expect(mockNavigate).toHaveBeenCalledWith("/pages/new-page-id", { + expect(mockNavigate).toHaveBeenCalledWith(`/notes/${DEFAULT_NOTE_ID}/new-page-id`, { replace: false, flushSync: true, }); @@ -170,8 +179,9 @@ describe("useWikiLinkNavigation", () => { }); it("re-clicking a just-created title navigates immediately without reopening dialog", async () => { - mockMutateAsync.mockResolvedValue({ id: "new-page-id" }); - const byTitleCache: Record = {}; + mockMutateAsync.mockResolvedValue({ id: "new-page-id", noteId: DEFAULT_NOTE_ID }); + const byTitleCache: Record = + {}; vi.mocked(usePageByTitle).mockImplementation( (title: string) => ({ @@ -197,7 +207,11 @@ describe("useWikiLinkNavigation", () => { }); // Simulate useCreatePage onSuccess: byTitle cache is now populated - byTitleCache["Fresh Page"] = { id: "new-page-id" }; + byTitleCache["Fresh Page"] = { + id: "new-page-id", + title: "Fresh Page", + noteId: DEFAULT_NOTE_ID, + }; mockNavigate.mockClear(); act(() => { @@ -205,7 +219,7 @@ describe("useWikiLinkNavigation", () => { }); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith("/pages/new-page-id", { + expect(mockNavigate).toHaveBeenCalledWith(`/notes/${DEFAULT_NOTE_ID}/new-page-id`, { replace: false, flushSync: true, }); diff --git a/src/components/editor/TiptapEditor/useWikiLinkNavigation.ts b/src/components/editor/TiptapEditor/useWikiLinkNavigation.ts index afcaa82d..61d70f50 100644 --- a/src/components/editor/TiptapEditor/useWikiLinkNavigation.ts +++ b/src/components/editor/TiptapEditor/useWikiLinkNavigation.ts @@ -5,14 +5,15 @@ import { useNoteTitleIndex } from "@/hooks/useNoteQueries"; interface UseWikiLinkNavigationOptions { /** - * 編集中ページの noteId。`null` は個人ページ、文字列値はノートネイティブ - * ページ。リンク先の検索スコープと遷移先 URL を切り替えるために使用する。 - * Issue #713 Phase 4。 + * 編集中ページの noteId。`null` はレガシー個人ページ呼び出しの fallback で、 + * Issue #889 Phase 3 で `/pages/:id` ルートが廃止された後は、解決された + * `foundPage.noteId` を使って `/notes/:noteId/:pageId` に統合的に遷移する。 + * 通常はノート ID を渡す(Issue #713 Phase 4 / #889 Phase 3)。 * - * Owning note ID of the page being edited. `null` scopes resolution to - * personal pages and navigates to `/pages/:id`; a string scopes resolution - * to same-note pages and navigates to `/notes/:noteId/:pageId`. See - * issue #713 Phase 4. + * Owning note ID of the page being edited. `null` is kept for legacy + * personal-page callers, but Issue #889 Phase 3 retired `/pages/:id` so + * navigation always lands on `/notes/:noteId/:pageId` using the resolved + * `foundPage.noteId`. Callers normally pass the owning note id. */ pageNoteId: string | null; } @@ -62,11 +63,18 @@ export function useWikiLinkNavigation( const personalResolved = useMemo(() => { if (!shouldQueryPersonal || !linkTitleToFind) { - return { data: null as { id: string; title: string } | null, isFetched: true }; + return { + data: null as { id: string; title: string; noteId: string } | null, + isFetched: true, + }; } if (personalLookup.data) { return { - data: { id: personalLookup.data.id, title: personalLookup.data.title }, + data: { + id: personalLookup.data.id, + title: personalLookup.data.title, + noteId: personalLookup.data.noteId, + }, isFetched: personalLookup.isFetched, }; } @@ -76,7 +84,7 @@ export function useWikiLinkNavigation( (p) => !p.isDeleted && (p.title ?? "").trim().toLowerCase() === normalized, ); return { - data: found ? { id: found.id, title: found.title } : null, + data: found ? { id: found.id, title: found.title, noteId: found.noteId } : null, isFetched: personalLookup.isFetched && !personalSummary.isLoading, }; }, [ @@ -104,7 +112,10 @@ export function useWikiLinkNavigation( const noteLookup = useMemo(() => { if (!shouldQueryNote || !linkTitleToFind) { - return { data: null as { id: string; title: string } | null, isFetched: true }; + return { + data: null as { id: string; title: string; noteId: string } | null, + isFetched: true, + }; } const normalized = linkTitleToFind.trim().toLowerCase(); const list = noteTitleIndexQuery.data ?? []; @@ -116,10 +127,19 @@ export function useWikiLinkNavigation( (p) => !p.isDeleted && (p.title ?? "").trim().toLowerCase() === normalized, ); return { - data: found ? { id: found.id, title: found.title } : null, + // `useNoteTitleIndex` の結果はノート所属が確定しているので noteId は + // 入力の `pageNoteId` を流用する(!== null は shouldQueryNote で保証済み)。 + // Note-scope hits all belong to `pageNoteId`, so reuse it for the noteId. + data: found && pageNoteId ? { id: found.id, title: found.title, noteId: pageNoteId } : null, isFetched: noteTitleIndexQuery.isFetched, }; - }, [shouldQueryNote, linkTitleToFind, noteTitleIndexQuery.data, noteTitleIndexQuery.isFetched]); + }, [ + shouldQueryNote, + linkTitleToFind, + noteTitleIndexQuery.data, + noteTitleIndexQuery.isFetched, + pageNoteId, + ]); const foundPage = pageNoteId === null ? personalResolved.data : noteLookup.data; const isFetched = pageNoteId === null ? personalResolved.isFetched : noteLookup.isFetched; @@ -156,19 +176,17 @@ export function useWikiLinkNavigation( if (!title.trim()) return; if (foundPage) { - // 既存ページが見つかった場合はそのページに移動。ノートスコープ時は - // 短縮形の `/notes/:noteId/:pageId`(App.tsx の canonical ルート)に - // 直接遷移して、旧パス `/notes/:noteId/pages/:pageId` のリダイレクトを - // 踏まないようにする。個人スコープ時は従来どおり `/pages/:id`。 + // Issue #889 Phase 3 で `/pages/:id` が廃止されたため、全ケースで + // `/notes/:noteId/:pageId` に遷移する。`foundPage.noteId` は + // 個人スコープ・ノートスコープいずれの解決パスでもセット済み。 // - // Existing page: route to `/notes/:noteId/:pageId` (the canonical - // note page route defined in `App.tsx`) to avoid the legacy - // `/notes/:noteId/pages/:pageId` redirect hop. Personal scope uses - // `/pages/:id` as before. - const target = pageNoteId - ? `/notes/${pageNoteId}/${foundPage.id}` - : `/pages/${foundPage.id}`; - navigate(target, { replace: false, flushSync: true }); + // After Issue #889 Phase 3 retired `/pages/:id`, navigation always + // targets `/notes/:noteId/:pageId`. Both resolution paths populate + // `foundPage.noteId` so this branch unifies cleanly. + navigate(`/notes/${foundPage.noteId}/${foundPage.id}`, { + replace: false, + flushSync: true, + }); } else { // ページが見つからなかった場合は確認ダイアログを表示 setPendingCreatePageTitle(title); @@ -184,12 +202,16 @@ export function useWikiLinkNavigation( }, [foundPage, isFetched, linkTitleToFind, navigate, pageNoteId]); // Handle create page confirmation - // 新規ページ作成パスは個人スコープでのみ有効。ノートネイティブページの - // 作成はノート配下の別フロー(`POST /api/notes/:noteId/pages`)で行うため、 - // ここでは個人ページとして作成し `/pages/:id` へ遷移する。Issue #713 Phase 4。 + // 新規ページ作成は `useCreatePage` 経由でデフォルトノートまたは指定ノートに + // 作成され、サーバが返す `note_id` を使って `/notes/:noteId/:pageId` に遷移する + // (Issue #889 Phase 3 で `/pages/:id` 経路は撤去)。ノートスコープ時の + // 新規作成は別経路(`POST /api/notes/:noteId/pages`)が担うため、現状は + // 個人デフォルトノートへのフォールバックだけ提供する。 // - // Page creation from a WikiLink is only supported in personal scope; note - // scope is handled by a separate flow (`POST /api/notes/:noteId/pages`). + // After Issue #889 Phase 3 the `/pages/:id` route is gone; `useCreatePage` + // attaches the new page to the caller's default note and the server returns + // `note_id`, so navigation always lands on `/notes/:noteId/:pageId`. Note- + // scoped creation is still handled separately via `POST /api/notes/:noteId/pages`. const handleConfirmCreate = useCallback(async () => { if (!pendingCreatePageTitle) return; if (pageNoteId) { @@ -206,7 +228,10 @@ export function useWikiLinkNavigation( }); setCreatePageDialogOpen(false); setPendingCreatePageTitle(null); - navigate(`/pages/${newPage.id}`, { replace: false, flushSync: true }); + navigate(`/notes/${newPage.noteId}/${newPage.id}`, { + replace: false, + flushSync: true, + }); } catch (error) { console.error("Failed to create page:", error); } diff --git a/src/components/layout/useFloatingActionButtonHandlers.ts b/src/components/layout/useFloatingActionButtonHandlers.ts index 99787b9c..c68b1001 100644 --- a/src/components/layout/useFloatingActionButtonHandlers.ts +++ b/src/components/layout/useFloatingActionButtonHandlers.ts @@ -10,6 +10,7 @@ import { useAddPageToNote } from "@/hooks/useNoteQueries"; import { useToast } from "@zedi/ui"; import { deleteCommittedThumbnail } from "@/lib/thumbnailCommit"; import { getThumbnailApiBaseUrl } from "@/components/editor/TiptapEditor/thumbnailApiHelpers"; +import type { Page } from "@/types/page"; import type { FABMenuOption } from "./FABMenu"; /** FAB の作成・クリップ・画像ダイアログ制御用オプション。Options for FAB create/clip/image dialog handlers. */ @@ -69,27 +70,41 @@ export function useFloatingActionButtonHandlers( /** * 作成済みページをノートに紐づけ、ノート配下のパスに遷移する。 - * Link the created page to the current note (if any) and navigate into it. + * Issue #889 Phase 3 で `/pages/:id` を廃止。`noteId` プロップが渡されている + * 場合は明示再紐づけ(ノートビューの FAB 経路)→`/notes/:noteId/:pageId`、 + * 未指定時はサーバが返す `newPage.noteId`(呼び出し元のデフォルトノート) + * 配下へ遷移する。紐づけに失敗した場合は toast を出して中断(スタンドアロン + * フォールバックを残すと誤ったノートのページとしてオープンしてしまう)。 + * + * Link the freshly-created page to the current note (if any) and navigate + * to it. After Issue #889 Phase 3 retired `/pages/:id`, callers always land + * on `/notes/:noteId/:pageId`: either the explicit `noteId` prop (after a + * successful re-link) or the page's own `noteId` (the caller's default + * note). On re-link failure we surface a toast and stop instead of routing + * the user into a misleading standalone view. */ const linkAndNavigate = useCallback( - async (pageId: string, navState?: Record): Promise => { - if (noteId) { + async (newPage: Page, navState?: Record): Promise => { + if (noteId && noteId !== newPage.noteId) { try { - await addPageToNoteMutation.mutateAsync({ noteId, pageId }); + await addPageToNoteMutation.mutateAsync({ noteId, pageId: newPage.id }); } catch (error) { - // 紐づけ失敗時はスタンドアロンページとして遷移させ、ユーザー操作を止めない。 - // If linking fails, fall back to the standalone page so the user - // can still see and edit their newly created page. console.error("Failed to attach page to note:", error); - navigate(`/pages/${pageId}`, navState ? { state: navState } : undefined); + toast({ + title: t("common.createPageFailed"), + variant: "destructive", + }); return; } - navigate(`/notes/${noteId}/${pageId}`, navState ? { state: navState } : undefined); + navigate(`/notes/${noteId}/${newPage.id}`, navState ? { state: navState } : undefined); return; } - navigate(`/pages/${pageId}`, navState ? { state: navState } : undefined); + navigate( + `/notes/${newPage.noteId}/${newPage.id}`, + navState ? { state: navState } : undefined, + ); }, - [addPageToNoteMutation, navigate, noteId], + [addPageToNoteMutation, navigate, noteId, toast, t], ); const handleMenuSelect = useCallback( @@ -166,7 +181,7 @@ export function useFloatingActionButtonHandlers( }); throw error; } - await linkAndNavigate(newPage.id, { initialContent: content }); + await linkAndNavigate(newPage, { initialContent: content }); }, [createPageMutation, linkAndNavigate, toast, t], ); @@ -195,7 +210,7 @@ export function useFloatingActionButtonHandlers( title: "", content, }); - await linkAndNavigate(newPage.id); + await linkAndNavigate(newPage); } catch (error) { console.error("Failed to create page from image:", error); toast({ diff --git a/src/components/page/LinkGroupRow.tsx b/src/components/page/LinkGroupRow.tsx index f763de03..867d13f6 100644 --- a/src/components/page/LinkGroupRow.tsx +++ b/src/components/page/LinkGroupRow.tsx @@ -6,7 +6,11 @@ import type { OutgoingLinkWithChildren } from "@/hooks/useLinkedPages"; interface LinkGroupRowProps { linkGroup: OutgoingLinkWithChildren; - onPageClick: (pageId: string) => void; + /** + * `/notes/:noteId/:pageId` 遷移用に noteId も渡す(Issue #889 Phase 3)。 + * Passes `noteId` so the parent can build the `/notes/:noteId/:pageId` URL. + */ + onPageClick: (pageId: string, noteId: string) => void; } /** @@ -21,7 +25,7 @@ export function LinkGroupRow({ linkGroup, onPageClick }: LinkGroupRowProps) { {/* Source link card (distinguished style) */} onPageClick(linkGroup.source.id)} + onClick={() => onPageClick(linkGroup.source.id, linkGroup.source.noteId)} > @@ -41,7 +45,11 @@ export function LinkGroupRow({ linkGroup, onPageClick }: LinkGroupRowProps) { {/* Child pages */} {linkGroup.children.map((child) => ( - onPageClick(child.id)} /> + onPageClick(child.id, child.noteId)} + /> ))}
diff --git a/src/components/page/LinkSection.tsx b/src/components/page/LinkSection.tsx index 518ee7e9..1d8a4dd7 100644 --- a/src/components/page/LinkSection.tsx +++ b/src/components/page/LinkSection.tsx @@ -6,7 +6,13 @@ interface LinkSectionProps { title?: string; icon?: ReactNode; pages: PageCard[]; - onPageClick: (pageId: string) => void; + /** + * 遷移先 URL は `/notes/:noteId/:pageId` のため、呼び出し元には pageId に + * 加えて noteId も渡す(Issue #889 Phase 3)。 + * `/notes/:noteId/:pageId` requires both ids — pass the page's `noteId` to + * the parent (Issue #889 Phase 3). + */ + onPageClick: (pageId: string, noteId: string) => void; } /** @@ -25,7 +31,11 @@ export function LinkSection({ title, icon, pages, onPageClick }: LinkSectionProp )}
{pages.map((page) => ( - onPageClick(page.id)} /> + onPageClick(page.id, page.noteId)} + /> ))}
diff --git a/src/components/page/LinkedPagesSection.test.tsx b/src/components/page/LinkedPagesSection.test.tsx index 734a749d..66abac4c 100644 --- a/src/components/page/LinkedPagesSection.test.tsx +++ b/src/components/page/LinkedPagesSection.test.tsx @@ -66,6 +66,7 @@ describe("LinkedPagesSection", () => { mockLinkedPagesData.outgoingLinks = [ { id: "page-1", + noteId: "note-1", title: "Outgoing Page", preview: "Preview text", updatedAt: Date.now(), @@ -74,6 +75,7 @@ describe("LinkedPagesSection", () => { mockLinkedPagesData.backlinks = [ { id: "backlink-1", + noteId: "note-1", title: "Backlink Page", preview: "Backlink preview", updatedAt: Date.now(), @@ -102,6 +104,7 @@ describe("LinkedPagesSection", () => { { source: { id: "source-page", + noteId: "note-1", title: "Source Page", preview: "Source preview", updatedAt: Date.now(), @@ -109,12 +112,14 @@ describe("LinkedPagesSection", () => { children: [ { id: "child-1", + noteId: "note-1", title: "Child Page 1", preview: "Child preview", updatedAt: Date.now(), }, { id: "child-2", + noteId: "note-1", title: "Child Page 2", preview: "Child preview 2", updatedAt: Date.now(), @@ -135,6 +140,7 @@ describe("LinkedPagesSection", () => { mockLinkedPagesData.outgoingLinks = [ { id: "target-page", + noteId: "target-note", title: "Target Page", preview: "Preview", updatedAt: Date.now(), @@ -145,7 +151,7 @@ describe("LinkedPagesSection", () => { await user.click(screen.getByText("Target Page")); - expect(mockNavigate).toHaveBeenCalledWith("/pages/target-page"); + expect(mockNavigate).toHaveBeenCalledWith("/notes/target-note/target-page"); }); it("should navigate to source page when link group source is clicked", async () => { @@ -154,6 +160,7 @@ describe("LinkedPagesSection", () => { { source: { id: "source-page", + noteId: "source-note", title: "Source Page", preview: "Source preview", updatedAt: Date.now(), @@ -161,6 +168,7 @@ describe("LinkedPagesSection", () => { children: [ { id: "child-1", + noteId: "source-note", title: "Child Page", preview: "Child preview", updatedAt: Date.now(), @@ -173,7 +181,7 @@ describe("LinkedPagesSection", () => { await user.click(screen.getByText("Source Page")); - expect(mockNavigate).toHaveBeenCalledWith("/pages/source-page"); + expect(mockNavigate).toHaveBeenCalledWith("/notes/source-note/source-page"); }); it("should navigate to child page when child card is clicked", async () => { @@ -182,6 +190,7 @@ describe("LinkedPagesSection", () => { { source: { id: "source-page", + noteId: "source-note", title: "Source Page", preview: "Source preview", updatedAt: Date.now(), @@ -189,6 +198,7 @@ describe("LinkedPagesSection", () => { children: [ { id: "child-page", + noteId: "child-note", title: "Child Page", preview: "Child preview", updatedAt: Date.now(), @@ -201,13 +211,13 @@ describe("LinkedPagesSection", () => { await user.click(screen.getByText("Child Page")); - expect(mockNavigate).toHaveBeenCalledWith("/pages/child-page"); + expect(mockNavigate).toHaveBeenCalledWith("/notes/child-note/child-page"); }); it("should create page and navigate when ghost link is clicked", async () => { const user = userEvent.setup(); mockLinkedPagesData.ghostLinks = ["New Page Title"]; - mockCreatePage.mockResolvedValue({ id: "new-page-id" }); + mockCreatePage.mockResolvedValue({ id: "new-page-id", noteId: "default-note" }); renderComponent(); @@ -218,7 +228,7 @@ describe("LinkedPagesSection", () => { }); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith("/pages/new-page-id", { + expect(mockNavigate).toHaveBeenCalledWith("/notes/default-note/new-page-id", { flushSync: true, }); }); @@ -226,15 +236,16 @@ describe("LinkedPagesSection", () => { it("should render all sections when all link types exist", () => { mockLinkedPagesData.outgoingLinks = [ - { id: "out-1", title: "Outgoing", preview: "", updatedAt: Date.now() }, + { id: "out-1", noteId: "note-1", title: "Outgoing", preview: "", updatedAt: Date.now() }, ]; mockLinkedPagesData.backlinks = [ - { id: "back-1", title: "Backlink", preview: "", updatedAt: Date.now() }, + { id: "back-1", noteId: "note-1", title: "Backlink", preview: "", updatedAt: Date.now() }, ]; mockLinkedPagesData.outgoingLinksWithChildren = [ { source: { id: "source-1", + noteId: "note-1", title: "SourceWithChildren", preview: "", updatedAt: Date.now(), @@ -242,6 +253,7 @@ describe("LinkedPagesSection", () => { children: [ { id: "child-1", + noteId: "note-1", title: "ChildPage", preview: "", updatedAt: Date.now(), diff --git a/src/components/page/LinkedPagesSection.tsx b/src/components/page/LinkedPagesSection.tsx index e642d94b..60fbd63f 100644 --- a/src/components/page/LinkedPagesSection.tsx +++ b/src/components/page/LinkedPagesSection.tsx @@ -73,10 +73,13 @@ export function LinkedPagesSection({ pageId, isSyncingLinks = false }: LinkedPag if (!hasAnyLinks) return null; /** - * + * リンクされたページへ遷移する。PageCard には `noteId` が含まれているので + * `/notes/:noteId/:pageId` を直接組み立てられる(Issue #889 Phase 3)。 + * Navigate to a linked page. `PageCard` carries `noteId`, so we can build + * `/notes/:noteId/:pageId` directly (Issue #889 Phase 3). */ - const handlePageClick = (id: string) => { - navigate(`/pages/${id}`); + const handlePageClick = (id: string, noteId: string) => { + navigate(`/notes/${noteId}/${id}`); }; /** @@ -89,7 +92,7 @@ export function LinkedPagesSection({ pageId, isSyncingLinks = false }: LinkedPag * */ const newPage = await createPageMutation.mutateAsync({ title }); - navigate(`/pages/${newPage.id}`, { flushSync: true }); + navigate(`/notes/${newPage.noteId}/${newPage.id}`, { flushSync: true }); } catch (error) { console.error("Failed to create page:", error); } diff --git a/src/components/page/PageCard.tsx b/src/components/page/PageCard.tsx index 4b03c1cb..f95b034a 100644 --- a/src/components/page/PageCard.tsx +++ b/src/components/page/PageCard.tsx @@ -32,13 +32,16 @@ interface PageCardProps { page: PageSummary; index?: number; /** - * ノート文脈での表示時は `noteId` を渡す。遷移先・削除セマンティクスが - * `/notes/:noteId/:pageId` 配下に切り替わる。未指定時は従来の `/pages/:id` - * (個人ページ向け)として動作する。 + * ノート文脈での表示時は `noteId` を渡す。削除セマンティクスがノートからの + * 取り外し(`useRemovePageFromNote`)に切り替わる。未指定時は通常のページ + * 削除(`useDeletePage`)として動作する。遷移先は常に `page.noteId` を使った + * `/notes/:noteId/:pageId` (Issue #889 Phase 3 で `/pages/:id` を廃止)。 * - * Pass `noteId` when rendering inside a note. Navigation and the delete - * mutation switch to the note-scoped variants. Without it the card behaves - * as the legacy personal-page card under `/pages/:id`. + * Pass `noteId` when rendering inside a note. The delete mutation switches + * to the note-scoped removal (`useRemovePageFromNote`). Without it the card + * uses the personal page delete. Navigation always targets + * `/notes/:noteId/:pageId` using `page.noteId` (Issue #889 Phase 3 retired + * the `/pages/:id` route). */ noteId?: string; /** @@ -84,7 +87,13 @@ const PageCard: React.FC = ({ page, index = 0, noteId, canDelete const isClipped = !!page.sourceUrl; const displayTitle = page.title || t("common.untitledPage"); - const targetHref = noteId ? `/notes/${noteId}/${page.id}` : `/pages/${page.id}`; + // Issue #889 Phase 3: `/pages/:id` は廃止。常に所属ノート配下の URL に遷移する。 + // `noteId` プロップが渡された場合は明示指定を優先(ノート内表示)し、無い場合は + // PageSummary の `noteId` を使う(Issue #825 で non-null 保証済み)。 + // Issue #889 Phase 3: `/pages/:id` is retired. Navigation always targets the + // note-scoped URL. Use the explicit `noteId` prop when rendering inside a + // note, else fall back to `page.noteId` (non-null since Issue #825). + const targetHref = `/notes/${noteId ?? page.noteId}/${page.id}`; const handleClick = () => { // ドラッグ直後のクリックを無視 / Ignore click right after drag @@ -134,7 +143,7 @@ const PageCard: React.FC = ({ page, index = 0, noteId, canDelete title: t("common.page.duplicated"), description: t("common.page.duplicatedWithTitle", { title: newTitle }), }); - navigate(`/pages/${newPage.id}`); + navigate(`/notes/${newPage.noteId}/${newPage.id}`); } catch (error) { console.error("Failed to duplicate page:", error); toast({ diff --git a/src/components/page/PageLinkCard.test.tsx b/src/components/page/PageLinkCard.test.tsx index 107367eb..7f476fce 100644 --- a/src/components/page/PageLinkCard.test.tsx +++ b/src/components/page/PageLinkCard.test.tsx @@ -7,6 +7,7 @@ import type { PageCard } from "@/hooks/useLinkedPages"; describe("PageLinkCard", () => { const createPageCard = (overrides?: Partial): PageCard => ({ id: "page-1", + noteId: "note-1", title: "Test Page", preview: "This is a test preview", updatedAt: Date.now() - 1000 * 60 * 60, // 1 hour ago diff --git a/src/components/pdf-reader/HighlightLayer.test.tsx b/src/components/pdf-reader/HighlightLayer.test.tsx index 60a43546..22726ca9 100644 --- a/src/components/pdf-reader/HighlightLayer.test.tsx +++ b/src/components/pdf-reader/HighlightLayer.test.tsx @@ -34,6 +34,7 @@ function makeHighlight(overrides: Partial): PdfHighlight { sourceId: "s1", ownerId: "u1", derivedPageId: null, + derivedPageNoteId: null, pdfPage: 1, rects: [{ x1: 10, y1: 50, x2: 60, y2: 70 }], text: "hello", diff --git a/src/components/pdf-reader/HighlightSidebar.test.tsx b/src/components/pdf-reader/HighlightSidebar.test.tsx index 242bcecf..454b834f 100644 --- a/src/components/pdf-reader/HighlightSidebar.test.tsx +++ b/src/components/pdf-reader/HighlightSidebar.test.tsx @@ -31,6 +31,7 @@ function makeHighlight(overrides: Partial): PdfHighlight { sourceId: "s1", ownerId: "u1", derivedPageId: null, + derivedPageNoteId: null, pdfPage: 1, rects: [{ x1: 0, y1: 0, x2: 1, y2: 1 }], text: "hello world", @@ -89,8 +90,14 @@ describe("HighlightSidebar", () => { hoisted.usePdfHighlightsMock.mockReturnValue({ data: { highlights: [ - makeHighlight({ id: "with-page", derivedPageId: "page-1" }), - makeHighlight({ id: "without-page", derivedPageId: null }), + // Issue #889 Phase 3: link build requires both pageId + noteId, so + // the sidebar only renders the link when both are populated. + makeHighlight({ + id: "with-page", + derivedPageId: "page-1", + derivedPageNoteId: "note-1", + }), + makeHighlight({ id: "without-page", derivedPageId: null, derivedPageNoteId: null }), ], }, isLoading: false, @@ -101,7 +108,7 @@ describe("HighlightSidebar", () => { const links = screen.getAllByRole("button", { name: /派生ページを開く/ }); expect(links.length).toBe(1); fireEvent.click(links[0]); - expect(onOpen).toHaveBeenCalledWith("page-1"); + expect(onOpen).toHaveBeenCalledWith("page-1", "note-1"); }); it("invokes delete mutation after confirm", () => { diff --git a/src/components/pdf-reader/HighlightSidebar.tsx b/src/components/pdf-reader/HighlightSidebar.tsx index f8cc8ff8..4aee57ef 100644 --- a/src/components/pdf-reader/HighlightSidebar.tsx +++ b/src/components/pdf-reader/HighlightSidebar.tsx @@ -25,8 +25,13 @@ export interface HighlightSidebarProps { activeHighlightId?: string | null; /** Called when the user clicks a sidebar row. */ onSelectHighlight?: (highlight: PdfHighlight) => void; - /** Called when the user clicks "Open derived page". */ - onOpenDerivedPage?: (pageId: string) => void; + /** + * Called when the user clicks "Open derived page". + * 派生ページの URL は `/notes/:noteId/:pageId` のため、`pageId` に加えて + * `noteId` も渡す(Issue #889 Phase 3 で `/pages/:id` を廃止)。 + * Receives both ids since the route is `/notes/:noteId/:pageId`. + */ + onOpenDerivedPage?: (pageId: string, noteId: string) => void; } const COLOR_SWATCHES: Record = { @@ -157,10 +162,15 @@ export function HighlightSidebar({ ))} )} - {h.derivedPageId && ( + {h.derivedPageId && h.derivedPageNoteId && (