From 640d40d98913766952593646400f4376fe634e7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 02:50:31 +0000 Subject: [PATCH 1/3] feat(search): surface PDF highlight bodies in Global Search (#864) Extend /api/search to also probe pdf_highlights.text with owner-scoping and a defensive sources.kind='pdf_local' check, then plumb the new discriminated rows through the client merge logic so the global search dropdown and /search page render highlight hits and deep-link to either the derived Zedi page or /sources/:sourceId/pdf#page=N. - Server: add highlight query gated by PDF_HIGHLIGHT_SEARCH_DISABLED env kill switch; tag every result row with kind ("page" / "pdf_highlight"). - Client: turn SearchSharedResponse into a discriminated union, route highlight rows via resolveSearchResultUrl, and score them below page matches to preserve "title > derived page > highlight body" ordering. - Tests: add unit coverage for owner-scoping, kind="pdf_local" filter, feature-flag kill switch, URL composition, and the merged result shape. --- .../api/src/__tests__/routes/search.test.ts | 266 ++++++++++++++++-- server/api/src/routes/search.ts | 104 ++++++- .../layout/Header/HeaderSearchBar.tsx | 62 +--- .../Header/HeaderSearchDropdownContent.tsx | 77 +++-- src/components/search/SearchResultCard.tsx | 48 +++- src/contexts/GlobalSearchContext.test.ts | 67 +++++ src/contexts/GlobalSearchContext.tsx | 79 +++--- src/hooks/useGlobalSearch.test.ts | 256 +++++++++++++---- src/hooks/useGlobalSearch.ts | 188 ++++++++++--- src/lib/api/types.ts | 65 ++++- src/pages/SearchResults.tsx | 167 ++++++++--- 11 files changed, 1069 insertions(+), 310 deletions(-) create mode 100644 src/contexts/GlobalSearchContext.test.ts diff --git a/server/api/src/__tests__/routes/search.test.ts b/server/api/src/__tests__/routes/search.test.ts index b5b3c0c6..328127fa 100644 --- a/server/api/src/__tests__/routes/search.test.ts +++ b/server/api/src/__tests__/routes/search.test.ts @@ -9,8 +9,15 @@ * Issue #823: `scope=own` restricts to the caller's default note; `scope=shared` spans * pages in notes reachable via owner / member / domain access. The `note_pages` table * is gone. + * + * Issue #864: ハイライト検索 (`pdf_highlights`) も同エンドポイントから返す。 + * 所有検証 (`owner_id = userId`) と `kind="pdf_local"` の二重防御を確認する。 + * + * Issue #864: this endpoint now also surfaces `pdf_highlights` rows. The tests + * below assert the owner filter and the `kind="pdf_local"` defense-in-depth + * check, and that the discriminator (`kind`) is set on every row. */ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Context, Next } from "hono"; import type { AppEnv } from "../../types/index.js"; @@ -64,9 +71,26 @@ function createSearchApp(dbResults: unknown[]) { return { app, chains }; } +/** + * デフォルトのモック DB 結果。1 番目がページ検索、2 番目が pdf_highlights 検索。 + * いずれも空配列を返すデフォルト。 + * + * Default mock results — first call answers the page query, second answers the + * pdf_highlights query. Both default to empty rows. + */ +function emptyDbResults() { + return [{ rows: [] }, { rows: [] }]; +} + describe("GET /api/search", () => { + beforeEach(() => { + // テスト間で env がリークしないようキルスイッチを毎回クリア。 + // Reset the kill switch between tests so env state does not leak. + delete process.env.PDF_HIGHLIGHT_SEARCH_DISABLED; + }); + it("returns 401 without auth header", async () => { - const { app } = createSearchApp([{ rows: [] }]); + const { app } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello", { method: "GET" }); @@ -88,7 +112,7 @@ describe("GET /api/search", () => { }); it("scope=own binds listing to default note id from getDefaultNoteOrNull (issue #823)", async () => { - const { app, chains } = createSearchApp([{ rows: [] }]); + const { app, chains } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello&scope=own", { method: "GET", @@ -96,9 +120,10 @@ describe("GET /api/search", () => { }); expect(res.status).toBe(200); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains.length).toBeGreaterThanOrEqual(1); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain("default-note-search-mock"); expect(serialised).toContain("p.note_id"); expect(serialised).not.toContain("is_default"); @@ -106,7 +131,7 @@ describe("GET /api/search", () => { }); it("defaults to scope=own when scope query parameter is omitted", async () => { - const { app, chains } = createSearchApp([{ rows: [] }]); + const { app, chains } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello", { method: "GET", @@ -114,16 +139,18 @@ describe("GET /api/search", () => { }); expect(res.status).toBe(200); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain("default-note-search-mock"); expect(serialised).toContain("p.note_id"); }); - it("scope=own returns empty results when default note is missing", async () => { + it("scope=own runs highlight search even when default note is missing", async () => { vi.mocked(getDefaultNoteOrNull).mockResolvedValueOnce(null); - const { app, chains } = createSearchApp([]); + // ページ検索は走らない(default note 無し)が、ハイライト検索は走る。 + // The page query is skipped (no default note), but the highlight query still runs. + const { app, chains } = createSearchApp([{ rows: [] }]); const res = await app.request("/api/search?q=hello&scope=own", { method: "GET", @@ -133,11 +160,14 @@ describe("GET /api/search", () => { expect(res.status).toBe(200); const body = (await res.json()) as { results: unknown[] }; expect(body.results).toEqual([]); - expect(chains.find((c) => c.startMethod === "execute")).toBeUndefined(); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains).toHaveLength(1); + const serialised = JSON.stringify(executeChains[0]?.startArgs); + expect(serialised).toContain("pdf_highlights"); }); it("scope=shared uses note ownership / member / domain EXISTS branches without note_pages", async () => { - const { app, chains } = createSearchApp([{ rows: [] }]); + const { app, chains } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello&scope=shared", { method: "GET", @@ -145,16 +175,16 @@ describe("GET /api/search", () => { }); expect(res.status).toBe(200); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).not.toContain("note_pages"); expect(serialised).toContain("note_members"); expect(serialised).toContain("OR EXISTS"); }); it("falls back to the default limit when the limit query is non-numeric", async () => { - const { app } = createSearchApp([{ rows: [] }]); + const { app } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello&scope=shared&limit=abc", { method: "GET", @@ -165,7 +195,7 @@ describe("GET /api/search", () => { }); it("scope=shared keeps note-scoped EXISTS predicates (no note_pages join)", async () => { - const { app, chains } = createSearchApp([{ rows: [] }]); + const { app, chains } = createSearchApp(emptyDbResults()); const res = await app.request("/api/search?q=hello&scope=shared", { method: "GET", @@ -173,15 +203,15 @@ describe("GET /api/search", () => { }); expect(res.status).toBe(200); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - expect(executeChain).toBeDefined(); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain(TEST_USER_ID); expect(serialised).not.toContain("note_pages"); expect(serialised).toContain("p.note_id"); }); - it("response rows include note_id so callers can distinguish pages by owning note", async () => { + it("response page rows are tagged with kind='page' and include note_id", async () => { const defaultNotePageId = "11111111-1111-1111-1111-111111111111"; const { app, chains } = createSearchApp([ { @@ -195,6 +225,7 @@ describe("GET /api/search", () => { }, ], }, + { rows: [] }, ]); const res = await app.request("/api/search?q=hello&scope=shared", { @@ -206,9 +237,11 @@ describe("GET /api/search", () => { const body = (await res.json()) as { results: Array> }; expect(body.results).toHaveLength(1); expect(body.results[0]).toHaveProperty("note_id"); + expect(body.results[0]).toHaveProperty("kind", "page"); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain("p.note_id"); }); @@ -226,6 +259,7 @@ describe("GET /api/search", () => { }, ], }, + { rows: [] }, ]); const res = await app.request("/api/search?q=hello&scope=own", { @@ -237,11 +271,189 @@ describe("GET /api/search", () => { const body = (await res.json()) as { results: Array> }; expect(body.results).toHaveLength(1); expect(body.results[0]).toHaveProperty("note_id", defaultNotePageId); + expect(body.results[0]).toHaveProperty("kind", "page"); - const executeChain = chains.find((chain) => chain.startMethod === "execute"); - const serialised = JSON.stringify(executeChain?.startArgs); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + const pagesChain = executeChains[0]; + const serialised = JSON.stringify(pagesChain?.startArgs); expect(serialised).toContain("p.note_id"); expect(serialised).toContain("default-note-search-mock"); expect(serialised).not.toContain("is_default"); }); + + // ── Issue #864: PDF ハイライト統合 ───────────────────────────────────────── + // PDF highlight integration tests (Issue #864). + + it("scope=own includes pdf_highlights rows filtered by owner_id (no leak of other users')", async () => { + const { app, chains } = createSearchApp([ + { rows: [] }, // ページ検索 / page query + { + rows: [ + { + highlight_id: "h-1", + source_id: "s-1", + owner_id: TEST_USER_ID, + pdf_page: 5, + text: "highlighted passage about hello", + derived_page_id: null, + updated_at: new Date("2026-05-01T00:00:00Z").toISOString(), + source_display_name: "paper.pdf", + source_title: null, + }, + ], + }, + ]); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results).toHaveLength(1); + expect(body.results[0]).toMatchObject({ + kind: "pdf_highlight", + highlight_id: "h-1", + source_id: "s-1", + pdf_page: 5, + derived_page_id: null, + source_display_name: "paper.pdf", + }); + + // 所有検証 (owner_id = userId) と `kind="pdf_local"` の両方が SQL に存在する。 + // Both the owner filter and the `kind="pdf_local"` defensive filter live in the SQL. + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains.length).toBe(2); + const highlightChain = executeChains[1]; + const serialised = JSON.stringify(highlightChain?.startArgs); + expect(serialised).toContain("h.owner_id"); + expect(serialised).toContain(TEST_USER_ID); + expect(serialised).toContain("pdf_local"); + }); + + it("scope=shared still scopes pdf_highlights to the caller's own rows only", async () => { + const { app, chains } = createSearchApp([ + { rows: [] }, + { + rows: [ + { + highlight_id: "h-2", + source_id: "s-2", + owner_id: TEST_USER_ID, + pdf_page: 1, + text: "shared lookup result text", + derived_page_id: "p-derived-1", + updated_at: new Date("2026-05-02T00:00:00Z").toISOString(), + source_display_name: "notes.pdf", + source_title: "Notes", + }, + ], + }, + ]); + + const res = await app.request("/api/search?q=shared&scope=shared", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results).toHaveLength(1); + expect(body.results[0]).toMatchObject({ + kind: "pdf_highlight", + highlight_id: "h-2", + derived_page_id: "p-derived-1", + }); + + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains).toHaveLength(2); + const highlightChain = executeChains[1]; + const serialised = JSON.stringify(highlightChain?.startArgs); + // 他ユーザーのハイライトを掴まないよう owner_id 比較が必ず入る。 + // The owner filter is always present regardless of scope. + expect(serialised).toContain("h.owner_id"); + expect(serialised).toContain(TEST_USER_ID); + }); + + it("pdf_highlight branch JOINs sources and restricts to kind='pdf_local'", async () => { + const { app, chains } = createSearchApp(emptyDbResults()); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + expect(executeChains).toHaveLength(2); + const highlightChain = executeChains[1]; + const serialised = JSON.stringify(highlightChain?.startArgs); + expect(serialised).toContain("pdf_highlights"); + expect(serialised).toContain("INNER JOIN sources"); + expect(serialised).toContain("pdf_local"); + }); + + it("PDF_HIGHLIGHT_SEARCH_DISABLED=1 skips the highlight query entirely", async () => { + process.env.PDF_HIGHLIGHT_SEARCH_DISABLED = "1"; + const { app, chains } = createSearchApp([{ rows: [] }]); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: unknown[] }; + expect(body.results).toEqual([]); + const executeChains = chains.filter((chain) => chain.startMethod === "execute"); + // ページ検索のみ実行され、ハイライト検索は走らない。 + // Only the page query runs; the highlight query is short-circuited. + expect(executeChains).toHaveLength(1); + const serialised = JSON.stringify(executeChains[0]?.startArgs); + expect(serialised).not.toContain("pdf_highlights"); + }); + + it("merges page rows (kind='page') and highlight rows (kind='pdf_highlight') in one response", async () => { + const pageId = "33333333-3333-3333-3333-333333333333"; + const { app } = createSearchApp([ + { + rows: [ + { + id: pageId, + title: "Page with hello", + content_preview: "hello there", + updated_at: new Date("2026-04-01T00:00:00Z").toISOString(), + note_id: "default-note-search-mock", + }, + ], + }, + { + rows: [ + { + highlight_id: "h-3", + source_id: "s-3", + owner_id: TEST_USER_ID, + pdf_page: 7, + text: "hello in PDF", + derived_page_id: null, + updated_at: new Date("2026-04-02T00:00:00Z").toISOString(), + source_display_name: "doc.pdf", + source_title: null, + }, + ], + }, + ]); + + const res = await app.request("/api/search?q=hello&scope=own", { + method: "GET", + headers: authHeaders(), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { results: Array> }; + expect(body.results).toHaveLength(2); + expect(body.results[0]).toMatchObject({ kind: "page", id: pageId }); + expect(body.results[1]).toMatchObject({ kind: "pdf_highlight", highlight_id: "h-3" }); + }); }); diff --git a/server/api/src/routes/search.ts b/server/api/src/routes/search.ts index adeb044f..10edc753 100644 --- a/server/api/src/routes/search.ts +++ b/server/api/src/routes/search.ts @@ -12,6 +12,30 @@ * - `scope=own` restricts to pages under the caller's default note. * - `scope=shared` spans pages in notes the caller can access (owner, accepted * member, or domain rule). + * + * PDF ハイライト統合 (Issue #864 / #389 follow-up): + * - 同じ検索 q を用いて `pdf_highlights.text` も対象に含める。 + * - ハイライトは常に呼び出し元 (`owner_id = userId`) 所有のみを返し、scope に依らず + * 他ユーザーに漏れない(テーブル単体で所有者を持つ前提)。 + * - 元ソースが `kind="pdf_local"` のもののみ対象(JOIN で防御的にフィルタ)。 + * - レスポンスは `kind` 識別子付きの discriminated union で返す + * (`kind="page"` / `kind="pdf_highlight"`)。クライアントはこれで分岐する。 + * - 環境変数 `PDF_HIGHLIGHT_SEARCH_DISABLED=1` をセットすると、ハイライト検索だけを + * 無効化できる(運用上のセーフティ)。ページ検索には影響しない。 + * + * PDF highlight integration (Issue #864, follow-up to #389): + * - The same query string also probes `pdf_highlights.text`. + * - Highlights are only returned to their owner — scope does NOT widen this; the + * table has a denormalized `owner_id` precisely for this case. + * - Defensive `JOIN sources` ensures we never surface highlights whose owning + * source row is somehow not `kind="pdf_local"` anymore. + * - The response is now a discriminated union tagged by `kind`: + * `"page"` (existing rows) and `"pdf_highlight"` (new rows). Clients branch on + * `kind` and the highlight rows carry `source_id` / `pdf_page` / `highlight_id` + * / `derived_page_id` so the UI can deep-link back to the PDF viewer or the + * derived Zedi page. + * - Set `PDF_HIGHLIGHT_SEARCH_DISABLED=1` to disable just the highlight part + * (kill switch); page search is unaffected. */ import { Hono } from "hono"; import { sql } from "drizzle-orm"; @@ -30,6 +54,15 @@ function clampLimit(raw: string | undefined): number { return Math.min(Math.max(safe, 1), 100); } +/** + * ハイライト検索のキルスイッチ。`PDF_HIGHLIGHT_SEARCH_DISABLED=1` or `=true` で有効。 + * Kill switch for the highlight search branch. + */ +function isPdfHighlightSearchDisabled(): boolean { + const v = (process.env.PDF_HIGHLIGHT_SEARCH_DISABLED ?? "").trim().toLowerCase(); + return v === "1" || v === "true"; +} + const app = new Hono(); app.get("/", authRequired, async (c) => { @@ -65,10 +98,10 @@ app.get("/", authRequired, async (c) => { )` : sql``; - let results; + let pageRows: unknown[] = []; if (scope === "shared") { - results = await db.execute(sql` + const sharedResults = await db.execute(sql` SELECT ${searchColumns} FROM pages p LEFT JOIN page_contents pc ON pc.page_id = p.id @@ -98,12 +131,17 @@ app.get("/", authRequired, async (c) => { ORDER BY p.updated_at DESC LIMIT ${limit} `); + pageRows = sharedResults.rows; } else { const defaultNote = await getDefaultNoteOrNull(db, userId); if (!defaultNote) { - return c.json({ results: [] }); + // デフォルトノートが無い場合でもハイライト検索は走り得るので、ページ部だけ空配列に。 + // Even without a default note, highlight search can still run, so only the + // page branch short-circuits here. + const highlightRows = await runPdfHighlightSearch(db, userId, pattern, limit); + return c.json({ results: highlightRows }); } - results = await db.execute(sql` + const ownResults = await db.execute(sql` SELECT ${searchColumns} FROM pages p LEFT JOIN page_contents pc ON pc.page_id = p.id @@ -116,9 +154,65 @@ app.get("/", authRequired, async (c) => { ORDER BY p.updated_at DESC LIMIT ${limit} `); + pageRows = ownResults.rows; } - return c.json({ results: results.rows }); + // 既存ページ行に kind="page" の識別子を付与してから、ハイライト結果と結合する。 + // Tag every page row with `kind: "page"` before merging with highlight rows. + const taggedPageRows = pageRows.map((row) => ({ + ...(row as Record), + kind: "page" as const, + })); + + const highlightRows = await runPdfHighlightSearch(db, userId, pattern, limit); + + return c.json({ results: [...taggedPageRows, ...highlightRows] }); }); +/** + * `pdf_highlights` を所有検証付きで検索し、`kind="pdf_highlight"` 行を返す。 + * 戻り値は `c.json` にそのまま流せる形に整える(snake_case のキー)。 + * + * Searches `pdf_highlights` for the caller's own highlights only and returns + * a list of `kind: "pdf_highlight"` rows shaped for `c.json` (snake_case). + * + * @param db リクエストの drizzle DB ハンドル。Drizzle DB handle from the request. + * @param userId 呼び出し元ユーザー ID。Owner filter — only the caller's rows are returned. + * @param pattern ILIKE 用にエスケープ済みのパターン。Pre-escaped ILIKE pattern. + * @param limit 結果上限。Maximum number of rows to return. + */ +async function runPdfHighlightSearch( + db: AppEnv["Variables"]["db"], + userId: string, + pattern: string, + limit: number, +): Promise>> { + if (isPdfHighlightSearchDisabled()) return []; + + const result = await db.execute(sql` + SELECT + h.id AS highlight_id, + h.source_id AS source_id, + h.owner_id AS owner_id, + h.pdf_page AS pdf_page, + h.text AS text, + h.derived_page_id AS derived_page_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 + WHERE h.owner_id = ${userId} + AND s.kind = 'pdf_local' + AND h.text ILIKE ${pattern} + ORDER BY h.updated_at DESC + LIMIT ${limit} + `); + + return result.rows.map((row) => ({ + ...(row as Record), + kind: "pdf_highlight" as const, + })); +} + export default app; diff --git a/src/components/layout/Header/HeaderSearchBar.tsx b/src/components/layout/Header/HeaderSearchBar.tsx index d98bc1fe..3478ab0a 100644 --- a/src/components/layout/Header/HeaderSearchBar.tsx +++ b/src/components/layout/Header/HeaderSearchBar.tsx @@ -3,6 +3,7 @@ import { Search } from "lucide-react"; import { Popover, PopoverAnchor } from "@zedi/ui"; import { Input } from "@zedi/ui"; import { useGlobalSearchContext } from "@/contexts/GlobalSearchContext"; +import type { GlobalSearchResultItem } from "@/hooks/useGlobalSearch"; import { useGlobalSearchShortcut } from "@/hooks/useGlobalSearchShortcut"; import { HeaderSearchDropdownContent } from "./HeaderSearchDropdownContent"; import { cn } from "@zedi/ui"; @@ -15,10 +16,10 @@ interface SearchKeyDownParams { totalItems: number; activeIndex: number; itemCount: number; - searchResults: Array<{ pageId: string; noteId?: string }>; + searchResults: GlobalSearchResultItem[]; setActiveIndex: (value: number | ((prev: number) => number)) => void; closeDropdown: () => void; - onSelectItem: (pageId: string, noteId?: string) => void; + onSelectItem: (item: GlobalSearchResultItem) => void; handleSearchSubmit: () => void; inputRef: React.RefObject; } @@ -65,7 +66,7 @@ function handleSearchKeyDown( handleSearchSubmit(); } else if (activeIndex >= 0 && activeIndex < itemCount) { const item = searchResults[activeIndex]; - if (item) onSelectItem(item.pageId, item.noteId); + if (item) onSelectItem(item); } break; } @@ -81,61 +82,25 @@ function handleSearchKeyDown( * */ export function HeaderSearchBar() { - /** - * - */ const { query, setQuery, searchResults, hasQuery, handleSelect, handleSearchSubmit } = useGlobalSearchContext(); - /** - * - */ const [dropdownOpen, setDropdownOpen] = useState(false); - /** - * - */ const [activeIndex, setActiveIndex] = useState(-1); - /** - * - */ const inputRef = useRef(null); - /** - * - */ const listRef = useRef(null); - /** - * - */ const footerRef = useRef(null); - /** - * - */ const handleShortcutFocus = useCallback(() => { inputRef.current?.focus(); inputRef.current?.select(); }, []); useGlobalSearchShortcut(handleShortcutFocus); - /** - * - */ const showResults = hasQuery && searchResults.length > 0; - /** - * - */ const showEmpty = hasQuery && searchResults.length === 0; - /** - * - */ const hasContent = showResults || showEmpty; - /** - * - */ const itemCount = showResults ? searchResults.length : 0; - /** - * - */ const totalItems = hasQuery ? itemCount + 1 : itemCount; useEffect(() => { @@ -151,45 +116,30 @@ export function HeaderSearchBar() { if (activeIndex === itemCount) { footerRef.current?.scrollIntoView({ block: "nearest" }); } else { - /** - * - */ const items = listRef.current?.querySelectorAll("[role='option']"); items?.[activeIndex]?.scrollIntoView({ block: "nearest" }); } }, [activeIndex, itemCount]); - /** - * - */ const closeDropdown = useCallback(() => { setDropdownOpen(false); setActiveIndex(-1); }, []); - /** - * - */ const onSelectItem = useCallback( - (pageId: string, noteId?: string) => { - handleSelect(pageId, noteId); + (item: GlobalSearchResultItem) => { + handleSelect(item); closeDropdown(); }, [handleSelect, closeDropdown], ); - /** - * - */ const getOptionId = useCallback( (index: number) => index === itemCount ? "header-search-footer" : `header-search-option-${index}`, [itemCount], ); - /** - * - */ const activeDescendant = activeIndex >= 0 ? getOptionId(activeIndex) : undefined; return ( diff --git a/src/components/layout/Header/HeaderSearchDropdownContent.tsx b/src/components/layout/Header/HeaderSearchDropdownContent.tsx index 1d562526..40ade765 100644 --- a/src/components/layout/Header/HeaderSearchDropdownContent.tsx +++ b/src/components/layout/Header/HeaderSearchDropdownContent.tsx @@ -1,6 +1,7 @@ -import { FileText, Link as LinkIcon, ArrowRight } from "lucide-react"; +import { FileText, Link as LinkIcon, ArrowRight, BookOpen } from "lucide-react"; import { PopoverContent } from "@zedi/ui"; import { cn } from "@zedi/ui"; +import type { GlobalSearchResultItem } from "@/hooks/useGlobalSearch"; const EMPTY_MESSAGE = "ページが見つかりません"; @@ -11,7 +12,7 @@ export interface HeaderSearchDropdownContentProps { hasContent: boolean; showEmpty: boolean; showResults: boolean; - searchResults: Array<{ pageId: string; noteId?: string; title: string; sourceUrl?: string }>; + searchResults: GlobalSearchResultItem[]; itemCount: number; activeIndex: number; query: string; @@ -19,12 +20,28 @@ export interface HeaderSearchDropdownContentProps { listRef: React.RefObject; footerRef: React.RefObject; getOptionId: (index: number) => string; - onSelectItem: (pageId: string, noteId?: string) => void; + onSelectItem: (item: GlobalSearchResultItem) => void; setActiveIndex: (value: number | ((prev: number) => number)) => void; closeDropdown: () => void; handleSearchSubmit: () => void; } +/** + * Issue #864 でグローバル検索結果に PDF ハイライト型 (`kind="pdf_highlight"`) が + * 加わったので、`key` と `aria-label` をアイテム種別ごとに分岐して安定化させる。 + * 既存ノートページ / 個人ページの key 形式は維持し、追加分だけ別 prefix にする。 + * + * Issue #864 introduced `kind="pdf_highlight"` rows; the React `key` and the + * accessible label branch on `kind` so the existing page-row keys stay stable + * and the new highlight rows get a distinct, collision-free namespace. + */ +function getResultKey(item: GlobalSearchResultItem): string { + if (item.kind === "pdf_highlight") { + return `pdf-${item.sourceId}-${item.highlightId}`; + } + return item.noteId ? `shared-${item.noteId}-${item.pageId}` : `personal-${item.pageId}`; +} + /** * */ @@ -73,29 +90,37 @@ export function HeaderSearchDropdownContent({ 候補 ({searchResults.length}件)

    - {searchResults.map(({ pageId, noteId, title, sourceUrl }, index) => ( -
  • - -
  • - ))} + {searchResults.map((item, index) => { + const isPdf = item.kind === "pdf_highlight"; + return ( +
  • + +
  • + ); + })}
)} diff --git a/src/components/search/SearchResultCard.tsx b/src/components/search/SearchResultCard.tsx index 6e1bfc76..44fe04a5 100644 --- a/src/components/search/SearchResultCard.tsx +++ b/src/components/search/SearchResultCard.tsx @@ -1,4 +1,4 @@ -import { FileText, Link as LinkIcon } from "lucide-react"; +import { FileText, Link as LinkIcon, BookOpen } from "lucide-react"; import { HighlightedSnippet } from "@/components/search/HighlightedSnippet"; import { MatchTypeBadge } from "@/components/search/MatchTypeBadge"; import type { MatchType } from "@/lib/searchUtils"; @@ -6,19 +6,39 @@ import { cn } from "@zedi/ui"; import { useAuthenticatedImageUrl } from "@/hooks/useAuthenticatedImageUrl"; /** + * 検索結果カードの表示用アイテム。判別可能 union で `kind` が `"page"` と + * `"pdf_highlight"` の 2 種を持つ。`kind="page"` は `pageId` が必須、 + * `kind="pdf_highlight"` は `sourceId` / `highlightId` / `pdfPage` を持つ。 * + * Discriminated union: `kind="page"` rows must carry `pageId`; PDF highlight + * rows carry `sourceId`/`highlightId`/`pdfPage` so the click handler can route + * to the derived page or the PDF viewer (Issue #864). */ -export interface SearchResultCardItem { - pageId: string; - noteId?: string; +export type SearchResultCardItem = SearchResultCardPageItem | SearchResultCardPdfHighlightItem; + +interface SearchResultCardBase { title: string; highlightedSnippet: string; matchType: MatchType; - sourceUrl?: string; thumbnailUrl?: string; updatedAt: number; } +export interface SearchResultCardPageItem extends SearchResultCardBase { + kind: "page"; + pageId: string; + noteId?: string; + sourceUrl?: string; +} + +export interface SearchResultCardPdfHighlightItem extends SearchResultCardBase { + kind: "pdf_highlight"; + highlightId: string; + sourceId: string; + pdfPage: number; + derivedPageId: string | null; +} + function formatDate(ts: number): string { if (ts <= 0 || !Number.isFinite(ts)) return ""; const d = new Date(ts); @@ -38,13 +58,14 @@ interface SearchResultCardProps { * */ export function SearchResultCard({ item, onClick }: SearchResultCardProps) { - /** - * - */ const { resolvedUrl: thumbnailSrc, hasError: thumbnailError } = useAuthenticatedImageUrl( item.thumbnailUrl, ); + const isPdf = item.kind === "pdf_highlight"; + const isShared = item.kind === "page" && Boolean(item.noteId); + const hasSourceUrl = item.kind === "page" && Boolean(item.sourceUrl); + return (