@deepcitation/deepcitation-js 1.1.22 → 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,30 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, } from "react";
2
+ import { forwardRef, memo, useCallback, useEffect, useMemo, useState, } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
  import { CheckIcon, WarningIcon } from "./icons.js";
5
- import { classNames, generateCitationInstanceId, generateCitationKey, getCitationDisplayText, } from "./utils.js";
6
- import { getCitationStatus } from "../parsing/parseCitation.js";
7
- import "./styles.css";
8
- const TWO_DOTS_THINKING_CONTENT = "..";
5
+ import { Popover, PopoverContent, PopoverTrigger } from "./Popover.js";
6
+ import { generateCitationInstanceId, generateCitationKey, getCitationDisplayText, } from "./utils.js";
7
+ import { useSmartDiff } from "./useSmartDiff.js";
9
8
  // =============================================================================
10
- // INDICATORS
9
+ // UTILITY FUNCTIONS
11
10
  // =============================================================================
12
- /**
13
- * Default indicator for verified citations (exact match).
14
- * Shows a green checkmark.
15
- */
16
- const DefaultVerifiedIndicator = () => (_jsx("span", { className: "dc-indicator dc-indicator--verified", "aria-hidden": "true", children: _jsx(CheckIcon, {}) }));
17
- /**
18
- * Default indicator for partial match citations.
19
- * Shows an orange/warning checkmark.
20
- */
21
- const DefaultPartialIndicator = () => (_jsx("span", { className: "dc-indicator dc-indicator--partial", "aria-hidden": "true", children: _jsx(CheckIcon, {}) }));
22
- // =============================================================================
23
- // HELPER FUNCTIONS
24
- // =============================================================================
25
- /**
26
- * Get status label for display in popover.
27
- */
11
+ function cn(...classes) {
12
+ return classes.filter(Boolean).join(" ");
13
+ }
28
14
  function getStatusLabel(status) {
29
15
  if (status.isVerified && !status.isPartialMatch)
30
16
  return "Verified";
@@ -37,258 +23,172 @@ function getStatusLabel(status) {
37
23
  return "";
38
24
  }
39
25
  /**
40
- * Get popover status CSS class.
41
- */
42
- function getPopoverStatusClass(status) {
43
- if (status.isVerified && !status.isPartialMatch)
44
- return "dc-popover-status--verified";
45
- if (status.isPartialMatch)
46
- return "dc-popover-status--partial";
47
- if (status.isMiss)
48
- return "dc-popover-status--miss";
49
- if (status.isPending)
50
- return "dc-popover-status--pending";
51
- return "";
52
- }
53
- /**
54
- * Get the "found status" class for text styling.
55
- * This determines if the text appears as found (blue) or not found (gray).
56
- *
57
- * Key insight: Partial matches ARE found - they just don't match exactly.
58
- * So partial matches get "verified" text styling (blue) but with a different indicator.
59
- */
60
- function getFoundStatusClass(status) {
61
- // Both verified AND partial are "found" - they get the same text styling
62
- if (status.isVerified || status.isPartialMatch)
63
- return "dc-citation--verified";
64
- if (status.isMiss)
65
- return "dc-citation--miss";
66
- if (status.isPending)
67
- return "dc-citation--pending";
68
- return "";
69
- }
70
- // =============================================================================
71
- // SUB-COMPONENTS
72
- // =============================================================================
73
- /**
74
- * Status tooltip content for miss/partial states.
75
- * Shows explanation when hovering over citations with issues.
26
+ * Derive citation status from a Verification object.
27
+ * The status comes from verification.status.
76
28
  */
77
- const StatusTooltipContent = ({ citation, status, verification, isExpanded, onToggleExpand, }) => {
78
- const { isMiss, isPartialMatch } = status;
79
- if (!isMiss && !isPartialMatch)
80
- return null;
81
- // Get search attempts from verification
82
- const searchAttempts = verification?.searchState?.searchAttempts;
83
- const failedAttempts = searchAttempts?.filter((a) => !a.success) || [];
84
- // Collect all unique phrases tried
85
- const allPhrases = [];
86
- const seenPhrases = new Set();
87
- for (const attempt of failedAttempts) {
88
- for (const phrase of attempt.searchPhrases || []) {
89
- if (!seenPhrases.has(phrase)) {
90
- seenPhrases.add(phrase);
91
- allPhrases.push(phrase);
92
- }
93
- }
94
- }
95
- // Fallback to citation text if no phrases recorded
96
- if (allPhrases.length === 0) {
97
- const searchedText = citation.fullPhrase || citation.keySpan?.toString() || "";
98
- if (searchedText) {
99
- allPhrases.push(searchedText);
100
- }
101
- }
102
- if (isMiss) {
103
- const hiddenCount = allPhrases.length - 1;
104
- return (_jsxs("span", { className: "dc-status-tooltip", role: "tooltip", children: [_jsxs("span", { className: "dc-status-header dc-status-header--miss", children: [_jsx(WarningIcon, {}), _jsx("span", { children: "Not found in source" })] }), allPhrases.length > 0 && (_jsxs("span", { className: "dc-search-phrases", children: [_jsxs("span", { className: "dc-search-phrases-header", children: [hiddenCount > 0 && (_jsx("button", { type: "button", className: "dc-search-phrases-toggle", onClick: onToggleExpand, children: isExpanded ? "collapse" : `+${hiddenCount} more` })), _jsxs("span", { className: "dc-status-label", children: ["Searched ", allPhrases.length, " phrase", allPhrases.length > 1 ? "s" : ""] })] }), _jsx("span", { className: "dc-search-phrases-list", children: (isExpanded ? allPhrases : allPhrases.slice(0, 1)).map((phrase, idx) => (_jsxs("span", { className: "dc-search-phrase-item", children: ["\"", phrase.length > 80 ? phrase.slice(0, 80) + "…" : phrase, "\""] }, idx))) })] }))] }));
29
+ function getStatusFromVerification(verification) {
30
+ const status = verification?.status;
31
+ // No verification or no status = pending
32
+ if (!verification || !status) {
33
+ return { isVerified: false, isMiss: false, isPartialMatch: false, isPending: true };
105
34
  }
106
- if (isPartialMatch) {
107
- const expectedText = citation.fullPhrase || citation.keySpan?.toString() || "";
108
- const actualText = verification?.matchSnippet || "";
109
- const textMatches = expectedText === actualText;
110
- const truncatedExpected = expectedText.length > 100
111
- ? expectedText.slice(0, 100) + ""
112
- : expectedText;
113
- const truncatedActual = actualText.length > 100 ? actualText.slice(0, 100) + "" : actualText;
114
- // Check for line ID and page number differences
115
- const expectedLineIds = citation.lineIds;
116
- const actualLineIds = verification?.lineIds;
117
- const lineIdDiffers = expectedLineIds &&
118
- actualLineIds &&
119
- JSON.stringify(expectedLineIds) !== JSON.stringify(actualLineIds);
120
- const expectedPage = citation.pageNumber;
121
- const actualPage = verification?.pageNumber;
122
- const pageDiffers = expectedPage != null &&
123
- actualPage != null &&
124
- expectedPage !== actualPage;
125
- return (_jsxs("span", { className: "dc-status-tooltip", role: "tooltip", children: [_jsxs("span", { className: "dc-status-header dc-status-header--partial", children: [_jsx(WarningIcon, {}), _jsx("span", { children: "Partial match" })] }), textMatches ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "dc-status-description", children: "Text matches, but location differs." }), truncatedExpected && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Text" }), _jsx("span", { className: "dc-status-text", children: truncatedExpected })] }))] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "dc-status-description", children: "Text differs from citation." }), truncatedExpected && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Expected" }), _jsx("span", { className: "dc-status-text dc-status-text--expected", children: truncatedExpected })] })), truncatedActual && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Found" }), _jsx("span", { className: "dc-status-text", children: truncatedActual })] }))] })), pageDiffers && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Page" }), _jsxs("span", { className: "dc-status-text", children: [_jsx("span", { className: "dc-status-text--expected", children: expectedPage }), " → ", actualPage] })] })), lineIdDiffers && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Line" }), _jsxs("span", { className: "dc-status-text", children: [_jsx("span", { className: "dc-status-text--expected", children: expectedLineIds?.join(", ") }), " → ", actualLineIds?.join(", ")] })] }))] }));
126
- }
127
- return null;
128
- };
35
+ const isMiss = status === "not_found";
36
+ const isPending = status === "pending" || status === "loading";
37
+ const isPartialMatch = status === "partial_text_found" ||
38
+ status === "found_on_other_page" ||
39
+ status === "found_on_other_line" ||
40
+ status === "first_word_found";
41
+ const isVerified = status === "found" ||
42
+ status === "found_key_span_only" ||
43
+ status === "found_phrase_missed_value" ||
44
+ isPartialMatch;
45
+ return { isVerified, isMiss, isPartialMatch, isPending };
46
+ }
129
47
  /**
130
- * Full-size image overlay component.
131
- * Uses portal to render at document body level.
48
+ * Full-screen image overlay for zoomed verification images.
49
+ * Click anywhere or press Escape to close.
132
50
  */
