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 (