freeze v0.2.0

This commit is contained in:
2026-05-16 20:07:52 +02:00
parent afeb46a210
commit bdbb6c0a1c
9 changed files with 857 additions and 161 deletions

View File

@@ -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>
);
};

View 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;

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -1 +1 @@
export const APP_VERSION = '0.1.3';
export const APP_VERSION = '0.2.0';