133
- const ImageOverlay = ({ src, alt, onClose, }) => {
134
- const handleBackdropClick = useCallback((e) => {
135
- if (e.target === e.currentTarget) {
136
- onClose();
137
- }
138
- }, [onClose]);
51
+ function ImageOverlay({ src, alt, onClose }) {
139
52
  useEffect(() => {
140
53
  const handleKeyDown = (e) => {
141
- if (e.key === "Escape") {
54
+ if (e.key === "Escape")
142
55
  onClose();
143
- }
144
56
  };
145
57
  document.addEventListener("keydown", handleKeyDown);
146
58
  return () => document.removeEventListener("keydown", handleKeyDown);
147
59
  }, [onClose]);
148
- // Use portal to render at body level, avoiding any parent positioning issues
149
- return createPortal(_jsx("div", { className: "dc-overlay", onClick: handleBackdropClick, role: "dialog", "aria-modal": "true", "aria-label": "Full size verification image", children: _jsx("div", { className: "dc-overlay-content", onClick: onClose, children: _jsx("img", { src: src, alt: alt, className: "dc-overlay-image", draggable: false }) }) }), document.body);
150
- };
60
+ return createPortal(_jsx("div", { className: "fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in-0", onClick: onClose, role: "dialog", "aria-modal": "true", "aria-label": "Full size verification image", children: _jsx("div", { className: "relative max-w-[95vw] max-h-[95vh] cursor-zoom-out", children: _jsx("img", { src: src, alt: alt, className: "max-w-full max-h-[95vh] object-contain rounded-lg shadow-2xl", draggable: false }) }) }), document.body);
61
+ }
62
+ // =============================================================================
63
+ // INDICATOR COMPONENTS
64
+ // =============================================================================
65
+ //
66
+ // Status indicators show the verification state visually:
67
+ //
68
+ // | Status | Indicator | Color | searchState.status values |
69
+ // |---------------|--------------------| -------|----------------------------------------------|
70
+ // | Pending | Spinner | Gray | "pending", "loading", null/undefined |
71
+ // | Verified | Checkmark (✓) | Green | "found", "found_key_span_only", etc. |
72
+ // | Partial Match | Checkmark (✓) | Amber | "found_on_other_page", "partial_text_found" |
73
+ // | Not Found | Warning triangle | Red | "not_found" |
74
+ //
75
+ // Use `renderIndicator` prop to customize. Use `variant="indicator"` to show only the icon.
76
+ // =============================================================================
77
+ /** Spinner component for loading/pending state */
78
+ const Spinner = ({ className }) => (_jsxs("svg", { className: cn("animate-spin", className), xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", width: "12", height: "12", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }));
79
+ /** Verified indicator - green checkmark for exact matches */
80
+ const VerifiedIndicator = () => (_jsx("span", { className: "inline-flex relative ml-0.5 text-green-600 dark:text-green-500", "aria-hidden": "true", children: _jsx(CheckIcon, {}) }));
81
+ /** Partial match indicator - amber checkmark for partial/relocated matches */
82
+ const PartialIndicator = () => (_jsx("span", { className: "inline-flex relative ml-0.5 text-amber-600 dark:text-amber-500", "aria-hidden": "true", children: _jsx(CheckIcon, {}) }));
83
+ /** Pending indicator - spinner for loading state */
84
+ const PendingIndicator = () => (_jsx("span", { className: "inline-flex ml-1 text-gray-400 dark:text-gray-500", "aria-hidden": "true", children: _jsx(Spinner, {}) }));
85
+ /** Miss indicator - red warning triangle for not found */
86
+ const MissIndicator = () => (_jsx("span", { className: "inline-flex relative ml-0.5 text-red-500 dark:text-red-400", "aria-hidden": "true", children: _jsx(WarningIcon, {}) }));
87
+ function DefaultPopoverContent({ citation, verification, status, onImageClick, }) {
88
+ const hasImage = verification?.verificationImageBase64;
89
+ const { isMiss, isPartialMatch } = status;
90
+ // Image view
91
+ if (hasImage) {
92
+ return (_jsxs("div", { className: "p-1", children: [_jsx("button", { type: "button", className: "block cursor-zoom-in", onClick: (e) => {
93
+ e.preventDefault();
94
+ e.stopPropagation();
95
+ onImageClick?.();
96
+ }, "aria-label": "Click to view full size", children: _jsx("img", { src: verification.verificationImageBase64, alt: "Citation verification", className: "max-w-[700px] max-h-[500px] w-auto h-auto object-contain rounded bg-gray-50 dark:bg-gray-800", loading: "lazy" }) }), (isMiss || isPartialMatch) && (_jsx(DiffDetails, { citation: citation, verification: verification, status: status }))] }));
97
+ }
98
+ // Text-only view
99
+ const statusLabel = getStatusLabel(status);
100
+ const hasSnippet = verification?.verifiedMatchSnippet;
101
+ const pageNumber = verification?.verifiedPageNumber;
102
+ if (!hasSnippet && !statusLabel)
103
+ return null;
104
+ return (_jsxs("div", { className: "p-3 flex flex-col gap-2 min-w-[200px] max-w-[400px]", children: [statusLabel && (_jsx("span", { className: cn("text-xs font-medium", status.isVerified && !status.isPartialMatch && "text-green-600 dark:text-green-500", status.isPartialMatch && "text-amber-600 dark:text-amber-500", status.isMiss && "text-red-600 dark:text-red-500", status.isPending && "text-gray-500 dark:text-gray-400"), children: statusLabel })), hasSnippet && (_jsxs("span", { className: "text-sm text-gray-700 dark:text-gray-300 italic", children: ["\"", verification.verifiedMatchSnippet, "\""] })), pageNumber && pageNumber > 0 && (_jsxs("span", { className: "text-xs text-gray-500 dark:text-gray-400", children: ["Page ", pageNumber] })), (isMiss || isPartialMatch) && (_jsx(DiffDetails, { citation: citation, verification: verification, status: status }))] }));
105
+ }
106
+ // =============================================================================
107
+ // DIFF DETAILS COMPONENT
108
+ // =============================================================================
151
109
  /**
152
- * Diff details for partial/miss verification states.
153
- * Shows expected vs found text, and location differences when applicable.
110
+ * Renders diff highlighting between expected citation text and actual found text.
111
+ * Uses the `diff` library via useSmartDiff hook for word-level highlighting.
154
112
  */
155
- const DiffDetails = ({ citation, verification, status, }) => {
113
+ function DiffDetails({ citation, verification, status, }) {
156
114
  const { isMiss, isPartialMatch } = status;
115
+ const expectedText = citation.fullPhrase || citation.keySpan?.toString() || "";
116
+ const actualText = verification?.verifiedMatchSnippet || "";
117
+ // Use the diff library for smart word-level diffing
118
+ const { diffResult, hasDiff, isHighVariance } = useSmartDiff(expectedText, actualText);
157
119
  if (!isMiss && !isPartialMatch)
158
120
  return null;
159
- const expectedText = citation.fullPhrase || citation.keySpan?.toString() || "";
160
- const actualText = verification?.matchSnippet || "";
161
- const textMatches = expectedText === actualText;
162
- const truncatedExpected = expectedText.length > 100 ? expectedText.slice(0, 100) + "…" : expectedText;
163
- const truncatedActual = actualText.length > 100 ? actualText.slice(0, 100) + "…" : actualText;
164
- // Check for line ID and page number differences
165
121
  const expectedLineIds = citation.lineIds;
166
- const actualLineIds = verification?.lineIds;
122
+ const actualLineIds = verification?.verifiedLineIds;
167
123
  const lineIdDiffers = expectedLineIds &&
168
124
  actualLineIds &&
169
125
  JSON.stringify(expectedLineIds) !== JSON.stringify(actualLineIds);
170
126
  const expectedPage = citation.pageNumber;
171
- const actualPage = verification?.pageNumber;
172
- const pageDiffers = expectedPage != null &&
173
- actualPage != null &&
174
- expectedPage !== actualPage;
175
- return (_jsxs("span", { className: "dc-diff-details", children: [isPartialMatch && textMatches ? (
176
- // Text matches but location differs - show text once
177
- truncatedExpected && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Text" }), _jsx("span", { className: "dc-status-text", children: truncatedExpected })] }))) : (
178
- // Text differs - show expected and found
179
- _jsxs(_Fragment, { children: [truncatedExpected && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Expected" }), _jsx("span", { className: "dc-status-text dc-status-text--expected", children: truncatedExpected })] })), isPartialMatch && truncatedActual && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Found" }), _jsx("span", { className: "dc-status-text", children: truncatedActual })] }))] })), isMiss && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Found" }), _jsx("span", { className: "dc-status-text dc-status-text--miss", children: "Not found in source" })] })), pageDiffers && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Page" }), _jsxs("span", { className: "dc-status-text", children: [_jsx("span", { className: "dc-status-text--expected", children: expectedPage }), " → ", actualPage] })] })), lineIdDiffers && (_jsxs("span", { className: "dc-status-searched", children: [_jsx("span", { className: "dc-status-label", children: "Line" }), _jsxs("span", { className: "dc-status-text", children: [_jsx("span", { className: "dc-status-text--expected", children: expectedLineIds?.join(", ") }), " → ", actualLineIds?.join(", ")] })] }))] }));
180
- };
181
- /**
182
- * Default popover content component.
183
- * Shows verification image if available, otherwise shows text info.
184
- * For partial/miss states, also displays expected vs found details.
185
- */
186
- const DefaultPopoverContent = ({ citation, verification, status, onImageClick, }) => {
187
- const hasImage = verification?.verificationImageBase64;
188
- const { isMiss, isPartialMatch } = status;
189
- const handleImageClick = useCallback((e) => {
190
- e.preventDefault();
191
- e.stopPropagation();
192
- if (hasImage && onImageClick) {
193
- onImageClick(verification.verificationImageBase64);
194
- }
195
- }, [hasImage, verification?.verificationImageBase64, onImageClick]);
196
- // If we have a verification image, show image + diff details for partial/miss
197
- if (hasImage) {
198
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "dc-popover-image-button", onClick: handleImageClick, "aria-label": "Click to view full size", children: _jsx("img", { src: verification.verificationImageBase64, alt: "Citation verification", className: "dc-popover-image", loading: "lazy" }) }), (isMiss || isPartialMatch) && (_jsx(DiffDetails, { citation: citation, verification: verification, status: status }))] }));
199
- }
200
- // No image - show text info
201
- const statusLabel = getStatusLabel(status);
202
- const statusClass = getPopoverStatusClass(status);
203
- const hasSnippet = verification?.matchSnippet;
204
- const pageNumber = verification?.pageNumber;
205
- if (!hasSnippet && !statusLabel) {
206
- return null;
127
+ const actualPage = verification?.verifiedPageNumber;
128
+ const pageDiffers = expectedPage != null && actualPage != null && expectedPage !== actualPage;
129
+ // For "not_found" status, show expected text and "Not found" message
130
+ if (isMiss) {
131
+ return (_jsxs("div", { className: "mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 text-xs space-y-2", children: [expectedText && (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Expected" }), _jsx("p", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] break-words text-red-600 dark:text-red-400 line-through opacity-70", children: expectedText.length > 100 ? expectedText.slice(0, 100) + "…" : expectedText })] })), _jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Found" }), _jsx("p", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] text-amber-600 dark:text-amber-500 italic", children: "Not found in source" })] })] }));
207
132
  }
