Merge branch 'azeem' of https://gitea.maskantech.in/gitea_admin/pdf into fix_issue

This commit is contained in:
saqib mir
2026-07-31 17:54:01 +05:30
17 changed files with 1375 additions and 901 deletions
+50 -18
View File
@@ -7,6 +7,7 @@ import type { InspectorTab } from './components/InspectorPanel';
import { SignatureModal } from './components/SignatureModal';
import { RedactPagesModal } from './components/RedactPagesModal';
import { AboutModal } from './components/AboutModal';
import { VersionHistoryModal } from './components/VersionHistoryModal';
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
@@ -57,6 +58,7 @@ function App() {
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
const [fonts, setFonts] = useState<FontInfo[]>([]);
const [outline, setOutline] = useState<OutlineItem[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
const [engineReady, setEngineReady] = useState(false);
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('pages');
@@ -68,6 +70,7 @@ function App() {
const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false);
const [aboutModalOpen, setAboutModalOpen] = useState(false);
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
const [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
@@ -464,6 +467,9 @@ function App() {
const handleMarkRedaction = (pageIndex: number, bounds: Rect) => {
setPendingRedactions(prev => [...prev, { id: rid('redmark'), pageIndex, bounds }]);
if (activeTool !== 'redact') {
setActiveTool('redact');
}
};
const handleApplyRedactions = () => {
@@ -512,11 +518,18 @@ function App() {
const handleExport = async () => {
if (!activeDoc) return;
if (!can('canCopy')) { denyToast('Exporting'); return; }
try {
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
} catch (e) {
console.error('Export failed', e);
}
setConfirmState({
title: 'Export Document',
message: `Are you sure you want to export "${activeDoc.filename}"?`,
confirmLabel: 'Export',
onConfirm: async () => {
try {
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
} catch (e) {
console.error('Export failed', e);
}
},
});
};
const handlePrint = async () => {
@@ -545,16 +558,7 @@ function App() {
};
const toggleInspector = () => {
setIsInspectorOpen((prev) => {
const next = !prev;
setTimeout(() => {
const w = activeDoc?.pageWidth || 612;
const inspectorWidth = next ? 322 : 0;
const avail = window.innerWidth - inspectorWidth - 48;
setZoom(Math.max(0.25, Math.min(3, avail / w)));
}, 50);
return next;
});
setIsInspectorOpen((prev) => !prev);
};
const fitWidth = () => {
@@ -618,6 +622,7 @@ function App() {
canExport={can('canCopy')}
canAssemble={can('canAssemble')}
onUpload={handleUpload}
onShowVersionHistory={() => setVersionHistoryModalOpen(true)}
isInspectorOpen={isInspectorOpen}
onToggleInspector={toggleInspector}
/>
@@ -647,6 +652,19 @@ function App() {
onApplyRedactions={handleApplyRedactions}
onClearRedactions={() => setPendingRedactions([])}
onRedactPages={() => setRedactPagesModalOpen(true)}
selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)}
onUpdateAnnotation={(patch) => {
const a = annotations.find(x => x.id === selectedAnnotationId);
if (a) handleUpdateAnnotation({ ...a, ...patch });
}}
onDeleteAnnotation={() => {
const a = annotations.find(x => x.id === selectedAnnotationId);
if (a) {
handleDeleteAnnotation(a);
setSelectedAnnotationId(null);
}
}}
onDeselectAnnotation={() => setSelectedAnnotationId(null)}
/>
<div className="relative min-h-0 flex-1">
@@ -687,9 +705,13 @@ function App() {
searchCurrentMatch={searchCurrentMatch}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationUpdate={handleUpdateAnnotation}
onAnnotationClick={() => {
setInspectorTab('notes');
if (!isInspectorOpen) setIsInspectorOpen(true);
onAnnotationClick={(anno) => {
if (activeTool === 'select') {
setSelectedAnnotationId(anno.id);
} else {
setInspectorTab('notes');
if (!isInspectorOpen) setIsInspectorOpen(true);
}
}}
onPageVisible={setCurrentPage}
onMarkRedaction={handleMarkRedaction}
@@ -726,6 +748,8 @@ function App() {
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
isExpanded={isInspectorExpanded}
onExpandedChange={setIsInspectorExpanded}
documents={documents}
selectedDocumentId={selectedDocId}
onSelectDocument={openDocument}
@@ -754,6 +778,14 @@ function App() {
metadata={metadata}
permissions={permissions ?? undefined}
fonts={fonts}
history={hist}
onRestoreHistory={(docId) => {
const index = hist.stack.findIndex(id => id === docId);
if (index !== -1) {
setHist(h => ({ ...h, index }));
preservePageRef.current = true;
}
}}
/>
)}
</div>
+178 -120
View File
@@ -3,17 +3,20 @@ import React from 'react';
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
import type { Annotation } from '../viewer/AnnotationLayer';
import { Thumbnail } from './Thumbnail';
import { EmptyState, Popover } from './ui';
import { EmptyState } from './ui';
import {
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon,
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon, HistoryIcon, XIcon, MoonIcon, SunIcon,
} from './icons';
import { useTheme } from '../lib/useTheme';
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms';
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms' | 'history';
interface InspectorPanelProps {
activeTab: InspectorTab;
onTabChange: (t: InspectorTab) => void;
isExpanded: boolean;
onExpandedChange: (expanded: boolean) => void;
documents: DocumentInfo[];
selectedDocumentId: string;
@@ -48,6 +51,9 @@ interface InspectorPanelProps {
metadata: DocumentMetadata | null;
permissions?: PDFPermissions;
fonts: FontInfo[];
history?: { stack: string[]; index: number };
onRestoreHistory?: (docId: string) => void;
}
const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boolean }[] = [
@@ -58,6 +64,7 @@ const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boo
{ id: 'fonts', label: 'Fonts', icon: <FontsIcon /> },
{ id: 'outline', label: 'Outline', icon: <OutlineIcon /> },
{ id: 'forms', label: 'Forms', icon: <FormsIcon />, stub: true },
{ id: 'history', label: 'History', icon: <HistoryIcon /> },
];
function formatBytes(bytes?: number) {
@@ -70,79 +77,77 @@ 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 { theme, toggleTheme } = useTheme();
return (
<aside className="flex h-full shrink-0 border-l border-[#ebedf0] bg-[#ffffff]" style={{ width: '322px' }}>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex h-[48px] shrink-0 items-center justify-between gap-2 border-b border-[#ebedf0]" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
<span className="shrink-0 text-[13px] font-bold text-[#18212e]">{activeDef.label}</span>
{p.documents.length > 0 ? (
<Popover
align="right"
width={272}
trigger={(open) => (
<CustomButton variant="unstyled" className={`flex h-7 min-w-0 max-w-[180px] items-center gap-1.5 rounded-[8px] px-2 text-left transition-colors ${open ? 'bg-[#edeff2]' : 'hover:bg-[#f6f7f9]'}`}>
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[#5b6573]">{selectedDoc?.filename ?? 'Document'}</span>
<ChevronDownIcon size={13} className="shrink-0 text-[#98a1ad]" />
</CustomButton>
)}
<aside className="flex h-full shrink-0 flex-row-reverse bg-bg-primary">
{/* Icon Strip (Right Edge) */}
<div className="flex w-[48px] shrink-0 flex-col items-center gap-2 border-l border-border-primary bg-bg-secondary py-3">
{TABS.map((t) => {
const active = p.isExpanded && p.activeTab === t.id;
return (
<CustomButton variant="unstyled"
key={t.id}
title={t.stub ? `${t.label} (coming soon)` : t.label}
aria-label={t.label}
onClick={() => {
if (p.isExpanded && p.activeTab === t.id) {
p.onExpandedChange(false);
} else {
p.onTabChange(t.id);
p.onExpandedChange(true);
}
}}
className={`relative flex h-9 w-9 items-center justify-center rounded-[8px] transition-colors cursor-pointer ${
active
? 'bg-brand-secondary text-brand-primary'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
<div className="flex flex-col">
<p className="px-1 pb-1 text-[10px] font-bold uppercase tracking-wide text-[#98a1ad]">Open documents</p>
{p.documents.map((d) => (
<CustomButton variant="unstyled"
key={d.id}
onClick={() => p.onSelectDocument(d.id)}
className={`flex flex-col rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-[#f6f7f9] ${d.id === p.selectedDocumentId ? 'bg-[#eef4ff]' : ''}`}
>
<span className={`truncate text-[12.5px] font-semibold ${d.id === p.selectedDocumentId ? 'text-[#2563eb]' : 'text-[#18212e]'}`}>{d.filename}</span>
<span className="text-[10.5px] text-[#98a1ad]">{formatBytes(d.sizeBytes)} · {d.totalPages} pages</span>
</CustomButton>
))}
</div>
</Popover>
) : (
<span className="text-[11.5px] font-medium text-[#98a1ad]">No document</span>
)}
</div>
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 18 }) : t.icon}
{t.id === 'notes' && p.annotations.some((a) => a.type !== 'widget') && (
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-primary" />
)}
{t.id === 'forms' && p.annotations.some((a) => a.type === 'widget') && (
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-primary" />
)}
</CustomButton>
);
})}
<div className="flex shrink-0 items-center justify-between border-b border-[#ebedf0] bg-[#f6f7f9]" style={{ paddingLeft: '10px', paddingRight: '10px', paddingTop: '7px', paddingBottom: '7px' }}>
{TABS.map((t) => {
const active = p.activeTab === t.id;
return (
<CustomButton variant="unstyled"
key={t.id}
title={t.stub ? `${t.label} (coming soon)` : t.label}
aria-label={t.label}
onClick={() => p.onTabChange(t.id)}
className={`relative flex h-8 w-8 items-center justify-center rounded-[8px] transition-colors cursor-pointer ${
active
? 'bg-[#eef4ff] text-[#2563eb]'
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
}`}
>
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 18 }) : t.icon}
{t.id === 'notes' && p.annotations.some((a) => a.type !== 'widget') && (
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
)}
{t.id === 'forms' && p.annotations.some((a) => a.type === 'widget') && (
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
)}
</CustomButton>
);
})}
</div>
<div className="flex-1 min-h-[16px]" />
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
{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} />}
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} permissions={p.permissions} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
{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} />}
</div>
<CustomButton variant="unstyled"
label={theme === 'dark' ? "Switch to light mode" : "Switch to dark mode"}
onClick={toggleTheme}
className="flex h-9 w-9 items-center justify-center rounded-[8px] text-text-secondary transition-colors hover:bg-bg-tertiary hover:text-text-primary mb-2"
>
{theme === 'dark' ? <SunIcon size={18} /> : <MoonIcon size={18} />}
</CustomButton>
</div>
{/* 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 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">
<XIcon size={16} />
</CustomButton>
</div>
<div className="scroll-micro min-h-0 flex-1 overflow-y-auto">
{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} />}
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} permissions={p.permissions} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
{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} />}
</div>
</div>
)}
</aside>
);
};
@@ -172,7 +177,7 @@ const PagesTab: React.FC<InspectorPanelProps> = (p) => {
setDraggedIdx(null);
}}
onDragEnd={() => setDraggedIdx(null)}
className={`cursor-grab active:cursor-grabbing ${draggedIdx === idx ? 'opacity-50' : ''} ${idx === p.currentPage ? '[&_.w-full.aspect-\\[3\\/4\\]]:!border-[#2563eb] [&>div>div]:!ring-2 [&_.w-full.aspect-\\[3\\/4\\]]:!ring-[#eef4ff]' : ''}`}
className={`cursor-grab active:cursor-grabbing ${draggedIdx === idx ? 'opacity-50' : ''} ${idx === p.currentPage ? '[&_.w-full.aspect-\\[3\\/4\\]]:!border-brand-primary [&>div>div]:!ring-2 [&_.w-full.aspect-\\[3\\/4\\]]:!ring-brand-secondary' : ''}`}
>
<Thumbnail
documentId={p.documentId}
@@ -197,15 +202,15 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
return (
<div className="flex flex-col gap-2 p-3">
{annotations.map((a) => (
<div key={a.id} className="group relative flex flex-col gap-1.5 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5 transition-colors hover:border-[#dadde2]">
<div key={a.id} className="group relative flex flex-col gap-1.5 rounded-[8px] border border-border-primary bg-bg-secondary p-2.5 transition-colors hover:border-border-secondary">
<CustomButton variant="unstyled" onClick={() => onNavigate(a)} className="flex flex-col gap-1.5 text-left">
<div className="flex items-center justify-between pr-6">
<span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide"
style={{ background: '#eef4ff', color: '#2563eb' }}>{a.type}</span>
<span className="text-[10px] text-[#98a1ad]">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
<span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide bg-brand-secondary text-brand-primary"
>{a.type}</span>
<span className="text-[10px] text-text-tertiary">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
</div>
{a.content && <p className="line-clamp-3 text-[12px] italic text-[#5b6573]">{a.content}</p>}
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
{a.content && <p className="line-clamp-3 text-[12px] italic text-text-secondary">{a.content}</p>}
<span className="text-[10px] font-medium text-text-tertiary">{a.author}</span>
</CustomButton>
{onUpdate && (['highlight', 'ink', 'comment', 'strikeout', 'underline', 'squiggly'].includes(a.type)) && (
@@ -235,7 +240,7 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
title="Delete annotation"
aria-label="Delete annotation"
onClick={(e) => { e.stopPropagation(); onDelete(a); }}
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-[6px] text-[#98a1ad] opacity-0 transition-opacity hover:bg-[#fdecec] hover:text-[#dc2626] group-hover:opacity-100"
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-[6px] text-text-tertiary opacity-0 transition-opacity hover:bg-[#fdecec] hover:text-[#dc2626] group-hover:opacity-100"
>
<TrashIcon size={15} />
</button>
@@ -257,11 +262,11 @@ const OutlineTab: React.FC<{ outline: OutlineItem[]; onNavigate: (pageIndex: num
key={i}
onClick={() => item.pageIndex >= 0 && onNavigate(item.pageIndex)}
disabled={item.pageIndex < 0}
className="flex items-center justify-between gap-2 rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-[#f6f7f9] disabled:opacity-50"
className="flex items-center justify-between gap-2 rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-bg-secondary disabled:opacity-50"
style={{ paddingLeft: `${8 + item.level * 14}px` }}
>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-[#18212e]" title={item.title}>{item.title || '(untitled)'}</span>
{item.pageIndex >= 0 && <span className="shrink-0 text-[10px] tabular-nums text-[#98a1ad]">{item.pageIndex + 1}</span>}
<span className="min-w-0 flex-1 truncate text-[12.5px] text-text-primary" title={item.title}>{item.title || '(untitled)'}</span>
{item.pageIndex >= 0 && <span className="shrink-0 text-[10px] tabular-nums text-text-tertiary">{item.pageIndex + 1}</span>}
</CustomButton>
))}
</div>
@@ -271,27 +276,40 @@ const OutlineTab: React.FC<{ outline: OutlineItem[]; onNavigate: (pageIndex: num
const SearchTab: React.FC<InspectorPanelProps> = (p) => {
return (
<div className="flex h-full flex-col">
<div className="border-b border-[#ebedf0] p-3">
<div className="border-b border-border-primary p-3">
<div className="flex items-center justify-between mb-3">
<span className="text-[13px] font-bold text-[#18212e]">Find</span>
<span className="text-[13px] font-bold text-text-primary">Find</span>
</div>
<div className="flex items-center gap-2 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-2.5 py-1.5 focus-within:border-[#2563eb]">
<SearchGlyph size={15} className="text-[#98a1ad]" />
<div className="flex items-center gap-2 rounded-[8px] border border-border-primary bg-bg-secondary px-2.5 py-1.5 focus-within:border-brand-primary">
<SearchGlyph size={15} className="shrink-0 text-text-tertiary" />
<input
autoFocus
value={p.searchQuery}
onChange={(e) => p.onSearchQueryChange(e.target.value)}
placeholder="Search document…"
className="w-full bg-transparent text-[13px] text-[#18212e] outline-none placeholder:text-[#98a1ad]"
className="w-full bg-transparent text-[13px] text-text-primary outline-none placeholder:text-text-tertiary"
/>
{p.searchQuery && (
<button
onClick={() => p.onSearchQueryChange('')}
className="flex shrink-0 items-center justify-center text-text-tertiary hover:text-text-primary transition-colors"
aria-label="Clear search"
title="Clear search"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
)}
</div>
<div className="mt-3 flex items-center justify-between px-0.5">
<span className="text-[11.5px] font-medium text-[#5b6573]">
<span className="text-[11.5px] font-medium text-text-secondary">
{p.searchResults.length > 0 ? `Search results: ${p.searchCurrentMatch + 1}/${p.searchResults.length}` : (p.searchQuery ? 'No matches' : '')}
</span>
{p.searchResults.length > 0 && (
<div className="flex items-center gap-1 text-[#5b6573]">
<div className="flex items-center gap-1 text-text-secondary">
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch - 1 + p.searchResults.length) % p.searchResults.length)}><ChevronUpIcon size={14} /></CustomButton>
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch + 1) % p.searchResults.length)}><ChevronDownIcon size={14} /></CustomButton>
</div>
@@ -299,12 +317,12 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
</div>
<div className="mt-2 flex flex-col gap-2 px-0.5">
<label className="flex items-center gap-2 text-[12px] text-[#5b6573] cursor-pointer">
<input type="checkbox" checked={p.searchCaseSensitive} onChange={(e) => p.onSearchCaseSensitiveChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
<label className="flex items-center gap-2 text-[12px] text-text-secondary cursor-pointer">
<input type="checkbox" checked={p.searchCaseSensitive} onChange={(e) => p.onSearchCaseSensitiveChange(e.target.checked)} className="rounded border-border-secondary text-brand-primary focus:ring-brand-primary" />
Case sensitive
</label>
<label className="flex items-center gap-2 text-[12px] text-[#5b6573] cursor-pointer">
<input type="checkbox" checked={p.searchWholeWords} onChange={(e) => p.onSearchWholeWordsChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
<label className="flex items-center gap-2 text-[12px] text-text-secondary cursor-pointer">
<input type="checkbox" checked={p.searchWholeWords} onChange={(e) => p.onSearchWholeWordsChange(e.target.checked)} className="rounded border-border-secondary text-brand-primary focus:ring-brand-primary" />
Whole words only
</label>
</div>
@@ -326,7 +344,7 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
<span>
{parts.map((part, index) =>
regex.test(part) ? (
<mark key={index} className={`font-bold rounded-[2px] px-0.5 ${isSelected ? 'bg-[#fef08a] text-black' : 'bg-[#eef4ff] text-[#2563eb]'}`}>{part}</mark>
<mark key={index} className={`font-bold rounded-[2px] px-0.5 ${isSelected ? 'bg-[#fef08a] text-black' : 'bg-brand-secondary text-brand-primary'}`}>{part}</mark>
) : (
<span key={index}>{part}</span>
)
@@ -341,12 +359,12 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
return (
<CustomButton variant="unstyled" key={i} onClick={() => p.onSelectSearchMatch(i)}
className={`mb-1 flex w-full flex-col gap-1.5 rounded-[6px] px-2.5 py-2 text-left transition-colors ${
isSelected ? 'bg-[#2563eb] text-white' : 'hover:bg-[#f6f7f9] text-[#5b6573]'
isSelected ? 'bg-brand-primary text-white' : 'hover:bg-bg-secondary text-text-secondary'
}`}>
<span className={`text-[12.5px] leading-relaxed ${isSelected ? 'text-white' : 'text-[#18212e]'}`}>
<span className={`text-[12.5px] leading-relaxed ${isSelected ? 'text-white' : 'text-text-primary'}`}>
{highlightText(r.text || p.searchQuery, p.searchQuery)}
</span>
<span className={`self-start rounded px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-[#1d4ed8] text-white' : 'bg-[#edeff2] text-[#98a1ad]'}`}>
<span className={`self-start rounded px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-brand-hover text-white' : 'bg-bg-tertiary text-text-tertiary'}`}>
Page {r.pageIndex + 1}
</span>
</CustomButton>
@@ -386,19 +404,19 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
return (
<div className="flex flex-col gap-0.5 p-3">
{rows.map(([k, v]) => (
<div key={k} className="grid grid-cols-[88px_1fr] gap-2 border-b border-[#ebedf0] py-2 last:border-0">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{k}</span>
<span className="break-words text-[12.5px] text-[#18212e]">{v && v.trim() ? v : <span className="text-[#98a1ad]"></span>}</span>
<div key={k} className="grid grid-cols-[88px_1fr] gap-2 border-b border-border-primary py-2 last:border-0">
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">{k}</span>
<span className="break-words text-[12.5px] text-text-primary">{v && v.trim() ? v : <span className="text-text-tertiary"></span>}</span>
</div>
))}
<div className="mt-3 border-t border-[#ebedf0] pt-3">
<div className="mt-3 border-t border-border-primary pt-3">
<div className="mb-2 flex items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">Security</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">Security</span>
{permissions?.isEncrypted
? <span className="rounded bg-[#fdecec] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#dc2626]">{permissions.encryption}</span>
: <span className="rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">Unencrypted</span>}
{permissions?.ownerUnlocked && <span className="rounded bg-[#eef4ff] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#2563eb]">Owner</span>}
: <span className="rounded bg-bg-tertiary px-1.5 py-0.5 text-[9px] font-bold uppercase text-text-tertiary">Unencrypted</span>}
{permissions?.ownerUnlocked && <span className="rounded bg-brand-secondary px-1.5 py-0.5 text-[9px] font-bold uppercase text-brand-primary">Owner</span>}
</div>
{permissions?.isEncrypted && !permissions.ownerUnlocked ? (
<div className="flex flex-col gap-1">
@@ -406,7 +424,7 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
const allowed = permissions[key] !== false;
return (
<div key={key} className="flex items-center justify-between text-[12px]">
<span className="text-[#5b6573]">{label}</span>
<span className="text-text-secondary">{label}</span>
<span className={allowed ? 'font-semibold text-[#16a34a]' : 'font-semibold text-[#dc2626]'}>
{allowed ? 'Allowed' : 'Restricted'}
</span>
@@ -415,11 +433,11 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
})}
</div>
) : (
<p className="text-[11px] text-[#98a1ad]">No usage restrictions.</p>
<p className="text-[11px] text-text-tertiary">No usage restrictions.</p>
)}
</div>
<p className="pt-3 text-[10.5px] text-[#98a1ad]">Document properties are read-only.</p>
<p className="pt-3 text-[10.5px] text-text-tertiary">Document properties are read-only.</p>
</div>
);
};
@@ -433,10 +451,10 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
return (
<div className="flex flex-col gap-2 p-3">
{unique.map((f, i) => (
<div key={i} className="rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5">
<div key={i} className="rounded-[8px] border border-border-primary bg-bg-secondary p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" title={f.fontName}>{f.fontName || 'Unknown'}</span>
{f.type && <span className="shrink-0 rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">{f.type}</span>}
<span className="truncate font-mono text-[12px] font-semibold text-text-primary" title={f.fontName}>{f.fontName || 'Unknown'}</span>
{f.type && <span className="shrink-0 rounded bg-bg-tertiary px-1.5 py-0.5 text-[9px] font-bold uppercase text-text-tertiary">{f.type}</span>}
</div>
<div className="mt-1.5 flex flex-wrap gap-1">
<Badge ok={f.isEmbedded} label={f.isEmbedded ? 'Embedded' : 'Not embedded'} />
@@ -454,8 +472,8 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
const Badge: React.FC<{ label: string; ok?: boolean; warn?: boolean }> = ({ label, ok, warn }) => (
<span className="rounded px-1.5 py-0.5 text-[9.5px] font-semibold"
style={{
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : '#edeff2',
color: warn ? '#d97706' : ok ? '#16a34a' : '#98a1ad',
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : 'var(--bg-tertiary)',
color: warn ? '#d97706' : ok ? '#16a34a' : 'var(--text-tertiary)',
}}>
{label}
</span>
@@ -469,25 +487,65 @@ const FormsTab: React.FC<{ fields: Annotation[]; onNavigate: (a: Annotation) =>
}
return (
<div className="flex flex-col gap-2 p-3">
<p className="px-1 text-[11px] font-medium text-[#98a1ad]">{fields.length} field{fields.length > 1 ? 's' : ''}</p>
<p className="px-1 text-[11px] font-medium text-text-tertiary">{fields.length} field{fields.length > 1 ? 's' : ''}</p>
{fields.map((f) => (
<CustomButton variant="unstyled" key={f.id} onClick={() => onNavigate(f)}
className="flex flex-col gap-1.5 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5 text-left transition-colors hover:border-[#dadde2]">
className="flex flex-col gap-1.5 rounded-[8px] border border-border-primary bg-bg-secondary p-2.5 text-left transition-colors hover:border-border-secondary">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-[#18212e]" title={f.fieldName}>{f.fieldName || '(unnamed field)'}</span>
<span className="shrink-0 rounded bg-[#eef4ff] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#2563eb]">{FIELD_TYPE_LABEL[f.fieldType || ''] || f.fieldType || 'Field'}</span>
<span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-text-primary" title={f.fieldName}>{f.fieldName || '(unnamed field)'}</span>
<span className="shrink-0 rounded bg-brand-secondary px-1.5 py-0.5 text-[9px] font-bold uppercase text-brand-primary">{FIELD_TYPE_LABEL[f.fieldType || ''] || f.fieldType || 'Field'}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-[#98a1ad]">Value</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-[#18212e]">{f.fieldValue && f.fieldValue.trim() ? f.fieldValue : <span className="text-[#98a1ad]"></span>}</span>
{f.pageIndex !== undefined && <span className="shrink-0 text-[10px] tabular-nums text-[#98a1ad]">p.{f.pageIndex + 1}</span>}
<span className="text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">Value</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-text-primary">{f.fieldValue && f.fieldValue.trim() ? f.fieldValue : <span className="text-text-tertiary"></span>}</span>
{f.pageIndex !== undefined && <span className="shrink-0 text-[10px] tabular-nums text-text-tertiary">p.{f.pageIndex + 1}</span>}
</div>
{f.fieldOptions && f.fieldOptions.length > 0 && (
<span className="truncate text-[10px] text-[#98a1ad]">Options: {f.fieldOptions.slice(0, 5).join(', ')}{f.fieldOptions.length > 5 ? '…' : ''}</span>
<span className="truncate text-[10px] text-text-tertiary">Options: {f.fieldOptions.slice(0, 5).join(', ')}{f.fieldOptions.length > 5 ? '…' : ''}</span>
)}
</CustomButton>
))}
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-[#98a1ad]">Form fields are view-only filling is on the Phase 3 roadmap.</p>
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-text-tertiary">Form fields are view-only filling is on the Phase 3 roadmap.</p>
</div>
);
};
const HistoryTab: React.FC<{ history?: { stack: string[]; index: number }; onRestore?: (docId: string) => void }> = ({ history, onRestore }) => {
if (!history || history.stack.length <= 1) {
return <EmptyState icon={<HistoryIcon size={30} />} title="No version history" hint="Edits made to the document will appear here." />;
}
return (
<div className="flex flex-col gap-2 p-3">
{history.stack.map((docId, idx) => {
const isCurrent = idx === history.index;
const originalIndex = history.stack.length - 1 - idx;
return (
<div
key={idx}
className={`flex items-center justify-between rounded-[8px] border p-2.5 transition-colors ${
isCurrent
? 'border-[#2563eb] bg-[#eff6ff]'
: 'border-border-primary bg-bg-secondary hover:border-border-secondary'
}`}
>
<div className="flex flex-col gap-1">
<span className={`text-[12.5px] font-semibold ${isCurrent ? 'text-[#2563eb]' : 'text-text-primary'}`}>
Version {originalIndex + 1} {isCurrent && '(Current)'}
</span>
<span className="text-[10px] text-text-tertiary">Document ID: {docId.split('_')[0]}...</span>
</div>
{!isCurrent && onRestore && (
<CustomButton
variant="primary"
size="sm"
onClick={() => onRestore(docId)}
>
Restore
</CustomButton>
)}
</div>
);
})}
</div>
);
};
+17 -35
View File
@@ -88,40 +88,6 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
</CustomButton>
)}
</div>
<div className="flex justify-between w-full">
{onMoveUp ? (
<CustomButton variant="unstyled"
onClick={(e) => {
e.stopPropagation();
onMoveUp();
}}
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
title="Move Page Up"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</CustomButton>
) : (
<div />
)}
{onMoveDown ? (
<CustomButton variant="unstyled"
onClick={(e) => {
e.stopPropagation();
onMoveDown();
}}
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
title="Move Page Down"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</CustomButton>
) : (
<div />
)}
</div>
</div>
</>
) : (
@@ -133,7 +99,23 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
</div>
)}
</div>
<span className="text-[11px] font-semibold text-[#5b6573] text-center mt-1">Page {pageIndex + 1}</span>
<div className="flex items-center justify-center gap-2 mt-1 px-1">
{onMoveUp ? (
<CustomButton variant="unstyled" onClick={(e) => { e.stopPropagation(); onMoveUp(); }} className="p-0.5 text-[#98a1ad] hover:text-[#18212e] transition-colors cursor-pointer" title="Move Page Left">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</CustomButton>
) : <div className="w-4 h-4 shrink-0" />}
<span className="text-[11px] font-semibold text-[#5b6573] text-center min-w-[40px]">Page {pageIndex + 1}</span>
{onMoveDown ? (
<CustomButton variant="unstyled" onClick={(e) => { e.stopPropagation(); onMoveDown(); }} className="p-0.5 text-[#98a1ad] hover:text-[#18212e] transition-colors cursor-pointer" title="Move Page Right">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</CustomButton>
) : <div className="w-4 h-4 shrink-0" />}
</div>
</div>
);
};
+14 -14
View File
@@ -72,12 +72,12 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
disabled
? 'cursor-not-allowed text-[#c5cad1]'
: active
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-[#eef4ff] text-[#2563eb]'
: t.danger ? 'text-[#5b6573] hover:bg-[#fdecec] hover:text-[#dc2626]'
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon}
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
</CustomButton>
@@ -93,16 +93,16 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
};
return (
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-[#ebedf0] bg-[#ffffff] scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border-primary bg-bg-primary scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
{TOOLS.map((t, i) =>
t === 'divider'
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-[#ebedf0]" />
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-border-primary" />
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />,
)}
<div className="flex-1 shrink-0 min-h-[16px]" />
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
<InfoIcon size={19} />
</CustomButton>
@@ -110,23 +110,23 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
align="left"
width={210}
trigger={(open) => (
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary'}`}>
<HelpIcon size={19} />
</CustomButton>
)}
>
<div className="text-[12px]">
<p className="mb-1.5 px-1 font-bold text-[#18212e]">Shortcuts</p>
<p className="mb-1.5 px-1 font-bold text-text-primary">Shortcuts</p>
{TOOLS.filter((t): t is ToolDef => t !== 'divider').map((t) => (
<div key={t.id} className="flex items-center justify-between px-1 py-0.5">
<span className="text-[#5b6573]">{t.label}</span>
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
<span className="text-text-secondary">{t.label}</span>
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
</div>
))}
<div className="my-1 h-px bg-[#ebedf0]" />
<div className="my-1 h-px bg-border-primary" />
<div className="flex items-center justify-between px-1 py-0.5">
<span className="text-[#5b6573]">Undo / Redo</span>
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
<span className="text-text-secondary">Undo / Redo</span>
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
</div>
</div>
</Popover>
+75 -13
View File
@@ -23,6 +23,10 @@ interface ToolbarProps {
onApplyRedactions?: () => void;
onClearRedactions?: () => void;
onRedactPages?: () => void;
selectedAnnotation?: import('../viewer/AnnotationLayer').Annotation | null;
onUpdateAnnotation?: (patch: Partial<import('../viewer/AnnotationLayer').Annotation>) => void;
onDeleteAnnotation?: () => void;
onDeselectAnnotation?: () => void;
}
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
@@ -51,36 +55,83 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
};
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-[#dc2626]' : 'text-[#98a1ad]'}`}>{children}</span>
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-brand-primary' : 'text-text-secondary'}`}>{children}</span>
);
const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{children}</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">{children}</span>
);
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-border-primary" />;
export const Toolbar: React.FC<ToolbarProps> = ({
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
redactionMode = 'area', onRedactionModeChange,
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
}) => {
const meta = TOOL_META[activeTool];
const isRedact = activeTool === 'redact';
if (selectedAnnotation) {
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
return (
<div
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary"
style={{ paddingLeft: '16px', paddingRight: '16px' }}
>
<div className="flex shrink-0 items-center gap-2">
<span
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
style={{
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
}}
>
{selectedMeta?.icon}
</span>
<span className="text-[13px] font-bold text-text-primary">Edit Annotation</span>
</div>
<Divider />
<div className="flex min-w-0 flex-1 items-center gap-3">
{(selectedAnnotation.type === 'highlight' || selectedAnnotation.type === 'underline' || selectedAnnotation.type === 'strikeout' || selectedAnnotation.type === 'squiggly' || selectedAnnotation.type === 'draw' || selectedAnnotation.type === 'textbox') && (
<>
<Label>Color</Label>
<ColorSwatches
value={selectedAnnotation.color || '#2563eb'}
onChange={(c) => onUpdateAnnotation?.({ color: c })}
palette={['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#18212e', '#ec4899', '#0891b2']}
/>
<Divider />
</>
)}
<div className="flex items-center gap-2">
<CustomButton variant="outline" size="sm" onClick={onDeselectAnnotation}>
Done
</CustomButton>
<CustomButton variant="outline" size="sm" onClick={onDeleteAnnotation}>
<span className="text-[#dc2626]">Delete</span>
</CustomButton>
</div>
</div>
</div>
);
}
return (
<div
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary"
style={{ paddingLeft: '16px', paddingRight: '16px' }}
>
<div className="flex shrink-0 items-center gap-2">
<span
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
style={{
background: isRedact ? '#fdecec' : '#eef4ff',
color: isRedact ? '#dc2626' : '#2563eb',
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
}}
>
{meta?.icon}
</span>
<span className="text-[13px] font-bold text-[#18212e]">{meta?.label}</span>
<span className="text-[13px] font-bold text-text-primary">{meta?.label}</span>
</div>
<Divider />
@@ -150,7 +201,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<SignatureIcon size={16} /> {hasSignature ? 'Change signature' : 'Create signature'}
</CustomButton>
{hasSignature ? <Hint>Click on the page to place it.</Hint> : <Hint>Draw, type, or upload a signature.</Hint>}
<span className="ml-auto shrink-0 rounded-full bg-[#f6f7f9] px-2 py-0.5 text-[10px] font-semibold text-[#98a1ad]">Visual signature not certified</span>
<span className="ml-auto shrink-0 rounded-full bg-bg-tertiary px-2 py-0.5 text-[10px] font-semibold text-text-tertiary">Visual signature not certified</span>
</>
)}
@@ -159,7 +210,7 @@ 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)}
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]' : ''}`}
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-brand-primary' : ''}`}
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}>
{s.label}
</CustomButton>
@@ -171,15 +222,15 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{activeTool === 'redact' && (
<>
<div className="flex items-center gap-1.5 border-r border-[#ebedf0] pr-3">
<div className="flex items-center gap-1.5 border-r border-border-primary 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]'}`}
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'area' ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary'}`}
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]'}`}
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'text' ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary'}`}
onClick={() => onRedactionModeChange?.('text')}
>
Text
@@ -203,11 +254,22 @@ export const Toolbar: React.FC<ToolbarProps> = ({
)}
</>
)}
{activeTool !== 'redact' && pendingRedactionCount > 0 && (
<div className="ml-auto flex shrink-0 items-center gap-2 border-l border-border-primary pl-3">
<CustomButton variant="primary" size="sm" onClick={onApplyRedactions}>
Apply {pendingRedactionCount} Redaction{pendingRedactionCount > 1 ? 's' : ''}
</CustomButton>
<CustomButton variant="outline" size="sm" onClick={onClearRedactions}>
Clear
</CustomButton>
</div>
)}
</div>
</div>
);
};
const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 py-0.5 text-[10px] font-semibold text-[#18212e]">{children}</kbd>
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 py-0.5 text-[10px] font-semibold text-text-primary">{children}</kbd>
);
+38 -47
View File
@@ -3,7 +3,7 @@ import React, { useRef } from 'react';
import { Popover } from './ui';
import {
UndoIcon, RedoIcon, ZoomInIcon, ZoomOutIcon, RotateIcon, DownloadIcon,
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon, PagesIcon,
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon,
} from './icons';
interface TopBarProps {
@@ -26,6 +26,7 @@ interface TopBarProps {
onExport: () => void;
onPrint: () => void;
onUpload: (file: File) => void;
onShowVersionHistory?: () => void;
isInspectorOpen: boolean;
onToggleInspector: () => void;
canPrint?: boolean;
@@ -38,8 +39,7 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
export const TopBar: React.FC<TopBarProps> = ({
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload,
isInspectorOpen, onToggleInspector,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory,
canPrint = true, canExport = true, canAssemble = true,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
@@ -51,109 +51,100 @@ export const TopBar: React.FC<TopBarProps> = ({
return (
<header
className="flex shrink-0 items-center justify-between gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
className="flex shrink-0 items-center justify-between gap-3 border-b border-border-primary bg-bg-primary"
style={{ height: '56px', paddingLeft: '24px', paddingRight: '24px' }}
>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-[#2563eb] text-white shadow-sm">
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-brand-primary text-white shadow-sm">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 3h7l5 5v13H7a2 2 0 01-2-2V5a2 2 0 012-2z" /><path d="M14 3v5h5" />
</svg>
</div>
<span className="text-[14px] font-extrabold tracking-tight text-[#18212e]">PDF Editor</span>
<span className="text-[14px] font-extrabold tracking-tight text-text-primary">PDF Editor</span>
</div>
<Popover
align="left"
width={210}
trigger={(open) => (
<CustomButton variant="unstyled" className={`flex h-8 items-center gap-1 rounded-[8px] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
<CustomButton variant="unstyled" className={`flex h-8 items-center gap-1 rounded-[8px] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'}`}>
File <ChevronDownIcon size={14} />
</CustomButton>
)}
>
<div className="flex flex-col text-[13px]">
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>} onClick={() => onShowVersionHistory?.()} disabled={!documentName}>Version History</MenuItem>
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
</div>
</Popover>
<CustomButton variant="icon" label="Open PDF file" size={32} onClick={() => fileRef.current?.click()} className="text-[#5b6573] hover:text-[#18212e]">
<UploadIcon size={18} />
</CustomButton>
{documentName && (
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[#5b6573]" title={documentName}>
{documentName}
</span>
<>
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-text-secondary" title={documentName}>
{documentName}
</span>
<SaveState isSaving={isSaving} saved={isDirtySaved} />
</>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<CustomButton variant="icon" label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo}><UndoIcon size={18} /></CustomButton>
<CustomButton variant="icon" label="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo}><RedoIcon size={18} /></CustomButton>
<CustomButton variant="icon" label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo} className="text-text-secondary hover:text-text-primary"><UndoIcon size={18} /></CustomButton>
<CustomButton variant="icon" label="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo} className="text-text-secondary hover:text-text-primary"><RedoIcon size={18} /></CustomButton>
</div>
<div className="flex items-center gap-0.5 rounded-[8px] bg-[#f6f7f9] p-0.5">
<CustomButton variant="icon" label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))}><ZoomOutIcon size={17} /></CustomButton>
<div className="flex items-center gap-0.5 rounded-[8px] bg-bg-secondary p-0.5">
<CustomButton variant="icon" label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))} className="text-text-secondary hover:text-text-primary"><ZoomOutIcon size={17} /></CustomButton>
<Popover
align="left"
width={140}
trigger={(open) => (
<CustomButton variant="unstyled" className={`h-7 w-[52px] rounded-[5px] text-[12px] font-bold tabular-nums transition-colors ${open ? 'bg-[#edeff2]' : 'text-[#18212e] hover:bg-[#edeff2]'}`}>
<CustomButton variant="unstyled" className={`h-7 w-[52px] rounded-[5px] text-[12px] font-bold tabular-nums transition-colors ${open ? 'bg-bg-tertiary' : 'text-text-primary hover:bg-bg-tertiary'}`}>
{Math.round(zoom * 100)}%
</CustomButton>
)}
>
<div className="flex flex-col text-[12.5px]">
<MenuItem icon={<FitIcon size={14} />} onClick={onFitWidth}>Fit width</MenuItem>
<div className="my-1 h-px bg-[#ebedf0]" />
{ZOOM_PRESETS.map((z) => (
<CustomButton variant="unstyled" key={z} className="rounded-[6px] px-2 py-1 text-left tabular-nums hover:bg-[#f6f7f9]" onClick={() => onZoomChange(z)}>
<CustomButton variant="unstyled" key={z} className="rounded-[6px] px-2 py-1 text-left tabular-nums hover:bg-bg-secondary" onClick={() => onZoomChange(z)}>
{Math.round(z * 100)}%
</CustomButton>
))}
<div className="my-1 h-px bg-border-primary" />
<CustomButton variant="unstyled" className="flex items-center justify-between rounded-[6px] px-2 py-1 text-left hover:bg-bg-secondary" onClick={onFitWidth}>
Fit Width <FitIcon size={12} className="text-text-tertiary" />
</CustomButton>
</div>
</Popover>
<CustomButton variant="icon" label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))}><ZoomInIcon size={17} /></CustomButton>
<CustomButton variant="icon" label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))} className="text-text-secondary hover:text-text-primary"><ZoomInIcon size={17} /></CustomButton>
</div>
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName || !canAssemble}><RotateIcon size={18} /></CustomButton>
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName || !canAssemble} className="text-text-secondary hover:text-text-primary"><RotateIcon size={18} /></CustomButton>
{documentName && (
<div className="flex items-center gap-1 text-[12px] font-semibold text-[#5b6573]">
<div className="flex items-center gap-1 text-[12px] font-semibold text-text-secondary">
<input
type="number"
min={1}
max={Math.max(1, totalPages)}
value={currentPage + 1}
onChange={(e) => {
const p = parseInt(e.target.value, 10);
type="text"
defaultValue={currentPage + 1}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
const p = parseInt(e.currentTarget.value, 10);
if (!Number.isNaN(p)) onGoToPage(Math.min(Math.max(1, p), totalPages) - 1);
}}
className="h-7 w-9 rounded-[6px] border border-[#dadde2] bg-[#ffffff] text-center tabular-nums outline-none focus:border-[#2563eb]"
className="h-7 w-9 rounded-[6px] border border-border-secondary bg-bg-primary text-center tabular-nums outline-none focus:border-brand-primary text-text-primary"
/>
<span className="text-[#98a1ad]">/ {Math.max(1, totalPages)}</span>
<span className="text-text-tertiary">/ {Math.max(1, totalPages)}</span>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<SaveState isSaving={isSaving} saved={isDirtySaved} />
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
<CustomButton variant="primary" size="sm" onClick={onExport} disabled={!documentName || !canExport}><DownloadIcon size={15} /> Export</CustomButton>
<CustomButton variant="icon"
label={isInspectorOpen ? "Hide panel" : "Show panel"}
size={34}
active={isInspectorOpen}
onClick={onToggleInspector}
className="text-[#5b6573] hover:text-[#18212e]"
>
<PagesIcon size={18} />
</CustomButton>
</div>
<input ref={fileRef} type="file" accept=".pdf" onChange={handleFile} className="hidden" />
@@ -165,14 +156,14 @@ const MenuItem: React.FC<{ icon: React.ReactNode; onClick: () => void; disabled?
<CustomButton variant="unstyled"
onClick={onClick}
disabled={disabled}
className="flex items-center gap-2 rounded-[6px] px-2.5 py-2 text-left transition-colors hover:bg-[#f6f7f9] disabled:opacity-40"
className="flex items-center gap-2 rounded-[6px] px-2.5 py-2 text-left transition-colors hover:bg-bg-secondary disabled:opacity-40 text-text-primary"
>
{icon} {children}
</CustomButton>
);
const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving, saved }) => {
if (isSaving) return <span className="flex items-center gap-1.5 text-[12px] font-medium text-[#5b6573]"><SpinnerIcon size={14} /> Saving</span>;
if (isSaving) return <span className="flex items-center gap-1.5 text-[12px] font-medium text-text-secondary"><SpinnerIcon size={14} /> Saving</span>;
if (!saved) return null;
return <span className="hidden items-center gap-1.5 text-[12px] font-medium text-[#16a34a] md:flex"><CheckIcon size={14} /> Saved</span>;
};
@@ -180,7 +171,7 @@ const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving,
const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> = ({ healthy, engineReady }) => {
if (healthy && engineReady) return null;
let color = '#98a1ad';
let color = 'var(--text-tertiary)';
let label = 'Offline · mock mode';
let tip = 'Gateway not reachable — running on mock data.';
if (healthy === null) {
@@ -191,7 +182,7 @@ const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> =
}
return (
<span
className="flex items-center gap-1.5 rounded-full border border-[#ebedf0] bg-[#f6f7f9] px-2 py-1 text-[11px] font-medium text-[#5b6573]"
className="flex items-center gap-1.5 rounded-full border border-border-primary bg-bg-secondary px-2 py-1 text-[11px] font-medium text-text-secondary"
title={tip}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: color }} />
@@ -0,0 +1,59 @@
import React from 'react';
import { Modal } from './ui';
import { CustomButton } from './custom/CustomButton';
interface VersionHistoryModalProps {
open: boolean;
onClose: () => void;
history: { stack: string[]; index: number };
onRestore: (documentId: string) => void;
}
export const VersionHistoryModal: React.FC<VersionHistoryModalProps> = ({ open, onClose, history, onRestore }) => {
return (
<Modal open={open} onClose={onClose} title="Version History" width={400}>
<div className="flex flex-col gap-3 p-4 max-h-[60vh] overflow-y-auto">
{history.stack.length === 0 ? (
<p className="text-[13px] text-[#5b6573]">No version history available yet.</p>
) : (
[...history.stack].reverse().map((docId, i) => {
const isCurrent = history.index === history.stack.length - 1 - i;
const originalIndex = history.stack.length - 1 - i;
return (
<div
key={`${docId}-${i}`}
className={`flex items-center justify-between rounded-[8px] border p-3 transition-colors ${
isCurrent ? 'border-[#2563eb] bg-[#eef4ff]' : 'border-[#ebedf0] bg-[#f6f7f9]'
}`}
>
<div className="flex flex-col">
<span className={`text-[13px] font-semibold ${isCurrent ? 'text-[#2563eb]' : 'text-[#18212e]'}`}>
Version {originalIndex + 1} {isCurrent && '(Current)'}
</span>
<span className="text-[11px] text-[#5b6573]">Document ID: {docId.split('_')[0]}...</span>
</div>
{!isCurrent && (
<CustomButton
variant="outline"
size="sm"
onClick={() => {
onRestore(docId);
onClose();
}}
>
Restore
</CustomButton>
)}
</div>
);
})
)}
</div>
<div className="flex justify-end gap-2 border-t border-[#ebedf0] p-3">
<CustomButton variant="outline" onClick={onClose}>
Close
</CustomButton>
</div>
</Modal>
);
};
@@ -32,7 +32,7 @@ export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = (
<div
style={{ width: 440, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-[#ffffff] shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-[#ebedf0]"
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-bg-primary shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-border-primary"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-2.5 p-7 pb-6">
@@ -50,13 +50,13 @@ export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = (
</svg>
</div>
)}
<h2 className="text-[17.5px] font-bold tracking-tight text-[#18212e]">{state.title}</h2>
<h2 className="text-[17.5px] font-bold tracking-tight text-text-primary">{state.title}</h2>
</div>
<p className="pl-[52px] text-[13.5px] leading-relaxed text-[#5b6573]">{state.message}</p>
<p className="pl-[52px] text-[13.5px] leading-relaxed text-text-secondary">{state.message}</p>
</div>
<div className="flex items-center justify-end gap-3 bg-[#f6f7f9] px-7 py-5 border-t border-[#ebedf0]">
<CustomButton variant="ghost" onClick={onClose} className="rounded-[8px] font-semibold px-4 text-[#5b6573]">
<div className="flex items-center justify-end gap-3 bg-bg-secondary px-7 py-5 border-t border-border-primary">
<CustomButton variant="ghost" onClick={onClose} className="rounded-[8px] font-semibold px-4 text-text-secondary">
Cancel
</CustomButton>
<CustomButton
+5
View File
@@ -46,6 +46,8 @@ export const SquigglyIcon: IC = mk(<path d="M3 12c1.5-3 3-3 4.5 0s3 3 4.5 0 3-3
export const StampIcon: IC = mk(<><path d="M9 12a3 3 0 113 0c-.7.6-1 1.3-1 2v1h-1v-1c0-.7-.3-1.4-1-2z" /><path d="M5 17h14M4 20h16" /></>);
export const RedactIcon: IC = mk(<rect x="4" y="4" width="16" height="16" rx="1.5" fill="currentColor" stroke="none" />);
export const ImageIcon: IC = mk(<><rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 17l-5-5L5 21" /></>);
export const MoonIcon: IC = mk(<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />);
export const SunIcon: IC = mk(<><circle cx="12" cy="12" r="5" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></>);
export const UndoIcon: IC = mk(<path d="M9 14L4 9l5-5M4 9h11a5 5 0 010 10h-3" />);
export const RedoIcon: IC = mk(<path d="M15 14l5-5-5-5M20 9H9a5 5 0 000 10h3" />);
@@ -56,11 +58,14 @@ export const DownloadIcon: IC = mk(<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2
export const ChevronDownIcon: IC = mk(<path d="M6 9l6 6 6-6" />);
export const ChevronUpIcon: IC = mk(<path d="M18 15l-6-6-6 6" />);
export const MenuIcon: IC = mk(<path d="M4 7h16M4 12h16M4 17h16" />);
export const HistoryIcon: IC = mk(<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />);
export const CheckIcon: IC = mk(<path d="M5 12l5 5L20 7" />);
export const XIcon: IC = mk(<path d="M6 6l12 12M18 6L6 18" />);
export const ShareIcon: IC = mk(<><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" /></>);
export const FitIcon: IC = mk(<path d="M8 3H5a2 2 0 00-2 2v3m0 8v3a2 2 0 002 2h3m8 0h3a2 2 0 002-2v-3m0-8V5a2 2 0 00-2-2h-3" />);
export const SidebarRightIcon: IC = mk(<><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><path d="M15 8l-4 4 4 4" /><line x1="9" y1="12" x2="15" y2="12" /></>);
export const PagesIcon: IC = mk(<><rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" /></>);
export const NotesIcon: IC = mk(<><path d="M5 4h14a1 1 0 011 1v11a1 1 0 01-1 1H9l-4 4V5a1 1 0 011-1z" /><path d="M8 9h8M8 12h5" /></>);
export const PropertiesIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
+15 -15
View File
@@ -30,7 +30,7 @@ export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'le
{open && (
<div
style={{ width }}
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-[#ebedf0] bg-[#ffffff] p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-border-primary bg-bg-primary p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
align === 'right' ? 'right-0' : 'left-0'
}`}
onClick={(e) => e.stopPropagation()}
@@ -57,12 +57,12 @@ export const ColorSwatches: React.FC<ColorSwatchesProps> = ({ value, onChange, p
title={c}
onClick={() => onChange(c)}
className={`h-5 w-5 rounded-full border transition-transform hover:scale-110 ${
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-[#2563eb] ring-offset-1' : 'border-[#dadde2]'
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-brand-primary ring-offset-1' : 'border-border-secondary'
}`}
style={{ background: c }}
/>
))}
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[#dadde2]"
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-border-secondary"
title="Custom color"
style={{ background: 'conic-gradient(from 0deg, #f87171, #facc15, #34d399, #60a5fa, #a78bfa, #f87171)' }}>
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="absolute inset-0 cursor-pointer opacity-0" />
@@ -82,7 +82,7 @@ interface SliderProps {
}
export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onChange, label, suffix = '', width = 110 }) => (
<div className="flex items-center gap-2">
{label && <span className="text-[11px] font-medium text-[#5b6573]">{label}</span>}
{label && <span className="text-[11px] font-medium text-text-secondary">{label}</span>}
<input
type="range"
min={min}
@@ -91,9 +91,9 @@ export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onCha
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
style={{ width }}
className="accent-[#2563eb]"
className="accent-brand-primary"
/>
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[#18212e]">{value}{suffix}</span>
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-text-primary">{value}{suffix}</span>
</div>
);
@@ -106,17 +106,17 @@ interface EmptyStateProps {
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, badge }) => (
<div className="flex flex-col items-center justify-center gap-2.5 px-6 py-14 text-center">
{icon && (
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#edeff2] text-[#98a1ad]">
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-bg-tertiary text-text-tertiary">
{icon}
</div>
)}
{badge && (
<span className="rounded-full bg-[#eef4ff] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[#2563eb]">
<span className="rounded-full bg-brand-secondary px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-brand-primary">
{badge}
</span>
)}
<p className="text-[13.5px] font-semibold text-[#18212e]">{title}</p>
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-[#98a1ad]">{hint}</p>}
<p className="text-[13.5px] font-semibold text-text-primary">{title}</p>
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-text-tertiary">{hint}</p>}
</div>
);
@@ -140,15 +140,15 @@ export const Modal: React.FC<ModalProps> = ({ open, onClose, title, children, wi
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/35 p-4" onMouseDown={onClose}>
<div
style={{ width }}
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-[#ebedf0] bg-[#ffffff] shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-border-primary bg-bg-primary shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[#ebedf0] px-6 py-4">
<h2 className="text-[15px] font-bold text-[#18212e]">{title}</h2>
<div className="flex items-center justify-between border-b border-border-primary px-6 py-4">
<h2 className="text-[15px] font-bold text-text-primary">{title}</h2>
<CustomButton variant="icon" label="Close" size={30} onClick={onClose}><XIcon size={18} /></CustomButton>
</div>
<div className="overflow-y-auto p-6">{children}</div>
{footer && <div className="flex justify-end gap-2.5 border-t border-[#ebedf0] bg-[#f6f7f9] px-6 py-4">{footer}</div>}
{footer && <div className="flex justify-end gap-2.5 border-t border-border-primary bg-bg-secondary px-6 py-4">{footer}</div>}
</div>
</div>
);
@@ -184,7 +184,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose })
)
}
>
<p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p>
<p className="text-[13px] leading-relaxed text-text-secondary">{state?.message}</p>
</Modal>
);
+65 -5
View File
@@ -1,7 +1,67 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss";
/* Sleek scrollbars */
@theme {
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-tertiary: var(--bg-tertiary);
--color-bg-canvas: var(--bg-canvas);
--color-border-primary: var(--border-primary);
--color-border-secondary: var(--border-secondary);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-tertiary: var(--text-tertiary);
--color-brand-primary: var(--brand-primary);
--color-brand-secondary: var(--brand-secondary);
--color-brand-tertiary: var(--brand-tertiary);
--color-brand-hover: var(--brand-hover);
}
:root {
--bg-primary: #ffffff;
--bg-secondary: #f6f7f9;
--bg-tertiary: #edeff2;
--bg-canvas: #f1f2f4;
--border-primary: #ebedf0;
--border-secondary: #dadde2;
--text-primary: #18212e;
--text-secondary: #5b6573;
--text-tertiary: #98a1ad;
--brand-primary: #2563eb;
--brand-secondary: #eef4ff;
--brand-tertiary: rgba(37, 99, 235, 0.1);
--brand-hover: #1d4ed8;
}
html.dark {
--bg-primary: #18181b;
--bg-secondary: #27272a;
--bg-tertiary: #3f3f46;
--bg-canvas: #09090b;
--border-primary: #3f3f46;
--border-secondary: #52525b;
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--brand-primary: #3b82f6;
--brand-secondary: rgba(59, 130, 246, 0.15);
--brand-tertiary: rgba(59, 130, 246, 0.1);
--brand-hover: #60a5fa;
}
body {
background-color: var(--bg-canvas);
color: var(--text-primary);
}/* Sleek scrollbars */
.scroll-thin::-webkit-scrollbar,
.custom-scrollbar::-webkit-scrollbar,
.viewer-viewport::-webkit-scrollbar { width: 10px; height: 10px; }
@@ -11,14 +71,14 @@
.scroll-thin::-webkit-scrollbar-thumb,
.custom-scrollbar::-webkit-scrollbar-thumb,
.viewer-viewport::-webkit-scrollbar-thumb {
background: #dadde2;
background: var(--border-secondary);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.scroll-thin::-webkit-scrollbar-thumb:hover,
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: #98a1ad; background-clip: padding-box; }
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); background-clip: padding-box; }
/* Hidden scrollbar (used by horizontal tab/tool strips) */
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
@@ -28,8 +88,8 @@
.scroll-micro { scrollbar-width: thin; }
.scroll-micro::-webkit-scrollbar { width: 4px; height: 4px; }
.scroll-micro::-webkit-scrollbar-track { background: transparent; }
.scroll-micro::-webkit-scrollbar-thumb { background: #dadde2; border-radius: 9999px; }
.scroll-micro::-webkit-scrollbar-thumb:hover { background: #98a1ad; }
.scroll-micro::-webkit-scrollbar-thumb { background: var(--border-secondary); border-radius: 9999px; }
.scroll-micro::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* Animations */
@keyframes spin { to { transform: rotate(360deg); } }
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
const savedTheme = localStorage.getItem('app-theme') as Theme | null;
if (savedTheme) {
return savedTheme;
}
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
}
return 'light';
});
useEffect(() => {
const root = window.document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('app-theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return { theme, toggleTheme };
}
+8 -3
View File
@@ -31,6 +31,7 @@ interface AnnotationLayerProps {
onAnnotationClick?: (annotation: Annotation) => void;
onAnnotationUpdate?: (annotation: Annotation) => void;
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
isSelectToolActive?: boolean;
}
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
@@ -42,12 +43,13 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
onAnnotationClick,
onAnnotationUpdate,
onFieldChange,
isSelectToolActive,
}) => {
const [draggingAnno, setDraggingAnno] = React.useState<string | null>(null);
const [dragStartPos, setDragStartPos] = React.useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
const isDraggable = (type: Annotation['type']) => type === 'comment' || type === 'signature';
const isDraggable = (type: Annotation['type']) => isSelectToolActive || type === 'comment' || type === 'signature';
const handlePointerDown = (e: React.PointerEvent, anno: Annotation) => {
e.stopPropagation();
@@ -79,7 +81,9 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
...anno.bbox,
x: anno.bbox.x + dragOffset.x,
y: anno.bbox.y + dragOffset.y,
}
},
quadPoints: anno.quadPoints?.map(q => q.map(pt => ({ x: pt.x + dragOffset.x, y: pt.y + dragOffset.y }))),
paths: anno.paths?.map(path => path.map(pt => ({ x: pt.x + dragOffset.x, y: pt.y + dragOffset.y }))),
});
} else {
onAnnotationClick?.(anno);
@@ -92,7 +96,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
>
{annotations
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget'].includes(anno.type))
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget', 'ink'].includes(anno.type))
.map((anno) => {
const scaledBbox = {
x: anno.bbox.x * zoom,
@@ -115,6 +119,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
onPointerDown={(e) => handlePointerDown(e, anno)}
onPointerMove={(e) => handlePointerMove(e, anno.id)}
onPointerUp={(e) => handlePointerUp(e, anno)}
onMouseDown={(e) => e.stopPropagation()}
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 ${['highlight', 'strikeout', 'underline', 'squiggly'].includes(anno.type) ? '' : 'hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)]'} type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
style={{
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
+19 -19
View File
@@ -46,12 +46,12 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
};
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="absolute top-full left-0 mt-1 p-2 bg-bg-secondary border border-border-primary 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]' : ''}`}
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-bg-secondary' : ''}`}
style={{ backgroundColor: c }}
onClick={() => {
setToolColors(prev => ({ ...prev, [action]: c }));
@@ -61,13 +61,13 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
/>
))}
</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">
<div className="text-xs text-text-secondary mt-1 cursor-pointer hover:text-text-primary transition-colors">More colors</div>
<div className="flex items-center justify-between text-xs text-text-secondary border-t border-border-primary pt-2 mt-1">
<span>Opacity</span>
<div className="flex items-center gap-2">
<button className="hover:text-white"></button>
<button className="hover:text-text-primary"></button>
<span>100%</span>
<button className="hover:text-white">+</button>
<button className="hover:text-text-primary">+</button>
</div>
</div>
</div>
@@ -76,7 +76,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
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]"
className="absolute z-50 flex items-center gap-1 bg-bg-primary rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-border-primary text-text-primary"
style={{ top, left, pointerEvents: 'auto' }}
onMouseDown={(e) => {
// Prevent clearing the selection layer when clicking the toolbar
@@ -85,7 +85,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
>
<button
onClick={() => onAction('copy')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
className="p-1.5 rounded hover:bg-bg-tertiary 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">
@@ -96,7 +96,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
<button
onClick={() => onAction('comment')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
className="p-1.5 rounded hover:bg-bg-tertiary 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">
@@ -106,12 +106,12 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
</svg>
</button>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<div className="w-px h-4 bg-border-primary 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"
className="p-1.5 rounded-l hover:bg-bg-tertiary 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">
@@ -122,7 +122,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
</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]"
className="p-1.5 rounded-r hover:bg-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
>
<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"/>
@@ -134,7 +134,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
<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"
className="p-1.5 rounded-l hover:bg-bg-tertiary 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">
@@ -144,7 +144,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
</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]"
className="p-1.5 rounded-r hover:bg-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
>
<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"/>
@@ -156,7 +156,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
<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"
className="p-1.5 rounded-l hover:bg-bg-tertiary 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">
@@ -167,7 +167,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
</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]"
className="p-1.5 rounded-r hover:bg-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
>
<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"/>
@@ -176,11 +176,11 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
{openDropdown === 'strikeout' && <ColorPicker action="strikeout" />}
</div>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<div className="w-px h-4 bg-border-primary 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"
className="px-2 py-1.5 rounded hover:bg-bg-tertiary 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">
@@ -192,7 +192,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
<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"
className="px-2 py-1.5 rounded hover:bg-bg-tertiary 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">
+12 -12
View File
@@ -135,40 +135,40 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
onClick={handleLayerClick}
>
{activeTool === 'signature' && !hasSignature && (
<div className="absolute inset-0 bg-[rgba(37,99,235,0.05)] flex items-center justify-center border-2 border-dashed border-[rgba(37,99,235,0.4)] rounded-[6px]"><span className="bg-[#2563eb] text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
<div className="absolute inset-0 bg-brand-tertiary flex items-center justify-center border-2 border-dashed border-brand-primary rounded-[6px]"><span className="bg-brand-primary text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
)}
{activeTool === 'signature' && hasSignature && !commentPopup && (
<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 signature</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary 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 signature</div>
)}
{activeTool === 'comment' && !commentPopup && (
<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>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary 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.label}</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary 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>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary 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>
)}
{commentPopup && (
<div
className="w-[264px] bg-[#ffffff] border border-[#ebedf0] rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
className="w-[264px] bg-bg-primary border border-border-primary rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
style={{ position: 'absolute', left: `${commentPopup.x * zoom}px`, top: `${commentPopup.y * zoom}px`, zIndex: 50 }}
onClick={(e) => e.stopPropagation()}
>
<form onSubmit={handleCommentSubmit}>
<div className="flex justify-between items-center py-[10px] px-[14px] bg-[#f6f7f9] border-b border-[#ebedf0]">
<span className="text-xs font-bold">Add sticky note</span>
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-[#98a1ad] hover:text-[#18212e]">
<div className="flex justify-between items-center py-[10px] px-[14px] bg-bg-secondary border-b border-border-primary">
<span className="text-xs font-bold text-text-primary">Add sticky note</span>
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-text-tertiary hover:text-text-primary">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</CustomButton>
</div>
<textarea
autoFocus value={commentText} onChange={(e) => setCommentText(e.target.value)}
placeholder="Type your comment here…" className="w-full bg-transparent text-[#18212e] border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-[#98a1ad]" rows={3}
placeholder="Type your comment here…" className="w-full bg-transparent text-text-primary border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-text-tertiary" rows={3}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCommentSubmit(e); } }}
/>
<div className="py-[10px] px-[14px] bg-[#f6f7f9] border-t border-[#ebedf0] flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-[#2563eb] text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-[#1d4ed8]">Save note</CustomButton></div>
<div className="py-[10px] px-[14px] bg-bg-secondary border-t border-border-primary flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-brand-primary text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-brand-hover">Save note</CustomButton></div>
</form>
</div>
)}
@@ -184,7 +184,7 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commitTextBox(); }
if (e.key === 'Escape') { setTextBox(null); setTextValue(''); }
}}
className="absolute z-50 bg-[rgba(255,255,255,0.85)] border-[1.5px] border-[#2563eb] rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
className="absolute z-50 bg-bg-primary border-[1.5px] border-brand-primary rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
placeholder="Type…"
style={{
left: `${textBox.x * zoom}px`,
File diff suppressed because it is too large Load Diff
+3
View File
@@ -4,4 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
allowedHosts: ['pdf-dev.maskantech.in'],
},
})