diff --git a/backend/app/schemas/annotations.py b/backend/app/schemas/annotations.py
index 577e459..2321105 100644
--- a/backend/app/schemas/annotations.py
+++ b/backend/app/schemas/annotations.py
@@ -29,6 +29,7 @@ class TextProps(BaseModel):
italic: bool = False
lineHeight: float = 1.2
highlightColor: Optional[str] = None
+ styles: Optional[dict] = None
class TextAnnotation(AnnotationBase):
type: Literal["text"]
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 0671781..ef49a01 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,6 +14,7 @@
"@types/uuid": "^10.0.0",
"date-fns": "^4.4.0",
"fabric": "^7.4.0",
+ "lucide-react": "^1.18.0",
"pdfjs-dist": "^6.0.227",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -4101,6 +4102,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz",
+ "integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 366c312..8265c09 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,6 +19,7 @@
"@types/uuid": "^10.0.0",
"date-fns": "^4.4.0",
"fabric": "^7.4.0",
+ "lucide-react": "^1.18.0",
"pdfjs-dist": "^6.0.227",
"react": "^19.2.6",
"react-dom": "^19.2.6",
diff --git a/frontend/src/features/editor/canvas/AnnotationLayer.tsx b/frontend/src/features/editor/canvas/AnnotationLayer.tsx
index 2f47e87..5fff558 100644
--- a/frontend/src/features/editor/canvas/AnnotationLayer.tsx
+++ b/frontend/src/features/editor/canvas/AnnotationLayer.tsx
@@ -31,6 +31,8 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
selection: activeToolName === 'select',
});
+ (window as any).__fabricCanvas = canvas;
+
// Sync fabric modifications back to Zustand
canvas.on('object:modified', (e) => {
const obj = e.target as any;
@@ -113,22 +115,23 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
const activeObj = canvas.getActiveObject() as any;
const activeId = activeObj?.id;
- canvas.clear();
+ // Remove all objects EXCEPT the active one
+ canvas.getObjects().forEach((obj: any) => {
+ if (obj.id !== activeId) {
+ canvas.remove(obj);
+ }
+ });
annotations.forEach(ann => {
// Only render annotations for THIS page
if (ann.page === pageNumber) {
+ if (ann.id === activeId) return; // Skip rendering the active object
const tool = getTool(ann.type);
if (tool && tool.renderToFabric) {
tool.renderToFabric(ann, canvas, viewportParams);
}
}
});
-
- if (activeId) {
- const newActive = canvas.getObjects().find((o: any) => o.id === activeId);
- if (newActive) canvas.setActiveObject(newActive);
- }
}, [width, height, annotations, pageNumber]); // We ignore viewportParams identity for now to avoid loops
// Handle tool activation/deactivation and event binding
@@ -168,13 +171,16 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
}, [activeToolName]);
const selection = useEditorStore(state => state.selection);
+ const draftAnnotation = useEditorStore(state => state.draftAnnotation);
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
+ const isDraftOnThisPage = draftAnnotation?.page === pageNumber;
+ const toolbarAnnotationId = isDraftOnThisPage ? draftAnnotation.id : (isSelectedOnThisPage ? selection : null);
return (
- {isSelectedOnThisPage && (
-
+ {toolbarAnnotationId && (
+
)}
);
diff --git a/frontend/src/features/editor/store.ts b/frontend/src/features/editor/store.ts
index 664dfe3..060eb4c 100644
--- a/frontend/src/features/editor/store.ts
+++ b/frontend/src/features/editor/store.ts
@@ -1,5 +1,5 @@
import { create } from 'zustand';
-import type { Annotation } from '../../lib/annotations/types';
+import type { Annotation, TextProps } from '../../lib/annotations/types';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
@@ -10,6 +10,8 @@ interface EditorState {
selection: string | null;
zoom: number;
saveStatus: SaveStatus;
+ defaultTextProps: Partial;
+ draftAnnotation: Annotation | null;
// Actions
setDocumentId: (id: string | null) => void;
@@ -21,6 +23,8 @@ interface EditorState {
setSelection: (id: string | null) => void;
setZoom: (zoom: number) => void;
setSaveStatus: (status: SaveStatus) => void;
+ setDefaultTextProps: (props: Partial) => void;
+ setDraftAnnotation: (ann: Annotation | null) => void;
}
export const useEditorStore = create((set) => ({
@@ -30,8 +34,10 @@ export const useEditorStore = create((set) => ({
selection: null,
zoom: 1,
saveStatus: 'idle',
+ defaultTextProps: {},
+ draftAnnotation: null,
- setDocumentId: (id) => set({ documentId: id }),
+ setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }),
setAnnotations: (annotations) => set({ annotations }),
addAnnotation: (annotation) => set((state) => ({
annotations: [...state.annotations, annotation]
@@ -47,4 +53,6 @@ export const useEditorStore = create((set) => ({
setSelection: (id) => set({ selection: id }),
setZoom: (zoom) => set({ zoom }),
setSaveStatus: (status) => set({ saveStatus: status }),
+ setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
+ setDraftAnnotation: (ann) => set({ draftAnnotation: ann }),
}));
diff --git a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
index 8681cb3..e5f6385 100644
--- a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
+++ b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx
@@ -1,39 +1,89 @@
+import { useState, useRef, useEffect } from 'react';
import { useEditorStore } from '../store';
-import type { TextAnnotation } from '../../../lib/annotations/types';
+import type { TextAnnotation, TextProps } from '../../../lib/annotations/types';
import type { ViewportParams } from '../../../lib/coords';
import { pdfRectToScreen } from '../../../lib/coords';
import { v4 as uuidv4 } from 'uuid';
+import {
+ Palette,
+ Highlighter,
+ Bold,
+ Italic,
+ Copy,
+ Trash2,
+ ChevronDown
+} from 'lucide-react';
interface TextFormatToolbarProps {
annotationId: string;
viewportParams: ViewportParams;
}
-const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans'];
-const SIZES = [8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
+const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
+const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000'];
export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatToolbarProps) {
- const { annotations, updateAnnotation, deleteAnnotation, addAnnotation } = useEditorStore();
+ const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore();
- const annotation = annotations.find(a => a.id === annotationId);
+ const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
+ const toolbarRef = useRef(null);
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (toolbarRef.current && !toolbarRef.current.contains(event.target as Node)) {
+ setActiveDropdown(null);
+ }
+ }
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const isDraft = draftAnnotation?.id === annotationId;
+ const annotation = isDraft ? draftAnnotation : annotations.find(a => a.id === annotationId);
if (!annotation || annotation.type !== 'text') return null;
const textAnn = annotation as TextAnnotation;
const props = textAnn.props;
- // Calculate screen position
const pt = pdfRectToScreen(textAnn.rect, viewportParams);
- // Place it just above the text box
const top = pt.y - 48; // 48px above
const left = pt.x;
- const updateProps = (newProps: Partial) => {
- updateAnnotation(annotationId, { props: { ...props, ...newProps } });
+ const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => {
+ const canvas = (window as any).__fabricCanvas as any;
+ let didInline = false;
+
+ if (canvas) {
+ const activeObj = canvas.getActiveObject();
+ if (activeObj && activeObj.id === annotationId) {
+ if (activeObj.isEditing) {
+ activeObj.setSelectionStyles({ [styleName]: value });
+ didInline = true;
+ } else {
+ activeObj.set(styleName, value);
+ }
+ canvas.requestRenderAll();
+ }
+ }
+
+ const finalGlobalValue = globalValue !== undefined ? globalValue : value;
+ const newProps = { [globalPropName]: finalGlobalValue } as any;
+ setDefaultTextProps(newProps);
+
+ if (!didInline && !isDraft) {
+ updateAnnotation(annotationId, { props: { ...props, ...newProps } });
+ } else if (!didInline && isDraft) {
+ useEditorStore.getState().setDraftAnnotation({
+ ...textAnn,
+ props: { ...props, ...newProps }
+ });
+ }
};
const handleDuplicate = () => {
+ if (isDraft) return; // Can't duplicate draft
const newId = uuidv4();
addAnnotation({
...textAnn,
@@ -44,104 +94,202 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
y: textAnn.rect.y + 20
}
});
- // Setting selection to new annotation requires it to be mounted, which is fine,
- // but we can just leave selection on the old one for now.
+ };
+
+ const handleDelete = () => {
+ if (isDraft) {
+ useEditorStore.getState().setDraftAnnotation(null);
+ const canvas = (window as any).__fabricCanvas as any;
+ if (canvas) {
+ const activeObj = canvas.getActiveObject();
+ if (activeObj && activeObj.id === annotationId) {
+ canvas.remove(activeObj);
+ }
+ }
+ } else {
+ deleteAnnotation(annotationId);
+ }
};
return (
- {/* Font Family */}
-
+ {/* Font Family Dropdown */}
+
+
+ {activeDropdown === 'font' && (
+
+ {FONTS.map(f => (
+
+ ))}
+
+ )}
+
-
+
- {/* Font Size */}
-
+ {/* Font Size Dropdown */}
+
+
+ {activeDropdown === 'size' && (
+
+ {SIZES.map(s => (
+
+ ))}
+
+ )}
+
-
+
{/* Bold / Italic */}
-
+
- {/* Text Color */}
-
- {COLORS.map(c => (
-