208
- return (_jsxs(_Fragment, { children: [statusLabel && (_jsx("span", { className: classNames("dc-popover-status", statusClass), children: statusLabel })), hasSnippet && (_jsxs("span", { className: "dc-popover-snippet", children: ["\"", verification.matchSnippet, "\""] })), pageNumber && pageNumber > 0 && (_jsxs("span", { className: "dc-popover-page", children: ["Page ", pageNumber] })), (isMiss || isPartialMatch) && (_jsx(DiffDetails, { citation: citation, verification: verification, status: status }))] }));
209
- };
133
+ // For partial matches, show word-level diff
134
+ return (_jsxs("div", { className: "mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 text-xs space-y-2", children: [expectedText && actualText && hasDiff ? (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Diff" }), _jsx("div", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] break-words text-gray-700 dark:text-gray-300", children: isHighVariance ? (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 text-[10px]", children: "Expected: " }), _jsx("span", { className: "text-red-600 dark:text-red-400 line-through opacity-70", children: expectedText.length > 100 ? expectedText.slice(0, 100) + "…" : expectedText })] }), _jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 text-[10px]", children: "Found: " }), _jsx("span", { className: "text-green-600 dark:text-green-400", children: actualText.length > 100 ? actualText.slice(0, 100) + "…" : actualText })] })] })) : (
135
+ // Inline word-level diff
136
+ diffResult.map((block, blockIndex) => (_jsx("span", { children: block.parts.map((part, partIndex) => {
137
+ const key = `p-${blockIndex}-${partIndex}`;
138
+ if (part.removed) {
139
+ return (_jsx("span", { className: "bg-red-200 dark:bg-red-900/50 text-red-700 dark:text-red-300 line-through", title: "Expected text", children: part.value }, key));
140
+ }
141
+ if (part.added) {
142
+ return (_jsx("span", { className: "bg-green-200 dark:bg-green-900/50 text-green-700 dark:text-green-300", title: "Actual text found", children: part.value }, key));
143
+ }
144
+ // Unchanged text
145
+ return _jsx("span", { children: part.value }, key);
146
+ }) }, `block-${blockIndex}`)))) })] })) : expectedText && !hasDiff ? (
147
+ // Text matches exactly (partial match is due to location difference)
148
+ _jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Text" }), _jsx("p", { className: "mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-[11px] break-words text-gray-700 dark:text-gray-300", children: expectedText.length > 100 ? expectedText.slice(0, 100) + "…" : expectedText })] })) : null, pageDiffers && (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Page" }), _jsxs("p", { className: "mt-1 font-mono text-[11px] text-gray-700 dark:text-gray-300", children: [_jsx("span", { className: "text-red-600 dark:text-red-400 line-through opacity-70", children: expectedPage }), " → ", actualPage] })] })), lineIdDiffers && (_jsxs("div", { children: [_jsx("span", { className: "text-gray-500 dark:text-gray-400 font-medium uppercase text-[10px]", children: "Line" }), _jsxs("p", { className: "mt-1 font-mono text-[11px] text-gray-700 dark:text-gray-300", children: [_jsx("span", { className: "text-red-600 dark:text-red-400 line-through opacity-70", children: expectedLineIds?.join(", ") }), " → ", actualLineIds?.join(", ")] })] }))] }));
149
+ }
210
150
  // =============================================================================
