Merge pull request 'azeem' (#77) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/77
This commit is contained in:
@@ -164,6 +164,7 @@ private:
|
||||
std::expected<void, EngineError> applyOp_replaceText(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_reflow(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_textOverlay(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_stamp(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_decoration(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_redaction(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_updateField(const nlohmann::json& op, int pageIndex);
|
||||
|
||||
@@ -42,7 +42,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
}
|
||||
|
||||
static const std::set<std::string> 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<void, EngineError> 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") {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
#include <chrono>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
@@ -111,6 +114,101 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_textOverlay(const nlohm
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> 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<float>(x), static_cast<float>(y),
|
||||
static_cast<float>(width), static_cast<float>(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<float>(x), static_cast<float>(y),
|
||||
static_cast<float>(width), static_cast<float>(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<std::string> lines;
|
||||
std::stringstream textStream(text);
|
||||
std::string line;
|
||||
while(std::getline(textStream, line, '\n')) {
|
||||
lines.push_back(line);
|
||||
}
|
||||
|
||||
float startY = static_cast<float>(y + height - fontSize * 1.1);
|
||||
parseHexColor(textColor, r, g, b);
|
||||
|
||||
for (size_t i = 0; i < lines.size(); i++) {
|
||||
float currentFontSize = static_cast<float>(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<FPDF_WIDESTRING>(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<float>(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<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::string type = op.value("type", "");
|
||||
|
||||
+83
-9
@@ -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<StampPreset | null>(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<number>();
|
||||
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)}
|
||||
/>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
@@ -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() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<RedactPagesModal
|
||||
open={redactPagesModalOpen}
|
||||
onClose={() => setRedactPagesModalOpen(false)}
|
||||
onConfirm={handleRedactPages}
|
||||
/>
|
||||
|
||||
<AboutModal
|
||||
open={aboutModalOpen}
|
||||
onClose={() => setAboutModalOpen(false)}
|
||||
|
||||
@@ -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<RedactPagesModalProps> = ({ open, onClose, onConfirm }) => {
|
||||
const [pagesString, setPagesString] = useState('');
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Redact Pages">
|
||||
<div className="flex w-[400px] flex-col gap-4 p-5">
|
||||
<p className="text-[13px] text-[#4b5563]">
|
||||
Enter the pages or page ranges to mark for redaction (e.g. "1, 3-5"). This will mark the entire page area for redaction.
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[11.5px] font-semibold text-[#18212e]">Pages</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-[36px] w-full rounded-[8px] border border-[#d1d5db] bg-white px-3 text-[13.5px] shadow-sm outline-none transition-colors focus:border-[#2563eb] focus:ring-1 focus:ring-[#2563eb]"
|
||||
placeholder="e.g. 1, 3-5"
|
||||
value={pagesString}
|
||||
onChange={(e) => setPagesString(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2 border-t border-[#ebedf0] pt-4">
|
||||
<CustomButton variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (pagesString.trim()) {
|
||||
onConfirm(pagesString.trim());
|
||||
setPagesString('');
|
||||
}
|
||||
}}
|
||||
disabled={!pagesString.trim()}
|
||||
>
|
||||
Mark for Redaction
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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<ToolSettings>) => 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<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
@@ -57,7 +60,8 @@ 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,
|
||||
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<ToolbarProps> = ({
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{STAMP_PRESETS.map((s) => (
|
||||
<CustomButton variant="unstyled" key={s.label} onClick={() => 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)` }}>
|
||||
<CustomButton variant="unstyled" key={s.label} onClick={() => 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}
|
||||
</CustomButton>
|
||||
))}
|
||||
@@ -167,6 +171,24 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
|
||||
{activeTool === 'redact' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 border-r border-[#ebedf0] pr-3">
|
||||
<button
|
||||
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'area' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
|
||||
onClick={() => onRedactionModeChange?.('area')}
|
||||
>
|
||||
Area
|
||||
</button>
|
||||
<button
|
||||
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'text' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
|
||||
onClick={() => onRedactionModeChange?.('text')}
|
||||
>
|
||||
Text
|
||||
</button>
|
||||
</div>
|
||||
<CustomButton variant="outline" size="sm" onClick={onRedactPages}>
|
||||
Redact Pages…
|
||||
</CustomButton>
|
||||
<Divider />
|
||||
{pendingRedactionCount > 0 ? (
|
||||
<>
|
||||
<CustomButton variant="primary" size="sm" onClick={onApplyRedactions}>
|
||||
@@ -177,7 +199,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
</CustomButton>
|
||||
</>
|
||||
) : (
|
||||
<Hint tone="warn">⚠ Drag a box to permanently remove content underneath.</Hint>
|
||||
<Hint tone="warn">⚠ Select text or drag a box to permanently remove content.</Hint>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,11 +55,18 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
||||
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' },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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<OverlayLayerProps> = ({
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
|
||||
)}
|
||||
{activeTool === 'stamp' && activeStamp && (
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place “{activeStamp}”</div>
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place “{activeStamp.label}”</div>
|
||||
)}
|
||||
{activeTool === 'textbox' && !textBox && (
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
|
||||
|
||||
@@ -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<PDFViewerRef, PDFViewerProps>(({
|
||||
pagesInfo,
|
||||
activeTool,
|
||||
toolSettings,
|
||||
redactionMode = 'area',
|
||||
hasSignature,
|
||||
signatureImageUrl,
|
||||
signatureAspect = 3,
|
||||
@@ -119,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;
|
||||
@@ -313,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');
|
||||
@@ -353,7 +364,96 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
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<PDFViewerRef, PDFViewerProps>(({
|
||||
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')) && (
|
||||
<SelectionLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
glyphs={pageTexts[page.index] || []}
|
||||
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') ? '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)}
|
||||
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}
|
||||
{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}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface RedactionLayerProps {
|
||||
onRedactionSelected: (bounds: Rect) => void;
|
||||
pendingRedactions?: { id: string, bounds: Rect }[];
|
||||
onRemoveRedaction?: (id: string) => void;
|
||||
mode?: 'area' | 'text';
|
||||
}
|
||||
|
||||
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
@@ -16,6 +17,7 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
onRedactionSelected,
|
||||
pendingRedactions = [],
|
||||
onRemoveRedaction,
|
||||
mode = 'area',
|
||||
}) => {
|
||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
||||
const [redactBox, setRedactBox] = useState<Rect | null>(null);
|
||||
@@ -66,8 +68,9 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
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<RedactionLayerProps> = ({
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
||||
zIndex: 30,
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 hidden bg-black group-hover:block" title="Click to remove" onClick={(e) => { e.stopPropagation(); onRemoveRedaction?.(redaction.id); }} />
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user