Files
PaperClipAI/ui/src/components/IssueDocumentAnnotations.tsx
T
DottaandPaperclip a92e36bac0 Add document annotation UI (Phase 4)
Wires the Phase 3 annotation API into IssueDocumentsSection with a
selection toolbar, anchored highlight overlay, side panel, and mobile
bottom sheet — no markdown body mutation. Covers open/resolved/stale/
orphaned states, deep links (#document-<key>&thread=...&comment=...),
dirty-draft disable, and revision remap refresh.

- New: ui/src/api/document-annotations.ts client
- New: DocumentAnnotationLayer (overlay rectangles + selection toolbar)
- New: DocumentAnnotationPanel (filter chips, threads, composer, mobile)
- New: IssueDocumentAnnotations wrapper (count chip, deep link, dirty
  rule); used by IssueDocumentsSection for non-system documents
- New: document-annotation-hash and document-annotation-selection helpers
- Tests: hash parsing, selection→anchor projection (validated against
  shared verifyDocumentAnchorSelector), panel filter/disable/deep-link
- Stories + screenshot script: open/resolved/stale/orphaned + dirty
  + mobile bottom sheet (design/PAP-9436-doc-annotation-ui/screenshots)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:03:47 +00:00

208 lines
7.9 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { DocumentAnnotationThreadWithComments, IssueDocument } from "@paperclipai/shared";
import { MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { documentAnnotationsApi } from "@/api/document-annotations";
import { queryKeys } from "@/lib/queryKeys";
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel";
export interface IssueDocumentAnnotationsProps {
issueId: string;
doc: IssueDocument;
/** The body that is being rendered/edited (current or historical revision). */
bodyMarkdown: string;
/** True when a draft has unsaved changes or is currently saving. */
draftDirty: boolean;
/** True when there is a remote conflict that requires user resolution. */
draftConflicted: boolean;
/** True when the document is being viewed in historical revision preview. */
historicalPreview: boolean;
/** Render the document body (rendered MarkdownBody or MarkdownEditor) inside the wrapper. */
children: ReactNode;
/** Current location hash so we can resolve deep-link targets. */
locationHash: string;
}
export function IssueDocumentAnnotations({
issueId,
doc,
bodyMarkdown,
draftDirty,
draftConflicted,
historicalPreview,
children,
locationHash,
}: IssueDocumentAnnotationsProps) {
const containerRef = useRef<HTMLElement | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(null);
const [focusedCommentId, setFocusedCommentId] = useState<string | null>(null);
const [pendingAnchor, setPendingAnchor] = useState<PendingAnchor | null>(null);
const [isMobile, setIsMobile] = useState(false);
const hashHandledRef = useRef<string | null>(null);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mediaQuery = window.matchMedia("(max-width: 1023px)");
const handler = () => setIsMobile(mediaQuery.matches);
handler();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}
return undefined;
}, []);
const annotationsQuery = useQuery({
queryKey: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
queryFn: () => documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
staleTime: 30_000,
});
const allThreads = annotationsQuery.data ?? [];
const openCount = useMemo(
() => allThreads.filter((thread) => thread.status === "open" && thread.anchorState !== "orphaned").length,
[allThreads],
);
// Resolve deep link `#document-<key>&thread=...&comment=...` once per change.
useEffect(() => {
if (!locationHash) return;
if (hashHandledRef.current === locationHash) return;
const target = parseDocumentAnnotationHash(locationHash);
if (!target || target.documentKey !== doc.key) return;
if (!target.threadId) return;
hashHandledRef.current = locationHash;
setPanelOpen(true);
setFocusedThreadId(target.threadId);
setFocusedCommentId(target.commentId);
}, [doc.key, locationHash]);
const newCommentDisabled = draftDirty || draftConflicted || historicalPreview || !doc.latestRevisionId;
const newCommentDisabledReason = historicalPreview
? "New comments are disabled while previewing a historical revision."
: draftConflicted
? "Resolve the document conflict before adding new comments."
: draftDirty
? "Save the draft to anchor new comments."
: !doc.latestRevisionId
? "Document has no saved revision yet."
: null;
const handleRequestComment = useCallback((anchor: PendingAnchor) => {
if (newCommentDisabled) return;
setPendingAnchor(anchor);
setPanelOpen(true);
}, [newCommentDisabled]);
const handleThreadFocus = useCallback((threadId: string | null) => {
setFocusedThreadId(threadId);
if (threadId) {
setPanelOpen(true);
setFocusedCommentId(null);
}
}, []);
const focusedThread = useMemo(() => {
if (!focusedThreadId) return null;
return allThreads.find((thread) => thread.id === focusedThreadId) ?? null;
}, [allThreads, focusedThreadId]);
const overlayThreads = useMemo(
() => allThreads.map((thread) => ({
id: thread.id,
selectedText: thread.selectedText,
status: thread.status,
anchorState: thread.anchorState,
})),
[allThreads],
);
return (
<div className="paperclip-doc-annotation-host flex flex-col gap-3 lg:flex-row lg:items-start">
<div className="relative min-w-0 flex-1">
<div className="mb-2 flex items-center justify-end gap-1.5">
<Button
type="button"
size="sm"
variant={openCount > 0 ? "secondary" : "ghost"}
className={cn(
"h-7 gap-1 rounded-full px-2 text-[11px] font-medium",
openCount === 0 && "text-muted-foreground",
)}
onClick={() => setPanelOpen((current) => !current)}
data-testid={`document-annotation-count-${doc.key}`}
aria-label={openCount === 0
? `Open comments on ${doc.key}`
: `Open ${openCount} unresolved comments on ${doc.key}`}
aria-expanded={panelOpen}
>
<MessageSquare className="h-3 w-3" aria-hidden="true" />
<span aria-hidden="true">{openCount}</span>
<span className="hidden sm:inline">{openCount === 1 ? " comment" : " comments"}</span>
</Button>
</div>
<section
ref={(element) => {
containerRef.current = element;
}}
className="relative"
data-testid={`document-annotation-body-${doc.key}`}
>
{children}
{!historicalPreview && doc.latestRevisionId ? (
<DocumentAnnotationLayer
containerRef={containerRef}
markdown={bodyMarkdown}
threads={overlayThreads}
focusedThreadId={focusedThread?.id ?? null}
onThreadFocus={handleThreadFocus}
pendingAnchor={pendingAnchor}
onPendingAnchorChange={(anchor) => setPendingAnchor(anchor)}
onRequestComment={handleRequestComment}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
hideResolved
/>
) : null}
</section>
</div>
{panelOpen ? (
<DocumentAnnotationPanel
open={panelOpen}
onOpenChange={(open) => {
setPanelOpen(open);
if (!open) {
setPendingAnchor(null);
setFocusedThreadId(null);
setFocusedCommentId(null);
}
}}
issueId={issueId}
documentKey={doc.key}
documentRevisionNumber={doc.latestRevisionNumber}
baseRevisionId={doc.latestRevisionId}
baseRevisionNumber={doc.latestRevisionNumber}
threads={allThreads as DocumentAnnotationThreadWithComments[]}
focusedThreadId={focusedThreadId}
focusedCommentId={focusedCommentId}
onFocusThread={(id) => {
setFocusedThreadId(id);
if (!id) setFocusedCommentId(null);
}}
pendingAnchor={pendingAnchor}
onClearPendingAnchor={() => setPendingAnchor(null)}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
isMobile={isMobile}
className={isMobile ? undefined : "lg:w-[360px] lg:max-w-[360px]"}
/>
) : null}
</div>
);
}