feat: implement PDF viewer, floating text toolbar, and document editing gateway router
This commit is contained in:
@@ -657,7 +657,7 @@ function App() {
|
|||||||
hasSignature={!!pendingSignature}
|
hasSignature={!!pendingSignature}
|
||||||
signatureImageUrl={pendingSignature?.url}
|
signatureImageUrl={pendingSignature?.url}
|
||||||
signatureAspect={pendingSignature?.aspect}
|
signatureAspect={pendingSignature?.aspect}
|
||||||
activeStamp={activeStamp?.label ?? null}
|
activeStamp={activeStamp}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
canCopy={can('canCopy')}
|
canCopy={can('canCopy')}
|
||||||
onFieldChange={(id, value, i) => {
|
onFieldChange={(id, value, i) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,13 +11,14 @@ import { SearchOverlayLayer } from './SearchOverlayLayer';
|
|||||||
import type { Rect } from '../lib/coordinateMapping';
|
import type { Rect } from '../lib/coordinateMapping';
|
||||||
import { gatewayService } from '../lib/gatewayService';
|
import { gatewayService } from '../lib/gatewayService';
|
||||||
import type { SearchResult, PageInfo, Glyph } 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 { toast } from '../lib/toast';
|
||||||
import { RedactionLayer } from './RedactionLayer';
|
import { RedactionLayer } from './RedactionLayer';
|
||||||
import { StreamEditLayer } from './StreamEditLayer';
|
import { StreamEditLayer } from './StreamEditLayer';
|
||||||
import { wasmFreeDocument } from '../lib/pdfiumEngine';
|
import { wasmFreeDocument } from '../lib/pdfiumEngine';
|
||||||
import { SignaturePlacementOverlay } from './SignaturePlacementOverlay';
|
import { SignaturePlacementOverlay } from './SignaturePlacementOverlay';
|
||||||
import type { PlacementRect } from './SignaturePlacementOverlay';
|
import type { PlacementRect } from './SignaturePlacementOverlay';
|
||||||
|
import { FloatingTextToolbar } from './FloatingTextToolbar';
|
||||||
|
|
||||||
interface PDFViewerProps {
|
interface PDFViewerProps {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -33,7 +34,7 @@ interface PDFViewerProps {
|
|||||||
/** The data-URL of the pending signature image (for the placement preview) */
|
/** The data-URL of the pending signature image (for the placement preview) */
|
||||||
signatureImageUrl?: string;
|
signatureImageUrl?: string;
|
||||||
signatureAspect?: number;
|
signatureAspect?: number;
|
||||||
activeStamp: string | null;
|
activeStamp: StampPreset | null;
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
searchResults?: SearchResult[];
|
searchResults?: SearchResult[];
|
||||||
@@ -121,6 +122,13 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
rect: PlacementRect;
|
rect: PlacementRect;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const [textSelection, setTextSelection] = useState<{
|
||||||
|
pageIndex: number;
|
||||||
|
text: string;
|
||||||
|
bbox: Rect;
|
||||||
|
lines: Rect[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageTexts({});
|
setPageTexts({});
|
||||||
const prev = prevDocumentIdRef.current;
|
const prev = prevDocumentIdRef.current;
|
||||||
@@ -315,6 +323,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
}, [textToolActive, visiblePages, documentId, pageTexts]);
|
}, [textToolActive, visiblePages, documentId, pageTexts]);
|
||||||
|
|
||||||
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
|
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
|
||||||
|
// This is still called on Ctrl+C for select mode
|
||||||
if (activeTool === 'select') {
|
if (activeTool === 'select') {
|
||||||
if (!canCopy) {
|
if (!canCopy) {
|
||||||
toast("Copying is not permitted by this document's restrictions", 'error');
|
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 [isPanning, setIsPanning] = useState(false);
|
||||||
const [panStart, setPanStart] = useState({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
|
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] || []}
|
glyphs={pageTexts[page.index] || []}
|
||||||
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || activeTool === 'redact') ? 'highlight' : 'select'}
|
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || activeTool === 'redact') ? 'highlight' : 'select'}
|
||||||
onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
|
onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
|
||||||
|
onSelectionChange={(sel) => setTextSelection(sel ? { ...sel, pageIndex: page.index } : null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTool === 'redact' && (
|
{textSelection && textSelection.pageIndex === page.index && (
|
||||||
<RedactionLayer
|
<FloatingTextToolbar
|
||||||
pageIndex={page.index}
|
selection={textSelection}
|
||||||
width={page.width}
|
onAction={(action, color) => handleToolbarAction(action as any, color)}
|
||||||
height={page.height}
|
|
||||||
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
|
|
||||||
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
|
|
||||||
onRemoveRedaction={onRemoveRedaction}
|
|
||||||
mode={redactionMode}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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
|
<OverlayLayer
|
||||||
pageIndex={page.index}
|
pageIndex={page.index}
|
||||||
width={page.width}
|
width={page.width}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface SelectionLayerProps {
|
|||||||
glyphs: Glyph[];
|
glyphs: Glyph[];
|
||||||
mode?: 'select' | 'highlight';
|
mode?: 'select' | 'highlight';
|
||||||
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
|
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';
|
const SEL_START_EVT = 'pdf-selection-start';
|
||||||
@@ -25,6 +26,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
|||||||
glyphs,
|
glyphs,
|
||||||
mode = 'select',
|
mode = 'select',
|
||||||
onTextSelected,
|
onTextSelected,
|
||||||
|
onSelectionChange,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
||||||
@@ -62,7 +64,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
|||||||
setBox(null);
|
setBox(null);
|
||||||
drag.current = null;
|
drag.current = null;
|
||||||
boxStart.current = null;
|
boxStart.current = null;
|
||||||
}, []);
|
onSelectionChange?.(null);
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOther = (e: Event) => {
|
const onOther = (e: Event) => {
|
||||||
@@ -160,14 +163,20 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
|||||||
const cur = selRef.current;
|
const cur = selRef.current;
|
||||||
if (!cur || cur.start === cur.end) {
|
if (!cur || cur.start === cur.end) {
|
||||||
setSel(null);
|
setSel(null);
|
||||||
|
onSelectionChange?.(null);
|
||||||
return;
|
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') {
|
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);
|
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
|
||||||
setSel(null);
|
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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
|||||||
if "," in img_data_str:
|
if "," in img_data_str:
|
||||||
img_data_str = img_data_str.split(",", 1)[1]
|
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)
|
raw_bytes = base64.b64decode(img_data_str)
|
||||||
img = Image.open(io.BytesIO(raw_bytes))
|
img = Image.open(io.BytesIO(raw_bytes))
|
||||||
img_rgba = img.convert("RGBA")
|
img_rgba = img.convert("RGBA")
|
||||||
@@ -407,6 +408,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
|||||||
bgra_bytes = img_bgra.tobytes()
|
bgra_bytes = img_bgra.tobytes()
|
||||||
|
|
||||||
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
|
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
|
||||||
|
temp_path = temp_path.replace("\\", "/")
|
||||||
created_temp_files.append(temp_path)
|
created_temp_files.append(temp_path)
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "wb") as tmp:
|
with os.fdopen(fd, "wb") as tmp:
|
||||||
|
|||||||
Reference in New Issue
Block a user