211
151
  // MAIN COMPONENT
212
152
  // =============================================================================
213
153
  /**
214
154
  * CitationComponent displays a citation with verification status.
215
155
  *
216
- * The component separates two concepts:
217
- * 1. **Found status** (text styling) - whether the citation was found in the document
218
- * - Verified & Partial both use "found" styling (blue text)
219
- * - Miss uses "not found" styling (gray/strikethrough)
156
+ * ## Interaction Pattern
157
+ *
158
+ * - **Hover**: Shows popover with verification image or details
159
+ * - **Click**: Opens full-size image overlay (if image available)
160
+ * - **Escape / Click overlay**: Closes the image overlay
220
161
  *
221
- * 2. **Match quality** (indicator styling) - how well the citation matched
222
- * - Exact match: green checkmark
223
- * - Partial match: orange checkmark
224
- * - Miss: no indicator
162
+ * ## Customization
225
163
  *
226
- * This means partial matches have blue text (because they were found) but
227
- * an orange indicator (because they didn't match exactly).
164
+ * Use `behaviorConfig.onClick` to completely replace the click behavior,
165
+ * or `eventHandlers.onClick` to add side effects (which disables defaults).
228
166
  */
229
167
  export const CitationComponent = forwardRef(({ citation, children, className, hideKeySpan = false, hideBrackets = false, fallbackDisplay, verification, variant = "brackets", eventHandlers, behaviorConfig, isMobile = false, renderIndicator, renderContent, popoverPosition = "top", renderPopoverContent, }, ref) => {
230
- const containerRef = useRef(null);
231
- const wrapperRef = useRef(null);
168
+ const [isHovering, setIsHovering] = useState(false);
232
169
  const [expandedImageSrc, setExpandedImageSrc] = useState(null);
233
- const [isTooltipExpanded, setIsTooltipExpanded] = useState(false);
234
- const [isPhrasesExpanded, setIsPhrasesExpanded] = useState(false);
235
- const handleImageClick = useCallback((imageSrc) => {
236
- setExpandedImageSrc(imageSrc);
237
- }, []);
238
- const handleCloseOverlay = useCallback(() => {
239
- setExpandedImageSrc(null);
240
- }, []);
241
- const handleTogglePhrases = useCallback((e) => {
242
- e?.preventDefault();
243
- e?.stopPropagation();
244
- setIsPhrasesExpanded((prev) => !prev);
245
- }, []);
246
- // Handle click outside to close expanded tooltip
247
- useEffect(() => {
248
- if (!isTooltipExpanded)
249
- return;
250
- const handleClickOutside = (event) => {
251
- if (wrapperRef.current &&
252
- !wrapperRef.current.contains(event.target)) {
253
- setIsTooltipExpanded(false);
254
- }
255
- };
256
- // Use capture phase to handle clicks before they bubble
257
- document.addEventListener("mousedown", handleClickOutside, true);
258
- return () => {
259
- document.removeEventListener("mousedown", handleClickOutside, true);
260
- };
261
- }, [isTooltipExpanded]);
262
- // Handle escape key to close expanded tooltip
263
- useEffect(() => {
264
- if (!isTooltipExpanded)
265
- return;
266
- const handleEscape = (event) => {
267
- if (event.key === "Escape") {
268
- setIsTooltipExpanded(false);
269
- }
270
- };
271
- document.addEventListener("keydown", handleEscape);
272
- return () => {
273
- document.removeEventListener("keydown", handleEscape);
274
- };
275
- }, [isTooltipExpanded]);
276
170
  const citationKey = useMemo(() => generateCitationKey(citation), [citation]);
277
171
  const citationInstanceId = useMemo(() => generateCitationInstanceId(citationKey), [citationKey]);
278
- // Create behavior context for custom handlers
172
+ // Derive status from verification object
173
+ const status = useMemo(() => getStatusFromVerification(verification), [verification]);
174
+ const { isMiss, isPartialMatch, isVerified, isPending } = status;
175
+ const displayText = useMemo(() => {
176
+ return getCitationDisplayText(citation, {
177
+ hideKeySpan: variant !== "text" && variant !== "minimal" && hideKeySpan,
178
+ fallbackDisplay,
179
+ });
180
+ }, [citation, variant, hideKeySpan, fallbackDisplay]);
181
+ // Behavior context for custom handlers
279
182
  const getBehaviorContext = useCallback(() => ({
280
183
  citation,
281
184
  citationKey,
282
185
  verification: verification ?? null,
283
- isTooltipExpanded,
186
+ isTooltipExpanded: isHovering,
284
187
  isImageExpanded: !!expandedImageSrc,
285
188
  hasImage: !!verification?.verificationImageBase64,
286
- }), [citation, citationKey, verification, isTooltipExpanded, expandedImageSrc]);
189
+ }), [citation, citationKey, verification, isHovering, expandedImageSrc]);
287
190
  // Apply behavior actions from custom handler
