feat: implement core application shell with toolbar, sidebar, and tool management components
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { TopBar } from './components/TopBar';
|
||||
import { ToolRail } from './components/ToolRail';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { Toolbar } from './components/Toolbar';
|
||||
import { InspectorPanel } from './components/InspectorPanel';
|
||||
import type { InspectorTab } from './components/InspectorPanel';
|
||||
@@ -65,6 +65,7 @@ function App() {
|
||||
|
||||
const [zoom, setZoom] = useState(1.0);
|
||||
const [activeTool, setActiveTool] = useState<ToolId>('select');
|
||||
const [activeGroup, setActiveGroup] = useState<import('./lib/tools').ToolGroup>('primary');
|
||||
const [toolSettings, setToolSettings] = useState<ToolSettings>(DEFAULT_TOOL_SETTINGS);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
|
||||
@@ -1209,21 +1210,24 @@ function App() {
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<ToolRail
|
||||
<Sidebar
|
||||
activeTool={activeTool}
|
||||
activeGroup={activeGroup}
|
||||
onToolChange={handleToolChange}
|
||||
onGroupChange={setActiveGroup}
|
||||
hasSignature={!!pendingSignature}
|
||||
onOpenSignature={() => setSignatureModalOpen(true)}
|
||||
onOpenAbout={() => setAboutModalOpen(true)}
|
||||
disabledTools={disabledTools}
|
||||
onRunOCR={handleRunOCR}
|
||||
isOCRLoading={isOCRLoading}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{activeTool !== 'create_pdf' && !createPdfModalOpen && (
|
||||
<Toolbar
|
||||
activeGroup={activeGroup}
|
||||
activeTool={activeTool}
|
||||
onToolChange={handleToolChange}
|
||||
settings={toolSettings}
|
||||
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
|
||||
onOpenSignature={() => setSignatureModalOpen(true)}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React from 'react';
|
||||
import type { ToolId, ToolGroup } from '../lib/tools';
|
||||
|
||||
import { Popover } from './ui';
|
||||
import {
|
||||
SelectIcon, PanIcon, DrawIcon, TextBoxIcon,
|
||||
SignatureIcon, PagesIcon, InfoIcon, HelpIcon
|
||||
} from './icons';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTool: ToolId;
|
||||
activeGroup: ToolGroup;
|
||||
onToolChange: (t: ToolId) => void;
|
||||
onGroupChange: (g: ToolGroup) => void;
|
||||
hasSignature: boolean;
|
||||
onOpenSignature: () => void;
|
||||
onOpenAbout: () => void;
|
||||
disabledTools?: Set<ToolId>;
|
||||
}
|
||||
|
||||
interface GroupDef {
|
||||
id: ToolGroup;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const GROUPS: GroupDef[] = [
|
||||
{ id: 'annotations', label: 'Annotations', icon: <DrawIcon /> },
|
||||
{ id: 'edit', label: 'Edit', icon: <TextBoxIcon /> },
|
||||
{ id: 'security', label: 'Security', icon: <SignatureIcon /> },
|
||||
{ id: 'organize', label: 'Organize', icon: <PagesIcon /> },
|
||||
];
|
||||
|
||||
const SidebarButton: React.FC<{
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ label, icon, active, disabled, onClick }) => (
|
||||
<CustomButton variant="unstyled"
|
||||
title={disabled ? `${label} (Disabled)` : label}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
aria-disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${disabled
|
||||
? 'cursor-not-allowed text-text-tertiary opacity-40 dark:text-zinc-600 dark:opacity-60'
|
||||
: active
|
||||
? 'bg-brand-secondary text-brand-primary'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: 'var(--brand-primary)' }} />}
|
||||
{React.isValidElement(icon) ? React.cloneElement(icon as React.ReactElement<{ size?: number }>, { size: 20 }) : icon}
|
||||
<span className="text-[10px] font-medium leading-none tracking-tight">{label}</span>
|
||||
</CustomButton>
|
||||
);
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeTool, activeGroup, onToolChange, onGroupChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools
|
||||
}) => {
|
||||
return (
|
||||
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border-primary bg-bg-primary scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
|
||||
|
||||
{/* Primary Tools */}
|
||||
<SidebarButton
|
||||
label="Select"
|
||||
icon={<SelectIcon />}
|
||||
active={activeTool === 'select'}
|
||||
disabled={disabledTools?.has('select')}
|
||||
onClick={() => {
|
||||
onGroupChange('primary');
|
||||
onToolChange('select');
|
||||
}}
|
||||
/>
|
||||
<SidebarButton
|
||||
label="Pan"
|
||||
icon={<PanIcon />}
|
||||
active={activeTool === 'pan'}
|
||||
disabled={disabledTools?.has('pan')}
|
||||
onClick={() => {
|
||||
onGroupChange('primary');
|
||||
onToolChange('pan');
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="my-1 h-px w-10 shrink-0 bg-border-primary" />
|
||||
|
||||
{/* Tool Groups */}
|
||||
{GROUPS.map(g => (
|
||||
<SidebarButton
|
||||
key={g.id}
|
||||
label={g.label}
|
||||
icon={g.icon}
|
||||
active={activeGroup === g.id}
|
||||
onClick={() => {
|
||||
onGroupChange(g.id);
|
||||
// Default tools for groups when clicked
|
||||
if (g.id === 'annotations') onToolChange('highlight');
|
||||
if (g.id === 'edit') onToolChange('edit_text');
|
||||
if (g.id === 'security') onToolChange('signature');
|
||||
if (g.id === 'organize') onToolChange('create_pdf');
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex-1 shrink-0 min-h-[16px]" />
|
||||
|
||||
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
|
||||
<InfoIcon size={19} />
|
||||
</CustomButton>
|
||||
|
||||
<Popover
|
||||
align="left"
|
||||
width={210}
|
||||
trigger={(open) => (
|
||||
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary'}`}>
|
||||
<HelpIcon size={19} />
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<div className="text-[12px]">
|
||||
<p className="mb-1.5 px-1 font-bold text-text-primary">Shortcuts</p>
|
||||
<div className="my-1 h-px bg-border-primary" />
|
||||
<div className="flex items-center justify-between px-1 py-0.5">
|
||||
<span className="text-text-secondary">Undo / Redo</span>
|
||||
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from './icons';
|
||||
|
||||
interface ToolbarProps {
|
||||
activeGroup: import('../lib/tools').ToolGroup;
|
||||
activeTool: ToolId;
|
||||
onToolChange: (t: ToolId) => void;
|
||||
settings: ToolSettings;
|
||||
onSettingsChange: (patch: Partial<ToolSettings>) => void;
|
||||
onOpenSignature: () => void;
|
||||
@@ -92,6 +94,14 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
},
|
||||
};
|
||||
|
||||
const GROUP_TOOLS: Record<import('../lib/tools').ToolGroup, ToolId[]> = {
|
||||
primary: ['select', 'pan'],
|
||||
annotations: ['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox'],
|
||||
edit: ['edit_text', 'ocr', 'stream_edit'],
|
||||
security: ['signature', 'stamp', 'watermark', 'redact'],
|
||||
organize: ['create_pdf', 'merge_pdf']
|
||||
};
|
||||
|
||||
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
|
||||
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-brand-primary' : 'text-text-secondary'}`}>{children}</span>
|
||||
);
|
||||
@@ -101,13 +111,14 @@ const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-border-primary" />;
|
||||
|
||||
export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
|
||||
activeGroup, activeTool, onToolChange, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
|
||||
redactionMode = 'area', onRedactionModeChange,
|
||||
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages, onOpenWatermark,
|
||||
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
|
||||
}) => {
|
||||
const meta = TOOL_META[activeTool];
|
||||
const isRedact = activeTool === 'redact';
|
||||
const toolsInGroup = GROUP_TOOLS[activeGroup] || [];
|
||||
|
||||
if (selectedAnnotation) {
|
||||
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
|
||||
@@ -156,20 +167,27 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary"
|
||||
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary overflow-x-auto"
|
||||
style={{ paddingLeft: '16px', paddingRight: '16px' }}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
|
||||
style={{
|
||||
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
|
||||
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
|
||||
}}
|
||||
>
|
||||
{meta?.icon}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-text-primary">{meta?.label}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{toolsInGroup.map((toolId) => {
|
||||
const t = TOOL_META[toolId];
|
||||
const isActive = activeTool === toolId;
|
||||
return (
|
||||
<button
|
||||
key={toolId}
|
||||
onClick={() => onToolChange(toolId)}
|
||||
className={`flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors ${
|
||||
isActive ? 'bg-brand-secondary text-brand-primary' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
title={t.label}
|
||||
>
|
||||
{t.icon}
|
||||
<span className="text-[12px] font-semibold">{t.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
|
||||
@@ -18,6 +18,29 @@ export type ToolId =
|
||||
| 'watermark'
|
||||
| 'merge_pdf';
|
||||
|
||||
export type ToolGroup = 'primary' | 'annotations' | 'edit' | 'security' | 'organize';
|
||||
|
||||
export const TOOL_GROUP_MAPPING: Record<ToolId, ToolGroup> = {
|
||||
select: 'primary',
|
||||
pan: 'primary',
|
||||
highlight: 'annotations',
|
||||
underline: 'annotations',
|
||||
strikeout: 'annotations',
|
||||
squiggly: 'annotations',
|
||||
draw: 'annotations',
|
||||
comment: 'annotations',
|
||||
textbox: 'annotations',
|
||||
edit_text: 'edit',
|
||||
ocr: 'edit',
|
||||
stream_edit: 'edit',
|
||||
signature: 'security',
|
||||
stamp: 'security',
|
||||
watermark: 'security',
|
||||
redact: 'security',
|
||||
create_pdf: 'organize',
|
||||
merge_pdf: 'organize',
|
||||
};
|
||||
|
||||
export interface ToolSettings {
|
||||
highlightColor: string;
|
||||
highlightOpacity: number;
|
||||
|
||||
Reference in New Issue
Block a user