mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
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>
This commit is contained in:
1 parent
cd5b735631
commit
a92e36bac0
19 files changed
+2178
-27
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
// Capture screenshots of the Document Annotation storybook stories.
|
||||
// Usage: node scripts/screenshot-doc-annotations.mjs <storybook-static-dir> <output-dir>
|
||||
|
||||
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 <storybook-static-dir> <output-dir>");
|
||||
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);
|
||||
});
|
||||
@@ -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<DocumentAnnotationThreadWithComments[]>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
get: (issueId: string, key: string, threadId: string) =>
|
||||
api.get<DocumentAnnotationThreadWithComments>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||
),
|
||||
create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) =>
|
||||
api.post<DocumentAnnotationThreadWithComments>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`,
|
||||
data,
|
||||
),
|
||||
addComment: (
|
||||
issueId: string,
|
||||
key: string,
|
||||
threadId: string,
|
||||
data: CreateDocumentAnnotationCommentRequest,
|
||||
) =>
|
||||
api.post<DocumentAnnotationComment>(
|
||||
`/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<DocumentAnnotationThread>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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<HTMLElement | null>;
|
||||
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<HighlightRect[]>([]);
|
||||
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const layerContainerRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
ref={layerContainerRef}
|
||||
className="paperclip-doc-annotation-layer pointer-events-none absolute inset-0 z-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div ref={overlayRef} className="relative h-full w-full">
|
||||
{highlightRects.map((rect, index) => {
|
||||
const isFocused = rect.threadId === focusedThreadId;
|
||||
return (
|
||||
<button
|
||||
key={`${rect.threadId}-${index}`}
|
||||
type="button"
|
||||
data-thread-id={rect.threadId}
|
||||
data-anchor-state={rect.anchorState}
|
||||
data-status={rect.status}
|
||||
data-focused={isFocused || undefined}
|
||||
aria-label={`Open annotation thread`}
|
||||
className={cn(
|
||||
"paperclip-doc-annotation-highlight pointer-events-auto absolute cursor-pointer rounded-sm border-b-2 transition-colors",
|
||||
rect.status === "resolved"
|
||||
? "border-dashed border-muted-foreground/40 hover:bg-muted/40"
|
||||
: rect.anchorState === "stale"
|
||||
? "border-dashed border-amber-500/60 hover:bg-amber-500/10"
|
||||
: isFocused
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/50 hover:bg-muted/30",
|
||||
)}
|
||||
style={{
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onThreadFocus(rect.threadId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{pendingAnchor && toolbarPosition ? (
|
||||
<div
|
||||
data-testid="document-annotation-selection-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="Selection actions"
|
||||
className="paperclip-doc-annotation-selection-toolbar pointer-events-auto absolute z-10 flex items-center gap-1 rounded-md border border-border bg-popover px-1 py-1 shadow-md"
|
||||
style={{ top: toolbarPosition.top, left: toolbarPosition.left }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={handleAddComment}
|
||||
disabled={newCommentDisabled}
|
||||
title={newCommentDisabled
|
||||
? newCommentDisabledReason ?? undefined
|
||||
: "Add comment on selection (⌘⇧M)"}
|
||||
>
|
||||
<MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AnnotationFilter>("open");
|
||||
const [composerValue, setComposerValue] = useState("");
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(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 (
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`}
|
||||
data-testid="document-annotation-panel"
|
||||
className={cn(baseClasses, "flex flex-col overflow-hidden", props.className)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-2 border-b border-border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Comments</p>
|
||||
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{props.documentKey} · rev {props.documentRevisionNumber}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
props.onFocusThread(null);
|
||||
props.onOpenChange(false);
|
||||
}}
|
||||
aria-label="Close annotation panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-1.5 border-b border-border px-3 py-2">
|
||||
{FILTERS.map((entry) => {
|
||||
const count = counts[entry.id];
|
||||
const isActive = filter === entry.id;
|
||||
return (
|
||||
<Button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isActive ? "default" : "secondary"}
|
||||
className="h-7 gap-1 px-2 text-[11px] font-medium"
|
||||
onClick={() => setFilter(entry.id)}
|
||||
>
|
||||
{entry.label}
|
||||
<span className="opacity-70">{count}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{props.newCommentDisabled && props.newCommentDisabledReason ? (
|
||||
<p
|
||||
data-testid="document-annotation-disabled-reason"
|
||||
className="border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{props.newCommentDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
{filteredThreads.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{filter === "open" ? "No open comments yet. Select text to add one." : `No ${filter} comments.`}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{filteredThreads.map((thread) => (
|
||||
<ThreadCard
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
expanded={thread.id === props.focusedThreadId}
|
||||
focusedCommentId={
|
||||
thread.id === props.focusedThreadId ? props.focusedCommentId : null
|
||||
}
|
||||
onFocus={() => props.onFocusThread(thread.id)}
|
||||
replyDraft={replyDrafts[thread.id] ?? ""}
|
||||
onReplyChange={(value) =>
|
||||
setReplyDrafts((current) => ({ ...current, [thread.id]: value }))
|
||||
}
|
||||
onSubmitReply={() => {
|
||||
const body = (replyDrafts[thread.id] ?? "").trim();
|
||||
if (!body) return;
|
||||
addReply.mutate({ threadId: thread.id, body });
|
||||
}}
|
||||
onResolveToggle={() =>
|
||||
updateStatus.mutate({
|
||||
threadId: thread.id,
|
||||
status: thread.status === "resolved" ? "open" : "resolved",
|
||||
})
|
||||
}
|
||||
onCopyLink={() => copyAnnotationLink(props.documentKey, thread.id)}
|
||||
pendingReply={addReply.isPending && addReply.variables?.threadId === thread.id}
|
||||
pendingStatus={updateStatus.isPending && updateStatus.variables?.threadId === thread.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{props.pendingAnchor ? (
|
||||
<div className="border-t border-border bg-muted/20 px-3 py-2">
|
||||
<p className="mb-1 text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
|
||||
New comment on selection
|
||||
</p>
|
||||
<blockquote className="mb-2 max-h-24 overflow-y-auto rounded-md border-l-2 border-muted-foreground/60 bg-background px-2 py-1 text-xs italic text-muted-foreground">
|
||||
{truncate(props.pendingAnchor.selectedText, 240)}
|
||||
</blockquote>
|
||||
<Textarea
|
||||
ref={composerRef}
|
||||
data-testid="document-annotation-composer"
|
||||
rows={3}
|
||||
value={composerValue}
|
||||
onChange={(event) => setComposerValue(event.target.value)}
|
||||
placeholder="Write a comment…"
|
||||
disabled={props.newCommentDisabled}
|
||||
className="resize-y text-sm"
|
||||
/>
|
||||
{createThread.isError ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{(createThread.error as Error).message || "Failed to create comment"}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
props.onClearPendingAnchor();
|
||||
setComposerValue("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={
|
||||
createThread.isPending
|
||||
|| !composerValue.trim()
|
||||
|| props.newCommentDisabled
|
||||
|| !props.baseRevisionId
|
||||
}
|
||||
onClick={() => createThread.mutate(composerValue.trim())}
|
||||
>
|
||||
{createThread.isPending ? "Posting…" : "Comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard(props: {
|
||||
thread: DocumentAnnotationThreadWithComments;
|
||||
expanded: boolean;
|
||||
focusedCommentId: string | null;
|
||||
onFocus: () => void;
|
||||
replyDraft: string;
|
||||
onReplyChange: (value: string) => void;
|
||||
onSubmitReply: () => void;
|
||||
onResolveToggle: () => void;
|
||||
onCopyLink: () => void;
|
||||
pendingReply: boolean;
|
||||
pendingStatus: boolean;
|
||||
}) {
|
||||
const { thread } = props;
|
||||
const statusVariant: { variant: "default" | "outline" | "secondary"; label: string } =
|
||||
thread.status === "resolved"
|
||||
? { variant: "outline", label: "Resolved" }
|
||||
: thread.anchorState === "orphaned"
|
||||
? { variant: "outline", label: "Orphaned" }
|
||||
: thread.anchorState === "stale"
|
||||
? { variant: "outline", label: "Stale" }
|
||||
: { variant: "default", label: "Open" };
|
||||
const latestComment = thread.comments[thread.comments.length - 1];
|
||||
|
||||
return (
|
||||
<li>
|
||||
<article
|
||||
role="article"
|
||||
data-thread-id={thread.id}
|
||||
data-anchor-state={thread.anchorState}
|
||||
data-status={thread.status}
|
||||
data-focused={props.expanded || undefined}
|
||||
aria-labelledby={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"rounded-md border bg-card transition-colors",
|
||||
props.expanded ? "border-primary/60" : "border-border",
|
||||
thread.status === "resolved" && "bg-muted/30",
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={props.onFocus}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 pt-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant={statusVariant.variant} className="px-1.5 py-0 text-[10px] uppercase tracking-[0.12em]">
|
||||
{statusVariant.label}
|
||||
</Badge>
|
||||
<span>{relativeTime(thread.updatedAt)}</span>
|
||||
</div>
|
||||
<blockquote
|
||||
id={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"mx-3 mt-1 max-h-20 overflow-hidden rounded-sm border-l-2 px-2 py-1 text-xs italic text-muted-foreground",
|
||||
thread.anchorState === "stale" || thread.status === "resolved"
|
||||
? "border-dashed border-muted-foreground/40"
|
||||
: "border-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{truncate(thread.selectedText, 160)}
|
||||
</blockquote>
|
||||
{props.expanded ? (
|
||||
<div className="space-y-2 px-3 py-2">
|
||||
{thread.comments.map((comment) => (
|
||||
<CommentRow
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
focused={props.focusedCommentId === comment.id}
|
||||
/>
|
||||
))}
|
||||
<Textarea
|
||||
data-testid={`document-annotation-reply-${thread.id}`}
|
||||
rows={2}
|
||||
value={props.replyDraft}
|
||||
onChange={(event) => props.onReplyChange(event.target.value)}
|
||||
placeholder="Reply…"
|
||||
className="resize-y text-sm"
|
||||
disabled={props.pendingReply}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={props.onCopyLink}
|
||||
className="gap-1"
|
||||
title="Copy link"
|
||||
>
|
||||
<Copy className="h-3 w-3" /> Copy link
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={props.onResolveToggle}
|
||||
disabled={props.pendingStatus}
|
||||
className="gap-1"
|
||||
>
|
||||
{thread.status === "resolved" ? (
|
||||
<>
|
||||
<RotateCcw className="h-3 w-3" /> Reopen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-3 w-3" /> Resolve
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!props.replyDraft.trim() || props.pendingReply}
|
||||
onClick={props.onSubmitReply}
|
||||
>
|
||||
{props.pendingReply ? "Sending…" : "Reply"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{thread.comments.length} comment{thread.comments.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
{latestComment ? <span className="ml-1">· {truncate(latestComment.body, 120)}</span> : null}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentRow({
|
||||
comment,
|
||||
focused,
|
||||
}: {
|
||||
comment: DocumentAnnotationComment;
|
||||
focused: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
id={`comment-${comment.id}`}
|
||||
data-focused={focused || undefined}
|
||||
className={cn(
|
||||
"rounded-md border border-border bg-background px-2 py-1.5",
|
||||
focused && "ring-2 ring-primary/40",
|
||||
)}
|
||||
>
|
||||
<div className="mb-0.5 flex items-center justify-between text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
|
||||
<span>{comment.authorType === "user" ? "Board" : "Agent"}</span>
|
||||
<span>{relativeTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(value: string, limit: number) {
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
}
|
||||
|
||||
async function copyAnnotationLink(documentKey: string, threadId: string) {
|
||||
if (typeof window === "undefined" || !navigator.clipboard) return;
|
||||
const { pathname } = window.location;
|
||||
const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${pathname}${hash}`);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
DocumentAnnotationThreadWithComments,
|
||||
IssueDocument,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
|
||||
|
||||
const mockAnnotationsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/document-annotations", () => ({
|
||||
documentAnnotationsApi: mockAnnotationsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./DocumentAnnotationLayer", () => ({
|
||||
DocumentAnnotationLayer: () => null,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeDoc(overrides: Partial<IssueDocument> = {}): IssueDocument {
|
||||
return {
|
||||
id: "doc-1",
|
||||
companyId: "co-1",
|
||||
issueId: "issue-1",
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "# Plan\n\nWe should keep the editor.",
|
||||
latestRevisionId: "rev-4",
|
||||
latestRevisionNumber: 4,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-1",
|
||||
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeThread(
|
||||
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
|
||||
): DocumentAnnotationThreadWithComments {
|
||||
const id = overrides.id ?? "thread-1";
|
||||
return {
|
||||
id,
|
||||
companyId: "co-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
documentKey: "plan",
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: "rev-4",
|
||||
originalRevisionNumber: 4,
|
||||
currentRevisionId: "rev-4",
|
||||
currentRevisionNumber: 4,
|
||||
selectedText: "should keep the editor",
|
||||
prefixText: "We ",
|
||||
suffixText: ".",
|
||||
normalizedStart: 0,
|
||||
normalizedEnd: 22,
|
||||
markdownStart: 0,
|
||||
markdownEnd: 22,
|
||||
anchorSelector: {
|
||||
quote: { exact: "should keep the editor", prefix: "We ", suffix: "." },
|
||||
position: { normalizedStart: 0, normalizedEnd: 22, markdownStart: 0, markdownEnd: 22 },
|
||||
},
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-04-01T00:01:00Z"),
|
||||
updatedAt: new Date("2026-04-01T00:02:00Z"),
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
companyId: "co-1",
|
||||
threadId: id,
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
body: "Please clarify this assumption.",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-04-01T00:01:00Z"),
|
||||
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("IssueDocumentAnnotations", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders the open count chip and opens the panel on click", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentAnnotations
|
||||
issueId="issue-1"
|
||||
doc={doc}
|
||||
bodyMarkdown={doc.body}
|
||||
draftDirty={false}
|
||||
draftConflicted={false}
|
||||
historicalPreview={false}
|
||||
locationHash=""
|
||||
>
|
||||
<p>Body content</p>
|
||||
</IssueDocumentAnnotations>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const chip = container.querySelector('[data-testid="document-annotation-count-plan"]');
|
||||
expect(chip).not.toBeNull();
|
||||
expect(chip!.textContent).toContain("1");
|
||||
|
||||
await act(async () => {
|
||||
(chip as HTMLButtonElement).click();
|
||||
});
|
||||
await flush();
|
||||
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
|
||||
expect(panel).not.toBeNull();
|
||||
});
|
||||
|
||||
it("auto-opens the panel and focuses the thread when deep-linked", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "thread-99" })]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentAnnotations
|
||||
issueId="issue-1"
|
||||
doc={doc}
|
||||
bodyMarkdown={doc.body}
|
||||
draftDirty={false}
|
||||
draftConflicted={false}
|
||||
historicalPreview={false}
|
||||
locationHash="#document-plan&thread=thread-99"
|
||||
>
|
||||
<p>Body</p>
|
||||
</IssueDocumentAnnotations>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
|
||||
expect(panel).not.toBeNull();
|
||||
const focusedThread = container.querySelector('[data-thread-id="thread-99"][data-focused]');
|
||||
expect(focusedThread).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows a disabled reason in the panel when the draft is dirty", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentAnnotations
|
||||
issueId="issue-1"
|
||||
doc={doc}
|
||||
bodyMarkdown={doc.body}
|
||||
draftDirty
|
||||
draftConflicted={false}
|
||||
historicalPreview={false}
|
||||
locationHash=""
|
||||
>
|
||||
<p>Body</p>
|
||||
</IssueDocumentAnnotations>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const chip = container.querySelector(
|
||||
'[data-testid="document-annotation-count-plan"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(chip).not.toBeNull();
|
||||
await act(async () => {
|
||||
chip!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const reason = container.querySelector(
|
||||
'[data-testid="document-annotation-disabled-reason"]',
|
||||
);
|
||||
expect(reason).not.toBeNull();
|
||||
expect(reason!.textContent).toMatch(/draft/i);
|
||||
});
|
||||
|
||||
it("filters resolved threads behind their tab", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "open-1" }),
|
||||
makeThread({ id: "resolved-1", status: "resolved" }),
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentAnnotations
|
||||
issueId="issue-1"
|
||||
doc={doc}
|
||||
bodyMarkdown={doc.body}
|
||||
draftDirty={false}
|
||||
draftConflicted={false}
|
||||
historicalPreview={false}
|
||||
locationHash=""
|
||||
>
|
||||
<p>Body</p>
|
||||
</IssueDocumentAnnotations>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const chip = container.querySelector(
|
||||
'[data-testid="document-annotation-count-plan"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => chip.click());
|
||||
await flush();
|
||||
|
||||
// Open filter shows only open
|
||||
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).toBeNull();
|
||||
|
||||
// Switch to Resolved
|
||||
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Resolved"),
|
||||
);
|
||||
expect(resolvedTab).not.toBeUndefined();
|
||||
await act(async () => resolvedTab!.click());
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { FoldCurtain } from "./FoldCurtain";
|
||||
import { IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
@@ -213,8 +214,10 @@ export function IssueDocumentsSection({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey)
|
||||
&& query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-revisions"
|
||||
&& query.queryKey[2] === issue.id,
|
||||
&& (
|
||||
(query.queryKey[1] === "document-revisions" && query.queryKey[2] === issue.id)
|
||||
|| (query.queryKey[1] === "document-annotations" && query.queryKey[2] === issue.id)
|
||||
),
|
||||
});
|
||||
}, [issue.id, queryClient]);
|
||||
|
||||
@@ -1153,31 +1156,44 @@ export function IssueDocumentsSection({
|
||||
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
{isHistoricalPreview ? (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
) : activeDraft ? (
|
||||
<MarkdownEditor
|
||||
value={displayedBody}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
) : (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
)}
|
||||
<IssueDocumentAnnotations
|
||||
issueId={issue.id}
|
||||
doc={doc}
|
||||
bodyMarkdown={displayedBody}
|
||||
draftDirty={Boolean(activeDraft) && (
|
||||
(activeDraft?.body ?? doc.body) !== doc.body
|
||||
|| (autosaveDocumentKey === doc.key && autosaveState === "saving")
|
||||
)}
|
||||
draftConflicted={Boolean(activeConflict)}
|
||||
historicalPreview={isHistoricalPreview}
|
||||
locationHash={location.hash}
|
||||
>
|
||||
{isHistoricalPreview ? (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
) : activeDraft ? (
|
||||
<MarkdownEditor
|
||||
value={displayedBody}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
) : (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
)}
|
||||
</IssueDocumentAnnotations>
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDocumentAnnotationHash,
|
||||
parseDocumentAnnotationHash,
|
||||
} from "./document-annotation-hash";
|
||||
|
||||
describe("parseDocumentAnnotationHash", () => {
|
||||
it("returns null for non-document hashes", () => {
|
||||
expect(parseDocumentAnnotationHash("")).toBeNull();
|
||||
expect(parseDocumentAnnotationHash("#issue-foo")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses document key only", () => {
|
||||
expect(parseDocumentAnnotationHash("#document-plan")).toEqual({
|
||||
documentKey: "plan",
|
||||
threadId: null,
|
||||
commentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses thread and comment targets", () => {
|
||||
expect(
|
||||
parseDocumentAnnotationHash("#document-plan&thread=t1&comment=c2"),
|
||||
).toEqual({
|
||||
documentKey: "plan",
|
||||
threadId: "t1",
|
||||
commentId: "c2",
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes URI-encoded keys", () => {
|
||||
expect(parseDocumentAnnotationHash("#document-my%20notes&thread=abc")).toEqual({
|
||||
documentKey: "my notes",
|
||||
threadId: "abc",
|
||||
commentId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDocumentAnnotationHash", () => {
|
||||
it("builds a hash without thread or comment", () => {
|
||||
expect(buildDocumentAnnotationHash({ documentKey: "plan", threadId: null, commentId: null })).toBe(
|
||||
"#document-plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes thread target", () => {
|
||||
expect(
|
||||
buildDocumentAnnotationHash({ documentKey: "plan", threadId: "t1", commentId: null }),
|
||||
).toBe("#document-plan&thread=t1");
|
||||
});
|
||||
|
||||
it("includes both targets", () => {
|
||||
expect(
|
||||
buildDocumentAnnotationHash({ documentKey: "plan", threadId: "t1", commentId: "c2" }),
|
||||
).toBe("#document-plan&thread=t1&comment=c2");
|
||||
});
|
||||
|
||||
it("survives a round trip", () => {
|
||||
const target = { documentKey: "plan-2", threadId: "t-abc", commentId: "c-xyz" };
|
||||
expect(parseDocumentAnnotationHash(buildDocumentAnnotationHash(target))).toEqual(target);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface DocumentAnnotationHashTarget {
|
||||
documentKey: string;
|
||||
threadId: string | null;
|
||||
commentId: string | null;
|
||||
}
|
||||
|
||||
const DOCUMENT_HASH_PREFIX = "#document-";
|
||||
|
||||
export function parseDocumentAnnotationHash(hash: string): DocumentAnnotationHashTarget | null {
|
||||
if (!hash.startsWith(DOCUMENT_HASH_PREFIX)) return null;
|
||||
const stripped = hash.slice(DOCUMENT_HASH_PREFIX.length);
|
||||
const [rawKey, ...rest] = stripped.split("&");
|
||||
if (!rawKey) return null;
|
||||
const documentKey = decodeURIComponent(rawKey);
|
||||
const params = new URLSearchParams(rest.join("&"));
|
||||
const threadId = params.get("thread");
|
||||
const commentId = params.get("comment");
|
||||
return {
|
||||
documentKey,
|
||||
threadId: threadId && threadId.length > 0 ? threadId : null,
|
||||
commentId: commentId && commentId.length > 0 ? commentId : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDocumentAnnotationHash(target: DocumentAnnotationHashTarget): string {
|
||||
const params = new URLSearchParams();
|
||||
if (target.threadId) params.set("thread", target.threadId);
|
||||
if (target.commentId) params.set("comment", target.commentId);
|
||||
const qs = params.toString();
|
||||
const encodedKey = encodeURIComponent(target.documentKey);
|
||||
return qs ? `${DOCUMENT_HASH_PREFIX}${encodedKey}&${qs}` : `${DOCUMENT_HASH_PREFIX}${encodedKey}`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { verifyDocumentAnchorSelector } from "@paperclipai/shared";
|
||||
import {
|
||||
buildAnchorFromContainerSelection,
|
||||
getContainerTextOffset,
|
||||
rangesForNormalizedSpan,
|
||||
} from "./document-annotation-selection";
|
||||
|
||||
const MARKDOWN = `# Plan
|
||||
|
||||
We **should** keep the current markdown stack for the first version.
|
||||
|
||||
- Highlight a text segment in a plan document.
|
||||
- Anchor comments without mutating markdown.
|
||||
|
||||
## Acceptance
|
||||
|
||||
The annotation feature is ready when the basic flow works.`;
|
||||
|
||||
const RENDERED_HTML = `
|
||||
<div>
|
||||
<h1>Plan</h1>
|
||||
<p>We should keep the current markdown stack for the first version.</p>
|
||||
<ul>
|
||||
<li>Highlight a text segment in a plan document.</li>
|
||||
<li>Anchor comments without mutating markdown.</li>
|
||||
</ul>
|
||||
<h2>Acceptance</h2>
|
||||
<p>The annotation feature is ready when the basic flow works.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = RENDERED_HTML;
|
||||
document.body.appendChild(div);
|
||||
return div.firstElementChild as HTMLElement;
|
||||
}
|
||||
|
||||
function selectText(container: HTMLElement, needle: string): Range {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
const data = (node as Text).data;
|
||||
const index = data.indexOf(needle);
|
||||
if (index !== -1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, index);
|
||||
range.setEnd(node, index + needle.length);
|
||||
return range;
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
throw new Error(`Could not find "${needle}" in container`);
|
||||
}
|
||||
|
||||
describe("buildAnchorFromContainerSelection", () => {
|
||||
it("produces a selector that verifies against the same markdown", () => {
|
||||
const container = makeContainer();
|
||||
const range = selectText(container, "current markdown stack");
|
||||
const offset = getContainerTextOffset(container, range);
|
||||
expect(offset).not.toBeNull();
|
||||
const anchor = buildAnchorFromContainerSelection({
|
||||
markdown: MARKDOWN,
|
||||
containerOffset: offset!,
|
||||
});
|
||||
expect(anchor).not.toBeNull();
|
||||
const verified = verifyDocumentAnchorSelector({
|
||||
markdown: MARKDOWN,
|
||||
selector: anchor!.selector,
|
||||
});
|
||||
expect(verified.ok).toBe(true);
|
||||
expect(verified.anchor?.selectedText).toBe("current markdown stack");
|
||||
});
|
||||
|
||||
it("returns null for empty selections", () => {
|
||||
const container = makeContainer();
|
||||
const range = document.createRange();
|
||||
range.setStart(container, 0);
|
||||
range.setEnd(container, 0);
|
||||
const offset = getContainerTextOffset(container, range);
|
||||
expect(offset).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when selection is outside container", () => {
|
||||
const container = makeContainer();
|
||||
const outside = document.createElement("div");
|
||||
outside.textContent = "outside";
|
||||
document.body.appendChild(outside);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(outside);
|
||||
const offset = getContainerTextOffset(container, range);
|
||||
expect(offset).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rangesForNormalizedSpan", () => {
|
||||
it("walks DOM text nodes to find span ranges", () => {
|
||||
const container = makeContainer();
|
||||
const ranges = rangesForNormalizedSpan({
|
||||
container,
|
||||
selectedText: "Highlight a text segment",
|
||||
});
|
||||
expect(ranges.length).toBeGreaterThan(0);
|
||||
const merged = ranges.map((range) => range.toString()).join("");
|
||||
expect(merged.replace(/\s+/g, " ")).toContain("Highlight a text segment");
|
||||
});
|
||||
|
||||
it("returns an empty array if selected text is missing", () => {
|
||||
const container = makeContainer();
|
||||
const ranges = rangesForNormalizedSpan({
|
||||
container,
|
||||
selectedText: "this string does not exist in the document",
|
||||
});
|
||||
expect(ranges).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
createDocumentAnchorSelector,
|
||||
normalizeAnchorText,
|
||||
projectMarkdownToText,
|
||||
resolveProjectionRange,
|
||||
type DocumentAnnotationAnchorSelector,
|
||||
type DocumentTextProjection,
|
||||
type DocumentTextRange,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export interface ContainerTextOffset {
|
||||
/** Byte offset of the selection start within the flattened container text. */
|
||||
startOffset: number;
|
||||
/** Byte offset of the selection end within the flattened container text. */
|
||||
endOffset: number;
|
||||
/** Raw flattened text content of the container. */
|
||||
containerText: string;
|
||||
/** Raw text inside the selection. */
|
||||
selectedText: string;
|
||||
}
|
||||
|
||||
export function getContainerTextOffset(
|
||||
container: HTMLElement,
|
||||
range: Range,
|
||||
): ContainerTextOffset | null {
|
||||
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) {
|
||||
return null;
|
||||
}
|
||||
const preRange = document.createRange();
|
||||
preRange.selectNodeContents(container);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
const startOffset = preRange.toString().length;
|
||||
preRange.setEnd(range.endContainer, range.endOffset);
|
||||
const endOffset = preRange.toString().length;
|
||||
if (endOffset <= startOffset) return null;
|
||||
return {
|
||||
startOffset,
|
||||
endOffset,
|
||||
containerText: container.textContent ?? "",
|
||||
selectedText: range.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelectionAnchorResult {
|
||||
selector: DocumentAnnotationAnchorSelector;
|
||||
range: DocumentTextRange;
|
||||
projection: DocumentTextProjection;
|
||||
}
|
||||
|
||||
export function buildAnchorFromContainerSelection(input: {
|
||||
markdown: string;
|
||||
containerOffset: ContainerTextOffset;
|
||||
}): SelectionAnchorResult | null {
|
||||
const projection = projectMarkdownToText(input.markdown);
|
||||
const needle = normalizeAnchorText(input.containerOffset.selectedText);
|
||||
if (!needle) return null;
|
||||
|
||||
const occurrences = findAllOccurrences(projection.text, needle);
|
||||
if (occurrences.length === 0) return null;
|
||||
|
||||
const renderedTextLength = Math.max(1, normalizeAnchorText(input.containerOffset.containerText).length);
|
||||
const renderedRatio = input.containerOffset.startOffset / renderedTextLength;
|
||||
const projectionLength = Math.max(1, projection.text.length);
|
||||
const expectedNormalized = Math.round(renderedRatio * projectionLength);
|
||||
|
||||
const best = pickClosestOccurrence(occurrences, expectedNormalized);
|
||||
if (best == null) return null;
|
||||
|
||||
const normalizedStart = best;
|
||||
const normalizedEnd = best + needle.length;
|
||||
const range = resolveProjectionRange(projection, normalizedStart, normalizedEnd);
|
||||
if (!range) return null;
|
||||
if (normalizeAnchorText(range.text) !== needle) return null;
|
||||
|
||||
const selector = createDocumentAnchorSelector(projection, range);
|
||||
return { selector, range, projection };
|
||||
}
|
||||
|
||||
function findAllOccurrences(haystack: string, needle: string): number[] {
|
||||
if (!needle) return [];
|
||||
const out: number[] = [];
|
||||
let cursor = haystack.indexOf(needle);
|
||||
while (cursor !== -1) {
|
||||
out.push(cursor);
|
||||
cursor = haystack.indexOf(needle, cursor + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickClosestOccurrence(occurrences: number[], expected: number): number | null {
|
||||
if (occurrences.length === 0) return null;
|
||||
if (occurrences.length === 1) return occurrences[0] ?? null;
|
||||
let best = occurrences[0] ?? 0;
|
||||
let bestDistance = Math.abs(best - expected);
|
||||
for (const candidate of occurrences) {
|
||||
const distance = Math.abs(candidate - expected);
|
||||
if (distance < bestDistance) {
|
||||
best = candidate;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk text nodes inside `container` and return a list of `Range`s that cover the
|
||||
* normalized-text span `[normalizedStart, normalizedEnd)`. Each Range can be
|
||||
* rectangle-projected to draw a highlight overlay.
|
||||
*/
|
||||
export function rangesForNormalizedSpan(input: {
|
||||
container: HTMLElement;
|
||||
selectedText: string;
|
||||
}): Range[] {
|
||||
const normalizedNeedle = normalizeAnchorText(input.selectedText);
|
||||
if (!normalizedNeedle) return [];
|
||||
const containerText = input.container.textContent ?? "";
|
||||
const normalizedContainerText = normalizeAnchorText(containerText);
|
||||
const containerOccurrenceIndex = normalizedContainerText.indexOf(normalizedNeedle);
|
||||
if (containerOccurrenceIndex === -1) return [];
|
||||
|
||||
// Convert from normalized container offset back to raw container offset
|
||||
// by walking the raw text and matching whitespace squashing.
|
||||
const rawIndex = mapNormalizedOffsetToRaw(containerText, containerOccurrenceIndex);
|
||||
if (rawIndex < 0) return [];
|
||||
|
||||
const rawNeedleLength = matchRawLengthForNormalized(
|
||||
containerText.slice(rawIndex),
|
||||
normalizedNeedle.length,
|
||||
);
|
||||
if (rawNeedleLength <= 0) return [];
|
||||
|
||||
const rawStart = rawIndex;
|
||||
const rawEnd = rawIndex + rawNeedleLength;
|
||||
return buildRangesForRawSpan(input.container, rawStart, rawEnd);
|
||||
}
|
||||
|
||||
function mapNormalizedOffsetToRaw(rawText: string, normalizedOffset: number): number {
|
||||
let normalizedCursor = 0;
|
||||
let lastWasWhitespace = true; // mimic trim() at start
|
||||
for (let index = 0; index < rawText.length; index += 1) {
|
||||
const char = rawText[index] ?? "";
|
||||
if (/\s/.test(char)) {
|
||||
if (!lastWasWhitespace) {
|
||||
if (normalizedCursor === normalizedOffset) return index;
|
||||
normalizedCursor += 1;
|
||||
lastWasWhitespace = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (normalizedCursor === normalizedOffset) return index;
|
||||
normalizedCursor += 1;
|
||||
lastWasWhitespace = false;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function matchRawLengthForNormalized(rawTail: string, normalizedLength: number): number {
|
||||
let normalizedCount = 0;
|
||||
let lastWasWhitespace = false;
|
||||
for (let index = 0; index < rawTail.length; index += 1) {
|
||||
const char = rawTail[index] ?? "";
|
||||
if (/\s/.test(char)) {
|
||||
if (!lastWasWhitespace) {
|
||||
normalizedCount += 1;
|
||||
if (normalizedCount >= normalizedLength) return index;
|
||||
lastWasWhitespace = true;
|
||||
}
|
||||
} else {
|
||||
normalizedCount += 1;
|
||||
lastWasWhitespace = false;
|
||||
if (normalizedCount >= normalizedLength) return index + 1;
|
||||
}
|
||||
}
|
||||
return rawTail.length;
|
||||
}
|
||||
|
||||
function buildRangesForRawSpan(container: HTMLElement, rawStart: number, rawEnd: number): Range[] {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
||||
const ranges: Range[] = [];
|
||||
let cursor = 0;
|
||||
let node: Node | null = walker.nextNode();
|
||||
while (node) {
|
||||
const textNode = node as Text;
|
||||
const length = textNode.data.length;
|
||||
const nodeStart = cursor;
|
||||
const nodeEnd = cursor + length;
|
||||
if (nodeEnd > rawStart && nodeStart < rawEnd) {
|
||||
const startWithin = Math.max(0, rawStart - nodeStart);
|
||||
const endWithin = Math.min(length, rawEnd - nodeStart);
|
||||
if (endWithin > startWithin) {
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, startWithin);
|
||||
range.setEnd(textNode, endWithin);
|
||||
ranges.push(range);
|
||||
}
|
||||
}
|
||||
cursor = nodeEnd;
|
||||
if (cursor >= rawEnd) break;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
@@ -63,6 +63,8 @@ export const queryKeys = {
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
|
||||
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||
documentAnnotations: (issueId: string, key: string, status: "open" | "resolved" | "all" = "all") =>
|
||||
["issues", "document-annotations", issueId, key, status] as const,
|
||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { DocumentAnnotationThreadWithComments } from "@paperclipai/shared";
|
||||
import { DocumentAnnotationPanel } from "@/components/DocumentAnnotationPanel";
|
||||
import { DocumentAnnotationLayer, type PendingAnchor } from "@/components/DocumentAnnotationLayer";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
|
||||
const sampleMarkdown = `# Plan: Document Highlights And Comment Threads
|
||||
|
||||
We should **keep** the current markdown document stack for the first version.
|
||||
The existing editor is MDXEditor on top of Lexical, and the current code already uses Lexical-level customization.
|
||||
|
||||
## Reader And Goal
|
||||
|
||||
Reader: board reviewer, CTO, and implementing engineers.
|
||||
|
||||
## Anchor Strategy
|
||||
|
||||
Do not insert comment markers into markdown. The markdown document body must
|
||||
remain portable and readable.
|
||||
|
||||
Use a sidecar anchor made from two selectors:
|
||||
|
||||
- Text quote selector: exact selected text plus prefix/suffix context.
|
||||
- Text position selector: normalized rendered-text offsets plus markdown source offsets.
|
||||
|
||||
## Future Work
|
||||
|
||||
Phase 5 covers QA validation across desktop and mobile.`;
|
||||
|
||||
function makeThread(
|
||||
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
|
||||
): DocumentAnnotationThreadWithComments {
|
||||
const id = overrides.id ?? "thread-1";
|
||||
return {
|
||||
id,
|
||||
companyId: "co-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
documentKey: "plan",
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: "rev-4",
|
||||
originalRevisionNumber: 4,
|
||||
currentRevisionId: "rev-4",
|
||||
currentRevisionNumber: 4,
|
||||
selectedText: "keep the current markdown document stack",
|
||||
prefixText: "We should ",
|
||||
suffixText: " for the first version",
|
||||
normalizedStart: 0,
|
||||
normalizedEnd: 40,
|
||||
markdownStart: 0,
|
||||
markdownEnd: 40,
|
||||
anchorSelector: {
|
||||
quote: {
|
||||
exact: "keep the current markdown document stack",
|
||||
prefix: "We should ",
|
||||
suffix: " for the first version",
|
||||
},
|
||||
position: { normalizedStart: 0, normalizedEnd: 40, markdownStart: 0, markdownEnd: 40 },
|
||||
},
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-05-12T10:00:00Z"),
|
||||
updatedAt: new Date("2026-05-12T10:01:00Z"),
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
companyId: "co-1",
|
||||
threadId: id,
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
body: "Could we benchmark the editor against a CRDT alternative before committing?",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-05-12T10:00:00Z"),
|
||||
updatedAt: new Date("2026-05-12T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
companyId: "co-1",
|
||||
threadId: id,
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
body: "We did a small spike — happy to share results in the plan.",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
createdByRunId: "run-1",
|
||||
createdAt: new Date("2026-05-12T10:01:00Z"),
|
||||
updatedAt: new Date("2026-05-12T10:01:00Z"),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const baseThreads: DocumentAnnotationThreadWithComments[] = [
|
||||
makeThread({ id: "open-1" }),
|
||||
makeThread({
|
||||
id: "stale-1",
|
||||
anchorState: "stale",
|
||||
anchorConfidence: "fuzzy",
|
||||
selectedText: "two selectors",
|
||||
prefixText: "anchor made from ",
|
||||
suffixText: ":",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-stale",
|
||||
companyId: "co-1",
|
||||
threadId: "stale-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
body: "Original wording was slightly different — re-anchor when convenient.",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-05-12T11:00:00Z"),
|
||||
updatedAt: new Date("2026-05-12T11:00:00Z"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeThread({
|
||||
id: "resolved-1",
|
||||
status: "resolved",
|
||||
selectedText: "Reader: board reviewer, CTO, and implementing engineers",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-resolved",
|
||||
companyId: "co-1",
|
||||
threadId: "resolved-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
body: "Updated reader list to add the security lead.",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
createdByRunId: "run-1",
|
||||
createdAt: new Date("2026-05-12T12:00:00Z"),
|
||||
updatedAt: new Date("2026-05-12T12:00:00Z"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
makeThread({
|
||||
id: "orphan-1",
|
||||
anchorState: "orphaned",
|
||||
selectedText: "an earlier paragraph that has been rewritten",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-orphan",
|
||||
companyId: "co-1",
|
||||
threadId: "orphan-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
body: "This anchor lost its location after the rewrite. Original quote preserved.",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-05-12T13:00:00Z"),
|
||||
updatedAt: new Date("2026-05-12T13:00:00Z"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
}
|
||||
|
||||
function StatesShowcase({ focusedThreadId = "open-1" }: { focusedThreadId?: string }) {
|
||||
const queryClient = useMemo(() => makeClient(), []);
|
||||
const bodyRef = useRef<HTMLElement | null>(null);
|
||||
const [pendingAnchor, setPendingAnchor] = useState<PendingAnchor | null>(null);
|
||||
const [focused, setFocused] = useState<string | null>(focusedThreadId);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="relative rounded-lg border border-border bg-card p-4">
|
||||
<section
|
||||
ref={(element) => {
|
||||
bodyRef.current = element;
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<MarkdownBody className="text-[15px] leading-7">{sampleMarkdown}</MarkdownBody>
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={bodyRef}
|
||||
markdown={sampleMarkdown}
|
||||
threads={baseThreads.map((thread) => ({
|
||||
id: thread.id,
|
||||
selectedText: thread.selectedText,
|
||||
status: thread.status,
|
||||
anchorState: thread.anchorState,
|
||||
}))}
|
||||
focusedThreadId={focused}
|
||||
onThreadFocus={(id) => setFocused(id)}
|
||||
pendingAnchor={pendingAnchor}
|
||||
onPendingAnchorChange={setPendingAnchor}
|
||||
onRequestComment={() => {}}
|
||||
hideResolved={false}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<DocumentAnnotationPanel
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
issueId="issue-1"
|
||||
documentKey="plan"
|
||||
documentRevisionNumber={4}
|
||||
baseRevisionId="rev-4"
|
||||
baseRevisionNumber={4}
|
||||
threads={baseThreads}
|
||||
focusedThreadId={focused}
|
||||
focusedCommentId={null}
|
||||
onFocusThread={(id) => setFocused(id)}
|
||||
pendingAnchor={null}
|
||||
onClearPendingAnchor={() => setPendingAnchor(null)}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DirtyDraftBlocked() {
|
||||
const queryClient = useMemo(() => makeClient(), []);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DocumentAnnotationPanel
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
issueId="issue-1"
|
||||
documentKey="plan"
|
||||
documentRevisionNumber={4}
|
||||
baseRevisionId="rev-4"
|
||||
baseRevisionNumber={4}
|
||||
threads={baseThreads}
|
||||
focusedThreadId={null}
|
||||
focusedCommentId={null}
|
||||
onFocusThread={() => {}}
|
||||
pendingAnchor={null}
|
||||
onClearPendingAnchor={() => {}}
|
||||
newCommentDisabled
|
||||
newCommentDisabledReason="Save the draft to anchor new comments."
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileBottomSheet() {
|
||||
const queryClient = useMemo(() => makeClient(), []);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="relative h-[640px] bg-card p-4">
|
||||
<MarkdownBody className="text-[15px] leading-7">{sampleMarkdown}</MarkdownBody>
|
||||
<DocumentAnnotationPanel
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
issueId="issue-1"
|
||||
documentKey="plan"
|
||||
documentRevisionNumber={4}
|
||||
baseRevisionId="rev-4"
|
||||
baseRevisionNumber={4}
|
||||
threads={baseThreads}
|
||||
focusedThreadId="open-1"
|
||||
focusedCommentId={null}
|
||||
onFocusThread={() => {}}
|
||||
pendingAnchor={null}
|
||||
onClearPendingAnchor={() => {}}
|
||||
isMobile
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Documents/Annotations",
|
||||
component: StatesShowcase,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Document annotation surface for issue documents. Covers the count chip, side panel, mobile bottom sheet, dirty-draft disable, and the open/resolved/stale/orphaned state matrix.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof StatesShowcase>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const DesktopOpenFocused: Story = {
|
||||
render: () => <StatesShowcase focusedThreadId="open-1" />,
|
||||
};
|
||||
|
||||
export const DesktopResolvedFocused: Story = {
|
||||
render: () => <StatesShowcase focusedThreadId="resolved-1" />,
|
||||
};
|
||||
|
||||
export const DesktopStaleFocused: Story = {
|
||||
render: () => <StatesShowcase focusedThreadId="stale-1" />,
|
||||
};
|
||||
|
||||
export const DesktopOrphanedFocused: Story = {
|
||||
render: () => <StatesShowcase focusedThreadId="orphan-1" />,
|
||||
};
|
||||
|
||||
export const DirtyDraftDisablesNewComments: Story = {
|
||||
render: () => <DirtyDraftBlocked />,
|
||||
};
|
||||
|
||||
export const MobileBottomSheetView: Story = {
|
||||
parameters: { viewport: { defaultViewport: "mobile1" } },
|
||||
render: () => <MobileBottomSheet />,
|
||||
};
|
||||
Reference in new issue
Block a user