288
191
  const applyBehaviorActions = useCallback((actions) => {
289
- if (actions.setTooltipExpanded !== undefined) {
290
- setIsTooltipExpanded(actions.setTooltipExpanded);
291
- }
292
192
  if (actions.setImageExpanded !== undefined) {
293
193
  if (typeof actions.setImageExpanded === "string") {
294
194
  setExpandedImageSrc(actions.setImageExpanded);
@@ -300,94 +200,55 @@ export const CitationComponent = forwardRef(({ citation, children, className, hi
300
200
  setExpandedImageSrc(null);
301
201
  }
302
202
  }
303
- if (actions.setPhrasesExpanded !== undefined) {
304
- setIsPhrasesExpanded(actions.setPhrasesExpanded);
305
- }
306
203
  }, [verification?.verificationImageBase64]);
307
- const handleToggleTooltip = useCallback((e) => {
204
+ // Click handler
205
+ const handleClick = useCallback((e) => {
308
206
  e.preventDefault();
309
207
  e.stopPropagation();
310
208
  const context = getBehaviorContext();
311
- // If custom onClick handler is provided via behaviorConfig, it REPLACES default behavior
209
+ // Custom onClick via behaviorConfig replaces default
312
210
  if (behaviorConfig?.onClick) {
313
211
  const result = behaviorConfig.onClick(context, e);
314
- // If custom handler returns actions, apply them
315
212
  if (result && typeof result === "object") {
316
213
  applyBehaviorActions(result);
317
214
  }
318
- // If returns false or void, no state changes
319
- // Always call eventHandlers.onClick regardless of custom behavior
320
215
  eventHandlers?.onClick?.(citation, citationKey, e);
321
216
  return;
322
217
  }
323
- // If eventHandlers.onClick is provided, disable default click behavior
324
- // (no popover pinning, no image expansion) - just call the handler
218
+ // Custom eventHandlers.onClick disables default
325
219
  if (eventHandlers?.onClick) {
326
220
  eventHandlers.onClick(citation, citationKey, e);
327
221
  return;
328
222
  }
329
- // Default click behavior (only runs when no click handlers are provided)
223
+ // Default: click opens image if available
330
224
  if (verification?.verificationImageBase64) {
331
- if (expandedImageSrc) {
332
- // Image is open - close it and unpin
333
- setExpandedImageSrc(null);
334
- setIsTooltipExpanded(false);
335
- }
336
- else if (isTooltipExpanded) {
337
- // Already pinned - second click expands image
338
- setExpandedImageSrc(verification.verificationImageBase64);
339
- }
340
- else {
341
- // First click - pin the popover open
342
- setIsTooltipExpanded(true);
343
- }
344
- }
345
- else {
346
- // No image - toggle phrases expansion for miss/partial tooltips
347
- setIsTooltipExpanded((prev) => !prev);
348
- setIsPhrasesExpanded((prev) => !prev);
225
+ setExpandedImageSrc(verification.verificationImageBase64);
349
226
  }
350
227
  }, [
351
- eventHandlers,
352
228
  behaviorConfig,
229
+ eventHandlers,
353
230
  citation,
354
231
  citationKey,
355
232
  verification?.verificationImageBase64,
356
- expandedImageSrc,
357
- isTooltipExpanded,
358
233
  getBehaviorContext,
359
234
  applyBehaviorActions,
360
235
  ]);
361
- const status = getCitationStatus(verification ?? null);
362
- // const { isVerified, isPending } = status;
363
- const { isMiss, isPartialMatch, isVerified, isPending } = status;
364
- const displayText = useMemo(() => {
365
- // For text/minimal variants, always show keySpan (hideKeySpan is ignored)
366
- // For brackets variant, show keySpan based on hideKeySpan prop
367
- return getCitationDisplayText(citation, {
368
- hideKeySpan: variant !== "text" &&
369
- variant !== "minimal" &&
370
- hideKeySpan,
371
- fallbackDisplay,
372
- });
373
- }, [citation, variant, hideKeySpan, fallbackDisplay]);
374
- // Found status class for text styling (blue for found, gray for miss)
375
- const foundStatusClass = useMemo(() => getFoundStatusClass(status), [status]);
376
- // Event handlers
236
+ // Hover handlers
377
237
  const handleMouseEnter = useCallback(() => {
378
- // Call custom onHover.onEnter handler (if provided)
238
+ setIsHovering(true);
379
239
  if (behaviorConfig?.onHover?.onEnter) {
380
240
  behaviorConfig.onHover.onEnter(getBehaviorContext());
381
241
  }
382
242
  eventHandlers?.onMouseEnter?.(citation, citationKey);
383
243
  }, [eventHandlers, behaviorConfig, citation, citationKey, getBehaviorContext]);
384
244
  const handleMouseLeave = useCallback(() => {
385
- // Call custom onHover.onLeave handler (if provided)
245
+ setIsHovering(false);
386
246
  if (behaviorConfig?.onHover?.onLeave) {
387
247
  behaviorConfig.onHover.onLeave(getBehaviorContext());
388
248
  }
389
249
  eventHandlers?.onMouseLeave?.(citation, citationKey);
390
250
  }, [eventHandlers, behaviorConfig, citation, citationKey, getBehaviorContext]);
251
+ // Touch handler for mobile
391
252
  const handleTouchEnd = useCallback((e) => {
392
253
  if (isMobile) {
393
254
  e.preventDefault();
@@ -396,35 +257,34 @@ export const CitationComponent = forwardRef(({ citation, children, className, hi
396
257
  }
397
258
  }, [eventHandlers, citation, citationKey, isMobile]);
398
259
  // Early return for miss with fallback display
399
- if (fallbackDisplay !== null &&
400
- fallbackDisplay !== undefined &&
401
- !hideKeySpan &&
402
- isMiss) {
403
- return (_jsx("span", { className: classNames("dc-citation-fallback", className), children: fallbackDisplay }));
260
+ if (fallbackDisplay !== null && fallbackDisplay !== undefined && !hideKeySpan && isMiss) {
261
+ return (_jsx("span", { className: cn("text-gray-400 dark:text-gray-500", className), children: fallbackDisplay }));
404
262
  }
405
- // Render the appropriate indicator based on match quality
263
+ // Status classes for text styling
264
+ const statusClasses = cn(
265
+ // Found status (text color) - verified or partial match
266
+ (isVerified || isPartialMatch) && variant === "brackets" && "text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline", isMiss && "opacity-70 line-through text-gray-400 dark:text-gray-500", isPending && "text-gray-500 dark:text-gray-400");
267
+ // Render indicator based on status priority:
268
+ // 1. Custom renderIndicator (if provided)
269
+ // 2. Pending → Spinner
270
+ // 3. Miss → Warning triangle
271
+ // 4. Partial match → Amber checkmark
272
+ // 5. Verified → Green checkmark
406
273
  const renderStatusIndicator = () => {
407
- if (renderIndicator) {
274
+ if (renderIndicator)
408
275
  return renderIndicator(status);
409
- }
410
- // Check partial match first since isVerified includes isPartialMatch
411
- if (isPartialMatch) {
412
- return _jsx(DefaultPartialIndicator, {});
413
- }
414
- else if (isVerified) {
415
- return _jsx(DefaultVerifiedIndicator, {});
416
- }
417
- else if (isPending) {
418
- return (_jsx("span", { className: "dc-indicator dc-indicator--pending", "aria-hidden": "true", children: TWO_DOTS_THINKING_CONTENT }));
419
- }
420
- else if (isMiss) {
421
- return null;
422
- }
276
+ if (isPending)
277
+ return _jsx(PendingIndicator, {});
278
+ if (isMiss)
279
+ return _jsx(MissIndicator, {});
280
+ if (isPartialMatch)
281
+ return _jsx(PartialIndicator, {});
282
+ if (isVerified)
283
+ return _jsx(VerifiedIndicator, {});
423
284
  return null;
424
285
  };
425
- // Render the citation content based on variant
286
+ // Render citation content
426
287
  const renderCitationContent = () => {
427
- // Custom render function takes full control
428
288
  if (renderContent) {
429
289
  return renderContent({
430
290
  citation,
@@ -434,54 +294,52 @@ export const CitationComponent = forwardRef(({ citation, children, className, hi
434
294
  isMergedDisplay: variant === "text" || variant === "brackets" || !hideKeySpan,
435
295
  });
436
296
  }
437
- // Indicator-only variant - just the checkmark/warning
438
297
  if (variant === "indicator") {
439
- return (_jsx("span", { className: "dc-citation-text", children: renderStatusIndicator() }));
298
+ return _jsx("span", { children: renderStatusIndicator() });
440
299
  }
441
- // Text variant - no special styling, shows keySpan with indicator
442
300
  if (variant === "text") {
443
- return (_jsxs("span", { className: "dc-citation-text dc-citation-text--plain", children: [displayText, renderStatusIndicator()] }));
301
+ return (_jsxs("span", { className: statusClasses, children: [displayText, renderStatusIndicator()] }));
444
302
  }
445
- // Minimal variant - no brackets, just text with indicator
446
303
  if (variant === "minimal") {
447
- return (_jsxs("span", { className: "dc-citation-text", children: [displayText, renderStatusIndicator()] }));
304
+ return (_jsxs("span", { className: cn("max-w-80 overflow-hidden text-ellipsis", statusClasses), children: [displayText, renderStatusIndicator()] }));
448
305
  }
449
- // Brackets variant (default) - keySpan/number in brackets with styling
450
- return (_jsxs("span", { className: "dc-citation-bracket", "aria-hidden": "true", role: "presentation", children: [!hideBrackets && "[", _jsxs("span", { className: "dc-citation-text", children: [displayText, renderStatusIndicator()] }), !hideBrackets && "]"] }));
306
+ // brackets variant (default)
307
+ return (_jsxs("span", { className: cn("inline-flex items-baseline gap-0.5 whitespace-nowrap", "font-mono text-xs leading-tight", "text-gray-500 dark:text-gray-400", "transition-colors"), "aria-hidden": "true", children: [!hideBrackets && "[", _jsxs("span", { className: cn("max-w-80 overflow-hidden text-ellipsis", statusClasses), children: [displayText, renderStatusIndicator()] }), !hideBrackets && "]"] }));
451
308
  };
452
- // Determine if popover should be shown
309
+ // Popover visibility
453
310
  const isPopoverHidden = popoverPosition === "hidden";
454
311
  const shouldShowPopover = !isPopoverHidden &&
455
312
  verification &&
456
- (verification.verificationImageBase64 || verification.matchSnippet);
457
- // Determine if status tooltip should be shown (miss/partial without full verification)
458
- const shouldShowStatusTooltip = !isPopoverHidden && (isMiss || isPartialMatch) && !shouldShowPopover;
459
- // Popover content - determine position class (only "top" or "bottom" add classes)
460
- const popoverPositionClass = popoverPosition === "bottom" ? "dc-popover--bottom" : "";
461
- const popoverContent = shouldShowPopover ? (_jsx("span", { className: classNames("dc-popover", popoverPositionClass), children: renderPopoverContent ? (renderPopoverContent({
313
+ (verification.verificationImageBase64 || verification.verifiedMatchSnippet);
314
+ const hasImage = !!verification?.verificationImageBase64;
315
+ // Image overlay
316
+ const imageOverlay = expandedImageSrc ? (_jsx(ImageOverlay, { src: expandedImageSrc, alt: "Citation verification - full size", onClose: () => setExpandedImageSrc(null) })) : null;
317
+ // Shared trigger element props
318
+ const triggerProps = {
319
+ "data-citation-id": citationKey,
320
+ "data-citation-instance": citationInstanceId,
321
+ className: cn("relative inline-flex items-baseline cursor-pointer", "px-0.5 -mx-0.5 rounded-sm", "transition-all duration-150", "hover:bg-blue-500/10 dark:hover:bg-blue-400/10", hasImage && "hover:cursor-zoom-in", className),
322
+ onMouseEnter: handleMouseEnter,
323
+ onMouseLeave: handleMouseLeave,
324
+ onClick: handleClick,
325
+ onTouchEndCapture: isMobile ? handleTouchEnd : undefined,
326
+ "aria-label": displayText ? `[${displayText}]` : undefined,
327
+ };
328
+ // Render with Radix Popover
329
+ if (shouldShowPopover) {
330
+ const popoverContentElement = renderPopoverContent ? (renderPopoverContent({
462
331
  citation,
463
332
  verification: verification ?? null,
464
333
  status,
465
- })) : (_jsx(DefaultPopoverContent, { citation: citation, verification: verification ?? null, status: status, onImageClick: handleImageClick })) })) : null;
466
- // Status tooltip for miss/partial explanations
467
- const statusTooltipContent = shouldShowStatusTooltip ? (_jsx(StatusTooltipContent, { citation: citation, status: status, verification: verification ?? null, isExpanded: isPhrasesExpanded, onToggleExpand: handleTogglePhrases })) : null;
468
- const citationTrigger = (_jsx("span", { ref: (node) => {
469
- containerRef.current =
470
- node;
471
- if (typeof ref === "function") {
472
- ref(node);
473
- }
474
- else if (ref) {
475
- ref.current = node;
476
- }
477
- }, "data-citation-id": citationKey, "data-citation-instance": citationInstanceId, "data-tooltip-expanded": isTooltipExpanded, "data-has-image": !!verification?.verificationImageBase64, className: classNames("dc-citation", `dc-citation--${variant}`, foundStatusClass, className), onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onClick: handleToggleTooltip, onTouchEndCapture: isMobile ? handleTouchEnd : undefined, "aria-label": displayText ? `[${displayText}]` : undefined, "aria-expanded": isTooltipExpanded, children: renderCitationContent() }));
478
- // Image overlay for full-size view
479
- const imageOverlay = expandedImageSrc ? (_jsx(ImageOverlay, { src: expandedImageSrc, alt: "Citation verification - full size", onClose: handleCloseOverlay })) : null;
480
- // Wrap with popover or status tooltip if needed
481
- if (shouldShowPopover || shouldShowStatusTooltip) {
482
- return (_jsxs(_Fragment, { children: [children, _jsxs("span", { className: "dc-popover-wrapper", ref: wrapperRef, "data-expanded": isTooltipExpanded, children: [citationTrigger, popoverContent, statusTooltipContent] }), imageOverlay] }));
334
+ })) : (_jsx(DefaultPopoverContent, { citation: citation, verification: verification ?? null, status: status, onImageClick: () => {
335
+ if (verification?.verificationImageBase64) {
336
+ setExpandedImageSrc(verification.verificationImageBase64);
337
+ }
338
+ } }));
339
+ return (_jsxs(_Fragment, { children: [children, _jsxs(Popover, { open: isHovering, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("span", { ref: ref, ...triggerProps, children: renderCitationContent() }) }), _jsx(PopoverContent, { side: popoverPosition === "bottom" ? "bottom" : "top", onPointerDownOutside: (e) => e.preventDefault(), onInteractOutside: (e) => e.preventDefault(), children: popoverContentElement })] }), imageOverlay] }));
483
340
  }
484
- return (_jsxs(_Fragment, { children: [children, citationTrigger, imageOverlay] }));
341
+ // Render without popover
342
+ return (_jsxs(_Fragment, { children: [children, _jsx("span", { ref: ref, ...triggerProps, children: renderCitationContent() }), imageOverlay] }));
485
343
  });
486
344
  CitationComponent.displayName = "CitationComponent";
487
345
  export const MemoizedCitationComponent = memo(CitationComponent);