Merge pull request 'azeem' (#41) from azeem into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/41
This commit is contained in:
furqan
2026-06-10 10:00:07 +00:00
22 changed files with 603 additions and 584 deletions
+3 -3
View File
@@ -1,13 +1,13 @@
<!doctype html>
<html lang="en">
<html lang="en" class="h-full w-full">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PDF Editor</title>
</head>
<body>
<div id="root"></div>
<body class="h-full w-full overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
<div id="root" class="flex h-full w-full flex-col"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+55 -12
View File
@@ -5,8 +5,10 @@ import { Toolbar } from './components/Toolbar';
import { InspectorPanel } from './components/InspectorPanel';
import type { InspectorTab } from './components/InspectorPanel';
import { SignatureModal } from './components/SignatureModal';
import { ToastViewport, ConfirmDialog } from './components/ui';
import type { ConfirmOptions } from './components/ui';
import { AboutModal } from './components/AboutModal';
import { ToastViewport } from './components/ui';
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer';
@@ -54,11 +56,14 @@ function App() {
// Tool aux state
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
const [aboutModalOpen, setAboutModalOpen] = useState(false);
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null);
const [confirmState, setConfirmState] = useState<(ConfirmOptions & { onConfirm: () => void }) | null>(null);
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
// Search
const [searchQuery, setSearchQuery] = useState('');
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
const [searchWholeWords, setSearchWholeWords] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
@@ -156,7 +161,7 @@ function App() {
return;
}
try {
const results = await gatewayService.searchDocument(selectedDocId, searchQuery);
const results = await gatewayService.searchDocument(selectedDocId, searchQuery, searchCaseSensitive, searchWholeWords);
setSearchResults(results);
setSearchCurrentMatch(0);
if (results.length > 0) viewerRef.current?.scrollToPage(results[0].pageIndex);
@@ -165,7 +170,7 @@ function App() {
}
}, searchQuery ? 300 : 0);
return () => clearTimeout(t);
}, [searchQuery, selectedDocId]);
}, [searchQuery, selectedDocId, searchCaseSensitive, searchWholeWords]);
const selectSearchMatch = (i: number) => {
if (i < 0 || i >= searchResults.length) return;
@@ -352,6 +357,33 @@ function App() {
}
};
const handlePrint = async () => {
if (!activeDoc) return;
try {
toast('Preparing print...', 'info');
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
iframe.onload = () => {
setTimeout(() => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
// Optional: cleanup after a delay
// setTimeout(() => document.body.removeChild(iframe), 10000);
}, 100);
};
document.body.appendChild(iframe);
} catch (e) {
console.error('Print failed', e);
toast('Print failed', 'error');
}
};
const toggleInspector = () => {
setIsInspectorOpen((prev) => {
const next = !prev;
@@ -378,7 +410,7 @@ function App() {
/* -------------------------------------------------------------- render */
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-[var(--canvas)] text-[var(--text)] antialiased">
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
<TopBar
documentName={activeDoc?.filename}
backendHealthy={backendHealthy}
@@ -397,6 +429,7 @@ function App() {
isDirtySaved={hist.stack.length > 1}
onRotate={handleRotate}
onExport={handleExport}
onPrint={handlePrint}
onUpload={handleUpload}
isInspectorOpen={isInspectorOpen}
onToggleInspector={toggleInspector}
@@ -408,6 +441,7 @@ function App() {
onToolChange={setActiveTool}
hasSignature={!!pendingSignature}
onOpenSignature={() => setSignatureModalOpen(true)}
onOpenAbout={() => setAboutModalOpen(true)}
/>
<div className="flex min-w-0 flex-1 flex-col">
@@ -425,7 +459,7 @@ function App() {
{isLoading && !activeDoc ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<div className="spinner" />
<p className="text-[12px] font-semibold uppercase tracking-wide text-[var(--text-dim)]">Loading</p>
<p className="text-[12px] font-semibold uppercase tracking-wide text-[#98a1ad]">Loading</p>
</div>
) : activeDoc ? (
<PDFViewer
@@ -452,15 +486,15 @@ function App() {
onPlaceSignature={handlePlaceSignature}
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[var(--text-dim)]">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--surface-3)]">
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#edeff2]">
<svg width="30" height="30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.4}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p className="text-[14px] font-semibold text-[var(--text)]">No document open</p>
<p className="text-[14px] font-semibold text-[#18212e]">No document open</p>
<p className="text-[12px]">Open a PDF to start editing.</p>
<label className="mt-2 inline-flex h-10 cursor-pointer items-center gap-2 rounded-[var(--r-md)] bg-[var(--accent)] px-5 text-[13.5px] font-semibold text-white shadow-sm transition-colors hover:bg-[var(--accent-hover)]">
<label className="mt-2 inline-flex h-10 cursor-pointer items-center gap-2 rounded-[8px] bg-[#2563eb] px-5 text-[13.5px] font-semibold text-white shadow-sm transition-colors hover:bg-[#1d4ed8]">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round"><path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" /></svg>
Open PDF
<input type="file" accept=".pdf" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = ''; }} />
@@ -488,6 +522,10 @@ function App() {
onNavigateAnnotation={navigateToAnnotation}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
searchCaseSensitive={searchCaseSensitive}
onSearchCaseSensitiveChange={setSearchCaseSensitive}
searchWholeWords={searchWholeWords}
onSearchWholeWordsChange={setSearchWholeWords}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
onSelectSearchMatch={selectSearchMatch}
@@ -508,7 +546,12 @@ function App() {
}}
/>
<ConfirmDialog state={confirmState} onClose={() => setConfirmState(null)} />
<AboutModal
open={aboutModalOpen}
onClose={() => setAboutModalOpen(false)}
/>
<CustomConfirmationModal state={confirmState} onClose={() => setConfirmState(null)} />
<ToastViewport />
</div>
);
+77
View File
@@ -0,0 +1,77 @@
import React, { useEffect, useRef } from 'react';
import { XIcon } from './icons';
interface AboutModalProps {
open: boolean;
onClose: () => void;
}
export const AboutModal: React.FC<AboutModalProps> = ({ open, onClose }) => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
useEffect(() => {
if (open) {
modalRef.current?.focus();
}
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div
ref={modalRef}
tabIndex={-1}
className="relative flex w-[480px] max-w-[90vw] flex-col items-center overflow-hidden rounded-[8px] bg-[#222222] p-8 text-center text-white shadow-2xl outline-none"
>
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-[4px] p-1 text-white/50 transition-colors hover:bg-white/10 hover:text-white"
>
<XIcon size={20} />
</button>
<div className="mb-6 flex flex-col items-center">
<div className="flex items-center justify-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[9px] bg-[#2563eb] text-white shadow-sm">
<svg width="22" height="22" 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-[26px] font-bold tracking-tight">MASKAN PDF</span>
</div>
<div className="mt-4 text-[14px] text-white/80">
PDF EDITOR
</div>
<div className="text-[14px] text-white/60">
Version 1.0.0
</div>
</div>
<div className="flex w-full flex-col gap-2 border-t border-white/10 pt-6">
<div className="text-[18px] font-bold">
Maskan Technologies
</div>
<div className="mt-2 text-[14px] leading-relaxed text-white/80">
<p>address: Bangalore, Karnataka, India</p>
<p>email: contact@maskantechnologies.com</p>
<p>tel.: +91 000-000-0000</p>
</div>
<div className="mt-4 text-[14px] font-medium text-white/80">
www.maskantechnologies.com
</div>
</div>
</div>
</div>
);
};
+133 -58
View File
@@ -1,3 +1,4 @@
import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo } from '../lib/gatewayService';
import type { Annotation } from '../viewer/AnnotationLayer';
@@ -5,7 +6,7 @@ import { Thumbnail } from './Thumbnail';
import { EmptyState, Popover } from './ui';
import {
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
ChevronDownIcon, SearchIcon as SearchGlyph,
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph,
} from './icons';
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms';
@@ -31,6 +32,10 @@ interface InspectorPanelProps {
searchQuery: string;
onSearchQueryChange: (q: string) => void;
searchCaseSensitive: boolean;
onSearchCaseSensitiveChange: (c: boolean) => void;
searchWholeWords: boolean;
onSearchWholeWordsChange: (w: boolean) => void;
searchResults: SearchResult[];
searchCurrentMatch: number;
onSelectSearchMatch: (i: number) => void;
@@ -61,63 +66,63 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
const activeDef = TABS.find((t) => t.id === p.activeTab)!;
return (
<aside className="flex h-full shrink-0 border-l border-[var(--border)] bg-[var(--surface)]" style={{ width: 'var(--inspector-w)' }}>
<aside className="flex h-full shrink-0 border-l border-[#ebedf0] bg-[#ffffff]" style={{ width: '322px' }}>
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Header: active section title + compact document switcher */}
<div className="flex h-[var(--strip-h)] shrink-0 items-center justify-between gap-2 border-b border-[var(--border)]" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
<span className="shrink-0 text-[13px] font-bold text-[var(--text)]">{activeDef.label}</span>
<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) => (
<button className={`flex h-7 min-w-0 max-w-[180px] items-center gap-1.5 rounded-[var(--r-md)] px-2 text-left transition-colors ${open ? 'bg-[var(--surface-3)]' : 'hover:bg-[var(--surface-2)]'}`}>
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--text-muted)]">{selectedDoc?.filename ?? 'Document'}</span>
<ChevronDownIcon size={13} className="shrink-0 text-[var(--text-dim)]" />
</button>
<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>
)}
>
<div className="flex flex-col">
<p className="px-1 pb-1 text-[10px] font-bold uppercase tracking-wide text-[var(--text-dim)]">Open documents</p>
<p className="px-1 pb-1 text-[10px] font-bold uppercase tracking-wide text-[#98a1ad]">Open documents</p>
{p.documents.map((d) => (
<button
<CustomButton variant="unstyled"
key={d.id}
onClick={() => p.onSelectDocument(d.id)}
className={`flex flex-col rounded-[var(--r-sm)] px-2 py-1.5 text-left transition-colors hover:bg-[var(--surface-2)] ${d.id === p.selectedDocumentId ? 'bg-[var(--accent-soft)]' : ''}`}
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-[var(--accent)]' : 'text-[var(--text)]'}`}>{d.filename}</span>
<span className="text-[10.5px] text-[var(--text-dim)]">{formatBytes(d.sizeBytes)} · {d.totalPages} pages</span>
</button>
<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-[var(--text-dim)]">No document</span>
<span className="text-[11.5px] font-medium text-[#98a1ad]">No document</span>
)}
</div>
{/* Icon-only tab switcher — evenly spaced so all 7 fit with breathing room */}
<div className="flex shrink-0 items-center justify-between border-b border-[var(--border)] bg-[var(--surface-2)]" style={{ paddingLeft: '10px', paddingRight: '10px', paddingTop: '7px', paddingBottom: '7px' }}>
<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 (
<button
<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-[var(--r-md)] transition-colors cursor-pointer ${
className={`relative flex h-8 w-8 items-center justify-center rounded-[8px] transition-colors cursor-pointer ${
active
? 'bg-[var(--accent-soft)] text-[var(--accent)]'
: 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'
? '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.length > 0 && (
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
)}
</button>
</CustomButton>
);
})}
</div>
@@ -145,11 +150,32 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
/* ----------------------------------------------------------------- Pages */
const PagesTab: React.FC<InspectorPanelProps> = (p) => {
const [draggedIdx, setDraggedIdx] = React.useState<number | null>(null);
if (!p.totalPages) return <EmptyState icon={<PagesIcon size={30} />} title="No pages" hint="Open a PDF to see its pages." />;
return (
<div className="grid grid-cols-2 gap-3 p-3">
{Array.from({ length: p.totalPages }).map((_, idx) => (
<div key={idx} className={idx === p.currentPage ? '[&_.thumbnail-preview]:!border-[var(--accent)] [&_.thumbnail-preview]:!ring-2 [&_.thumbnail-preview]:!ring-[var(--accent-soft)]' : ''}>
<div key={idx}
draggable
onDragStart={(e) => {
setDraggedIdx(idx);
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => {
e.preventDefault();
if (draggedIdx !== null && draggedIdx !== idx) {
p.onReorderPage(draggedIdx, idx);
}
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]' : ''}`}
>
<Thumbnail
documentId={p.documentId}
pageIndex={idx}
@@ -174,18 +200,18 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
return (
<div className="flex flex-col gap-2 p-3">
{annotations.map((a) => (
<button key={a.id} onClick={() => onNavigate(a)}
className="flex flex-col gap-1.5 rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)] p-2.5 text-left transition-colors hover:border-[var(--border-strong)]">
<CustomButton variant="unstyled" key={a.id} onClick={() => onNavigate(a)}
className="flex flex-col gap-1.5 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5 text-left transition-colors hover:border-[#dadde2]">
<div className="flex items-center justify-between">
<span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide"
style={{ background: 'var(--accent-soft)', color: 'var(--accent)' }}>{a.type}</span>
<span className="text-[10px] text-[var(--text-dim)]">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
style={{ background: '#eef4ff', color: '#2563eb' }}>{a.type}</span>
<span className="text-[10px] text-[#98a1ad]">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
</div>
{a.content && <p className="line-clamp-3 text-[12px] italic text-[var(--text-muted)]">{a.content}</p>}
<span className="text-[10px] font-medium text-[var(--text-dim)]">{a.author}</span>
</button>
{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>
</CustomButton>
))}
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-[var(--text-dim)]">
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-[#98a1ad]">
Editing & deleting saved annotations is coming soon for now annotations are append-only.
</p>
</div>
@@ -194,38 +220,87 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
/* ---------------------------------------------------------------- Search */
const SearchTab: React.FC<InspectorPanelProps> = (p) => {
let flatIndex = -1;
return (
<div className="flex h-full flex-col">
<div className="border-b border-[var(--border)] p-3">
<div className="flex items-center gap-2 rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1.5 focus-within:border-[var(--accent)]">
<SearchGlyph size={15} className="text-[var(--text-dim)]" />
<div className="border-b border-[#ebedf0] p-3">
<div className="flex items-center justify-between mb-3">
<span className="text-[13px] font-bold text-[#18212e]">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]" />
<input
autoFocus
value={p.searchQuery}
onChange={(e) => p.onSearchQueryChange(e.target.value)}
placeholder="Search document…"
className="w-full bg-transparent text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-dim)]"
className="w-full bg-transparent text-[13px] text-[#18212e] outline-none placeholder:text-[#98a1ad]"
/>
</div>
{p.searchQuery && (
<p className="mt-2 px-0.5 text-[11px] font-medium text-[var(--text-muted)]">
{p.searchResults.length > 0 ? `${p.searchResults.length} match${p.searchResults.length > 1 ? 'es' : ''}` : 'No matches'}
</p>
<div className="mt-3 flex items-center justify-between px-0.5">
<span className="text-[11.5px] font-medium text-[#5b6573]">
{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]">
<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>
)}
</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]" />
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]" />
Whole words only
</label>
</div>
</div>
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
{p.searchResults.map((r) => {
flatIndex++;
const i = flatIndex;
{p.searchResults.map((r, i) => {
const isSelected = i === p.searchCurrentMatch;
const highlightText = (text: string, query: string) => {
if (!query) return text;
const flags = p.searchCaseSensitive ? 'g' : 'gi';
let regexStr = query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
if (p.searchWholeWords) regexStr = `\\b${regexStr}\\b`;
try {
const regex = new RegExp(`(${regexStr})`, flags);
const parts = text.split(regex);
return (
<button key={i} onClick={() => p.onSelectSearchMatch(i)}
className={`mb-1 flex w-full items-center gap-2 rounded-[var(--r-sm)] px-2.5 py-2 text-left text-[12px] transition-colors ${
i === p.searchCurrentMatch ? 'bg-[var(--accent-soft)] text-[var(--text)]' : 'hover:bg-[var(--surface-2)] text-[var(--text-muted)]'
<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>
) : (
<span key={index}>{part}</span>
)
)}
</span>
);
} catch (e) {
return text;
}
};
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]'
}`}>
<span className="rounded bg-[var(--surface-3)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--text-dim)]">p.{r.pageIndex + 1}</span>
<span className="truncate">{r.text || p.searchQuery}</span>
</button>
<span className={`text-[12.5px] leading-relaxed ${isSelected ? 'text-white' : 'text-[#18212e]'}`}>
{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]'}`}>
Page {r.pageIndex + 1}
</span>
</CustomButton>
);
})}
{!p.searchQuery && (
@@ -254,12 +329,12 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; sizeBytes?: n
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-[var(--border)] py-2 last:border-0">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-dim)]">{k}</span>
<span className="break-words text-[12.5px] text-[var(--text)]">{v && v.trim() ? v : <span className="text-[var(--text-dim)]"></span>}</span>
<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>
))}
<p className="pt-2 text-[10.5px] text-[var(--text-dim)]">Document properties are read-only.</p>
<p className="pt-2 text-[10.5px] text-[#98a1ad]">Document properties are read-only.</p>
</div>
);
};
@@ -275,10 +350,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-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)] p-2.5">
<div key={i} className="rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-[12px] font-semibold text-[var(--text)]" title={f.name}>{f.name || 'Unknown'}</span>
{f.type && <span className="shrink-0 rounded bg-[var(--surface-3)] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[var(--text-dim)]">{f.type}</span>}
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" title={f.name}>{f.name || '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>}
</div>
<div className="mt-1.5 flex flex-wrap gap-1">
<Badge ok={f.isEmbedded} label={f.isEmbedded ? 'Embedded' : 'Not embedded'} />
@@ -296,8 +371,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 ? 'var(--warning-soft)' : ok ? 'var(--success-soft)' : 'var(--surface-3)',
color: warn ? 'var(--warning)' : ok ? 'var(--success)' : 'var(--text-dim)',
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : '#edeff2',
color: warn ? '#d97706' : ok ? '#16a34a' : '#98a1ad',
}}>
{label}
</span>
+17 -16
View File
@@ -1,5 +1,6 @@
import { CustomButton } from './custom/CustomButton';
import React, { useRef, useState } from 'react';
import { Modal, Button } from './ui';
import { Modal } from './ui';
import { toast } from '../lib/toast';
interface SignatureModalProps {
@@ -95,17 +96,17 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
width={580}
footer={
<>
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button variant="primary" onClick={handleConfirm}>Use signature</Button>
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
<CustomButton variant="primary" onClick={handleConfirm}>Use signature</CustomButton>
</>
}
>
<div className="mb-5 flex gap-1.5 rounded-[var(--r-md)] bg-[var(--surface-3)] p-1.5">
<div className="mb-5 flex gap-1.5 rounded-[8px] bg-[#edeff2] p-1.5">
{(['draw', 'type', 'upload'] as Mode[]).map((m) => (
<button key={m} onClick={() => setMode(m)}
className={`flex-1 rounded-[var(--r-sm)] py-2 text-[13px] font-semibold capitalize transition-colors ${mode === m ? 'bg-[var(--surface)] text-[var(--accent)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text)]'}`}>
<CustomButton variant="unstyled" key={m} onClick={() => setMode(m)}
className={`flex-1 rounded-[6px] py-2 text-[13px] font-semibold capitalize transition-colors ${mode === m ? 'bg-[#ffffff] text-[#2563eb] shadow-sm' : 'text-[#5b6573] hover:text-[#18212e]'}`}>
{m}
</button>
</CustomButton>
))}
</div>
@@ -119,12 +120,12 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
className="h-[200px] w-full touch-none rounded-[var(--r-md)] border border-dashed border-[var(--border-strong)] bg-[var(--surface-2)]"
className="h-[200px] w-full touch-none rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9]"
style={{ cursor: 'crosshair' }}
/>
<div className="mt-2 flex justify-between">
<span className="text-[11px] text-[var(--text-dim)]">Draw your signature above</span>
<button className="text-[12px] font-semibold text-[var(--accent)] hover:underline" onClick={clearCanvas}>Clear</button>
<span className="text-[11px] text-[#98a1ad]">Draw your signature above</span>
<CustomButton variant="unstyled" className="text-[12px] font-semibold text-[#2563eb] hover:underline" onClick={clearCanvas}>Clear</CustomButton>
</div>
</div>
)}
@@ -136,10 +137,10 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
value={typed}
onChange={(e) => setTyped(e.target.value)}
placeholder="Type your name"
className="w-full rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2.5 text-[14px] outline-none focus:border-[var(--accent)]"
className="w-full rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-3 py-2.5 text-[14px] outline-none focus:border-[#2563eb]"
/>
<div className="mt-3 flex h-[120px] items-center justify-center rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)]">
<span style={{ fontFamily: '"Brush Script MT","Segoe Script",cursive', fontStyle: 'italic', fontSize: 52, color: 'var(--text)' }}>
<div className="mt-3 flex h-[120px] items-center justify-center rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9]">
<span style={{ fontFamily: '"Brush Script MT","Segoe Script",cursive', fontStyle: 'italic', fontSize: 52, color: '#18212e' }}>
{typed || 'Preview'}
</span>
</div>
@@ -148,13 +149,13 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
{mode === 'upload' && (
<div>
<label className="flex h-[160px] cursor-pointer flex-col items-center justify-center gap-2 rounded-[var(--r-md)] border border-dashed border-[var(--border-strong)] bg-[var(--surface-2)] text-[var(--text-muted)] hover:border-[var(--accent)]">
<label className="flex h-[160px] cursor-pointer flex-col items-center justify-center gap-2 rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9] text-[#5b6573] hover:border-[#2563eb]">
{uploaded ? (
<img src={uploaded.url} alt="signature" className="max-h-[130px] max-w-[90%] object-contain" />
) : (
<>
<span className="text-[13px] font-semibold">Click to upload an image</span>
<span className="text-[11px] text-[var(--text-dim)]">PNG with transparent background works best</span>
<span className="text-[11px] text-[#98a1ad]">PNG with transparent background works best</span>
</>
)}
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
@@ -162,7 +163,7 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
</div>
)}
<p className="mt-4 text-[11px] text-[var(--text-dim)]">Visual signature only not a certified e-signature.</p>
<p className="mt-4 text-[11px] text-[#98a1ad]">Visual signature only not a certified e-signature.</p>
</Modal>
);
};
+17 -16
View File
@@ -1,3 +1,4 @@
import { CustomButton } from './custom/CustomButton';
import React, { useEffect, useState } from 'react';
import { gatewayService } from '../lib/gatewayService';
@@ -55,12 +56,12 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
}, [documentId, pageIndex]);
return (
<div className="thumbnail-card" onClick={onClick}>
<div className="thumbnail-preview relative group overflow-hidden bg-white transition-colors">
<div className="flex flex-col gap-[6px] cursor-pointer" onClick={onClick}>
<div className="w-full aspect-[3/4] bg-[#ffffff] border border-[#ebedf0] rounded-[8px] flex flex-col items-center justify-center text-[#5b6573] transition-[border-color,box-shadow,transform] duration-150 shadow-[0_1px_2px_rgba(16,24,40,0.06),0_1px_3px_rgba(16,24,40,0.10)] hover:border-[#2563eb] hover:shadow-[0_4px_12px_rgba(16,24,40,0.10)] relative group overflow-hidden bg-white transition-colors">
{loading ? (
<div className="flex flex-col items-center justify-center h-full w-full bg-[var(--surface-2)]">
<div className="w-5 h-5 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin mb-2" />
<span className="thumbnail-label">Loading</span>
<div className="flex flex-col items-center justify-center h-full w-full bg-[#f6f7f9]">
<div className="w-5 h-5 border-2 border-[#2563eb] border-t-transparent rounded-full animate-spin mb-2" />
<span className="text-[11px] font-semibold text-[#5b6573]">Loading</span>
</div>
) : imageUrl ? (
<>
@@ -73,7 +74,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
<div className="absolute inset-0 bg-slate-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2 pointer-events-auto">
<div className="flex justify-end">
{onDelete && totalPages && totalPages > 1 && (
<button
<CustomButton variant="unstyled"
onClick={(e) => {
e.stopPropagation();
onDelete();
@@ -84,39 +85,39 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</CustomButton>
)}
</div>
<div className="flex justify-between w-full">
{onMoveUp ? (
<button
<CustomButton variant="unstyled"
onClick={(e) => {
e.stopPropagation();
onMoveUp();
}}
className="p-1 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white rounded shadow transition-colors cursor-pointer"
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>
</button>
</CustomButton>
) : (
<div />
)}
{onMoveDown ? (
<button
<CustomButton variant="unstyled"
onClick={(e) => {
e.stopPropagation();
onMoveDown();
}}
className="p-1 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white rounded shadow transition-colors cursor-pointer"
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>
</button>
</CustomButton>
) : (
<div />
)}
@@ -125,14 +126,14 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
</>
) : (
<div className="flex flex-col items-center justify-center h-full w-full">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className="thumbnail-svg-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className="text-[#98a1ad] mb-[6px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="thumbnail-label text-rose-400">Error</span>
<span className="text-[11px] font-semibold text-[#5b6573] text-rose-400">Error</span>
</div>
)}
</div>
<span className="thumbnail-label text-center mt-1">Page {pageIndex + 1}</span>
<span className="text-[11px] font-semibold text-[#5b6573] text-center mt-1">Page {pageIndex + 1}</span>
</div>
);
};
+25 -19
View File
@@ -1,9 +1,10 @@
import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { ToolId } from '../lib/tools';
import { Popover } from './ui';
import {
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
SignatureIcon, StampIcon, RedactIcon, InfoIcon,
SignatureIcon, StampIcon, RedactIcon, InfoIcon, HelpIcon,
} from './icons';
interface ToolDef { id: ToolId; label: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
@@ -27,63 +28,68 @@ interface ToolRailProps {
onToolChange: (t: ToolId) => void;
hasSignature: boolean;
onOpenSignature: () => void;
onOpenAbout: () => void;
}
const RailButton: React.FC<{ t: ToolDef; active: boolean; onClick: () => void }> = ({ t, active, onClick }) => (
<button
<CustomButton variant="unstyled"
title={`${t.label} · ${t.shortcut}`}
aria-label={t.label}
aria-pressed={active}
onClick={onClick}
className={`relative flex h-11 w-11 items-center justify-center rounded-[10px] transition-colors ${
active
? t.danger ? 'bg-[var(--danger-soft)] text-[var(--danger)]' : 'bg-[var(--accent-soft)] text-[var(--accent)]'
: t.danger ? 'text-[var(--text-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]'
: 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'
? 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]'
}`}
>
{active && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? 'var(--danger)' : 'var(--accent)' }} />}
{active && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon}
</button>
</CustomButton>
);
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature }) => {
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout }) => {
const pickTool = (id: ToolId) => {
onToolChange(id);
if (id === 'signature' && !hasSignature) onOpenSignature();
};
return (
<nav className="flex h-full shrink-0 flex-col items-center gap-1.5 border-r border-[var(--border)] bg-[var(--surface)]" style={{ width: 'var(--rail-w)', paddingTop: '14px', paddingBottom: '12px' }}>
<nav className="flex h-full shrink-0 flex-col items-center gap-1.5 border-r border-[#ebedf0] bg-[#ffffff]" style={{ width: '80px', paddingTop: '14px', paddingBottom: '12px' }}>
{TOOLS.map((t, i) =>
t === 'divider'
? <div key={`d${i}`} className="my-0.5 h-px w-6 bg-[var(--border)]" />
? <div key={`d${i}`} className="my-0.5 h-px w-6 bg-[#ebedf0]" />
: <RailButton key={t.id} t={t} active={activeTool === t.id} onClick={() => pickTool(t.id)} />,
)}
<div className="flex-1" />
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
<InfoIcon size={19} />
</CustomButton>
<Popover
align="left"
width={210}
trigger={(open) => (
<button title="Keyboard shortcuts" className={`flex h-9 w-9 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[var(--surface-3)] text-[var(--text)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'}`}>
<InfoIcon size={19} />
</button>
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
<HelpIcon size={19} />
</CustomButton>
)}
>
<div className="text-[12px]">
<p className="mb-1.5 px-1 font-bold text-[var(--text)]">Shortcuts</p>
<p className="mb-1.5 px-1 font-bold text-[#18212e]">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-[var(--text-muted)]">{t.label}</span>
<kbd className="rounded border border-[var(--border)] bg-[var(--surface-2)] px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
<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>
</div>
))}
<div className="my-1 h-px bg-[var(--border)]" />
<div className="my-1 h-px bg-[#ebedf0]" />
<div className="flex items-center justify-between px-1 py-0.5">
<span className="text-[var(--text-muted)]">Undo / Redo</span>
<kbd className="rounded border border-[var(--border)] bg-[var(--surface-2)] px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
<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>
</div>
</div>
</Popover>
+17 -16
View File
@@ -1,7 +1,8 @@
import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { ToolId, ToolSettings } from '../lib/tools';
import { STAMP_PRESETS } from '../lib/tools';
import { ColorSwatches, Slider, Button } from './ui';
import { ColorSwatches, Slider } from './ui';
import {
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
SignatureIcon, StampIcon, RedactIcon,
@@ -30,12 +31,12 @@ 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-[var(--danger)]' : 'text-[var(--text-dim)]'}`}>{children}</span>
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-[#dc2626]' : 'text-[#98a1ad]'}`}>{children}</span>
);
const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-dim)]">{children}</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{children}</span>
);
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[var(--border)]" />;
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
export const Toolbar: React.FC<ToolbarProps> = ({
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
@@ -44,21 +45,21 @@ export const Toolbar: React.FC<ToolbarProps> = ({
const isRedact = activeTool === 'redact';
return (
<div
className="flex h-[var(--strip-h)] shrink-0 items-center gap-3 border-b border-[var(--border)] bg-[var(--surface)]"
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
style={{ paddingLeft: '16px', paddingRight: '16px' }}
>
{/* Active tool identity */}
<div className="flex shrink-0 items-center gap-2">
<span
className="flex h-7 w-7 items-center justify-center rounded-[var(--r-md)]"
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
style={{
background: isRedact ? 'var(--danger-soft)' : 'var(--accent-soft)',
color: isRedact ? 'var(--danger)' : 'var(--accent)',
background: isRedact ? '#fdecec' : '#eef4ff',
color: isRedact ? '#dc2626' : '#2563eb',
}}
>
{meta.icon}
</span>
<span className="text-[13px] font-bold text-[var(--text)]">{meta.label}</span>
<span className="text-[13px] font-bold text-[#18212e]">{meta.label}</span>
</div>
<Divider />
@@ -101,11 +102,11 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{activeTool === 'signature' && (
<>
<Button variant={hasSignature ? 'outline' : 'primary'} size="sm" onClick={onOpenSignature}>
<CustomButton variant={hasSignature ? 'outline' : 'primary'} size="sm" onClick={onOpenSignature}>
<SignatureIcon size={16} /> {hasSignature ? 'Change signature' : 'Create signature'}
</Button>
</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-[var(--surface-2)] px-2 py-0.5 text-[10px] font-semibold text-[var(--text-dim)]">Visual signature not certified</span>
<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>
</>
)}
@@ -113,11 +114,11 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<>
<div className="flex items-center gap-2">
{STAMP_PRESETS.map((s) => (
<button key={s.label} onClick={() => onSelectStamp(s.label, s.color)}
className={`shrink-0 rounded-[var(--r-sm)] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp === s.label ? 'ring-2 ring-offset-1 ring-[var(--accent)]' : ''}`}
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s.label, s.color)}
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
style={{ color: s.color, borderColor: s.color, background: `color-mix(in srgb, ${s.color} 8%, white)` }}>
{s.label}
</button>
</CustomButton>
))}
</div>
<Hint>Pick a stamp, then click to place it.</Hint>
@@ -131,5 +132,5 @@ export const Toolbar: React.FC<ToolbarProps> = ({
};
const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<kbd className="rounded border border-[var(--border)] bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--text)]">{children}</kbd>
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 py-0.5 text-[10px] font-semibold text-[#18212e]">{children}</kbd>
);
+41 -40
View File
@@ -1,5 +1,6 @@
import { CustomButton } from './custom/CustomButton';
import React, { useRef } from 'react';
import { Button, IconButton, Popover } from './ui';
import { Popover } from './ui';
import {
UndoIcon, RedoIcon, ZoomInIcon, ZoomOutIcon, RotateIcon, DownloadIcon,
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon, PagesIcon,
@@ -23,6 +24,7 @@ interface TopBarProps {
isDirtySaved: boolean;
onRotate: () => void;
onExport: () => void;
onPrint: () => void;
onUpload: (file: File) => void;
isInspectorOpen: boolean;
onToggleInspector: () => void;
@@ -33,7 +35,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, onUpload,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload,
isInspectorOpen, onToggleInspector,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
@@ -45,43 +47,42 @@ export const TopBar: React.FC<TopBarProps> = ({
return (
<header
className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--border)] bg-[var(--surface)]"
style={{ height: 'var(--topbar-h)', paddingLeft: '24px', paddingRight: '24px' }}
className="flex shrink-0 items-center justify-between gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
style={{ height: '56px', paddingLeft: '24px', paddingRight: '24px' }}
>
{/* Left: brand + file menu + doc name */}
<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-[var(--accent)] text-white shadow-sm">
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-[#2563eb] 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-[var(--text)]">PDF Editor</span>
<span className="text-[14px] font-extrabold tracking-tight text-[#18212e]">PDF Editor</span>
</div>
<Popover
align="left"
width={210}
trigger={(open) => (
<button className={`flex h-8 items-center gap-1 rounded-[var(--r-md)] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-[var(--surface-3)] text-[var(--text)]' : 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'}`}>
<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]'}`}>
File <ChevronDownIcon size={14} />
</button>
</CustomButton>
)}
>
<div className="flex flex-col text-[13px]">
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF</MenuItem>
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName}>Export / Download</MenuItem>
<div className="my-1 h-px bg-[var(--border)]" />
<div className="px-2.5 py-2 text-[12px] text-[var(--text-dim)]">Print coming soon</div>
<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}>Print</MenuItem>
</div>
</Popover>
<IconButton label="Open PDF file" size={34} onClick={() => fileRef.current?.click()} className="text-[var(--text-muted)] hover:text-[var(--text)]">
<CustomButton variant="icon" label="Open PDF file" size={32} onClick={() => fileRef.current?.click()} className="text-[#5b6573] hover:text-[#18212e]">
<UploadIcon size={18} />
</IconButton>
</CustomButton>
{documentName && (
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[var(--text-muted)]" title={documentName}>
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[#5b6573]" title={documentName}>
{documentName}
</span>
)}
@@ -90,38 +91,38 @@ export const TopBar: React.FC<TopBarProps> = ({
{/* Center: history · zoom · rotate · page nav */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<IconButton label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo}><UndoIcon size={18} /></IconButton>
<IconButton label="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo}><RedoIcon size={18} /></IconButton>
<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>
</div>
<div className="flex items-center gap-0.5 rounded-[var(--r-md)] bg-[var(--surface-2)] p-0.5">
<IconButton label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))}><ZoomOutIcon size={17} /></IconButton>
<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>
<Popover
align="left"
width={140}
trigger={(open) => (
<button className={`h-7 w-[52px] rounded-[5px] text-[12px] font-bold tabular-nums transition-colors ${open ? 'bg-[var(--surface-3)]' : 'text-[var(--text)] hover:bg-[var(--surface-3)]'}`}>
<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]'}`}>
{Math.round(zoom * 100)}%
</button>
</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-[var(--border)]" />
<div className="my-1 h-px bg-[#ebedf0]" />
{ZOOM_PRESETS.map((z) => (
<button key={z} className="rounded-[var(--r-sm)] px-2 py-1 text-left tabular-nums hover:bg-[var(--surface-2)]" onClick={() => onZoomChange(z)}>
<CustomButton variant="unstyled" key={z} className="rounded-[6px] px-2 py-1 text-left tabular-nums hover:bg-[#f6f7f9]" onClick={() => onZoomChange(z)}>
{Math.round(z * 100)}%
</button>
</CustomButton>
))}
</div>
</Popover>
<IconButton label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))}><ZoomInIcon size={17} /></IconButton>
<CustomButton variant="icon" label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))}><ZoomInIcon size={17} /></CustomButton>
</div>
<IconButton label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName}><RotateIcon size={18} /></IconButton>
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName}><RotateIcon size={18} /></CustomButton>
{documentName && (
<div className="flex items-center gap-1 text-[12px] font-semibold text-[var(--text-muted)]">
<div className="flex items-center gap-1 text-[12px] font-semibold text-[#5b6573]">
<input
type="number"
min={1}
@@ -131,9 +132,9 @@ export const TopBar: React.FC<TopBarProps> = ({
const p = parseInt(e.target.value, 10);
if (!Number.isNaN(p)) onGoToPage(Math.min(Math.max(1, p), totalPages) - 1);
}}
className="h-7 w-9 rounded-[var(--r-sm)] border border-[var(--border-strong)] bg-[var(--surface)] text-center tabular-nums outline-none focus:border-[var(--accent)]"
className="h-7 w-9 rounded-[6px] border border-[#dadde2] bg-[#ffffff] text-center tabular-nums outline-none focus:border-[#2563eb]"
/>
<span className="text-[var(--text-dim)]">/ {Math.max(1, totalPages)}</span>
<span className="text-[#98a1ad]">/ {Math.max(1, totalPages)}</span>
</div>
)}
</div>
@@ -142,16 +143,16 @@ export const TopBar: React.FC<TopBarProps> = ({
<div className="flex shrink-0 items-center gap-2">
<SaveState isSaving={isSaving} saved={isDirtySaved} />
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
<Button variant="primary" size="sm" onClick={onExport} disabled={!documentName}><DownloadIcon size={15} /> Export</Button>
<IconButton
<CustomButton variant="primary" size="sm" onClick={onExport} disabled={!documentName}><DownloadIcon size={15} /> Export</CustomButton>
<CustomButton variant="icon"
label={isInspectorOpen ? "Hide panel" : "Show panel"}
size={34}
active={isInspectorOpen}
onClick={onToggleInspector}
className="text-[var(--text-muted)] hover:text-[var(--text)]"
className="text-[#5b6573] hover:text-[#18212e]"
>
<PagesIcon size={18} />
</IconButton>
</CustomButton>
</div>
<input ref={fileRef} type="file" accept=".pdf" onChange={handleFile} className="hidden" />
@@ -160,37 +161,37 @@ export const TopBar: React.FC<TopBarProps> = ({
};
const MenuItem: React.FC<{ icon: React.ReactNode; onClick: () => void; disabled?: boolean; children: React.ReactNode }> = ({ icon, onClick, disabled, children }) => (
<button
<CustomButton variant="unstyled"
onClick={onClick}
disabled={disabled}
className="flex items-center gap-2 rounded-[var(--r-sm)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--surface-2)] disabled:opacity-40"
className="flex items-center gap-2 rounded-[6px] px-2.5 py-2 text-left transition-colors hover:bg-[#f6f7f9] disabled:opacity-40"
>
{icon} {children}
</button>
</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-[var(--text-muted)]"><SpinnerIcon size={14} /> Saving</span>;
if (isSaving) return <span className="flex items-center gap-1.5 text-[12px] font-medium text-[#5b6573]"><SpinnerIcon size={14} /> Saving</span>;
if (!saved) return null;
return <span className="hidden items-center gap-1.5 text-[12px] font-medium text-[var(--success)] md:flex"><CheckIcon size={14} /> Saved</span>;
return <span className="hidden items-center gap-1.5 text-[12px] font-medium text-[#16a34a] md:flex"><CheckIcon size={14} /> Saved</span>;
};
const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> = ({ healthy, engineReady }) => {
// Fully connected (gateway up + engine on) → no noise.
if (healthy && engineReady) return null;
let color = 'var(--text-dim)';
let color = '#98a1ad';
let label = 'Offline · mock mode';
let tip = 'Gateway not reachable — running on mock data.';
if (healthy === null) {
color = 'var(--warning)'; label = 'Connecting…'; tip = 'Connecting to gateway…';
color = '#d97706'; label = 'Connecting…'; tip = 'Connecting to gateway…';
} else if (healthy && !engineReady) {
color = 'var(--warning)'; label = 'Engine off · mock data';
color = '#d97706'; label = 'Engine off · mock data';
tip = 'Gateway is connected, but the PDF engine is disabled. Set PDFENGINE_ENGINE_AVAILABLE=true and restart the gateway for live rendering/editing.';
}
return (
<span
className="flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-[11px] font-medium text-[var(--text-muted)]"
className="flex items-center gap-1.5 rounded-full border border-[#ebedf0] bg-[#f6f7f9] px-2 py-1 text-[11px] font-medium text-[#5b6573]"
title={tip}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: color }} />
+3 -2
View File
@@ -1,3 +1,4 @@
import { CustomButton } from './custom/CustomButton';
import React, { useEffect, useRef, useState } from 'react';
import { wasmLoader } from '../lib/wasmLoader';
import { gatewayService } from '../lib/gatewayService';
@@ -159,7 +160,7 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
<span className="w-2.5 h-2.5 rounded-full bg-indigo-500 animate-pulse shadow-[0_0_8px_#6366f1]" />
<h3 className="font-bold text-sm tracking-wider uppercase text-indigo-400">WASM Compiler Inspect</h3>
</div>
<button
<CustomButton variant="unstyled"
onClick={onClose}
className="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-slate-800 transition-colors"
title="Close Inspector"
@@ -167,7 +168,7 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</CustomButton>
</div>
{/* Content */}
@@ -0,0 +1,67 @@
import React from 'react';
export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'danger' | 'subtle' | 'icon' | 'unstyled';
export interface CustomButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: 'sm' | 'md' | number;
active?: boolean;
label?: string;
}
const buttonVariants: Record<Exclude<ButtonVariant, 'icon' | 'unstyled'>, string> = {
primary: 'bg-[#2563eb] text-white hover:bg-[#1d4ed8] shadow-sm',
ghost: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]',
outline: 'border border-[#dadde2] bg-[#ffffff] text-[#18212e] hover:bg-[#f6f7f9]',
danger: 'bg-[#dc2626] text-white hover:brightness-95 shadow-sm',
subtle: 'bg-[#edeff2] text-[#18212e] hover:bg-[#ebedf0]',
};
export const CustomButton: React.FC<CustomButtonProps> = ({
variant = 'outline',
size = 'md',
active,
label,
className = '',
children,
...rest
}) => {
if (variant === 'unstyled') {
return (
<button className={className} {...rest}>
{children}
</button>
);
}
if (variant === 'icon') {
const s = typeof size === 'number' ? size : 34;
return (
<button
title={label}
aria-label={label}
aria-pressed={active}
style={{ width: s, height: s, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
className={`shrink-0 rounded-[8px] transition-colors disabled:opacity-35 disabled:cursor-not-allowed ${
active
? 'bg-[#eef4ff] text-[#2563eb]'
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
} ${className}`}
{...rest}
>
{children}
</button>
);
}
return (
<button
className={`inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[8px] font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
size === 'sm' ? 'h-[34px] px-4 text-[13px]' : 'h-10 px-5 text-[13.5px]'
} ${buttonVariants[variant]} ${className}`}
{...rest}
>
{children}
</button>
);
};
@@ -0,0 +1,79 @@
import React, { useEffect } from 'react';
import { CustomButton } from './CustomButton';
export interface CustomConfirmationOptions {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}
export interface CustomConfirmationModalProps {
state: (CustomConfirmationOptions & { onConfirm: () => void }) | null;
onClose: () => void;
}
export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = ({ state, onClose }) => {
useEffect(() => {
if (!state) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [state, onClose]);
if (!state) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" onMouseDown={onClose}>
{/* Backdrop */}
<div
className="absolute inset-0 bg-[#0f172a]/30 backdrop-blur-[2px]"
style={{ animation: 'toastIn 0.2s ease-out' }}
/>
{/* Modal Content */}
<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]"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-2.5 p-7 pb-6">
<div className="flex items-center gap-3">
{state.danger ? (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#fdecec] text-[#dc2626]">
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#eef4ff] text-[#2563eb]">
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
)}
<h2 className="text-[17.5px] font-bold tracking-tight text-[#18212e]">{state.title}</h2>
</div>
<p className="pl-[52px] text-[13.5px] leading-relaxed text-[#5b6573]">{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]">
Cancel
</CustomButton>
<CustomButton
variant={state.danger ? 'danger' : 'primary'}
onClick={() => { state.onConfirm(); onClose(); }}
className={`rounded-[8px] px-5 font-semibold text-white shadow-sm transition-colors ${
state.danger
? 'bg-[#dc2626] hover:bg-[#b91c1c]'
: 'bg-[#2563eb] hover:bg-[#1d4ed8]'
}`}
>
{state.confirmLabel ?? 'Confirm'}
</CustomButton>
</div>
</div>
</div>
);
};
+2
View File
@@ -53,6 +53,7 @@ export const ZoomOutIcon: IC = mk(<><circle cx="11" cy="11" r="7" /><path d="M8
export const RotateIcon: IC = mk(<path d="M4 4v5h5M4.5 13a7.5 7.5 0 102-7.5L4 9" />);
export const DownloadIcon: IC = mk(<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-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 CheckIcon: IC = mk(<path d="M5 12l5 5L20 7" />);
export const XIcon: IC = mk(<path d="M6 6l12 12M18 6L6 18" />);
@@ -74,5 +75,6 @@ export const CopyIcon: IC = mk(<><rect x="9" y="9" width="11" height="11" rx="2"
export const PlusIcon: IC = mk(<path d="M12 5v14M5 12h14" />);
export const UploadIcon: IC = mk(<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" />);
export const InfoIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
export const HelpIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M9 10a3 3 0 116 0c0 1.5-2 2-2 3M12 17h.01" /></>);
export const SpinnerIcon: IC = ({ size = 18, className }) =>
base(size, `${className ?? ''} animate-spin`, 2, <><path d="M12 3a9 9 0 109 9" opacity="0.85" /><path d="M21 12a9 9 0 00-9-9" opacity="0.25" /></>);
+27 -70
View File
@@ -2,51 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { subscribeToasts, dismissToast } from '../lib/toast';
import type { ToastItem } from '../lib/toast';
import { XIcon, CheckIcon, InfoIcon } from './icons';
type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'danger' | 'subtle';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: 'sm' | 'md';
}
const buttonVariants: Record<ButtonVariant, string> = {
primary: 'bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] shadow-sm',
ghost: 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]',
outline: 'border border-[var(--border-strong)] bg-[var(--surface)] text-[var(--text)] hover:bg-[var(--surface-2)]',
danger: 'bg-[var(--danger)] text-white hover:brightness-95 shadow-sm',
subtle: 'bg-[var(--surface-3)] text-[var(--text)] hover:bg-[var(--border)]',
};
export const Button: React.FC<ButtonProps> = ({ variant = 'outline', size = 'md', className = '', children, ...rest }) => (
<button
className={`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--r-md)] font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
size === 'sm' ? 'h-9 px-4 text-[13px]' : 'h-10 px-5 text-[13.5px]'
} ${buttonVariants[variant]} ${className}`}
{...rest}
>
{children}
</button>
);
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
active?: boolean;
size?: number;
}
export const IconButton: React.FC<IconButtonProps> = ({ label, active, className = '', children, size = 34, ...rest }) => (
<button
title={label}
aria-label={label}
aria-pressed={active}
style={{ width: size, height: size, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
className={`rounded-[var(--r-md)] transition-colors disabled:opacity-35 disabled:cursor-not-allowed ${
active
? 'bg-[var(--accent-soft)] text-[var(--accent)]'
: 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'
} ${className}`}
{...rest}
>
{children}
</button>
);
import { CustomButton } from './custom/CustomButton';
interface PopoverProps {
trigger: (open: boolean) => React.ReactNode;
@@ -76,7 +32,7 @@ export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'le
{open && (
<div
style={{ width }}
className={`absolute top-[calc(100%+6px)] z-50 rounded-[var(--r-lg)] border border-[var(--border)] bg-[var(--surface)] p-2.5 shadow-[var(--shadow-3)] ${
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)] ${
align === 'right' ? 'right-0' : 'left-0'
}`}
onClick={(e) => e.stopPropagation()}
@@ -97,17 +53,18 @@ const DEFAULT_PALETTE = ['#facc15', '#fb923c', '#f87171', '#f472b6', '#a78bfa',
export const ColorSwatches: React.FC<ColorSwatchesProps> = ({ value, onChange, palette = DEFAULT_PALETTE }) => (
<div className="flex items-center gap-1.5">
{palette.map((c) => (
<button
<CustomButton
variant="unstyled"
key={c}
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-[var(--accent)] ring-offset-1' : 'border-[var(--border-strong)]'
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-[#2563eb] ring-offset-1' : 'border-[#dadde2]'
}`}
style={{ background: c }}
/>
))}
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[var(--border-strong)]"
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[#dadde2]"
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" />
@@ -127,7 +84,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-[var(--text-muted)]">{label}</span>}
{label && <span className="text-[11px] font-medium text-[#5b6573]">{label}</span>}
<input
type="range"
min={min}
@@ -136,9 +93,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-[var(--accent)]"
className="accent-[#2563eb]"
/>
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[var(--text)]">{value}{suffix}</span>
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[#18212e]">{value}{suffix}</span>
</div>
);
@@ -151,17 +108,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-[var(--surface-3)] text-[var(--text-dim)]">
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#edeff2] text-[#98a1ad]">
{icon}
</div>
)}
{badge && (
<span className="rounded-full bg-[var(--accent-soft)] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[var(--accent)]">
<span className="rounded-full bg-[#eef4ff] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[#2563eb]">
{badge}
</span>
)}
<p className="text-[13.5px] font-semibold text-[var(--text)]">{title}</p>
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-[var(--text-dim)]">{hint}</p>}
<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>}
</div>
);
@@ -185,15 +142,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-[var(--r-lg)] border border-[var(--border)] bg-[var(--surface)] shadow-[var(--shadow-3)]"
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-[#ebedf0] bg-[#ffffff] shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--border)] px-6 py-4">
<h2 className="text-[15px] font-bold text-[var(--text)]">{title}</h2>
<IconButton label="Close" size={30} onClick={onClose}><XIcon size={18} /></IconButton>
<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>
<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-[var(--border)] bg-[var(--surface-2)] px-6 py-4">{footer}</div>}
{footer && <div className="flex justify-end gap-2.5 border-t border-[#ebedf0] bg-[#f6f7f9] px-6 py-4">{footer}</div>}
</div>
</div>
);
@@ -218,25 +175,25 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose })
footer={
state && (
<>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
<CustomButton variant="outline" onClick={onClose}>Cancel</CustomButton>
<CustomButton
variant={state.danger ? 'danger' : 'primary'}
onClick={() => { state.onConfirm(); onClose(); }}
>
{state.confirmLabel ?? 'Confirm'}
</Button>
</CustomButton>
</>
)
}
>
<p className="text-[13px] leading-relaxed text-[var(--text-muted)]">{state?.message}</p>
<p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p>
</Modal>
);
const toastStyles: Record<ToastItem['kind'], string> = {
info: 'border-[var(--border)] bg-[var(--text)] text-white',
success: 'border-transparent bg-[var(--success)] text-white',
error: 'border-transparent bg-[var(--danger)] text-white',
info: 'border-[#ebedf0] bg-[#18212e] text-white',
success: 'border-transparent bg-[#16a34a] text-white',
error: 'border-transparent bg-[#dc2626] text-white',
};
export const ToastViewport: React.FC = () => {
const [items, setItems] = useState<ToastItem[]>([]);
@@ -246,14 +203,14 @@ export const ToastViewport: React.FC = () => {
{items.map((t) => (
<div
key={t.id}
className={`pointer-events-auto flex items-center gap-2 rounded-full border px-4 py-2 text-[12.5px] font-semibold shadow-[var(--shadow-3)] ${toastStyles[t.kind]}`}
className={`pointer-events-auto flex items-center gap-2 rounded-full border px-4 py-2 text-[12.5px] font-semibold shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${toastStyles[t.kind]}`}
style={{ animation: 'toastIn 0.18s ease-out' }}
>
{t.kind === 'success' && <CheckIcon size={15} />}
{t.kind === 'error' && <XIcon size={15} />}
{t.kind === 'info' && <InfoIcon size={15} />}
<span>{t.message}</span>
<button className="ml-1 opacity-60 hover:opacity-100" onClick={() => dismissToast(t.id)}><XIcon size={13} /></button>
<CustomButton variant="unstyled" className="ml-1 opacity-60 hover:opacity-100" onClick={() => dismissToast(t.id)}><XIcon size={13} /></CustomButton>
</div>
))}
</div>
+2 -299
View File
@@ -1,89 +1,6 @@
@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";
:root {
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Surfaces */
--canvas: #f1f2f4; /* document workspace background */
--surface: #ffffff; /* panels, bars */
--surface-2: #f6f7f9; /* subtle raised / hover */
--surface-3: #edeff2; /* pressed / track */
/* Borders — intentionally faint; rely on surface/canvas contrast for separation */
--border: #ebedf0;
--border-strong: #dadde2;
/* Text */
--text: #18212e;
--text-muted: #5b6573;
--text-dim: #98a1ad;
/* Primary accent (professional blue) */
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-soft: #eef4ff;
--accent-text: #ffffff;
/* Semantic */
--success: #16a34a;
--success-soft: #e9f7ee;
--warning: #d97706;
--warning-soft: #fdf3e7;
--danger: #dc2626;
--danger-soft: #fdecec;
/* Tool accents (used by contextual strip swatches) */
--tool-highlight: #facc15;
--tool-ink: #2563eb;
--tool-redact: #ef4444;
/* Radii */
--r-sm: 6px;
--r-md: 8px;
--r-lg: 12px;
/* Elevation */
--shadow-1: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.10);
--shadow-2: 0 4px 12px rgba(16, 24, 40, 0.10);
--shadow-3: 0 12px 32px rgba(16, 24, 40, 0.16);
/* Sizing */
--topbar-h: 56px;
--strip-h: 48px;
--rail-w: 80px;
--inspector-w: 322px;
font-family: var(--font-sans);
color-scheme: light;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
width: 100%;
height: 100%;
}
body {
overflow: hidden;
background: var(--canvas);
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 14px;
}
#root {
display: flex;
flex-direction: column;
}
/* Sleek scrollbars */
.scroll-thin::-webkit-scrollbar,
.custom-scrollbar::-webkit-scrollbar,
@@ -94,233 +11,19 @@ body {
.scroll-thin::-webkit-scrollbar-thumb,
.custom-scrollbar::-webkit-scrollbar-thumb,
.viewer-viewport::-webkit-scrollbar-thumb {
background: var(--border-strong);
background: #dadde2;
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: var(--text-dim); background-clip: padding-box; }
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: #98a1ad; background-clip: padding-box; }
/* Hidden scrollbar (used by horizontal tab/tool strips) */
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
.scrollbar-none::-webkit-scrollbar { width: 0; height: 0; display: none; }
/* ==========================================================================
VIEWER (class names consumed by PDFViewer + layers — keep stable)
========================================================================== */
.viewer-viewport {
flex: 1;
height: 100%;
overflow: auto;
background: var(--canvas);
display: flex;
justify-content: center;
align-items: start;
outline: none;
}
.viewer-content-container {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 28px 0;
}
.page-container {
position: absolute;
background: white;
border-radius: 3px;
box-shadow: var(--shadow-2);
outline: 1px solid rgba(16, 24, 40, 0.04);
}
.shadow-premium { box-shadow: var(--shadow-2); }
.page-loading-state {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--surface-2);
color: var(--text-muted);
gap: 12px;
border-radius: 3px;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--accent-soft);
border-top-color: var(--accent);
border-radius: 9999px;
animation: spin 0.8s linear infinite;
}
.page-loading-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.2px;
color: var(--text-dim);
}
/* Layering */
.selection-layer { position: absolute; top: 0; left: 0; z-index: 20; cursor: text; }
.selection-highlight {
position: absolute;
background: rgba(37, 99, 235, 0.22);
pointer-events: none;
border-radius: 1px;
}
.selection-glyph {
position: absolute;
background: rgba(37, 99, 235, 0.28);
pointer-events: none;
}
.annotation-layer { position: absolute; top: 0; left: 0; z-index: 30; }
.annotation-box {
position: absolute;
cursor: pointer;
border-radius: 2px;
transition: opacity 0.15s, box-shadow 0.15s;
}
.annotation-box:hover { box-shadow: 0 2px 8px rgba(16, 24, 40, 0.18); }
.annotation-box.type-highlight { mix-blend-mode: multiply; }
.annotation-box.type-comment { display: flex; align-items: center; justify-content: center; }
.annotation-box.type-strikeout { display: flex; align-items: center; justify-content: center; }
.strikeout-line { width: 100%; height: 2px; background: var(--danger); opacity: 0.85; }
.annotation-box.type-signature {
border: 2px dashed rgba(37, 99, 235, 0.5);
background: rgba(37, 99, 235, 0.05);
border-radius: 4px;
display: flex; align-items: center; justify-content: center;
}
.signature-badge {
color: rgba(37, 99, 235, 0.85);
background: rgba(255, 255, 255, 0.85);
border-radius: 9999px;
padding: 4px;
box-shadow: var(--shadow-1);
}
.overlay-layer { position: absolute; top: 0; left: 0; z-index: 40; }
.overlay-signature-alert {
position: absolute; inset: 0;
background: rgba(37, 99, 235, 0.05);
display: flex; align-items: center; justify-content: center;
border: 2px dashed rgba(37, 99, 235, 0.4);
border-radius: 6px;
}
.overlay-badge {
background: var(--accent);
color: white;
font-size: 11px;
font-weight: 700;
padding: 5px 12px;
border-radius: 9999px;
box-shadow: var(--shadow-2);
letter-spacing: 0.3px;
}
.overlay-toast {
position: absolute;
top: 14px; left: 50%;
transform: translateX(-50%);
background: var(--text);
color: white;
font-size: 11px;
font-weight: 600;
padding: 5px 12px;
border-radius: 9999px;
box-shadow: var(--shadow-2);
white-space: nowrap;
}
/* Comment popup */
.comment-popup-container {
width: 264px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-3);
overflow: hidden;
animation: slideUp 0.18s ease-out;
}
.comment-form-header {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 14px;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.comment-textarea {
width: 100%;
background: transparent;
color: var(--text);
border: none;
padding: 12px 14px;
font-size: 13px;
resize: none;
outline: none;
font-family: var(--font-sans);
}
.comment-textarea::placeholder { color: var(--text-dim); }
.comment-form-footer {
padding: 10px 14px;
background: var(--surface-2);
border-top: 1px solid var(--border);
display: flex; justify-content: flex-end;
}
.comment-submit-btn {
background: var(--accent);
color: white;
border: none;
padding: 7px 16px;
border-radius: var(--r-sm);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.comment-submit-btn:hover { background: var(--accent-hover); }
/* In-canvas text box editor (free_text tool) */
.textbox-editor {
position: absolute;
z-index: 50;
background: rgba(255,255,255,0.85);
border: 1.5px solid var(--accent);
border-radius: 4px;
box-shadow: var(--shadow-2);
outline: none;
resize: none;
overflow: hidden;
font-family: var(--font-sans);
line-height: 1.25;
padding: 2px 4px;
}
/* Thumbnails */
.thumbnail-card { display: flex; flex-direction: column; gap: 6px; cursor: pointer; }
.thumbnail-preview {
width: 100%;
aspect-ratio: 3 / 4;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--text-muted);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
box-shadow: var(--shadow-1);
}
.thumbnail-preview:hover { border-color: var(--accent); box-shadow: var(--shadow-2); }
.thumbnail-preview.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.thumbnail-svg-icon { color: var(--text-dim); margin-bottom: 6px; }
.thumbnail-label { font-size: 11px; font-weight: 600; color: var(--text-muted); }
/* Animations */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
+6 -2
View File
@@ -333,10 +333,14 @@ class GatewayService {
return response.json();
}
async searchDocument(documentId: string, query: string): Promise<SearchResult[]> {
async searchDocument(documentId: string, query: string, caseSensitive: boolean = false, wholeWords: boolean = false): Promise<SearchResult[]> {
if (!query) return [];
const urlParams = new URLSearchParams({ q: query });
const urlParams = new URLSearchParams({
q: query,
...(caseSensitive && { case_sensitive: 'true' }),
...(wholeWords && { whole_words: 'true' })
});
const response = await fetch(`${this.baseUrl}/documents/${documentId}/search?${urlParams.toString()}`);
if (response.status === 501) {
+4 -4
View File
@@ -34,7 +34,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
}) => {
return (
<div
className="annotation-layer"
className="absolute top-0 left-0 z-30"
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: 'none' }}
>
{annotations
@@ -60,7 +60,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
e.stopPropagation();
onAnnotationClick?.(anno);
}}
className={`annotation-box type-${anno.type}`}
className={`absolute cursor-pointer rounded-[2px] transition-[opacity,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)] type-${anno.type}`}
style={{
left: `${scaledBbox.x}px`,
top: `${scaledBbox.y}px`,
@@ -80,9 +80,9 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
</svg>
</div>
)}
{anno.type === 'strikeout' && <div className="strikeout-line" />}
{anno.type === 'strikeout' && <div className="w-full h-[2px] bg-[#dc2626] opacity-[0.85]" />}
{anno.type === 'signature' && (
<div className="signature-badge">
<div className="text-[rgba(37,99,235,0.85)] bg-[rgba(255,255,255,0.85)] rounded-full p-1 shadow-[0_1px_2px_rgba(16,24,40,0.06),0_1px_3px_rgba(16,24,40,0.10)]">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"></path>
</svg>
+14 -13
View File
@@ -1,3 +1,4 @@
import { CustomButton } from '../components/custom/CustomButton';
import React, { useState, useRef } from 'react';
import type { Annotation } from './AnnotationLayer';
import type { Rect } from '../lib/coordinateMapping';
@@ -125,47 +126,47 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
return (
<div
ref={rootRef}
className="overlay-layer"
className="absolute top-0 left-0 z-40"
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: POINTER_TOOLS.includes(activeTool) ? 'auto' : 'none' }}
onClick={handleLayerClick}
>
{/* Tool hints */}
{activeTool === 'signature' && !hasSignature && (
<div className="overlay-signature-alert"><span className="overlay-badge">Create a signature to place it here</span></div>
<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>
)}
{activeTool === 'signature' && hasSignature && !commentPopup && (
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to place signature</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place signature</div>
)}
{activeTool === 'comment' && !commentPopup && (
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
)}
{activeTool === 'stamp' && activeStamp && (
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to place {activeStamp}</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place {activeStamp}</div>
)}
{activeTool === 'textbox' && !textBox && (
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
)}
{/* Comment popup */}
{commentPopup && (
<div
className="comment-popup-container"
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]"
style={{ position: 'absolute', left: `${commentPopup.x * zoom}px`, top: `${commentPopup.y * zoom}px`, zIndex: 50 }}
onClick={(e) => e.stopPropagation()}
>
<form onSubmit={handleCommentSubmit}>
<div className="comment-form-header">
<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>
<button type="button" onClick={() => setCommentPopup(null)} className="text-[var(--text-dim)] hover:text-[var(--text)]">
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-[#98a1ad] hover:text-[#18212e]">
<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>
</button>
</CustomButton>
</div>
<textarea
autoFocus value={commentText} onChange={(e) => setCommentText(e.target.value)}
placeholder="Type your comment here…" className="comment-textarea" rows={3}
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}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCommentSubmit(e); } }}
/>
<div className="comment-form-footer"><button type="submit" className="comment-submit-btn">Save note</button></div>
<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>
</form>
</div>
)}
@@ -182,7 +183,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="textbox-editor"
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]"
placeholder="Type…"
style={{
left: `${textBox.x * zoom}px`,
+6 -6
View File
@@ -347,10 +347,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className={`viewer-viewport ${activeTool === 'pan' ? (isPanning ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
className={`flex-1 h-full overflow-auto bg-[#f1f2f4] flex justify-center items-start outline-none ${activeTool === 'pan' ? (isPanning ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
>
<div
className="viewer-content-container"
className="relative w-full flex flex-col items-center py-7"
style={{ height: `${totalContentHeight}px` }}
>
{visiblePages.map((page) => {
@@ -359,7 +359,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
return (
<div
key={page.index}
className="page-container shadow-premium"
className="absolute bg-white rounded-[3px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline outline-1 outline-[rgba(16,24,40,0.04)] shadow-[0_4px_12px_rgba(16,24,40,0.10)]"
style={{
top: `${page.top}px`,
width: `${page.width}px`,
@@ -440,9 +440,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
/>
</>
) : (
<div className="page-loading-state">
<div className="spinner" />
<span className="page-loading-label">Loading Page {page.index + 1}...</span>
<div className="w-full h-full flex flex-col items-center justify-center bg-[#f6f7f9] text-[#5b6573] gap-3 rounded-[3px]">
<div className="w-7 h-7 border-[3px] border-[#eef4ff] border-t-[#2563eb] rounded-full animate-spin" />
<span className="text-[12px] font-semibold tracking-[0.2px] text-[#98a1ad]">Loading Page {page.index + 1}...</span>
</div>
)}
</div>
+1 -1
View File
@@ -78,7 +78,7 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
top: `${redactBox.y}px`,
width: `${redactBox.width}px`,
height: `${redactBox.height}px`,
border: '2px dashed var(--color-error, #f43f5e)',
border: '2px dashed #f43f5e',
backgroundColor: 'rgba(244, 63, 94, 0.15)',
pointerEvents: 'none',
boxShadow: '0 0 12px rgba(244, 63, 94, 0.25)',
+3 -3
View File
@@ -41,10 +41,10 @@ export const SearchOverlayLayer: React.FC<SearchOverlayLayerProps> = ({
return (
<div
key={rectIdx}
className={`absolute rounded-sm border ${
className={`absolute ${
isActive
? 'bg-orange-500/50 border-orange-600/80 shadow-[0_0_8px_rgba(249,115,22,0.6)] z-30'
: 'bg-yellow-400/40 border-yellow-500/60'
? 'bg-[#f97316]/40 shadow-[0_0_4px_3px_rgba(249,115,22,0.3)] rounded-[4px] z-30'
: 'bg-[#fef08a]/50 rounded-[3px]'
}`}
style={{
left: `${rect.x * zoom}px`,
+3 -3
View File
@@ -106,7 +106,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, z
return (
<div
ref={containerRef}
className="selection-layer"
className="absolute top-0 left-0 z-20 cursor-text"
style={{ width: `${width}px`, height: `${height}px` }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@@ -115,11 +115,11 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, z
>
{/* Live per-glyph highlight */}
{hitGlyphs.map((g, i) => (
<div key={i} className="selection-glyph" style={{ left: g.gx, top: g.gy, width: g.gw, height: g.gh }} />
<div key={i} className="absolute bg-[rgba(37,99,235,0.28)] pointer-events-none" style={{ left: g.gx, top: g.gy, width: g.gw, height: g.gh }} />
))}
{/* Drag rectangle (only when nothing is being hit, e.g. mock mode) */}
{selectionBox && hitGlyphs.length === 0 && (
<div className="selection-highlight" style={{ left: selectionBox.x, top: selectionBox.y, width: selectionBox.width, height: selectionBox.height }} />
<div className="absolute bg-[rgba(37,99,235,0.22)] pointer-events-none rounded-[1px]" style={{ left: selectionBox.x, top: selectionBox.y, width: selectionBox.width, height: selectionBox.height }} />
)}
</div>
);