freeze v0.2.0
This commit is contained in:
23
src/App.tsx
23
src/App.tsx
@@ -8,6 +8,7 @@ import WorkspacePanel from './components/WorkspacePanel';
|
||||
import ActionDialog, {
|
||||
type ActionDialogAction,
|
||||
} from './components/ActionDialog';
|
||||
import HelpDialog from './components/HelpDialog';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import type {
|
||||
StoredWorkspace,
|
||||
@@ -105,6 +106,7 @@ const App: React.FC = () => {
|
||||
content: React.ReactNode;
|
||||
actions: ActionDialogAction[];
|
||||
} | null>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const [pdf, setPdf] = useState<PdfFile | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
@@ -785,6 +787,23 @@ const App: React.FC = () => {
|
||||
|
||||
const hasPdf = !!pdf;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isEditableKeyboardTarget(e.target)) return;
|
||||
|
||||
if (e.key === 'F1' || e.key === '?') {
|
||||
e.preventDefault();
|
||||
setHelpOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// === UI interactions ===
|
||||
const handleRotatePageClockwise = (pageId: string) => {
|
||||
const before = getCurrentCommandState();
|
||||
@@ -1257,7 +1276,7 @@ const App: React.FC = () => {
|
||||
previewVisualIndex >= 0 && previewVisualIndex < pages.length - 1;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout onOpenHelp={() => setHelpOpen(true)}>
|
||||
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
|
||||
|
||||
<WorkspacePanel
|
||||
@@ -1446,6 +1465,8 @@ const App: React.FC = () => {
|
||||
>
|
||||
{actionDialog?.content}
|
||||
</ActionDialog>
|
||||
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
166
src/components/HelpDialog.tsx
Normal file
166
src/components/HelpDialog.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface HelpDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: 'F1 / ?', description: 'Open this help and tutorial dialog' },
|
||||
{ keys: 'Ctrl/⌘ + A', description: 'Select all pages in the current workspace' },
|
||||
{ keys: 'Delete / Backspace', description: 'Delete the selected pages after confirmation' },
|
||||
{ keys: 'Esc', description: 'Clear the page selection or close an open dialog' },
|
||||
{ keys: 'Ctrl/⌘ + Z', description: 'Undo the latest workspace command' },
|
||||
{ keys: 'Ctrl/⌘ + Shift + Z', description: 'Redo the next workspace command' },
|
||||
{ keys: 'Ctrl/⌘ + Y', description: 'Redo the next workspace command' },
|
||||
{ keys: '← / → in preview', description: 'Move to the previous or next page in the preview overlay' },
|
||||
];
|
||||
|
||||
const tutorialSteps = [
|
||||
{
|
||||
title: '1. Open a PDF or load a workspace',
|
||||
body: 'Start by selecting a local PDF file. If you saved workspaces before, you can restore one from browser storage instead.',
|
||||
},
|
||||
{
|
||||
title: '2. Arrange pages visually',
|
||||
body: 'Drag page cards to reorder them. Rotate single pages, open the large preview with a click, or remove pages you do not want in the export.',
|
||||
},
|
||||
{
|
||||
title: '3. Select, copy, and delete pages',
|
||||
body: 'Use the checkbox on a page card to select it. Shift-click extends a range. Dragging a selected page moves the whole selection; the copy controls duplicate selected pages into a chosen slot.',
|
||||
},
|
||||
{
|
||||
title: '4. Save your workspace or export a PDF',
|
||||
body: 'Saving a workspace keeps the current working state in this browser. Exporting creates a new PDF file for download.',
|
||||
},
|
||||
{
|
||||
title: '5. Use history deliberately',
|
||||
body: 'Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.',
|
||||
},
|
||||
];
|
||||
|
||||
const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
onClose();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="help-dialog-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="help-dialog-title"
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="help-dialog-panel">
|
||||
<div className="help-dialog-header">
|
||||
<div>
|
||||
<h2 id="help-dialog-title">Help & tutorial</h2>
|
||||
<p>
|
||||
PDF Workbench is a browser-only page workspace. Use it to quickly
|
||||
rearrange, split, merge, rotate, duplicate, and export PDFs without
|
||||
uploading documents to a server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="help-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close help"
|
||||
title="Close help (Esc)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="help-dialog-content">
|
||||
<section className="help-section">
|
||||
<h3>Quick tutorial</h3>
|
||||
<div className="help-step-list">
|
||||
{tutorialSteps.map((step) => (
|
||||
<article key={step.title} className="help-step">
|
||||
<h4>{step.title}</h4>
|
||||
<p>{step.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="help-section">
|
||||
<h3>Keyboard shortcuts</h3>
|
||||
<div className="shortcut-grid">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<React.Fragment key={shortcut.keys}>
|
||||
<kbd>{shortcut.keys}</kbd>
|
||||
<span>{shortcut.description}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<p className="help-note">
|
||||
Shortcuts are ignored while typing in text fields or other editable
|
||||
controls.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="help-section help-concepts">
|
||||
<h3>Important concepts</h3>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Browser-only processing</dt>
|
||||
<dd>
|
||||
PDF operations run in your browser. A self-hosted server only
|
||||
delivers the static app files.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Workspace</dt>
|
||||
<dd>
|
||||
A named local editing state, including the PDF binary, page
|
||||
order, rotations, selection, and command history.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Export</dt>
|
||||
<dd>
|
||||
A generated PDF download. Exported files are separate from
|
||||
saved workspaces.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>History</dt>
|
||||
<dd>
|
||||
Undoable commands show what changed, when it changed, and
|
||||
where the current point in history is.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpDialog;
|
||||
@@ -3,9 +3,10 @@ import { APP_VERSION } from '../version';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
onOpenHelp?: () => void;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
|
||||
return (
|
||||
<div className="app-root">
|
||||
<header className="app-header">
|
||||
@@ -18,8 +19,22 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-version" title={`Version ${APP_VERSION}`}>
|
||||
v{APP_VERSION}
|
||||
<div className="app-header-actions">
|
||||
{onOpenHelp && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-help-button"
|
||||
onClick={onOpenHelp}
|
||||
aria-haspopup="dialog"
|
||||
title="Open help and keyboard shortcuts (F1 or ?)"
|
||||
>
|
||||
Help <span aria-hidden="true">?</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="app-version" title={`Version ${APP_VERSION}`}>
|
||||
v{APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
198
src/styles.css
198
src/styles.css
@@ -197,4 +197,200 @@ button.secondary {
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.app-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app-help-button {
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-help-button:hover,
|
||||
.app-help-button:focus-visible {
|
||||
background: #374151;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.help-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.help-dialog-panel {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(88vh, 760px);
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.help-dialog-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.help-dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.help-dialog-header p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #4b5563;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.help-close-button {
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.help-dialog-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.9rem;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.help-section h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.help-step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.help-step {
|
||||
border-radius: 0.65rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.help-step h4 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-step p,
|
||||
.help-note {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #4b5563;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.shortcut-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 0.45rem 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shortcut-grid kbd {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #111827;
|
||||
box-shadow: inset 0 -1px 0 #d1d5db;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-grid span {
|
||||
color: #4b5563;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.help-concepts {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.help-concepts dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-concepts dl > div {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.help-concepts dt {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.help-concepts dd {
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.help-dialog-content,
|
||||
.help-concepts dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-header-content {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '0.1.3';
|
||||
export const APP_VERSION = '0.2.0';
|
||||
Reference in New Issue
Block a user