Skip to content

Commit 5460f88

Browse files
authored
feat: add pdf annotations (#67)
1 parent edb63c8 commit 5460f88

File tree

11 files changed

+872
-48
lines changed

11 files changed

+872
-48
lines changed
Lines changed: 158 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,178 @@
11
"use client";
22

3-
import { CanvasLayer, Page, Pages, Root, TextLayer } from "@anaralabs/lector";
4-
import React from "react";
3+
import {
4+
CanvasLayer,
5+
Page,
6+
Pages,
7+
Root,
8+
TextLayer,
9+
AnnotationHighlightLayer,
10+
type Annotation,
11+
SelectionTooltip,
12+
useAnnotations,
13+
useSelectionDimensions,
14+
usePdfJump,
15+
} from "@anaralabs/lector";
16+
import React, { useCallback, useEffect, useState } from "react";
517
import "pdfjs-dist/web/pdf_viewer.css";
618

719
import { GlobalWorkerOptions } from "pdfjs-dist";
820
import ZoomMenu from "./zoom-menu";
921
import DocumentMenu from "./document-menu";
1022
import { PageNavigation } from "./page-navigation";
23+
import { SelectionTooltipContent, TooltipContent, TooltipContentProps } from "./annotationts";
1124

1225
const fileUrl = "/pdf/pathways.pdf";
26+
const STORAGE_KEY = "pdf-annotations";
1327

1428
GlobalWorkerOptions.workerSrc = new URL(
1529
"pdfjs-dist/build/pdf.worker.mjs",
1630
import.meta.url
1731
).toString();
1832

19-
export const AnaraViewer = () => {
33+
interface PDFContentProps {
34+
onAnnotationsChange: (annotations: Annotation[]) => void;
35+
initialAnnotations?: Annotation[];
36+
focusedAnnotationId?: string;
37+
onAnnotationClick: (annotation: Annotation | null) => void;
38+
}
39+
40+
41+
const PDFContent = ({
42+
onAnnotationsChange,
43+
focusedAnnotationId,
44+
onAnnotationClick,
45+
}: PDFContentProps) => {
46+
const { addAnnotation, annotations } = useAnnotations();
47+
const { getSelection } = useSelectionDimensions();
48+
const { jumpToHighlightRects } = usePdfJump();
49+
50+
51+
useEffect(() => {
52+
onAnnotationsChange(annotations);
53+
}, [annotations, onAnnotationsChange]);
54+
55+
const handleCreateAnnotation = useCallback(() => {
56+
const selection = getSelection();
57+
if (!selection || !selection.highlights.length) return;
58+
59+
const newAnnotation = {
60+
pageNumber: selection.highlights[0].pageNumber,
61+
highlights: selection.highlights,
62+
color: "rgba(255, 255, 0, 0.3)",
63+
};
64+
65+
addAnnotation(newAnnotation);
66+
67+
window.getSelection()?.removeAllRanges();
68+
}, [addAnnotation, getSelection]);
69+
70+
useEffect(() => {
71+
if (annotations.length === 0) return;
72+
73+
const lastAnnotation = annotations[annotations.length - 1];
74+
const isNewAnnotation = Date.now() - new Date(lastAnnotation.createdAt).getTime() < 1000;
75+
76+
if (isNewAnnotation) {
77+
onAnnotationClick(lastAnnotation);
78+
}
79+
}, [annotations, onAnnotationClick]);
80+
81+
useEffect(() => {
82+
if (!focusedAnnotationId) return;
83+
84+
const annotation = annotations.find(a => a.id === focusedAnnotationId);
85+
if (!annotation || !annotation.highlights.length) return;
86+
87+
jumpToHighlightRects(
88+
annotation.highlights,
89+
"pixels",
90+
"center",
91+
-50
92+
);
93+
}, [focusedAnnotationId, annotations, jumpToHighlightRects]);
94+
95+
const handlePagesClick = useCallback((e: React.MouseEvent) => {
96+
const target = e.target as HTMLElement;
97+
98+
if (target.closest('[role="tooltip"]')) {
99+
return;
100+
}
101+
102+
const clickedHighlight = target.closest('[data-highlight-id]');
103+
104+
// If we clicked on a highlight, let the AnnotationHighlightLayer handle it
105+
if (clickedHighlight) {
106+
return;
107+
}
108+
109+
if (focusedAnnotationId) {
110+
onAnnotationClick(null);
111+
}
112+
}, [focusedAnnotationId, onAnnotationClick]);
113+
114+
const renderTooltipContent = useCallback(({ annotation, onClose }: TooltipContentProps) => {
115+
return <TooltipContent annotation={annotation} onClose={onClose} />;
116+
}, []);
117+
20118
return (
21-
<Root
22-
className="border overflow-hidden flex flex-col w-full h-[600px] rounded-lg"
23-
source={fileUrl}
24-
isZoomFitWidth={true}
25-
loader={<div className="w-full"></div>}
119+
<Pages
120+
className="dark:invert-[94%] dark:hue-rotate-180 dark:brightness-[80%] dark:contrast-[228%] dark:bg-gray-100"
121+
onClick={handlePagesClick}
26122
>
27-
<div className="p-1 relative flex justify-between border-b">
28-
<ZoomMenu />
29-
<PageNavigation />
30-
<DocumentMenu documentUrl={fileUrl} />
31-
</div>
32-
<Pages className="dark:invert-[94%] dark:hue-rotate-180 dark:brightness-[80%] dark:contrast-[228%] dark:bg-gray-100">
33-
<Page>
34-
<CanvasLayer />
35-
<TextLayer />
36-
</Page>
37-
</Pages>
38-
</Root>
123+
<Page>
124+
<CanvasLayer />
125+
<TextLayer />
126+
<AnnotationHighlightLayer
127+
focusedAnnotationId={focusedAnnotationId}
128+
onAnnotationClick={onAnnotationClick}
129+
renderTooltipContent={renderTooltipContent}
130+
/>
131+
<SelectionTooltip>
132+
<SelectionTooltipContent onHighlight={handleCreateAnnotation} />
133+
</SelectionTooltip>
134+
</Page>
135+
</Pages>
136+
);
137+
};
138+
139+
export const AnaraViewer = () => {
140+
const [savedAnnotations, setSavedAnnotations] = React.useState<Annotation[]>([]);
141+
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string>();
142+
143+
const handleAnnotationsChange = useCallback((annotations: Annotation[]) => {
144+
try {
145+
localStorage.setItem(STORAGE_KEY, JSON.stringify(annotations));
146+
setSavedAnnotations(annotations);
147+
} catch (error) {
148+
console.error('Error saving annotations:', error);
149+
}
150+
}, []);
151+
152+
const handleAnnotationClick = useCallback((annotation: Annotation | null) => {
153+
setFocusedAnnotationId(annotation?.id);
154+
}, []);
155+
156+
return (
157+
<div className="flex flex-col gap-4">
158+
<Root
159+
className="border overflow-hidden flex flex-col w-full h-[600px] rounded-lg"
160+
source={fileUrl}
161+
isZoomFitWidth={true}
162+
loader={<div className="w-full"></div>}
163+
>
164+
<div className="p-1 relative flex justify-between border-b">
165+
<ZoomMenu />
166+
<PageNavigation />
167+
<DocumentMenu documentUrl={fileUrl} />
168+
</div>
169+
<PDFContent
170+
initialAnnotations={savedAnnotations}
171+
onAnnotationsChange={handleAnnotationsChange}
172+
focusedAnnotationId={focusedAnnotationId}
173+
onAnnotationClick={handleAnnotationClick}
174+
/>
175+
</Root>
176+
</div>
39177
);
40178
};
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
2+
3+
import {
4+
type Annotation,
5+
useAnnotations,
6+
} from "@anaralabs/lector";
7+
import React, { useCallback, useState } from "react";
8+
9+
export const SelectionTooltipContent = ({ onHighlight }: { onHighlight: () => void }) => {
10+
return (
11+
<button
12+
className="bg-white shadow-lg rounded-md px-3 py-1 hover:bg-yellow-200/70"
13+
onClick={onHighlight}
14+
>
15+
Add Annotation
16+
</button>
17+
);
18+
};
19+
20+
export interface AnnotationListProps {
21+
annotations: Annotation[];
22+
focusedAnnotationId?: string;
23+
onAnnotationClick: (annotation: Annotation | null) => void;
24+
}
25+
26+
export const AnnotationList = ({ annotations, focusedAnnotationId, onAnnotationClick }: AnnotationListProps) => {
27+
return (
28+
<div className="h-32 border overflow-y-auto bg-white rounded-lg">
29+
<div className="p-2">
30+
<h3 className="font-semibold mb-2">Annotations</h3>
31+
<div className="space-y-2">
32+
{annotations.map((annotation) => (
33+
<div
34+
key={annotation.id}
35+
className={`p-2 rounded cursor-pointer transition-colors ${
36+
focusedAnnotationId === annotation.id
37+
? 'bg-yellow-100'
38+
: 'hover:bg-gray-100'
39+
}`}
40+
onClick={() => onAnnotationClick(annotation)}
41+
>
42+
<div className="flex items-center gap-2">
43+
<div
44+
className="w-4 h-4 rounded"
45+
style={{ backgroundColor: annotation.color }}
46+
/>
47+
<div className="flex-grow">
48+
<div className="text-sm">
49+
{annotation.comment || 'No comment'}
50+
</div>
51+
<div className="text-xs text-gray-500">
52+
Page {annotation.pageNumber}
53+
</div>
54+
</div>
55+
</div>
56+
</div>
57+
))}
58+
</div>
59+
</div>
60+
</div>
61+
);
62+
};
63+
64+
export interface TooltipContentProps {
65+
annotation: Annotation;
66+
onClose: () => void;
67+
}
68+
69+
export const TooltipContent = ({ annotation, onClose }: TooltipContentProps) => {
70+
const { updateAnnotation, deleteAnnotation } = useAnnotations();
71+
const [comment, setComment] = useState(annotation.comment || "");
72+
const [isEditing, setIsEditing] = useState(false);
73+
74+
const handleSaveComment = useCallback((e: React.MouseEvent) => {
75+
e.stopPropagation();
76+
updateAnnotation(annotation.id, { comment });
77+
setIsEditing(false);
78+
onClose?.();
79+
}, [annotation.id, comment, updateAnnotation, onClose]);
80+
81+
const handleColorChange = useCallback((e: React.MouseEvent, color: string) => {
82+
e.stopPropagation();
83+
updateAnnotation(annotation.id, { color });
84+
onClose?.();
85+
}, [annotation.id, updateAnnotation, onClose]);
86+
87+
const handleStartEditing = useCallback((e: React.MouseEvent) => {
88+
e.stopPropagation();
89+
setIsEditing(true);
90+
}, []);
91+
92+
const handleCancelEdit = useCallback((e: React.MouseEvent) => {
93+
e.stopPropagation();
94+
setIsEditing(false);
95+
}, []);
96+
97+
const handleDelete = useCallback((e: React.MouseEvent) => {
98+
e.stopPropagation();
99+
deleteAnnotation(annotation.id);
100+
onClose?.();
101+
}, [annotation.id, deleteAnnotation, onClose]);
102+
103+
const colors = [
104+
"rgba(255, 255, 0, 0.3)", // Yellow
105+
"rgba(0, 255, 0, 0.3)", // Green
106+
"rgba(255, 182, 193, 0.3)", // Pink
107+
"rgba(135, 206, 235, 0.3)", // Sky Blue
108+
];
109+
110+
const handleTooltipClick = useCallback((e: React.MouseEvent) => {
111+
e.stopPropagation();
112+
}, []);
113+
114+
return (
115+
<div className="flex flex-col gap-2" onClick={handleTooltipClick}>
116+
{/* Color picker and delete button */}
117+
<div className="flex items-center justify-center gap-2">
118+
{colors.map((color) => (
119+
<button
120+
key={color}
121+
className="w-6 h-6 rounded"
122+
style={{ backgroundColor: color }}
123+
onClick={(e) => handleColorChange(e, color)}
124+
/>
125+
))}
126+
</div>
127+
128+
{/* Comment section */}
129+
{isEditing ? (
130+
<div className="flex flex-col gap-2">
131+
<textarea
132+
value={comment}
133+
onChange={(e) => setComment(e.target.value)}
134+
className="border rounded p-2 text-sm"
135+
placeholder="Add a comment..."
136+
rows={3}
137+
onClick={(e) => e.stopPropagation()}
138+
/>
139+
<div className="flex justify-end gap-2">
140+
<button
141+
onClick={handleCancelEdit}
142+
className="px-2 py-1 text-sm text-gray-600 hover:text-gray-800"
143+
>
144+
Cancel
145+
</button>
146+
<button
147+
onClick={handleSaveComment}
148+
className="px-2 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600"
149+
>
150+
Save
151+
</button>
152+
</div>
153+
</div>
154+
) : (
155+
<div className="flex flex-row gap-2">
156+
{annotation.comment ? (
157+
<div className="text-sm text-gray-700">{annotation.comment}</div>
158+
) : (
159+
<>
160+
<button
161+
onClick={handleStartEditing}
162+
className="text-sm text-blue-500 hover:text-blue-600"
163+
>
164+
Add comment
165+
</button>
166+
167+
<button
168+
onClick={handleDelete}
169+
className="text-sm text-red-500 hover:text-red-600"
170+
>
171+
Delete
172+
</button>
173+
</>
174+
)}
175+
</div>
176+
)}
177+
</div>
178+
);
179+
};

packages/lector/size.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"name": "Client",
44
"passed": true,
5-
"size": 67878,
5+
"size": 69735,
66
"sizeLimit": 150000
77
}
88
]

0 commit comments

Comments
 (0)