diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index 598bae2..30da966 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -164,6 +164,7 @@ private: std::expected applyOp_replaceText(const nlohmann::json& op, int pageIndex); std::expected applyOp_reflow(const nlohmann::json& op, int pageIndex); std::expected applyOp_textOverlay(const nlohmann::json& op, int pageIndex); + std::expected applyOp_stamp(const nlohmann::json& op, int pageIndex); std::expected applyOp_decoration(const nlohmann::json& op, int pageIndex); std::expected applyOp_redaction(const nlohmann::json& op, int pageIndex); std::expected applyOp_updateField(const nlohmann::json& op, int pageIndex); diff --git a/engine/src/parser/pdfium_edit.cpp b/engine/src/parser/pdfium_edit.cpp index 4b98ddf..7faa26f 100644 --- a/engine/src/parser/pdfium_edit.cpp +++ b/engine/src/parser/pdfium_edit.cpp @@ -42,7 +42,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } static const std::set kContentOps = { - "replace_text", "reflow_paragraph", "text_overlay", "add_text", + "replace_text", "reflow_paragraph", "text_overlay", "add_text", "stamp", "underline", "strikeout", "squiggly", "redaction", "image_overlay", "highlight", "free_text", "comment", "freehand"}; if (kContentOps.count(type)) markEdited(pageIndex); @@ -54,6 +54,8 @@ std::expected PdfiumDocument::applyEdits(const std::string& e r = applyOp_reflow(op, pageIndex); } else if (type == "text_overlay" || type == "add_text") { r = applyOp_textOverlay(op, pageIndex); + } else if (type == "stamp") { + r = applyOp_stamp(op, pageIndex); } else if (type == "underline" || type == "strikeout" || type == "squiggly") { r = applyOp_decoration(op, pageIndex); } else if (type == "redaction") { diff --git a/engine/src/parser/pdfium_edit_annotations.cpp b/engine/src/parser/pdfium_edit_annotations.cpp index 037cf2d..2537a19 100644 --- a/engine/src/parser/pdfium_edit_annotations.cpp +++ b/engine/src/parser/pdfium_edit_annotations.cpp @@ -1,4 +1,7 @@ #include "parser/pdfium_internal.hpp" +#include +#include +#include namespace pdfengine::parser { @@ -111,6 +114,101 @@ std::expected PdfiumDocument::applyOp_textOverlay(const nlohm #endif } +std::expected PdfiumDocument::applyOp_stamp(const nlohmann::json& op, int pageIndex) { +#ifdef PDFENGINE_WITH_PDFIUM + if (!op.contains("data") || !op["data"].is_object()) { + spdlog::error("stamp operation missing 'data' object"); + return std::unexpected(EngineError::InvalidFormat); + } + auto data = op["data"]; + std::string text = data.value("text", ""); + double x = data.value("x", 0.0); + double y = data.value("y", 0.0); + double width = data.value("width", 100.0); + double height = data.value("height", 30.0); + double fontSize = data.value("fontSize", 18.0); + std::string textColor = data.value("textColor", "#000000"); + std::string bgColor = data.value("backgroundColor", "#ffffff"); + std::string borderColor = data.value("borderColor", "#000000"); + bool includeDate = data.value("includeDate", false); + + if (includeDate) { + auto now = std::chrono::system_clock::now(); + auto in_time_t = std::chrono::system_clock::to_time_t(now); + std::stringstream ss; + ss << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %H:%M"); + text += "\n" + ss.str(); + } + + FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex); + if (!page) { + spdlog::error("Failed to load page index {} for stamp", pageIndex); + return std::unexpected(EngineError::Unknown); + } + + // Background rect + FPDF_PAGEOBJECT bgRect = FPDFPageObj_CreateNewRect( + static_cast(x), static_cast(y), + static_cast(width), static_cast(height) + ); + unsigned int r=0, g=0, b=0; + parseHexColor(bgColor, r, g, b); + FPDFPageObj_SetFillColor(bgRect, r, g, b, 255); + FPDFPath_SetDrawMode(bgRect, FPDF_FILLMODE_WINDING, 0); + FPDFPage_InsertObject(page, bgRect); + + // Border rect + FPDF_PAGEOBJECT borderRect = FPDFPageObj_CreateNewRect( + static_cast(x), static_cast(y), + static_cast(width), static_cast(height) + ); + parseHexColor(borderColor, r, g, b); + FPDFPageObj_SetStrokeColor(borderRect, r, g, b, 255); + FPDFPageObj_SetStrokeWidth(borderRect, 2.5f); + FPDFPath_SetDrawMode(borderRect, 0, 1); + FPDFPage_InsertObject(page, borderRect); + + FPDF_FONT font = FPDFText_LoadStandardFont(doc_, "Helvetica-Bold"); + + std::vector lines; + std::stringstream textStream(text); + std::string line; + while(std::getline(textStream, line, '\n')) { + lines.push_back(line); + } + + float startY = static_cast(y + height - fontSize * 1.1); + parseHexColor(textColor, r, g, b); + + for (size_t i = 0; i < lines.size(); i++) { + float currentFontSize = static_cast(i == 0 ? fontSize : fontSize * 0.5); + FPDF_PAGEOBJECT textObj = FPDFPageObj_CreateTextObj(doc_, font, currentFontSize); + FPDFPageObj_SetFillColor(textObj, r, g, b, 255); + auto utf16 = utf8_to_utf16le(lines[i]); + FPDFText_SetText(textObj, reinterpret_cast(utf16.data())); + + float left=0, bottom=0, right=0, top=0; + FPDFPageObj_GetBounds(textObj, &left, &bottom, &right, &top); + float textWidth = right - left; + + float textX = static_cast(x + width / 2.0 - textWidth / 2.0); + float textY = startY - (i * fontSize * 0.7f); + + FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, textX, textY); + FPDFPage_InsertObject(page, textObj); + } + + if (!FPDFPage_GenerateContent(page)) { + spdlog::error("Failed to generate page content after stamp"); + } + FPDF_ClosePage(page); + return {}; +#else + (void)op; (void)pageIndex; + return std::unexpected(EngineError::Unknown); +#endif +} + std::expected PdfiumDocument::applyOp_decoration(const nlohmann::json& op, int pageIndex) { #ifdef PDFENGINE_WITH_PDFIUM std::string type = op.value("type", ""); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 70a841c..f9da649 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { Toolbar } from './components/Toolbar'; import { InspectorPanel } from './components/InspectorPanel'; import type { InspectorTab } from './components/InspectorPanel'; import { SignatureModal } from './components/SignatureModal'; +import { RedactPagesModal } from './components/RedactPagesModal'; import { AboutModal } from './components/AboutModal'; import { ToastViewport } from './components/ui'; import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal'; @@ -20,7 +21,7 @@ import { viewportRectToPdf } from './lib/coordinateMapping'; import type { Rect } from './lib/coordinateMapping'; import { toast } from './lib/toast'; import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools'; -import type { ToolId, ToolSettings } from './lib/tools'; +import type { ToolId, ToolSettings, StampPreset } from './lib/tools'; const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`; @@ -65,9 +66,11 @@ function App() { const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null); const [signatureModalOpen, setSignatureModalOpen] = useState(false); + const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false); const [aboutModalOpen, setAboutModalOpen] = useState(false); const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null); - const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null); + const [activeStamp, setActiveStamp] = useState(null); + const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area'); const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null); const [searchQuery, setSearchQuery] = useState(''); @@ -332,15 +335,76 @@ function App() { if (!activeStamp) return; if (!can('canAnnotate')) { denyToast('Stamping'); return; } const fontSize = 22; - const width = Math.max(60, activeStamp.label.length * fontSize * 0.62); - const height = fontSize * 1.5; + const padding = 12; // visual padding + const dateWidth = 16 * (fontSize * 0.5) * 0.7; // date string approx length + const labelWidth = activeStamp.label.length * fontSize * 0.7; + const contentWidth = Math.max(labelWidth, dateWidth); + const width = Math.max(80, contentWidth) + (padding * 2); + const height = fontSize * 1.5 + (padding * 2); const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex)); applyOps([{ - id: rid('stamp'), type: 'text_overlay', pageIndex, - data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text: activeStamp.label, fontSize, fontFamily: 'Helvetica-Bold', color: activeStamp.color }, + id: rid('stamp'), type: 'stamp', pageIndex, + data: { + x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, + text: activeStamp.label, + textColor: activeStamp.textColor, + backgroundColor: activeStamp.backgroundColor, + borderColor: activeStamp.borderColor, + fontSize, + includeDate: true // We can toggle this later, true by default for now + }, }], `Stamp “${activeStamp.label}” placed`); }; + const handleRedactPages = (pagesString: string) => { + if (!activeDoc) return; + const ranges = pagesString.split(',').map(s => s.trim()); + const pagesToRedact = new Set(); + for (const r of ranges) { + if (r.includes('-')) { + const parts = r.split('-'); + if (parts.length === 2) { + const start = parseInt(parts[0], 10); + const end = parseInt(parts[1], 10); + if (!isNaN(start) && !isNaN(end)) { + for (let i = Math.min(start, end); i <= Math.max(start, end); i++) { + if (i >= 1 && i <= activeDoc.totalPages) pagesToRedact.add(i - 1); + } + } + } + } else { + const val = parseInt(r, 10); + if (!isNaN(val) && val >= 1 && val <= activeDoc.totalPages) { + pagesToRedact.add(val - 1); + } + } + } + + const newRedactions: { id: string, pageIndex: number, bounds: Rect }[] = []; + pagesToRedact.forEach(pageIndex => { + const pInfo = activeDoc.pages?.[pageIndex]; + if (!pInfo) return; + newRedactions.push({ + id: rid('redmark'), + pageIndex, + bounds: { + x: 0, + y: 0, + width: pInfo.width, + height: pInfo.height + } + }); + }); + + if (newRedactions.length > 0) { + setPendingRedactions(p => [...p, ...newRedactions]); + toast(`Marked ${newRedactions.length} page${newRedactions.length > 1 ? 's' : ''} for redaction`, 'success'); + } else { + toast('No valid pages found in range', 'error'); + } + setRedactPagesModalOpen(false); + }; + const handlePlaceSignature = (pageIndex: number, pdfRect: { x: number; y: number; width: number; height: number }, _rotation: number) => { if (!pendingSignature) return; if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; } @@ -562,11 +626,14 @@ function App() { onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))} onOpenSignature={() => setSignatureModalOpen(true)} hasSignature={!!pendingSignature} - activeStamp={activeStamp?.label ?? null} - onSelectStamp={(label, color) => setActiveStamp({ label, color })} + activeStamp={activeStamp} + onSelectStamp={setActiveStamp} + redactionMode={redactionMode} + onRedactionModeChange={setRedactionMode} pendingRedactionCount={pendingRedactions.length} onApplyRedactions={handleApplyRedactions} onClearRedactions={() => setPendingRedactions([])} + onRedactPages={() => setRedactPagesModalOpen(true)} />
@@ -586,10 +653,11 @@ function App() { pagesInfo={activeDoc.pages} activeTool={activeTool} toolSettings={toolSettings} + redactionMode={redactionMode} hasSignature={!!pendingSignature} signatureImageUrl={pendingSignature?.url} signatureAspect={pendingSignature?.aspect} - activeStamp={activeStamp?.label ?? null} + activeStamp={activeStamp} annotations={annotations} canCopy={can('canCopy')} onFieldChange={(id, value, i) => { @@ -688,6 +756,12 @@ function App() { }} /> + setRedactPagesModalOpen(false)} + onConfirm={handleRedactPages} + /> + setAboutModalOpen(false)} diff --git a/frontend/src/components/RedactPagesModal.tsx b/frontend/src/components/RedactPagesModal.tsx new file mode 100644 index 0000000..d73896b --- /dev/null +++ b/frontend/src/components/RedactPagesModal.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { Modal } from './ui'; +import { CustomButton } from './custom/CustomButton'; + +interface RedactPagesModalProps { + open: boolean; + onClose: () => void; + onConfirm: (pagesString: string) => void; +} + +export const RedactPagesModal: React.FC = ({ open, onClose, onConfirm }) => { + const [pagesString, setPagesString] = useState(''); + + return ( + +
+

+ Enter the pages or page ranges to mark for redaction (e.g. "1, 3-5"). This will mark the entire page area for redaction. +

+
+ + setPagesString(e.target.value)} + autoFocus + /> +
+ +
+ + Cancel + + { + if (pagesString.trim()) { + onConfirm(pagesString.trim()); + setPagesString(''); + } + }} + disabled={!pagesString.trim()} + > + Mark for Redaction + +
+
+
+ ); +}; diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 00107fe..fc8fb68 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -1,6 +1,6 @@ import { CustomButton } from './custom/CustomButton'; import React from 'react'; -import type { ToolId, ToolSettings } from '../lib/tools'; +import type { ToolId, ToolSettings, StampPreset } from '../lib/tools'; import { STAMP_PRESETS } from '../lib/tools'; import { ColorSwatches, Slider } from './ui'; import { @@ -15,11 +15,14 @@ interface ToolbarProps { onSettingsChange: (patch: Partial) => void; onOpenSignature: () => void; hasSignature: boolean; - activeStamp: string | null; - onSelectStamp: (label: string, color: string) => void; + activeStamp?: StampPreset | null; + onSelectStamp: (stamp: StampPreset) => void; + redactionMode?: 'area' | 'text'; + onRedactionModeChange?: (mode: 'area' | 'text') => void; pendingRedactionCount?: number; onApplyRedactions?: () => void; onClearRedactions?: () => void; + onRedactPages?: () => void; } const TOOL_META: Record = { @@ -57,7 +60,8 @@ const Divider = () =>
; export const Toolbar: React.FC = ({ activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp, - pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, + redactionMode = 'area', onRedactionModeChange, + pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages, }) => { const meta = TOOL_META[activeTool]; const isRedact = activeTool === 'redact'; @@ -154,9 +158,9 @@ export const Toolbar: React.FC = ({ <>
{STAMP_PRESETS.map((s) => ( - onSelectStamp(s.label, s.color)} - className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`} - style={{ color: s.color, borderColor: s.color, background: `color-mix(in srgb, ${s.color} 8%, white)` }}> + onSelectStamp(s)} + className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp?.label === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`} + style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}> {s.label} ))} @@ -167,6 +171,24 @@ export const Toolbar: React.FC = ({ {activeTool === 'redact' && ( <> +
+ + +
+ + Redact Pages… + + {pendingRedactionCount > 0 ? ( <> @@ -177,7 +199,7 @@ export const Toolbar: React.FC = ({ ) : ( - ⚠ Drag a box to permanently remove content underneath. + ⚠ Select text or drag a box to permanently remove content. )} )} diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 9d99027..ed9af1e 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -136,6 +136,20 @@ export interface TextOverlayData { color: string; } +export interface StampData { + text: string; + x: number; + y: number; + width: number; + height: number; + textColor: string; + backgroundColor: string; + borderColor: string; + fontSize: number; + includeDate: boolean; + timestamp?: string; +} + export interface RedactionData { x: number; y: number; @@ -207,6 +221,7 @@ export interface PageReorderData { export type EditOperationDataMap = { text_overlay: TextOverlayData; + stamp: StampData; redaction: RedactionData; image_overlay: ImageOverlayData; highlight: HighlightData; diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts index 9ea8619..d049b91 100644 --- a/frontend/src/lib/tools.ts +++ b/frontend/src/lib/tools.ts @@ -55,11 +55,18 @@ export const TOOL_SHORTCUTS: Record = { q: 'stream_edit', }; -export const STAMP_PRESETS = [ - { label: 'APPROVED', color: '#16a34a' }, - { label: 'DRAFT', color: '#6b7280' }, - { label: 'CONFIDENTIAL', color: '#dc2626' }, - { label: 'REVIEWED', color: '#2563eb' }, - { label: 'FINAL', color: '#7c3aed' }, - { label: 'VOID', color: '#dc2626' }, +export interface StampPreset { + label: string; + textColor: string; + backgroundColor: string; + borderColor: string; +} + +export const STAMP_PRESETS: StampPreset[] = [ + { label: 'APPROVED', textColor: '#16a34a', backgroundColor: '#dcfce7', borderColor: '#16a34a' }, + { label: 'DRAFT', textColor: '#6b7280', backgroundColor: '#f3f4f6', borderColor: '#6b7280' }, + { label: 'CONFIDENTIAL', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' }, + { label: 'REVIEWED', textColor: '#2563eb', backgroundColor: '#dbeafe', borderColor: '#2563eb' }, + { label: 'FINAL', textColor: '#7c3aed', backgroundColor: '#ede9fe', borderColor: '#7c3aed' }, + { label: 'VOID', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' }, ]; 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/OverlayLayer.tsx b/frontend/src/viewer/OverlayLayer.tsx index 83b96b3..f535415 100644 --- a/frontend/src/viewer/OverlayLayer.tsx +++ b/frontend/src/viewer/OverlayLayer.tsx @@ -2,6 +2,7 @@ import { CustomButton } from '../components/custom/CustomButton'; import React, { useState, useRef } from 'react'; import type { Annotation } from './AnnotationLayer'; import type { Rect } from '../lib/coordinateMapping'; +import type { StampPreset } from '../lib/tools'; interface OverlayLayerProps { pageIndex: number; @@ -14,7 +15,7 @@ interface OverlayLayerProps { textColor: string; fontSize: number; hasSignature: boolean; - activeStamp: string | null; + activeStamp: StampPreset | null; onAnnotationAdded?: (anno: Annotation) => void; onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void; onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void; @@ -143,7 +144,7 @@ export const OverlayLayer: React.FC = ({
Click to add a sticky note
)} {activeTool === 'stamp' && activeStamp && ( -
Click to place “{activeStamp}”
+
Click to place “{activeStamp.label}”
)} {activeTool === 'textbox' && !textBox && (
Click to add a text box
diff --git a/frontend/src/viewer/PDFViewer.tsx b/frontend/src/viewer/PDFViewer.tsx index 8e2c789..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; @@ -26,13 +27,14 @@ interface PDFViewerProps { pageHeight: number; zoom: number; pagesInfo?: PageInfo[]; - activeTool: string; + activeTool: ToolId; toolSettings: ToolSettings; + redactionMode?: 'area' | 'text'; hasSignature: boolean; /** 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[]; @@ -78,6 +80,7 @@ export const PDFViewer = React.forwardRef(({ pagesInfo, activeTool, toolSettings, + redactionMode = 'area', hasSignature, signatureImageUrl, signatureAspect = 3, @@ -119,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; @@ -313,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'); @@ -353,7 +364,96 @@ export const PDFViewer = React.forwardRef(({ activeTool === 'strikeout' ? toolSettings.strikeoutColor : toolSettings.squigglyColor; onDecorateText?.(pageIndex, lines, activeTool, color); + return; } + if (activeTool === 'redact') { + if (lines.length > 0) { + lines.forEach(line => { + onMarkRedaction?.(pageIndex, { + x: line.x / zoom, + y: line.y / zoom, + width: line.width / zoom, + height: line.height / zoom, + }); + }); + } + return; + } + }; + + 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); @@ -471,29 +571,36 @@ export const PDFViewer = React.forwardRef(({ onFieldChange={onFieldChange} /> - {(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') && ( + {(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || (activeTool === 'redact' && redactionMode === 'text')) && ( 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} + {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; pendingRedactions?: { id: string, bounds: Rect }[]; onRemoveRedaction?: (id: string) => void; + mode?: 'area' | 'text'; } export const RedactionLayer: React.FC = ({ @@ -16,6 +17,7 @@ export const RedactionLayer: React.FC = ({ onRedactionSelected, pendingRedactions = [], onRemoveRedaction, + mode = 'area', }) => { const [dragStart, setDragStart] = useState(null); const [redactBox, setRedactBox] = useState(null); @@ -66,8 +68,9 @@ export const RedactionLayer: React.FC = ({ left: 0, width: `${width}px`, height: `${height}px`, - cursor: 'crosshair', + cursor: mode === 'area' ? 'crosshair' : 'default', zIndex: 25, + pointerEvents: mode === 'area' ? 'auto' : 'none', }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} @@ -104,6 +107,7 @@ export const RedactionLayer: React.FC = ({ backgroundColor: 'rgba(239, 68, 68, 0.2)', zIndex: 30, cursor: 'pointer', + pointerEvents: 'auto', }} >
{ e.stopPropagation(); onRemoveRedaction?.(redaction.id); }} /> diff --git a/frontend/src/viewer/SelectionLayer.tsx b/frontend/src/viewer/SelectionLayer.tsx index 1c468c5..500f9b2 100644 --- a/frontend/src/viewer/SelectionLayer.tsx +++ b/frontend/src/viewer/SelectionLayer.tsx @@ -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 = ({ 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 b2e750e..179e289 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -21,6 +21,19 @@ class TextOverlayData(BaseModel): fontFamily: str color: str +class StampData(BaseModel): + text: str + x: float + y: float + width: float + height: float + textColor: str + backgroundColor: str + borderColor: str + fontSize: float = Field(..., gt=0) + includeDate: bool = False + timestamp: str | None = None + class RedactionData(BaseModel): x: float @@ -96,6 +109,12 @@ class TextOverlayOperation(BaseModel): pageIndex: int = Field(..., ge=0) data: TextOverlayData +class StampOperation(BaseModel): + id: str + type: Literal["stamp"] + pageIndex: int = Field(..., ge=0) + data: StampData + class RedactionOperation(BaseModel): id: str @@ -299,6 +318,7 @@ class SignatureOperation(BaseModel): EditOperation = Annotated[ TextOverlayOperation + | StampOperation | RedactionOperation | ImageOverlayOperation | HighlightOperation @@ -329,8 +349,8 @@ class EditsRequest(BaseModel): _OP_PERMISSION = { "highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate", "squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate", - "free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate", - "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate", + "free_text": "canAnnotate", "text_overlay": "canAnnotate", "stamp": "canAnnotate", + "image_overlay": "canAnnotate", "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate", "replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify", "update_field": "canFillForms", "page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble", @@ -377,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") @@ -387,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: