diff --git a/design/PAP-9436-doc-annotation-ui/screenshots/01-desktop-open-focused.png b/design/PAP-9436-doc-annotation-ui/screenshots/01-desktop-open-focused.png new file mode 100644 index 00000000000..08d81c06aa7 Binary files /dev/null and b/design/PAP-9436-doc-annotation-ui/screenshots/01-desktop-open-focused.png differ diff --git a/design/PAP-9436-doc-annotation-ui/screenshots/02-desktop-resolved-focused.png b/design/PAP-9436-doc-annotation-ui/screenshots/02-desktop-resolved-focused.png new file mode 100644 index 00000000000..0fae21dbb85 Binary files /dev/null and b/design/PAP-9436-doc-annotation-ui/screenshots/02-desktop-resolved-focused.png differ diff --git a/design/PAP-9436-doc-annotation-ui/screenshots/03-desktop-stale-focused.png b/design/PAP-9436-doc-annotation-ui/screenshots/03-desktop-stale-focused.png new file mode 100644 index 00000000000..31beb55195c Binary files /dev/null and b/design/PAP-9436-doc-annotation-ui/screenshots/03-desktop-stale-focused.png differ diff --git a/design/PAP-9436-doc-annotation-ui/screenshots/04-desktop-orphaned-focused.png b/design/PAP-9436-doc-annotation-ui/screenshots/04-desktop-orphaned-focused.png new file mode 100644 index 00000000000..c99d7b989d0 Binary files /dev/null and b/design/PAP-9436-doc-annotation-ui/screenshots/04-desktop-orphaned-focused.png differ diff --git a/design/PAP-9436-doc-annotation-ui/screenshots/05-dirty-draft-disabled.png b/design/PAP-9436-doc-annotation-ui/screenshots/05-dirty-draft-disabled.png new file mode 100644 index 00000000000..8f064ba5576 Binary files /dev/null and b/design/PAP-9436-doc-annotation-ui/screenshots/05-dirty-draft-disabled.png differ diff --git a/design/PAP-9436-doc-annotation-ui/screenshots/06-mobile-bottom-sheet.png b/design/PAP-9436-doc-annotation-ui/screenshots/06-mobile-bottom-sheet.png new file mode 100644 index 00000000000..b649fd0c9d3 Binary files /dev/null and b/design/PAP-9436-doc-annotation-ui/screenshots/06-mobile-bottom-sheet.png differ diff --git a/scripts/screenshot-doc-annotations.mjs b/scripts/screenshot-doc-annotations.mjs new file mode 100644 index 00000000000..81a4f4c883f --- /dev/null +++ b/scripts/screenshot-doc-annotations.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Capture screenshots of the Document Annotation storybook stories. +// Usage: node scripts/screenshot-doc-annotations.mjs + +import http from "node:http"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { chromium } from "@playwright/test"; + +async function main() { + const [, , staticDir, outDir] = process.argv; + if (!staticDir || !outDir) { + console.error("usage: node scripts/screenshot-doc-annotations.mjs "); + process.exit(1); + } + await fs.mkdir(outDir, { recursive: true }); + const absStaticDir = path.resolve(staticDir); + + const server = http.createServer(async (req, res) => { + try { + let urlPath = decodeURIComponent((req.url || "/").split("?")[0]); + if (urlPath.endsWith("/")) urlPath += "iframe.html"; + const filePath = path.resolve(absStaticDir, `.${urlPath}`); + if (!filePath.startsWith(absStaticDir + path.sep) && filePath !== absStaticDir) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const buf = await fs.readFile(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mime = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".map": "application/json", + }[ext] || "application/octet-stream"; + res.writeHead(200, { "content-type": mime }); + res.end(buf); + } catch (err) { + res.writeHead(404); + res.end(String(err)); + } + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + const baseUrl = `http://127.0.0.1:${port}/iframe.html`; + + const browser = await chromium.launch(); + try { + const stories = [ + { id: "product-documents-annotations--desktop-open-focused", file: "01-desktop-open-focused.png", width: 1280, height: 900 }, + { id: "product-documents-annotations--desktop-resolved-focused", file: "02-desktop-resolved-focused.png", width: 1280, height: 900 }, + { id: "product-documents-annotations--desktop-stale-focused", file: "03-desktop-stale-focused.png", width: 1280, height: 900 }, + { id: "product-documents-annotations--desktop-orphaned-focused", file: "04-desktop-orphaned-focused.png", width: 1280, height: 900 }, + { id: "product-documents-annotations--dirty-draft-disables-new-comments", file: "05-dirty-draft-disabled.png", width: 720, height: 720 }, + { id: "product-documents-annotations--mobile-bottom-sheet-view", file: "06-mobile-bottom-sheet.png", width: 375, height: 812 }, + ]; + + for (const story of stories) { + const ctx = await browser.newContext({ + viewport: { width: story.width, height: story.height }, + deviceScaleFactor: 2, + colorScheme: "light", + }); + const page = await ctx.newPage(); + const url = `${baseUrl}?id=${story.id}&viewMode=story`; + await page.goto(url, { waitUntil: "networkidle" }); + await page.evaluate(() => { + document.documentElement.style.colorScheme = "light"; + }); + await page.waitForTimeout(700); + const out = path.join(outDir, story.file); + await page.screenshot({ path: out, fullPage: true }); + console.log("wrote", out); + await ctx.close(); + } + } finally { + await browser.close(); + server.close(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/ui/src/api/document-annotations.ts b/ui/src/api/document-annotations.ts new file mode 100644 index 00000000000..81bad503e60 --- /dev/null +++ b/ui/src/api/document-annotations.ts @@ -0,0 +1,59 @@ +import type { + CreateDocumentAnnotationCommentRequest, + CreateDocumentAnnotationThreadRequest, + DocumentAnnotationComment, + DocumentAnnotationThread, + DocumentAnnotationThreadStatus, + DocumentAnnotationThreadWithComments, + UpdateDocumentAnnotationThreadRequest, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export type DocumentAnnotationListFilter = "open" | "resolved" | "all"; + +export const documentAnnotationsApi = { + list: ( + issueId: string, + key: string, + options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {}, + ) => { + const params = new URLSearchParams(); + if (options.status) params.set("status", options.status); + if (options.includeComments) params.set("includeComments", "true"); + const qs = params.toString(); + return api.get( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations${qs ? `?${qs}` : ""}`, + ); + }, + get: (issueId: string, key: string, threadId: string) => + api.get( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`, + ), + create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`, + data, + ), + addComment: ( + issueId: string, + key: string, + threadId: string, + data: CreateDocumentAnnotationCommentRequest, + ) => + api.post( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}/comments`, + data, + ), + updateStatus: ( + issueId: string, + key: string, + threadId: string, + status: DocumentAnnotationThreadStatus, + ) => { + const payload: UpdateDocumentAnnotationThreadRequest = { status }; + return api.patch( + `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`, + payload, + ); + }, +}; diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx new file mode 100644 index 00000000000..52e709e91e4 --- /dev/null +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { MessageSquarePlus } from "lucide-react"; +import type { + DocumentAnnotationAnchorState, + DocumentAnnotationThreadStatus, +} from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + buildAnchorFromContainerSelection, + getContainerTextOffset, + rangesForNormalizedSpan, +} from "@/lib/document-annotation-selection"; +import type { DocumentAnnotationAnchorSelector } from "@paperclipai/shared"; + +export interface AnnotationOverlayThread { + id: string; + selectedText: string; + status: DocumentAnnotationThreadStatus; + anchorState: DocumentAnnotationAnchorState; + unreadCount?: number; +} + +export interface PendingAnchor { + selector: DocumentAnnotationAnchorSelector; + selectedText: string; +} + +export interface AnnotationLayerProps { + containerRef: React.RefObject; + markdown: string; + threads: AnnotationOverlayThread[]; + focusedThreadId: string | null; + onThreadFocus: (threadId: string) => void; + /** Tracks the most recently captured pending selection. */ + pendingAnchor: PendingAnchor | null; + onPendingAnchorChange: (anchor: PendingAnchor | null) => void; + onRequestComment: (anchor: PendingAnchor) => void; + /** Disables the "add comment" affordance when set. */ + newCommentDisabled?: boolean; + newCommentDisabledReason?: string | null; + /** Hide resolved highlights even when included in the threads list. */ + hideResolved?: boolean; + /** Test-only: override window object for layout calculations. */ + testWindow?: { innerWidth: number; innerHeight: number }; +} + +interface HighlightRect { + threadId: string; + status: DocumentAnnotationThreadStatus; + anchorState: DocumentAnnotationAnchorState; + top: number; + left: number; + width: number; + height: number; +} + +interface ToolbarPosition { + top: number; + left: number; +} + +const POSITION_REFRESH_INTERVAL_MS = 250; + +export function DocumentAnnotationLayer({ + containerRef, + markdown, + threads, + focusedThreadId, + onThreadFocus, + pendingAnchor, + onPendingAnchorChange, + onRequestComment, + newCommentDisabled = false, + newCommentDisabledReason = null, + hideResolved = true, +}: AnnotationLayerProps) { + const [highlightRects, setHighlightRects] = useState([]); + const [toolbarPosition, setToolbarPosition] = useState(null); + const overlayRef = useRef(null); + const layerContainerRef = useRef(null); + + const visibleThreads = useMemo(() => { + if (!hideResolved) return threads; + return threads.filter((thread) => thread.status !== "resolved" || thread.anchorState === "orphaned" || thread.id === focusedThreadId); + }, [threads, hideResolved, focusedThreadId]); + + const computeHighlightRects = useCallback(() => { + const container = containerRef.current; + const overlay = overlayRef.current; + if (!container || !overlay) { + setHighlightRects([]); + return; + } + const overlayRect = overlay.getBoundingClientRect(); + const next: HighlightRect[] = []; + for (const thread of visibleThreads) { + if (thread.anchorState === "orphaned") continue; + const ranges = rangesForNormalizedSpan({ + container, + selectedText: thread.selectedText, + }); + for (const range of ranges) { + for (const rect of Array.from(range.getClientRects())) { + if (rect.width === 0 || rect.height === 0) continue; + next.push({ + threadId: thread.id, + status: thread.status, + anchorState: thread.anchorState, + top: rect.top - overlayRect.top, + left: rect.left - overlayRect.left, + width: rect.width, + height: rect.height, + }); + } + } + } + setHighlightRects(next); + }, [containerRef, visibleThreads]); + + useLayoutEffect(() => { + computeHighlightRects(); + }, [computeHighlightRects]); + + useEffect(() => { + if (typeof window === "undefined") return; + let cancelled = false; + const schedule = () => { + if (cancelled) return; + computeHighlightRects(); + }; + const interval = window.setInterval(schedule, POSITION_REFRESH_INTERVAL_MS); + const handleResize = () => schedule(); + window.addEventListener("resize", handleResize); + window.addEventListener("scroll", handleResize, true); + return () => { + cancelled = true; + window.clearInterval(interval); + window.removeEventListener("resize", handleResize); + window.removeEventListener("scroll", handleResize, true); + }; + }, [computeHighlightRects]); + + useEffect(() => { + if (typeof document === "undefined") return; + const handleSelectionChange = () => { + const container = containerRef.current; + const overlay = overlayRef.current; + if (!container || !overlay) return; + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + onPendingAnchorChange(null); + setToolbarPosition(null); + return; + } + const range = selection.getRangeAt(0); + if (!container.contains(range.commonAncestorContainer)) { + onPendingAnchorChange(null); + setToolbarPosition(null); + return; + } + const containerOffset = getContainerTextOffset(container, range); + if (!containerOffset) { + onPendingAnchorChange(null); + setToolbarPosition(null); + return; + } + const anchor = buildAnchorFromContainerSelection({ + markdown, + containerOffset, + }); + if (!anchor) { + onPendingAnchorChange(null); + setToolbarPosition(null); + return; + } + onPendingAnchorChange({ + selector: anchor.selector, + selectedText: containerOffset.selectedText, + }); + + const overlayRect = overlay.getBoundingClientRect(); + const rect = range.getBoundingClientRect(); + const top = Math.max(0, rect.top - overlayRect.top - 36); + const left = Math.max(0, rect.left - overlayRect.left + rect.width / 2 - 80); + setToolbarPosition({ top, left }); + }; + document.addEventListener("selectionchange", handleSelectionChange); + return () => document.removeEventListener("selectionchange", handleSelectionChange); + }, [containerRef, markdown, onPendingAnchorChange]); + + const handleAddComment = () => { + if (pendingAnchor) onRequestComment(pendingAnchor); + }; + + return ( + + + ); +} diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx new file mode 100644 index 00000000000..9de526c76a8 --- /dev/null +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -0,0 +1,465 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { + DocumentAnnotationComment, + DocumentAnnotationThreadStatus, + DocumentAnnotationThreadWithComments, +} from "@paperclipai/shared"; +import { Check, Copy, RotateCcw, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { cn, relativeTime } from "@/lib/utils"; +import { documentAnnotationsApi } from "@/api/document-annotations"; +import { MarkdownBody } from "./MarkdownBody"; +import type { PendingAnchor } from "./DocumentAnnotationLayer"; + +type AnnotationFilter = "open" | "resolved" | "stale" | "orphan"; + +const FILTERS: { id: AnnotationFilter; label: string }[] = [ + { id: "open", label: "Open" }, + { id: "resolved", label: "Resolved" }, + { id: "stale", label: "Stale" }, + { id: "orphan", label: "Orphaned" }, +]; + +export interface AnnotationPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + issueId: string; + documentKey: string; + documentRevisionNumber: number; + baseRevisionId: string | null; + baseRevisionNumber: number; + threads: DocumentAnnotationThreadWithComments[]; + focusedThreadId: string | null; + onFocusThread: (threadId: string | null) => void; + focusedCommentId: string | null; + /** External pending anchor captured from the layer for the composer. */ + pendingAnchor: PendingAnchor | null; + onClearPendingAnchor: () => void; + newCommentDisabled?: boolean; + newCommentDisabledReason?: string | null; + /** When mobile is true, render as a bottom sheet style panel instead of side panel. */ + isMobile?: boolean; + className?: string; +} + +export function DocumentAnnotationPanel(props: AnnotationPanelProps) { + const queryClient = useQueryClient(); + const [filter, setFilter] = useState("open"); + const [composerValue, setComposerValue] = useState(""); + const [replyDrafts, setReplyDrafts] = useState>({}); + const composerRef = useRef(null); + + const filteredThreads = useMemo(() => { + return props.threads.filter((thread) => { + if (filter === "open") return thread.status === "open" && thread.anchorState !== "orphaned"; + if (filter === "resolved") return thread.status === "resolved"; + if (filter === "stale") return thread.anchorState === "stale"; + if (filter === "orphan") return thread.anchorState === "orphaned"; + return true; + }); + }, [props.threads, filter]); + + const counts = useMemo(() => { + const result = { open: 0, resolved: 0, stale: 0, orphan: 0 }; + for (const thread of props.threads) { + if (thread.status === "resolved") result.resolved += 1; + if (thread.anchorState === "stale") result.stale += 1; + if (thread.anchorState === "orphaned") result.orphan += 1; + if (thread.status === "open" && thread.anchorState !== "orphaned") result.open += 1; + } + return result; + }, [props.threads]); + + const createThread = useMutation({ + mutationFn: async (body: string) => { + if (!props.pendingAnchor) throw new Error("No selection to anchor to."); + if (!props.baseRevisionId) throw new Error("Document has no revision yet."); + return documentAnnotationsApi.create(props.issueId, props.documentKey, { + baseRevisionId: props.baseRevisionId, + baseRevisionNumber: props.baseRevisionNumber, + selector: props.pendingAnchor.selector, + body, + }); + }, + onSuccess: (thread) => { + props.onClearPendingAnchor(); + setComposerValue(""); + props.onFocusThread(thread.id); + invalidateAll(); + }, + }); + + const addReply = useMutation({ + mutationFn: ({ threadId, body }: { threadId: string; body: string }) => + documentAnnotationsApi.addComment(props.issueId, props.documentKey, threadId, { body }), + onSuccess: (_data, variables) => { + setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" })); + invalidateAll(); + }, + }); + + const updateStatus = useMutation({ + mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) => + documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status), + onSuccess: () => invalidateAll(), + }); + + const invalidateAll = useCallback(() => { + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) + && query.queryKey[0] === "issues" + && query.queryKey[1] === "document-annotations" + && query.queryKey[2] === props.issueId + && query.queryKey[3] === props.documentKey, + }); + }, [props.documentKey, props.issueId, queryClient]); + + useEffect(() => { + if (!props.open) { + setComposerValue(""); + } + }, [props.open]); + + useEffect(() => { + if (props.pendingAnchor && props.open) { + composerRef.current?.focus(); + } + }, [props.open, props.pendingAnchor]); + + useEffect(() => { + if (!props.focusedThreadId) return; + const focused = props.threads.find((thread) => thread.id === props.focusedThreadId); + if (!focused) return; + if (focused.anchorState === "orphaned") setFilter("orphan"); + else if (focused.anchorState === "stale") setFilter("stale"); + else if (focused.status === "resolved") setFilter("resolved"); + else setFilter("open"); + }, [props.focusedThreadId, props.threads]); + + if (!props.open) return null; + + const baseClasses = props.isMobile + ? "fixed inset-x-0 bottom-0 z-40 max-h-[80vh] rounded-t-xl border-t border-border bg-background shadow-2xl" + : "sticky top-4 z-30 flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col rounded-lg border border-border bg-card shadow-md"; + + return ( +