feat: implement PDF viewer, floating text toolbar, and document editing gateway router

This commit is contained in:
azeeee05
2026-07-09 12:39:47 +05:30
parent f9ec4e1680
commit ad51b4b270
5 changed files with 323 additions and 16 deletions
+1 -1
View File
@@ -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) => {
+206
View File
@@ -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<FloatingTextToolbarProps> = ({ 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<HTMLDivElement>(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' }) => (
<div className="absolute top-full left-0 mt-1 p-2 bg-[#262626] border border-[#3f3f46] rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
<div className="grid grid-cols-5 gap-1">
{COLORS.map((c) => (
<button
key={c}
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-[#262626]' : ''}`}
style={{ backgroundColor: c }}
onClick={() => {
setToolColors(prev => ({ ...prev, [action]: c }));
onAction(action, c);
setOpenDropdown(null);
}}
/>
))}
</div>
<div className="text-xs text-[#a1a1aa] mt-1 cursor-pointer hover:text-white transition-colors">More colors</div>
<div className="flex items-center justify-between text-xs text-[#a1a1aa] border-t border-[#3f3f46] pt-2 mt-1">
<span>Opacity</span>
<div className="flex items-center gap-2">
<button className="hover:text-white"></button>
<span>100%</span>
<button className="hover:text-white">+</button>
</div>
</div>
</div>
);
return (
<div
ref={menuRef}
className="absolute z-50 flex items-center gap-1 bg-[#262626] rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-[#3f3f46]"
style={{ top, left, pointerEvents: 'auto' }}
onMouseDown={(e) => {
// Prevent clearing the selection layer when clicking the toolbar
e.stopPropagation();
}}
>
<button
onClick={() => onAction('copy')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Copy"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button
onClick={() => onAction('comment')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Add Comment"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
<line x1="9" y1="12" x2="15" y2="12" />
<line x1="12" y1="9" x2="12" y2="15" />
</svg>
</button>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<div className="relative flex items-center group">
<button
onClick={() => handleAction('highlight')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Highlight"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 11-6 6v3h9l3-3"/>
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>
<path d="M12 21h10" strokeWidth="3" stroke={toolColors.highlight} />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'highlight' ? null : 'highlight')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'highlight' && <ColorPicker action="highlight" />}
</div>
<div className="relative flex items-center group">
<button
onClick={() => handleAction('underline')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Underline"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4v6a6 6 0 0 0 12 0V4"/>
<line x1="4" y1="20" x2="20" y2="20" stroke={toolColors.underline} strokeWidth="3" />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'underline' ? null : 'underline')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'underline' && <ColorPicker action="underline" />}
</div>
<div className="relative flex items-center group">
<button
onClick={() => handleAction('strikeout')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Strikeout"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4H9a3 3 0 0 0-2.83 4"/>
<path d="M14 12a4 4 0 0 1 0 8H6"/>
<line x1="4" y1="12" x2="20" y2="12" stroke={toolColors.strikeout} strokeWidth="2.5" />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'strikeout' ? null : 'strikeout')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'strikeout' && <ColorPicker action="strikeout" />}
</div>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<button
onClick={() => onAction('redact')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Redact Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v18H3z"/>
<path d="M3 3l18 18"/>
</svg>
Redact Text
</button>
<button
onClick={() => onAction('edit')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Edit Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
<path d="M15 5l4 4"/>
</svg>
Edit Text
</button>
</div>
);
};
+101 -11
View File
@@ -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<PDFViewerRef, PDFViewerProps>(({
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<PDFViewerRef, PDFViewerProps>(({
}, [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<PDFViewerRef, PDFViewerProps>(({
}
};
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<PDFViewerRef, PDFViewerProps>(({
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' && (
<RedactionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
onRemoveRedaction={onRemoveRedaction}
mode={redactionMode}
{textSelection && textSelection.pageIndex === page.index && (
<FloatingTextToolbar
selection={textSelection}
onAction={(action, color) => handleToolbarAction(action as any, color)}
/>
)}
<RedactionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
onRemoveRedaction={onRemoveRedaction}
mode={(activeTool === 'redact' && redactionMode === 'area') ? 'area' : 'text'}
/>
<OverlayLayer
pageIndex={page.index}
width={page.width}
+13 -4
View File
@@ -13,6 +13,7 @@ interface SelectionLayerProps {
glyphs: Glyph[];
mode?: 'select' | 'highlight';
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => 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<SelectionLayerProps> = ({
glyphs,
mode = 'select',
onTextSelected,
onSelectionChange,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
@@ -62,7 +64,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
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<SelectionLayerProps> = ({
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 });
}
};
+2
View File
@@ -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: