watermark:all the features completed
This commit is contained in:
+13
-3
@@ -117,6 +117,7 @@ function App() {
|
||||
const [isOCRLoading, setIsOCRLoading] = useState(false);
|
||||
const [creatorPageCount, setCreatorPageCount] = useState<number>(1);
|
||||
const [creatorPages, setCreatorPages] = useState<PageLayout[]>([]);
|
||||
const [watermarkPreview, setWatermarkPreview] = useState<WatermarkConfig | null>(null);
|
||||
|
||||
const handleCreatorPageCountChange = useCallback((count: number, pages?: PageLayout[]) => {
|
||||
setCreatorPageCount(count);
|
||||
@@ -658,7 +659,9 @@ function App() {
|
||||
const handleToolChange = async (tool: ToolId) => {
|
||||
if (tool === 'watermark') {
|
||||
setActiveTool('watermark');
|
||||
setWatermarkModalOpen(true);
|
||||
setInspectorTab('watermark');
|
||||
setIsInspectorOpen(true);
|
||||
setIsInspectorExpanded(true);
|
||||
return;
|
||||
}
|
||||
if (tool === 'create_pdf') {
|
||||
@@ -775,7 +778,7 @@ function App() {
|
||||
|
||||
const fitWidth = () => {
|
||||
const w = activeDoc?.pageWidth || 612;
|
||||
const inspectorWidth = isInspectorOpen ? 322 : 0;
|
||||
const inspectorWidth = isInspectorOpen ? 360 : 0;
|
||||
const avail = window.innerWidth - inspectorWidth - 48;
|
||||
setZoom(Math.max(0.25, Math.min(3, avail / w)));
|
||||
};
|
||||
@@ -868,7 +871,11 @@ function App() {
|
||||
onApplyRedactions={handleApplyRedactions}
|
||||
onClearRedactions={() => setPendingRedactions([])}
|
||||
onRedactPages={() => setRedactPagesModalOpen(true)}
|
||||
onOpenWatermark={() => setWatermarkModalOpen(true)}
|
||||
onOpenWatermark={() => {
|
||||
setInspectorTab('watermark');
|
||||
setIsInspectorOpen(true);
|
||||
setIsInspectorExpanded(true);
|
||||
}}
|
||||
selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)}
|
||||
onUpdateAnnotation={(patch) => {
|
||||
const a = annotations.find(x => x.id === selectedAnnotationId);
|
||||
@@ -937,6 +944,7 @@ function App() {
|
||||
activeStamp={activeStamp}
|
||||
annotations={annotations}
|
||||
canCopy={can('canCopy')}
|
||||
watermarkPreview={watermarkPreview}
|
||||
onFieldChange={(id, value, i) => {
|
||||
if (!can('canFillForms')) { denyToast('Filling form fields'); return; }
|
||||
applyOps([{
|
||||
@@ -1085,6 +1093,8 @@ function App() {
|
||||
preservePageRef.current = true;
|
||||
}
|
||||
}}
|
||||
onApplyWatermark={handleApplyWatermark}
|
||||
onPreviewChange={setWatermarkPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,14 @@ import { EmptyState } from './ui';
|
||||
import {
|
||||
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
|
||||
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon, HistoryIcon, XIcon, MoonIcon, SunIcon,
|
||||
TextBoxIcon, ImageIcon,
|
||||
} from './icons';
|
||||
import { useTheme } from '../lib/useTheme';
|
||||
|
||||
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms' | 'history';
|
||||
import { ColorSwatches } from './ui';
|
||||
import type { WatermarkConfig } from './WatermarkModal';
|
||||
|
||||
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms' | 'history' | 'watermark';
|
||||
|
||||
interface InspectorPanelProps {
|
||||
activeTab: InspectorTab;
|
||||
@@ -58,6 +62,8 @@ interface InspectorPanelProps {
|
||||
|
||||
history?: { stack: string[]; index: number };
|
||||
onRestoreHistory?: (docId: string) => void;
|
||||
onApplyWatermark?: (config: WatermarkConfig, targetPageIndices: number[]) => Promise<void>;
|
||||
onPreviewChange?: (config: WatermarkConfig | null) => void;
|
||||
}
|
||||
|
||||
const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boolean }[] = [
|
||||
@@ -80,7 +86,7 @@ function formatBytes(bytes?: number) {
|
||||
|
||||
export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
const selectedDoc = p.documents.find((d) => d.id === p.selectedDocumentId);
|
||||
const activeDef = TABS.find((t) => t.id === p.activeTab)!;
|
||||
const activeDef = TABS.find((t) => t.id === p.activeTab) || { id: p.activeTab, label: p.activeTab === 'watermark' ? 'Watermark' : p.activeTab, icon: null };
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
@@ -131,7 +137,7 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
|
||||
{/* Expanded Content Drawer */}
|
||||
{p.isExpanded && (
|
||||
<div className="flex w-[322px] min-w-0 shrink-0 flex-col border-l border-border-primary">
|
||||
<div className="flex w-[360px] min-w-0 shrink-0 flex-col border-l border-border-primary overflow-x-hidden">
|
||||
<div className="flex h-[48px] shrink-0 items-center justify-between border-b border-border-primary" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
|
||||
<span className="shrink-0 text-[13px] font-bold text-text-primary">{activeDef.label}</span>
|
||||
<CustomButton variant="unstyled" onClick={() => p.onExpandedChange(false)} className="flex h-7 w-7 items-center justify-center rounded-[6px] text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
|
||||
@@ -139,7 +145,7 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="scroll-micro min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="scroll-micro min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{p.activeTab === 'pages' && <PagesTab {...p} />}
|
||||
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />}
|
||||
{p.activeTab === 'search' && <SearchTab {...p} />}
|
||||
@@ -148,6 +154,7 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
{p.activeTab === 'outline' && <OutlineTab outline={p.outline} onNavigate={p.onNavigateOutline} />}
|
||||
{p.activeTab === 'forms' && <FormsTab fields={p.annotations.filter((a) => a.type === 'widget')} onNavigate={p.onNavigateAnnotation} />}
|
||||
{p.activeTab === 'history' && <HistoryTab history={p.history} onRestore={p.onRestoreHistory} />}
|
||||
{p.activeTab === 'watermark' && <WatermarkTab totalPages={p.totalPages} currentPage={p.currentPage} onApplyWatermark={p.onApplyWatermark} onPreviewChange={p.onPreviewChange} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -690,4 +697,360 @@ const HistoryTab: React.FC<{ history?: { stack: string[]; index: number }; onRes
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WatermarkTab: React.FC<{
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
onApplyWatermark?: (config: WatermarkConfig, targetPageIndices: number[]) => Promise<void>;
|
||||
onPreviewChange?: (config: WatermarkConfig | null) => void;
|
||||
}> = ({ totalPages, currentPage, onApplyWatermark, onPreviewChange }) => {
|
||||
const [tab, setTab] = React.useState<'text' | 'image'>('text');
|
||||
const [text, setText] = React.useState('CONFIDENTIAL');
|
||||
const [fontFamily, setFontFamily] = React.useState('Helvetica');
|
||||
const [fontSize, setFontSize] = React.useState(48);
|
||||
const [fontWeight, setFontWeight] = React.useState('bold');
|
||||
const [color, setColor] = React.useState('#7c3aed');
|
||||
const [opacity, setOpacity] = React.useState(40);
|
||||
const [rotation, setRotation] = React.useState(-45);
|
||||
const [position, setPosition] = React.useState<'top_left' | 'top_center' | 'top_right' | 'center_left' | 'center' | 'center_right' | 'bottom_left' | 'bottom_center' | 'bottom_right'>('center');
|
||||
const [targetPages, setTargetPages] = React.useState<'all' | 'current' | 'custom'>('all');
|
||||
const [customRangeStr, setCustomRangeStr] = React.useState(`1-${totalPages || 1}`);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCustomRangeStr(`1-${totalPages || 1}`);
|
||||
}, [totalPages]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onPreviewChange?.({
|
||||
text,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
color,
|
||||
opacity,
|
||||
rotation,
|
||||
position,
|
||||
targetPages,
|
||||
customRangeStr,
|
||||
});
|
||||
return () => {
|
||||
onPreviewChange?.(null);
|
||||
};
|
||||
}, [text, fontFamily, fontSize, fontWeight, color, opacity, rotation, position, targetPages, customRangeStr, onPreviewChange]);
|
||||
|
||||
const parsePageIndices = (): { indices: number[]; error?: string } => {
|
||||
if (targetPages === 'all') {
|
||||
return { indices: Array.from({ length: Math.max(1, totalPages) }, (_, i) => i) };
|
||||
}
|
||||
if (targetPages === 'current') {
|
||||
return { indices: [currentPage] };
|
||||
}
|
||||
const cleaned = customRangeStr.trim();
|
||||
if (!cleaned) return { indices: [], error: 'Please enter a valid page range.' };
|
||||
const indicesSet = new Set<number>();
|
||||
const parts = cleaned.split(',');
|
||||
for (const part of parts) {
|
||||
const p = part.trim();
|
||||
if (!p) continue;
|
||||
if (p.includes('-')) {
|
||||
const [startStr, endStr] = p.split('-');
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = parseInt(endStr, 10);
|
||||
if (isNaN(start) || isNaN(end) || start > end || start < 1 || end > totalPages) {
|
||||
return { indices: [], error: `Invalid page range "${p}". Page numbers must be between 1 and ${totalPages}.` };
|
||||
}
|
||||
for (let i = start; i <= end; i++) {
|
||||
indicesSet.add(i - 1);
|
||||
}
|
||||
} else {
|
||||
const num = parseInt(p, 10);
|
||||
if (isNaN(num) || num < 1 || num > totalPages) {
|
||||
return { indices: [], error: `Invalid page number "${p}". Page numbers must be between 1 and ${totalPages}.` };
|
||||
}
|
||||
indicesSet.add(num - 1);
|
||||
}
|
||||
}
|
||||
const result = Array.from(indicesSet).sort((a, b) => a - b);
|
||||
if (result.length === 0) return { indices: [], error: 'No valid pages selected.' };
|
||||
return { indices: result };
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (isSubmitting || !onApplyWatermark) return;
|
||||
const trimmedText = text.trim();
|
||||
if (!trimmedText) {
|
||||
setErrorMessage('Watermark text cannot be empty.');
|
||||
return;
|
||||
}
|
||||
const { indices, error } = parsePageIndices();
|
||||
if (error || indices.length === 0) {
|
||||
setErrorMessage(error || 'Invalid page range.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
await onApplyWatermark(
|
||||
{
|
||||
text: trimmedText,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
color,
|
||||
opacity,
|
||||
rotation,
|
||||
position,
|
||||
targetPages,
|
||||
customRangeStr,
|
||||
},
|
||||
indices
|
||||
);
|
||||
setSuccessMessage(`Watermark added to ${indices.length} page(s)!`);
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (err: any) {
|
||||
setErrorMessage(err.message || 'Failed to apply watermark.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const posGrid: { id: WatermarkConfig['position']; label: string }[] = [
|
||||
{ id: 'top_left', label: 'TL' },
|
||||
{ id: 'top_center', label: 'TC' },
|
||||
{ id: 'top_right', label: 'TR' },
|
||||
{ id: 'center_left', label: 'CL' },
|
||||
{ id: 'center', label: 'C' },
|
||||
{ id: 'center_right', label: 'CR' },
|
||||
{ id: 'bottom_left', label: 'BL' },
|
||||
{ id: 'bottom_center', label: 'BC' },
|
||||
{ id: 'bottom_right', label: 'BR' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 text-[13px]">
|
||||
{/* Sub Tabs: Text | Image */}
|
||||
<div className="flex rounded-lg bg-bg-secondary p-1 border border-border-primary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('text')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[12px] font-bold transition-all ${
|
||||
tab === 'text' ? 'bg-white text-brand-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<TextBoxIcon size={14} /> Text
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('image')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[12px] font-bold transition-all ${
|
||||
tab === 'image' ? 'bg-white text-brand-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<ImageIcon size={14} /> Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-2.5 text-red-700 text-xs font-semibold flex items-center justify-between">
|
||||
<span>⚠️ {errorMessage}</span>
|
||||
<button type="button" onClick={() => setErrorMessage(null)} className="text-red-500 font-bold ml-2">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 p-2.5 text-emerald-700 text-xs font-semibold flex items-center justify-between">
|
||||
<span>✅ {successMessage}</span>
|
||||
<button type="button" onClick={() => setSuccessMessage(null)} className="text-emerald-500 font-bold ml-2">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'text' ? (
|
||||
<>
|
||||
{/* Text Input */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[11.5px] font-bold text-text-primary">Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Enter watermark text..."
|
||||
className="h-9 w-full rounded-lg border border-border-primary bg-bg-primary px-3 text-[13px] text-text-primary outline-none transition-all focus:border-brand-primary focus:ring-1 focus:ring-brand-primary font-medium"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1 pt-1">
|
||||
{['CONFIDENTIAL', 'DRAFT', 'SAMPLE', 'PROPERTY OF COMPANY', 'FINAL', 'INTERNAL USE ONLY'].map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setText(preset)}
|
||||
className={`rounded px-2 py-0.5 text-[10.5px] font-semibold border transition-all cursor-pointer ${
|
||||
text === preset
|
||||
? 'bg-brand-primary text-white border-brand-primary'
|
||||
: 'bg-bg-secondary text-text-secondary border-border-primary hover:bg-bg-tertiary'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Format */}
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-border-primary bg-bg-secondary/40 p-3">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-text-tertiary">Text Format</span>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={fontFamily}
|
||||
onChange={(e) => setFontFamily(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="h-8 rounded-lg border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none"
|
||||
>
|
||||
<option value="Helvetica">Helvetica</option>
|
||||
<option value="Times-Roman">Times New Roman</option>
|
||||
<option value="Courier">Courier</option>
|
||||
<option value="Arial">Arial</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(Number(e.target.value))}
|
||||
disabled={isSubmitting}
|
||||
className="h-8 rounded-lg border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none"
|
||||
>
|
||||
{[16, 24, 36, 48, 72, 96, 120].map((sz) => (
|
||||
<option key={sz} value={sz}>{sz} pt</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFontWeight((w) => (w === 'bold' ? 'normal' : 'bold'))}
|
||||
className={`h-7 w-7 rounded-lg font-bold text-[12px] border transition-colors cursor-pointer ${
|
||||
fontWeight === 'bold' ? 'bg-brand-primary text-white border-brand-primary' : 'bg-bg-primary text-text-secondary border-border-primary hover:bg-bg-tertiary'
|
||||
}`}
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<span className="text-[11px] font-semibold text-text-secondary">Color:</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0 min-w-0">
|
||||
<ColorSwatches
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
palette={['#7c3aed', '#dc2626', '#2563eb', '#16a34a', '#d97706', '#18212e', '#ffffff']}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position (3x3 Grid) */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[11.5px] font-bold text-text-primary">Position</label>
|
||||
<div className="grid grid-cols-3 gap-1.5 w-full max-w-[210px] mx-auto p-2 bg-bg-secondary rounded-xl border border-border-primary">
|
||||
{posGrid.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setPosition(p.id)}
|
||||
className={`h-10 rounded-lg flex items-center justify-center transition-all cursor-pointer ${
|
||||
position === p.id
|
||||
? 'bg-brand-primary text-white shadow-md ring-2 ring-brand-primary/30'
|
||||
: 'bg-white text-text-secondary hover:bg-bg-tertiary border border-border-primary'
|
||||
}`}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rotation & Transparency */}
|
||||
<div className="grid grid-cols-2 gap-3 items-center pt-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11.5px] font-bold text-text-primary">Rotation</label>
|
||||
<select
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(Number(e.target.value))}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none transition-all focus:border-brand-primary focus:ring-1 focus:ring-brand-primary"
|
||||
>
|
||||
<option value={0}>Do not rotate</option>
|
||||
<option value={45}>45 degrees</option>
|
||||
<option value={90}>90 degrees</option>
|
||||
<option value={180}>180 degrees</option>
|
||||
<option value={270}>270 degrees</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11.5px] font-bold text-text-primary">Transparency</label>
|
||||
<select
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none transition-all focus:border-brand-primary focus:ring-1 focus:ring-brand-primary"
|
||||
>
|
||||
<option value={100}>No transparency</option>
|
||||
<option value={75}>75%</option>
|
||||
<option value={50}>50%</option>
|
||||
<option value={25}>25%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Pages */}
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
<label className="text-[11.5px] font-bold text-text-primary">Apply to Pages</label>
|
||||
<select
|
||||
value={targetPages}
|
||||
onChange={(e) => setTargetPages(e.target.value as any)}
|
||||
disabled={isSubmitting}
|
||||
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2.5 text-[12px] font-medium text-text-primary outline-none"
|
||||
>
|
||||
<option value="all">All Pages ({totalPages})</option>
|
||||
<option value="current">Current Page ({currentPage + 1})</option>
|
||||
<option value="custom">Custom Page Range…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{targetPages === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
value={customRangeStr}
|
||||
onChange={(e) => setCustomRangeStr(e.target.value)}
|
||||
placeholder={`1-${totalPages}`}
|
||||
className="h-8 w-full rounded-lg border border-border-primary bg-bg-primary px-2.5 text-[12px] text-text-primary outline-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Primary Action Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={isSubmitting}
|
||||
className="mt-3 flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-brand-primary text-white text-[13.5px] font-bold shadow-md hover:bg-brand-primary/95 active:scale-[0.99] transition-all cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Adding Watermark...' : 'Add Watermark →'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-6 text-center text-text-tertiary">
|
||||
<span className="text-3xl mb-2">🖼</span>
|
||||
<p className="font-bold text-[13px] text-text-primary mb-1">Image Watermark</p>
|
||||
<p className="text-[11.5px]">Upload an image logo or stamp to place across PDF pages.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -212,9 +212,10 @@ export const WatermarkModal: React.FC<WatermarkModalProps> = ({
|
||||
disabled={isSubmitting}
|
||||
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none"
|
||||
>
|
||||
<option value="Helvetica">Helvetica (Sans-Serif)</option>
|
||||
<option value="Times-Roman">Times-Roman (Serif)</option>
|
||||
<option value="Courier">Courier (Monospace)</option>
|
||||
<option value="Helvetica">Helvetica</option>
|
||||
<option value="Times-Roman">Times New Roman</option>
|
||||
<option value="Courier">Courier</option>
|
||||
<option value="Arial">Arial</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -251,29 +252,34 @@ export const WatermarkModal: React.FC<WatermarkModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 items-center">
|
||||
<Slider
|
||||
label="Opacity"
|
||||
min={5}
|
||||
max={100}
|
||||
step={5}
|
||||
value={opacity}
|
||||
onChange={setOpacity}
|
||||
suffix="%"
|
||||
width={100}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-text-secondary">Rotation:</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-text-secondary">Rotation:</label>
|
||||
<select
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(Number(e.target.value))}
|
||||
disabled={isSubmitting}
|
||||
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none"
|
||||
>
|
||||
<option value={-45}>-45° Diagonal</option>
|
||||
<option value={45}>45° Diagonal</option>
|
||||
<option value={0}>0° Horizontal</option>
|
||||
<option value={90}>90° Vertical</option>
|
||||
<option value={0}>Do not rotate</option>
|
||||
<option value={45}>45 degrees</option>
|
||||
<option value={90}>90 degrees</option>
|
||||
<option value={180}>180 degrees</option>
|
||||
<option value={270}>270 degrees</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-text-secondary">Transparency:</label>
|
||||
<select
|
||||
value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
disabled={isSubmitting}
|
||||
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none"
|
||||
>
|
||||
<option value={100}>No transparency</option>
|
||||
<option value={75}>75%</option>
|
||||
<option value={50}>50%</option>
|
||||
<option value={25}>25%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,6 +97,7 @@ interface PDFViewerProps {
|
||||
layoutData?: PageLayoutResponse | null;
|
||||
availableFonts?: FontInfo[];
|
||||
onEditBlock?: (pageIndex: number, payload: BlockEditPayload) => void;
|
||||
watermarkPreview?: import('../components/WatermarkModal').WatermarkConfig | null;
|
||||
}
|
||||
|
||||
interface PageLayout {
|
||||
@@ -154,6 +155,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||
onDecorateText,
|
||||
onFieldChange,
|
||||
canCopy = true,
|
||||
watermarkPreview = null,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -1017,6 +1019,15 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||
searchCurrentMatch={searchCurrentMatch}
|
||||
/>
|
||||
|
||||
{watermarkPreview && isPageTargetedByWatermark(page.index, watermarkPreview) && (
|
||||
<WatermarkPreviewOverlay
|
||||
preview={watermarkPreview}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Interactive signature placement overlay */}
|
||||
{activePlacement &&
|
||||
activePlacement.pageIndex === page.index &&
|
||||
@@ -1064,3 +1075,85 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const isPageTargetedByWatermark = (
|
||||
pageIndex: number,
|
||||
preview: import('../components/WatermarkModal').WatermarkConfig
|
||||
): boolean => {
|
||||
if (preview.targetPages === 'all') return true;
|
||||
if (preview.targetPages === 'current') return true;
|
||||
if (preview.targetPages === 'custom') {
|
||||
const parts = (preview.customRangeStr || '').split(',');
|
||||
for (const part of parts) {
|
||||
const p = part.trim();
|
||||
if (!p) continue;
|
||||
if (p.includes('-')) {
|
||||
const [s, e] = p.split('-').map((x) => parseInt(x, 10));
|
||||
if (!isNaN(s) && !isNaN(e) && pageIndex >= s - 1 && pageIndex <= e - 1) return true;
|
||||
} else {
|
||||
const num = parseInt(p, 10);
|
||||
if (!isNaN(num) && pageIndex === num - 1) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const WatermarkPreviewOverlay: React.FC<{
|
||||
preview: import('../components/WatermarkModal').WatermarkConfig;
|
||||
width: number;
|
||||
height: number;
|
||||
zoom: number;
|
||||
}> = ({ preview, width, height, zoom }) => {
|
||||
if (!preview.text.trim()) return null;
|
||||
|
||||
let justifyContent = 'center';
|
||||
let alignItems = 'center';
|
||||
|
||||
if (preview.position.includes('left')) justifyContent = 'flex-start';
|
||||
if (preview.position.includes('right')) justifyContent = 'flex-end';
|
||||
if (preview.position.includes('top')) alignItems = 'flex-start';
|
||||
if (preview.position.includes('bottom')) alignItems = 'flex-end';
|
||||
|
||||
const margin = 36 * zoom;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
padding: `${margin}px`,
|
||||
};
|
||||
|
||||
const scaledFontSize = preview.fontSize * zoom;
|
||||
const opacityVal = preview.opacity / 100.0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none z-30 flex overflow-hidden select-none"
|
||||
style={{
|
||||
width: `${width * zoom}px`,
|
||||
height: `${height * zoom}px`,
|
||||
justifyContent,
|
||||
alignItems,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="transition-all duration-150 transform-gpu flex items-center justify-center tracking-wide"
|
||||
style={{
|
||||
fontFamily: preview.fontFamily.includes('Times')
|
||||
? 'Times New Roman, serif'
|
||||
: preview.fontFamily.includes('Courier')
|
||||
? 'Courier New, monospace'
|
||||
: 'Inter, Helvetica, sans-serif',
|
||||
fontSize: `${scaledFontSize}px`,
|
||||
fontWeight: preview.fontWeight === 'bold' ? 'bold' : 'normal',
|
||||
color: preview.color,
|
||||
opacity: opacityVal,
|
||||
transform: `rotate(${preview.rotation}deg)`,
|
||||
textShadow: '0 0 4px rgba(0,0,0,0.15)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{preview.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user