diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d44a09b..f9da649 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -657,7 +657,7 @@ function App() { hasSignature={!!pendingSignature} signatureImageUrl={pendingSignature?.url} signatureAspect={pendingSignature?.aspect} - activeStamp={activeStamp?.label ?? null} + activeStamp={activeStamp} annotations={annotations} canCopy={can('canCopy')} onFieldChange={(id, value, i) => { diff --git a/frontend/src/viewer/FloatingTextToolbar.tsx b/frontend/src/viewer/FloatingTextToolbar.tsx new file mode 100644 index 0000000..3a2b051 --- /dev/null +++ b/frontend/src/viewer/FloatingTextToolbar.tsx @@ -0,0 +1,206 @@ +import React, { useState, useRef, useEffect } from 'react'; +import type { Rect } from '../lib/coordinateMapping'; + +interface FloatingTextToolbarProps { + selection: { text: string; bbox: Rect; lines: Rect[] }; + onAction: (action: 'copy' | 'comment' | 'highlight' | 'underline' | 'strikeout' | 'squiggly' | 'redact' | 'edit', overrideColor?: string) => void; +} + +const COLORS = [ + '#facc15', '#4ade80', '#2dd4bf', '#a78bfa', '#e879f9', + '#fb923c', '#60a5fa', '#f472b6', '#22d3ee', '#34d399', + '#16a34a', '#a855f7', '#2563eb', '#fef08a', '#ef4444', + '#ffffff', '#e5e5e5', '#a3a3a3', '#52525b', '#000000', +]; + +export const FloatingTextToolbar: React.FC = ({ selection, onAction }) => { + const { bbox } = selection; + // Calculate position: just above the bounding box + const top = bbox.y - 48; // 48px above + const left = bbox.x; + + const [openDropdown, setOpenDropdown] = useState<'highlight' | 'underline' | 'strikeout' | null>(null); + const [toolColors, setToolColors] = useState({ + highlight: '#facc15', + underline: '#f43f5e', + strikeout: '#ef4444', + }); + + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpenDropdown(null); + } + }; + if (openDropdown) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [openDropdown]); + + const handleAction = (action: 'highlight' | 'underline' | 'strikeout') => { + onAction(action, toolColors[action]); + setOpenDropdown(null); + }; + + const ColorPicker = ({ action }: { action: 'highlight' | 'underline' | 'strikeout' }) => ( +
+
+ {COLORS.map((c) => ( +
+
More colors
+
+ Opacity +
+ + 100% + +
+
+
+ ); + + return ( +
{ + // Prevent clearing the selection layer when clicking the toolbar + e.stopPropagation(); + }} + > + + + + +
+ +
+ + + {openDropdown === 'highlight' && } +
+ +
+ + + {openDropdown === 'underline' && } +
+ +
+ + + {openDropdown === 'strikeout' && } +
+ +
+ + + + +
+ ); +}; diff --git a/frontend/src/viewer/PDFViewer.tsx b/frontend/src/viewer/PDFViewer.tsx index 9ec40ad..0e2e6a8 100644 --- a/frontend/src/viewer/PDFViewer.tsx +++ b/frontend/src/viewer/PDFViewer.tsx @@ -11,13 +11,14 @@ import { SearchOverlayLayer } from './SearchOverlayLayer'; import type { Rect } from '../lib/coordinateMapping'; import { gatewayService } from '../lib/gatewayService'; import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService'; -import type { ToolSettings } from '../lib/tools'; +import type { ToolSettings, ToolId, StampPreset } from '../lib/tools'; import { toast } from '../lib/toast'; import { RedactionLayer } from './RedactionLayer'; import { StreamEditLayer } from './StreamEditLayer'; import { wasmFreeDocument } from '../lib/pdfiumEngine'; import { SignaturePlacementOverlay } from './SignaturePlacementOverlay'; import type { PlacementRect } from './SignaturePlacementOverlay'; +import { FloatingTextToolbar } from './FloatingTextToolbar'; interface PDFViewerProps { documentId: string; @@ -33,7 +34,7 @@ interface PDFViewerProps { /** The data-URL of the pending signature image (for the placement preview) */ signatureImageUrl?: string; signatureAspect?: number; - activeStamp: string | null; + activeStamp: StampPreset | null; annotations: Annotation[]; searchQuery?: string; searchResults?: SearchResult[]; @@ -121,6 +122,13 @@ export const PDFViewer = React.forwardRef(({ rect: PlacementRect; } | null>(null); + const [textSelection, setTextSelection] = useState<{ + pageIndex: number; + text: string; + bbox: Rect; + lines: Rect[]; + } | null>(null); + useEffect(() => { setPageTexts({}); const prev = prevDocumentIdRef.current; @@ -315,6 +323,7 @@ export const PDFViewer = React.forwardRef(({ }, [textToolActive, visiblePages, documentId, pageTexts]); const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => { + // This is still called on Ctrl+C for select mode if (activeTool === 'select') { if (!canCopy) { toast("Copying is not permitted by this document's restrictions", 'error'); @@ -372,6 +381,81 @@ export const PDFViewer = React.forwardRef(({ } }; + const handleToolbarAction = (action: string, overrideColor?: string) => { + if (!textSelection) return; + const { pageIndex, text, bbox, lines } = textSelection; + switch (action) { + case 'copy': + if (!canCopy) { + toast("Copying is not permitted by this document's restrictions", 'error'); + break; + } + navigator.clipboard?.writeText(text).then( + () => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'), + () => toast('Copy failed — clipboard unavailable', 'error') + ); + break; + case 'highlight': { + const newAnno: Annotation = { + id: generateUniqueId(), + type: 'highlight', + pageIndex, + bbox: { + x: bbox.x / zoom, + y: bbox.y / zoom, + width: bbox.width / zoom, + height: bbox.height / zoom, + }, + color: overrideColor || toolSettings.highlightColor, + opacity: toolSettings.highlightOpacity, + author: 'Current User', + content: text, + }; + onAnnotationAdded?.(newAnno); + break; + } + case 'underline': + onDecorateText?.(pageIndex, lines, 'underline', overrideColor || toolSettings.underlineColor); + break; + case 'strikeout': + onDecorateText?.(pageIndex, lines, 'strikeout', overrideColor || toolSettings.strikeoutColor); + break; + case 'squiggly': + onDecorateText?.(pageIndex, lines, 'squiggly', overrideColor || toolSettings.squigglyColor); + break; + case 'redact': + onMarkRedaction?.(pageIndex, { + x: bbox.x / zoom, + y: bbox.y / zoom, + width: bbox.width / zoom, + height: bbox.height / zoom + }); + break; + case 'edit': + toast('Please select the Edit Text tool from the toolbar to edit text content', 'info'); + break; + case 'comment': { + const newAnno: Annotation = { + id: generateUniqueId(), + type: 'comment', + pageIndex, + bbox: { + x: bbox.x / zoom, + y: bbox.y / zoom, + width: 24, + height: 24, + }, + color: '#facc15', + author: 'Current User', + content: 'New Comment\n\n' + text, + }; + onAnnotationAdded?.(newAnno); + break; + } + } + setTextSelection(null); + }; + const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 }); @@ -496,21 +580,27 @@ export const PDFViewer = React.forwardRef(({ glyphs={pageTexts[page.index] || []} mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || activeTool === 'redact') ? 'highlight' : 'select'} onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)} + onSelectionChange={(sel) => setTextSelection(sel ? { ...sel, pageIndex: page.index } : null)} /> )} - {activeTool === 'redact' && ( - onMarkRedaction?.(page.index, bounds)} - pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)} - onRemoveRedaction={onRemoveRedaction} - mode={redactionMode} + {textSelection && textSelection.pageIndex === page.index && ( + handleToolbarAction(action as any, color)} /> )} + onMarkRedaction?.(page.index, bounds)} + pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)} + onRemoveRedaction={onRemoveRedaction} + mode={(activeTool === 'redact' && redactionMode === 'area') ? 'area' : 'text'} + /> + void; + onSelectionChange?: (sel: { text: string, bbox: Rect, lines: Rect[] } | null) => void; } const SEL_START_EVT = 'pdf-selection-start'; @@ -25,6 +26,7 @@ export const SelectionLayer: React.FC = ({ glyphs, mode = 'select', onTextSelected, + onSelectionChange, }) => { const containerRef = useRef(null); const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]); @@ -62,7 +64,8 @@ export const SelectionLayer: React.FC = ({ setBox(null); drag.current = null; boxStart.current = null; - }, []); + onSelectionChange?.(null); + }, [onSelectionChange]); useEffect(() => { const onOther = (e: Event) => { @@ -160,14 +163,20 @@ export const SelectionLayer: React.FC = ({ const cur = selRef.current; if (!cur || cur.start === cur.end) { setSel(null); + onSelectionChange?.(null); return; } + + const text = model.textOfRange(cur); + const u = model.unionRect(cur); + const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom })); + if (mode === 'highlight') { - const text = model.textOfRange(cur); - const u = model.unionRect(cur); - const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom })); if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines); setSel(null); + onSelectionChange?.(null); + } else if (mode === 'select') { + if (u) onSelectionChange?.({ text, bbox: { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines }); } }; diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index 92b880a..179e289 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -397,6 +397,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest): if "," in img_data_str: img_data_str = img_data_str.split(",", 1)[1] + img_data_str += "=" * ((4 - len(img_data_str) % 4) % 4) raw_bytes = base64.b64decode(img_data_str) img = Image.open(io.BytesIO(raw_bytes)) img_rgba = img.convert("RGBA") @@ -407,6 +408,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest): bgra_bytes = img_bgra.tobytes() fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_") + temp_path = temp_path.replace("\\", "/") created_temp_files.append(temp_path) try: with os.fdopen(fd, "wb") as tmp: