feat: implement PDF toolbar, viewer components, and redaction support layer
This commit is contained in:
+27
-10
@@ -370,19 +370,31 @@ function App() {
|
||||
setCurrentPage(to);
|
||||
};
|
||||
|
||||
const handleRedactArea = (pageIndex: number, bounds: Rect) => {
|
||||
const [pendingRedactions, setPendingRedactions] = useState<{ id: string, pageIndex: number, bounds: Rect }[]>([]);
|
||||
|
||||
const handleMarkRedaction = (pageIndex: number, bounds: Rect) => {
|
||||
setPendingRedactions(prev => [...prev, { id: rid('redmark'), pageIndex, bounds }]);
|
||||
};
|
||||
|
||||
const handleApplyRedactions = () => {
|
||||
if (!activeDoc) return;
|
||||
if (!can('canModify')) { denyToast('Redaction'); return; }
|
||||
const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex));
|
||||
if (pendingRedactions.length === 0) return;
|
||||
|
||||
setConfirmState({
|
||||
title: 'Redact area?',
|
||||
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone after export.',
|
||||
confirmLabel: 'Redact', danger: true,
|
||||
title: 'Apply Redactions?',
|
||||
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone.',
|
||||
confirmLabel: 'Apply', danger: true,
|
||||
onConfirm: () => {
|
||||
applyOps([{
|
||||
id: rid('redact'), type: 'redaction', pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#ffffff' },
|
||||
}], 'Area redacted');
|
||||
const ops = pendingRedactions.map(mark => {
|
||||
const pdf = viewportRectToPdf(mark.bounds, zoom, pageHeightPts(mark.pageIndex));
|
||||
return {
|
||||
id: mark.id, type: 'redaction' as const, pageIndex: mark.pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#000000' },
|
||||
};
|
||||
});
|
||||
applyOps(ops, 'Redactions applied');
|
||||
setPendingRedactions([]);
|
||||
setActiveTool('select');
|
||||
},
|
||||
});
|
||||
@@ -543,6 +555,9 @@ function App() {
|
||||
hasSignature={!!pendingSignature}
|
||||
activeStamp={activeStamp?.label ?? null}
|
||||
onSelectStamp={(label, color) => setActiveStamp({ label, color })}
|
||||
pendingRedactionCount={pendingRedactions.length}
|
||||
onApplyRedactions={handleApplyRedactions}
|
||||
onClearRedactions={() => setPendingRedactions([])}
|
||||
/>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
@@ -585,7 +600,9 @@ function App() {
|
||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||
}}
|
||||
onPageVisible={setCurrentPage}
|
||||
onRedactArea={handleRedactArea}
|
||||
onMarkRedaction={handleMarkRedaction}
|
||||
pendingRedactions={pendingRedactions}
|
||||
onRemoveRedaction={(id) => setPendingRedactions(p => p.filter(x => x.id !== id))}
|
||||
onPlaceText={handlePlaceText}
|
||||
onEditText={handleEditText}
|
||||
onReflowParagraph={handleReflowParagraph}
|
||||
|
||||
@@ -17,6 +17,9 @@ interface ToolbarProps {
|
||||
hasSignature: boolean;
|
||||
activeStamp: string | null;
|
||||
onSelectStamp: (label: string, color: string) => void;
|
||||
pendingRedactionCount?: number;
|
||||
onApplyRedactions?: () => void;
|
||||
onClearRedactions?: () => void;
|
||||
}
|
||||
|
||||
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
@@ -54,6 +57,7 @@ const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
|
||||
|
||||
export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
|
||||
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions,
|
||||
}) => {
|
||||
const meta = TOOL_META[activeTool];
|
||||
const isRedact = activeTool === 'redact';
|
||||
@@ -161,7 +165,22 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTool === 'redact' && <Hint tone="warn">⚠ Drag a box to permanently remove content underneath.</Hint>}
|
||||
{activeTool === 'redact' && (
|
||||
<>
|
||||
{pendingRedactionCount > 0 ? (
|
||||
<>
|
||||
<CustomButton variant="primary" size="sm" onClick={onApplyRedactions}>
|
||||
Apply {pendingRedactionCount} Redaction{pendingRedactionCount > 1 ? 's' : ''}
|
||||
</CustomButton>
|
||||
<CustomButton variant="outline" size="sm" onClick={onClearRedactions}>
|
||||
Clear
|
||||
</CustomButton>
|
||||
</>
|
||||
) : (
|
||||
<Hint tone="warn">⚠ Drag a box to permanently remove content underneath.</Hint>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,9 @@ interface PDFViewerProps {
|
||||
onAnnotationUpdate?: (anno: Annotation) => void;
|
||||
onAnnotationClick?: (anno: Annotation) => void;
|
||||
onPageVisible?: (pageIndex: number) => void;
|
||||
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
||||
onMarkRedaction?: (pageIndex: number, bounds: Rect) => void;
|
||||
pendingRedactions?: { id: string, pageIndex: number, bounds: Rect }[];
|
||||
onRemoveRedaction?: (id: string) => void;
|
||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
|
||||
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
|
||||
@@ -80,7 +82,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onAnnotationUpdate,
|
||||
onAnnotationClick,
|
||||
onPageVisible,
|
||||
onRedactArea,
|
||||
onMarkRedaction,
|
||||
pendingRedactions = [],
|
||||
onRemoveRedaction,
|
||||
onPlaceText,
|
||||
onEditText,
|
||||
onReflowParagraph,
|
||||
@@ -469,7 +473,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
onRedactionSelected={(bounds) => onRedactArea?.(page.index, bounds)}
|
||||
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
|
||||
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
|
||||
onRemoveRedaction={onRemoveRedaction}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ interface RedactionLayerProps {
|
||||
width: number;
|
||||
height: number;
|
||||
onRedactionSelected: (bounds: Rect) => void;
|
||||
pendingRedactions?: { id: string, bounds: Rect }[];
|
||||
onRemoveRedaction?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
width,
|
||||
height,
|
||||
onRedactionSelected,
|
||||
pendingRedactions = [],
|
||||
onRemoveRedaction,
|
||||
}) => {
|
||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
||||
const [redactBox, setRedactBox] = useState<Rect | null>(null);
|
||||
@@ -86,6 +90,25 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingRedactions.map((redaction) => (
|
||||
<div
|
||||
key={redaction.id}
|
||||
className="group absolute"
|
||||
style={{
|
||||
left: `${redaction.bounds.x}px`,
|
||||
top: `${redaction.bounds.y}px`,
|
||||
width: `${redaction.bounds.width}px`,
|
||||
height: `${redaction.bounds.height}px`,
|
||||
border: '2px solid #ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
||||
zIndex: 30,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 hidden bg-black group-hover:block" title="Click to remove" onClick={(e) => { e.stopPropagation(); onRemoveRedaction?.(redaction.id); }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user