refactoring, linting, formatting
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
export interface ActionDialogAction {
|
||||
label: string;
|
||||
onClick: () => void | Promise<void>;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
title?: string;
|
||||
@@ -18,21 +18,21 @@ interface ActionDialogProps {
|
||||
}
|
||||
|
||||
const backgroundByVariant: Record<
|
||||
NonNullable<ActionDialogAction['variant']>,
|
||||
NonNullable<ActionDialogAction["variant"]>,
|
||||
string
|
||||
> = {
|
||||
primary: '#2563eb',
|
||||
secondary: '#e5e7eb',
|
||||
danger: '#dc2626',
|
||||
primary: "#2563eb",
|
||||
secondary: "#e5e7eb",
|
||||
danger: "#dc2626",
|
||||
};
|
||||
|
||||
const colorByVariant: Record<
|
||||
NonNullable<ActionDialogAction['variant']>,
|
||||
NonNullable<ActionDialogAction["variant"]>,
|
||||
string
|
||||
> = {
|
||||
primary: 'white',
|
||||
secondary: '#111827',
|
||||
danger: 'white',
|
||||
primary: "white",
|
||||
secondary: "#111827",
|
||||
danger: "white",
|
||||
};
|
||||
|
||||
const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
@@ -46,16 +46,16 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
@@ -72,42 +72,42 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
background: 'rgba(15, 23, 42, 0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
background: "rgba(15, 23, 42, 0.55)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '440px',
|
||||
background: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
width: "100%",
|
||||
maxWidth: "440px",
|
||||
background: "white",
|
||||
borderRadius: "0.75rem",
|
||||
boxShadow: "0 20px 40px rgba(15, 23, 42, 0.35)",
|
||||
padding: "1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="action-dialog-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1rem',
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -117,18 +117,18 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
width: '1.8rem',
|
||||
height: '1.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
border: "none",
|
||||
borderRadius: "999px",
|
||||
width: "1.8rem",
|
||||
height: "1.8rem",
|
||||
background: "#e5e7eb",
|
||||
color: "#111827",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.1rem",
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
@@ -138,8 +138,8 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.9rem',
|
||||
color: '#4b5563',
|
||||
fontSize: "0.9rem",
|
||||
color: "#4b5563",
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
@@ -148,15 +148,15 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: '0.25rem',
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{actions.map((action) => {
|
||||
const variant = action.variant ?? 'secondary';
|
||||
const variant = action.variant ?? "secondary";
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -169,15 +169,15 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
autoFocus={action.autoFocus}
|
||||
title={action.title}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "0.45rem 0.8rem",
|
||||
background: action.disabled
|
||||
? '#e5e7eb'
|
||||
? "#e5e7eb"
|
||||
: backgroundByVariant[variant],
|
||||
color: action.disabled ? '#6b7280' : colorByVariant[variant],
|
||||
cursor: action.disabled ? 'default' : 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
color: action.disabled ? "#6b7280" : colorByVariant[variant],
|
||||
cursor: action.disabled ? "default" : "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
@@ -190,4 +190,4 @@ const ActionDialog: React.FC<ActionDialogProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionDialog;
|
||||
export default ActionDialog;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { SplitResult } from '../pdf/pdfTypes';
|
||||
import React from "react";
|
||||
import type {
|
||||
PdfDownload,
|
||||
SplitPdfDownload,
|
||||
} from "../hooks/usePdfGeneratedOutputs";
|
||||
|
||||
interface ActionsPanelProps {
|
||||
hasPdf: boolean;
|
||||
@@ -11,11 +14,9 @@ interface ActionsPanelProps {
|
||||
onExtractSelected: () => void;
|
||||
onExportReordered: () => void;
|
||||
|
||||
splitResults: SplitResult[];
|
||||
subsetDownloadUrl: string | null;
|
||||
subsetFilename: string | null;
|
||||
exportDownloadUrl: string | null;
|
||||
exportFilename: string | null;
|
||||
splitDownloads: SplitPdfDownload[];
|
||||
subsetDownload: PdfDownload | null;
|
||||
exportDownload: PdfDownload | null;
|
||||
}
|
||||
|
||||
const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
@@ -25,11 +26,9 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
onSplit,
|
||||
onExtractSelected,
|
||||
onExportReordered,
|
||||
splitResults,
|
||||
subsetDownloadUrl,
|
||||
subsetFilename,
|
||||
exportDownloadUrl,
|
||||
exportFilename,
|
||||
splitDownloads,
|
||||
subsetDownload,
|
||||
exportDownload,
|
||||
}) => {
|
||||
const disabled = !hasPdf || isBusy;
|
||||
|
||||
@@ -41,20 +40,20 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Tools</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
<p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
|
||||
Use these tools on the current in-memory document (reordered, rotated,
|
||||
with deletions). Nothing is uploaded to a server.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="button-row"
|
||||
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
style={{ justifyContent: "space-between", flexWrap: "wrap" }}
|
||||
>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={disabled}
|
||||
onClick={onExportReordered}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
style={{ flex: "1 1 45%" }}
|
||||
>
|
||||
🧾 Export new PDF
|
||||
</button>
|
||||
@@ -63,11 +62,11 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
className="secondary"
|
||||
disabled={disabled || selectedCount === 0}
|
||||
onClick={handleExtractSelectedClick}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
style={{ flex: "1 1 45%" }}
|
||||
title={
|
||||
selectedCount === 0
|
||||
? 'Select at least one page'
|
||||
: 'Create a PDF from selected pages'
|
||||
? "Select at least one page"
|
||||
: "Create a PDF from selected pages"
|
||||
}
|
||||
>
|
||||
📤 Extract selected ({selectedCount})
|
||||
@@ -77,58 +76,52 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
className="secondary"
|
||||
disabled={disabled}
|
||||
onClick={onSplit}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
style={{ flex: "1 1 45%" }}
|
||||
>
|
||||
📂 Split into single PDFs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{subsetDownloadUrl && subsetFilename && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Subset result:</strong>{' '}
|
||||
{subsetDownload && (
|
||||
<div style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
|
||||
<strong>Subset result:</strong>{" "}
|
||||
<a
|
||||
className="download-link"
|
||||
href={subsetDownloadUrl}
|
||||
download={subsetFilename}
|
||||
href={subsetDownload.url}
|
||||
download={subsetDownload.filename}
|
||||
>
|
||||
Download {subsetFilename}
|
||||
Download {subsetDownload.filename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportDownloadUrl && exportFilename && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<strong>Exported document:</strong>{' '}
|
||||
{exportDownload && (
|
||||
<div style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
|
||||
<strong>Exported document:</strong>{" "}
|
||||
<a
|
||||
className="download-link"
|
||||
href={exportDownloadUrl}
|
||||
download={exportFilename}
|
||||
href={exportDownload.url}
|
||||
download={exportDownload.filename}
|
||||
>
|
||||
Download {exportFilename}
|
||||
Download {exportDownload.filename}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitResults.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
{splitDownloads.length > 0 && (
|
||||
<div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}>
|
||||
<strong>Single-page PDFs:</strong>
|
||||
<div>
|
||||
{splitResults.map((r) => {
|
||||
const url = URL.createObjectURL(r.blob);
|
||||
return (
|
||||
<a
|
||||
key={r.pageIndex}
|
||||
className="download-link"
|
||||
href={url}
|
||||
download={r.filename}
|
||||
onClick={() => {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}}
|
||||
>
|
||||
{r.filename}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
{splitDownloads.map((download) => (
|
||||
<a
|
||||
key={download.id}
|
||||
className="download-link"
|
||||
href={download.url}
|
||||
download={download.filename}
|
||||
>
|
||||
{download.filename}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { PdfFile } from '../pdf/pdfTypes';
|
||||
import React from "react";
|
||||
import type { PdfFile } from "../pdf/pdfTypes";
|
||||
|
||||
interface FileLoaderProps {
|
||||
pdf: PdfFile | null;
|
||||
@@ -11,7 +11,7 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onFileLoaded(file);
|
||||
e.target.value = '';
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ const FileLoader: React.FC<FileLoaderProps> = ({ pdf, onFileLoaded }) => {
|
||||
<input type="file" accept="application/pdf" onChange={handleChange} />
|
||||
|
||||
{pdf && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<div style={{ marginTop: "0.75rem", fontSize: "0.9rem" }}>
|
||||
<div>
|
||||
<strong>Loaded:</strong> {pdf.name}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
interface HelpDialogProps {
|
||||
open: boolean;
|
||||
@@ -6,36 +6,51 @@ interface HelpDialogProps {
|
||||
}
|
||||
|
||||
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' },
|
||||
{ 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: "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: "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: "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: "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.',
|
||||
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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -44,7 +59,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (e.key !== "Escape") return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -52,10 +67,10 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
@@ -79,8 +94,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
<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.
|
||||
rearrange, split, merge, rotate, duplicate, and export PDFs
|
||||
without uploading documents to a server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -119,8 +134,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
|
||||
))}
|
||||
</div>
|
||||
<p className="help-note">
|
||||
Shortcuts are ignored while typing in text fields or other editable
|
||||
controls.
|
||||
Shortcuts are ignored while typing in text fields or other
|
||||
editable controls.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { APP_VERSION } from '../version';
|
||||
import React from "react";
|
||||
import { APP_VERSION } from "../version";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -44,4 +44,4 @@ const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
export default Layout;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import type { PdfFile } from '../pdf/pdfTypes';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { PdfFile } from "../pdf/pdfTypes";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import pdfjsWorker from "pdfjs-dist/build/pdf.worker?worker&url";
|
||||
|
||||
// pdf.js worker setup
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -10,10 +10,10 @@ import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
|
||||
interface PagePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
pdf: PdfFile | null;
|
||||
pageIndex: number | null; // original page index, 0-based
|
||||
rotation: number; // degrees
|
||||
pageIndex: number | null; // original page index, 0-based
|
||||
rotation: number; // degrees
|
||||
|
||||
visualIndex: number | null; // current position in order, 0-based
|
||||
visualIndex: number | null; // current position in order, 0-based
|
||||
totalPages: number;
|
||||
|
||||
canGoPrevious: boolean;
|
||||
@@ -43,28 +43,28 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft' && canGoPrevious) {
|
||||
if (e.key === "ArrowLeft" && canGoPrevious) {
|
||||
e.preventDefault();
|
||||
onPrevious();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight' && canGoNext) {
|
||||
if (e.key === "ArrowRight" && canGoNext) {
|
||||
e.preventDefault();
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]);
|
||||
|
||||
@@ -77,7 +77,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
try {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
|
||||
const scale = Math.min(
|
||||
maxWidth / viewport.width,
|
||||
maxHeight / viewport.height
|
||||
maxHeight / viewport.height,
|
||||
);
|
||||
|
||||
const scaledViewport = page.getViewport({ scale });
|
||||
@@ -110,7 +110,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
const visibleCanvas = canvasRef.current;
|
||||
if (!visibleCanvas) return;
|
||||
|
||||
const visibleCtx = visibleCanvas.getContext('2d');
|
||||
const visibleCtx = visibleCanvas.getContext("2d");
|
||||
if (!visibleCtx) return;
|
||||
|
||||
let canvasWidth = scaledViewport.width;
|
||||
@@ -126,8 +126,8 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
visibleCanvas.width = canvasWidth;
|
||||
visibleCanvas.height = canvasHeight;
|
||||
|
||||
const baseCanvas = document.createElement('canvas');
|
||||
const baseCtx = baseCanvas.getContext('2d');
|
||||
const baseCanvas = document.createElement("canvas");
|
||||
const baseCtx = baseCanvas.getContext("2d");
|
||||
if (!baseCtx) return;
|
||||
|
||||
baseCanvas.width = scaledViewport.width;
|
||||
@@ -161,7 +161,7 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
visibleCtx.drawImage(baseCanvas, 0, 0);
|
||||
visibleCtx.restore();
|
||||
} catch (e) {
|
||||
console.error('Error rendering preview', e);
|
||||
console.error("Error rendering preview", e);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -181,30 +181,30 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
background: "rgba(15, 23, 42, 0.8)",
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: '#111827',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '0.75rem',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
overflow: 'visible',
|
||||
position: "relative",
|
||||
background: "#111827",
|
||||
borderRadius: "0.75rem",
|
||||
padding: "0.75rem",
|
||||
maxWidth: "90vw",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Previous page */}
|
||||
@@ -216,22 +216,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
}}
|
||||
disabled={!canGoPrevious}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: canGoPrevious ? '#374151' : '#1f2937',
|
||||
color: canGoPrevious ? '#e5e7eb' : '#6b7280',
|
||||
cursor: canGoPrevious ? 'pointer' : 'default',
|
||||
fontSize: '1.35rem',
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "2.5rem",
|
||||
height: "2.5rem",
|
||||
borderRadius: "999px",
|
||||
border: "none",
|
||||
background: canGoPrevious ? "#374151" : "#1f2937",
|
||||
color: canGoPrevious ? "#e5e7eb" : "#6b7280",
|
||||
cursor: canGoPrevious ? "pointer" : "default",
|
||||
fontSize: "1.35rem",
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2,
|
||||
}}
|
||||
title="Previous page (←)"
|
||||
@@ -249,22 +249,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
}}
|
||||
disabled={!canGoNext}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: '50%',
|
||||
transform: 'translate(50%, -50%)',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: canGoNext ? '#374151' : '#1f2937',
|
||||
color: canGoNext ? '#e5e7eb' : '#6b7280',
|
||||
cursor: canGoNext ? 'pointer' : 'default',
|
||||
fontSize: '1.35rem',
|
||||
top: "50%",
|
||||
transform: "translate(50%, -50%)",
|
||||
width: "2.5rem",
|
||||
height: "2.5rem",
|
||||
borderRadius: "999px",
|
||||
border: "none",
|
||||
background: canGoNext ? "#374151" : "#1f2937",
|
||||
color: canGoNext ? "#e5e7eb" : "#6b7280",
|
||||
cursor: canGoNext ? "pointer" : "default",
|
||||
fontSize: "1.35rem",
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2,
|
||||
}}
|
||||
title="Next page (→)"
|
||||
@@ -281,22 +281,22 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
transform: 'translate(50%, -50%)',
|
||||
width: '2.25rem',
|
||||
height: '2.25rem',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: '#374151',
|
||||
color: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.2rem',
|
||||
transform: "translate(50%, -50%)",
|
||||
width: "2.25rem",
|
||||
height: "2.25rem",
|
||||
borderRadius: "999px",
|
||||
border: "none",
|
||||
background: "#374151",
|
||||
color: "#e5e7eb",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.2rem",
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 3,
|
||||
}}
|
||||
title="Close preview (Esc)"
|
||||
@@ -308,14 +308,14 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '75vh',
|
||||
background: 'white',
|
||||
borderRadius: '0.5rem',
|
||||
maxWidth: "100%",
|
||||
maxHeight: "75vh",
|
||||
background: "white",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ color: '#e5e7eb', fontSize: '0.85rem' }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: "0.85rem" }}>
|
||||
{positionLabel} · Original page {pageIndex + 1} · Rot {rotation}°
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,4 +323,4 @@ const PagePreviewModal: React.FC<PagePreviewModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default PagePreviewModal;
|
||||
export default PagePreviewModal;
|
||||
|
||||
224
src/components/PageWorkspace/CopyPagesDialog.tsx
Normal file
224
src/components/PageWorkspace/CopyPagesDialog.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
interface CopyPagesDialogProps {
|
||||
selectedCount: number;
|
||||
pageCount: number;
|
||||
targetPosition: string;
|
||||
error: string | null;
|
||||
onTargetPositionChange: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: (e?: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
const CopyPagesDialog: React.FC<CopyPagesDialogProps> = ({
|
||||
selectedCount,
|
||||
pageCount,
|
||||
targetPosition,
|
||||
error,
|
||||
onTargetPositionChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="copy-pages-dialog-title"
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: "rgba(15, 23, 42, 0.55)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={onConfirm}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "420px",
|
||||
background: "white",
|
||||
borderRadius: "0.75rem",
|
||||
boxShadow: "0 20px 40px rgba(15, 23, 42, 0.35)",
|
||||
padding: "1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="copy-pages-dialog-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
Copy selected pages
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "999px",
|
||||
width: "1.8rem",
|
||||
height: "1.8rem",
|
||||
background: "#e5e7eb",
|
||||
color: "#111827",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.1rem",
|
||||
lineHeight: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
aria-label="Close copy dialog"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "0.9rem",
|
||||
color: "#4b5563",
|
||||
}}
|
||||
>
|
||||
Copy{" "}
|
||||
<strong>
|
||||
{selectedCount === 1
|
||||
? "1 selected page"
|
||||
: `${selectedCount} selected pages`}
|
||||
</strong>{" "}
|
||||
to a new position.
|
||||
</p>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Insert before position
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={pageCount + 1}
|
||||
value={targetPosition}
|
||||
autoFocus
|
||||
onChange={(e) => onTargetPositionChange(e.target.value)}
|
||||
style={{
|
||||
padding: "0.45rem 0.55rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid #d1d5db",
|
||||
fontSize: "0.95rem",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<div>1 = before the first page</div>
|
||||
<div>{pageCount + 1} = after the last page</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: "0.5rem",
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
color: "#b91c1c",
|
||||
padding: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "0.45rem 0.8rem",
|
||||
background: "#e5e7eb",
|
||||
color: "#111827",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "0.45rem 0.8rem",
|
||||
background: "#16a34a",
|
||||
color: "white",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Copy pages
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyPagesDialog;
|
||||
27
src/components/PageWorkspace/DropIndicator.tsx
Normal file
27
src/components/PageWorkspace/DropIndicator.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
interface DropIndicatorProps {
|
||||
side: "left" | "right" | "end";
|
||||
color: string;
|
||||
}
|
||||
|
||||
const DropIndicator: React.FC<DropIndicatorProps> = ({ side, color }) => {
|
||||
const isEnd = side === "end";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: side === "left" ? "-4px" : isEnd ? "8px" : undefined,
|
||||
right: side === "right" ? "-4px" : undefined,
|
||||
top: "4px",
|
||||
bottom: "4px",
|
||||
width: "3px",
|
||||
borderRadius: "999px",
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropIndicator;
|
||||
213
src/components/PageWorkspace/PageCard.tsx
Normal file
213
src/components/PageWorkspace/PageCard.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React from "react";
|
||||
import type { PageRef } from "../../pdf/pdfTypes";
|
||||
import DropIndicator from "./DropIndicator";
|
||||
|
||||
interface PageCardProps {
|
||||
page: PageRef;
|
||||
visualIndex: number;
|
||||
thumbnail?: string;
|
||||
selected: boolean;
|
||||
isDraggingCard: boolean;
|
||||
isBusy: boolean;
|
||||
isCopyDragging: boolean;
|
||||
showLeftLine: boolean;
|
||||
showRightLine: boolean;
|
||||
dropIndicatorColor: string;
|
||||
onDragStart: React.DragEventHandler<HTMLDivElement>;
|
||||
onDragEnd: React.DragEventHandler<HTMLDivElement>;
|
||||
onDragOver: React.DragEventHandler<HTMLDivElement>;
|
||||
onOpenPreview: () => void;
|
||||
onToggleSelect: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onRotateClockwise: () => void;
|
||||
onRotateCounterclockwise: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const pageActionButtonStyle: React.CSSProperties = {
|
||||
border: "none",
|
||||
borderRadius: "999px",
|
||||
padding: "0.15rem 0.4rem",
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const PageCard: React.FC<PageCardProps> = ({
|
||||
page,
|
||||
visualIndex,
|
||||
thumbnail,
|
||||
selected,
|
||||
isDraggingCard,
|
||||
isBusy,
|
||||
isCopyDragging,
|
||||
showLeftLine,
|
||||
showRightLine,
|
||||
dropIndicatorColor,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onOpenPreview,
|
||||
onToggleSelect,
|
||||
onRotateClockwise,
|
||||
onRotateCounterclockwise,
|
||||
onDelete,
|
||||
}) => {
|
||||
const background = isDraggingCard
|
||||
? isCopyDragging
|
||||
? "#dcfce7"
|
||||
: "#dbeafe"
|
||||
: selected
|
||||
? "#eff6ff"
|
||||
: "#f9fafb";
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onClick={onOpenPreview}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "162px",
|
||||
padding: "0.4rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
background,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
cursor: isBusy ? "default" : isCopyDragging ? "copy" : "grab",
|
||||
opacity: isBusy ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSelect}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
left: "4px",
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
borderRadius: "0.4rem",
|
||||
border: "1px solid #9ca3af",
|
||||
background: selected ? "#2563eb" : "rgba(255,255,255,0.9)",
|
||||
color: selected ? "white" : "transparent",
|
||||
fontSize: "0.8rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title="Select page"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
|
||||
{showLeftLine && <DropIndicator side="left" color={dropIndicatorColor} />}
|
||||
{showRightLine && (
|
||||
<DropIndicator side="right" color={dropIndicatorColor} />
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "110px",
|
||||
height: "90px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{thumbnail ? (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={`Page ${page.sourcePageIndex + 1}`}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
background: "white",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "60px",
|
||||
height: "80px",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px dashed #d1d5db",
|
||||
background: "#f3f4f6",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: "0.8rem" }}>
|
||||
Page {page.sourcePageIndex + 1}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.7rem", color: "#6b7280" }}>
|
||||
Pos {visualIndex + 1} · Rot {page.rotation}°
|
||||
</span>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRotateClockwise();
|
||||
}}
|
||||
style={{
|
||||
...pageActionButtonStyle,
|
||||
background: "#e5e7eb",
|
||||
}}
|
||||
>
|
||||
↻ 90°
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRotateCounterclockwise();
|
||||
}}
|
||||
style={{
|
||||
...pageActionButtonStyle,
|
||||
background: "#e5e7eb",
|
||||
}}
|
||||
>
|
||||
↺ 90°
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{
|
||||
...pageActionButtonStyle,
|
||||
background: "#fecaca",
|
||||
color: "#b91c1c",
|
||||
}}
|
||||
title="Remove this page from the exported PDF"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageCard;
|
||||
130
src/components/PageWorkspace/PageGrid.tsx
Normal file
130
src/components/PageWorkspace/PageGrid.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from "react";
|
||||
import type { PageRef } from "../../pdf/pdfTypes";
|
||||
import DropIndicator from "./DropIndicator";
|
||||
import PageCard from "./PageCard";
|
||||
|
||||
interface PageGridProps {
|
||||
pages: PageRef[];
|
||||
thumbnails: Record<string, string>;
|
||||
selectedPageIds: string[];
|
||||
isBusy: boolean;
|
||||
draggingIndex: number | null;
|
||||
dropIndex: number | null;
|
||||
draggingSelectionActive: boolean;
|
||||
isCopyDragging: boolean;
|
||||
dropIndicatorColor: string;
|
||||
onDragStart: (visualIndex: number) => React.DragEventHandler<HTMLDivElement>;
|
||||
onDragEnd: React.DragEventHandler<HTMLDivElement>;
|
||||
onCardDragOver: (
|
||||
visualIndex: number,
|
||||
) => React.DragEventHandler<HTMLDivElement>;
|
||||
onEndSlotDragOver: React.DragEventHandler<HTMLDivElement>;
|
||||
onDrop: React.DragEventHandler<HTMLDivElement>;
|
||||
onOpenPreview: (pageId: string) => void;
|
||||
onToggleSelect: (
|
||||
pageId: string,
|
||||
visualIndex: number,
|
||||
) => React.MouseEventHandler<HTMLButtonElement>;
|
||||
onRotateClockwise: (pageId: string) => void;
|
||||
onRotateCounterclockwise: (pageId: string) => void;
|
||||
onDelete: (pageId: string) => void;
|
||||
}
|
||||
|
||||
const PageGrid: React.FC<PageGridProps> = ({
|
||||
pages,
|
||||
thumbnails,
|
||||
selectedPageIds,
|
||||
isBusy,
|
||||
draggingIndex,
|
||||
dropIndex,
|
||||
draggingSelectionActive,
|
||||
isCopyDragging,
|
||||
dropIndicatorColor,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onCardDragOver,
|
||||
onEndSlotDragOver,
|
||||
onDrop,
|
||||
onOpenPreview,
|
||||
onToggleSelect,
|
||||
onRotateClockwise,
|
||||
onRotateCounterclockwise,
|
||||
onDelete,
|
||||
}) => {
|
||||
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
|
||||
|
||||
const showLeftLine = (visualIndex: number) =>
|
||||
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
|
||||
|
||||
const showRightLine = (visualIndex: number) =>
|
||||
dropIndex !== null &&
|
||||
dropIndex === visualIndex + 1 &&
|
||||
draggingIndex !== null;
|
||||
|
||||
const showEndLine = () =>
|
||||
dropIndex !== null && dropIndex === pages.length && draggingIndex !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: "0.75rem",
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{pages.map((page, visualIndex) => {
|
||||
const selected = isSelected(page.id);
|
||||
const isDraggingCard =
|
||||
draggingIndex != null &&
|
||||
((draggingSelectionActive && selected) ||
|
||||
(!draggingSelectionActive && visualIndex === draggingIndex));
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
key={page.id}
|
||||
page={page}
|
||||
visualIndex={visualIndex}
|
||||
thumbnail={thumbnails[page.id]}
|
||||
selected={selected}
|
||||
isDraggingCard={isDraggingCard}
|
||||
isBusy={isBusy}
|
||||
isCopyDragging={isCopyDragging}
|
||||
showLeftLine={showLeftLine(visualIndex)}
|
||||
showRightLine={showRightLine(visualIndex)}
|
||||
dropIndicatorColor={dropIndicatorColor}
|
||||
onDragStart={onDragStart(visualIndex)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onCardDragOver(visualIndex)}
|
||||
onOpenPreview={() => onOpenPreview(page.id)}
|
||||
onToggleSelect={onToggleSelect(page.id, visualIndex)}
|
||||
onRotateClockwise={() => onRotateClockwise(page.id)}
|
||||
onRotateCounterclockwise={() => onRotateCounterclockwise(page.id)}
|
||||
onDelete={() => onDelete(page.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{pages.length > 0 && (
|
||||
<div
|
||||
onDragOver={onEndSlotDragOver}
|
||||
onDrop={onDrop}
|
||||
style={{
|
||||
width: "20px",
|
||||
height: "120px",
|
||||
position: "relative",
|
||||
alignSelf: "stretch",
|
||||
}}
|
||||
>
|
||||
{showEndLine() && (
|
||||
<DropIndicator side="end" color={dropIndicatorColor} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageGrid;
|
||||
112
src/components/PageWorkspace/PageSelectionToolbar.tsx
Normal file
112
src/components/PageWorkspace/PageSelectionToolbar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from "react";
|
||||
|
||||
interface PageSelectionToolbarProps {
|
||||
selectedCount: number;
|
||||
onCopySelected: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
const pillButtonStyle: React.CSSProperties = {
|
||||
border: "none",
|
||||
borderRadius: "999px",
|
||||
padding: "0.15rem 0.6rem",
|
||||
fontSize: "0.8rem",
|
||||
};
|
||||
|
||||
const PageSelectionToolbar: React.FC<PageSelectionToolbarProps> = ({
|
||||
selectedCount,
|
||||
onCopySelected,
|
||||
onDeleteSelected,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
}) => {
|
||||
const hasSelection = selectedCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Selected: <strong>{selectedCount}</strong>
|
||||
</span>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.4rem",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{hasSelection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopySelected}
|
||||
disabled={!hasSelection}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: "#dcfce7",
|
||||
color: "#166534",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title="Copy selected pages to another position"
|
||||
>
|
||||
Copy selected
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasSelection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteSelected}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: "#fee2e2",
|
||||
color: "#b91c1c",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Delete selected
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectAll}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: "#8dcd8d",
|
||||
color: "#111827",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
disabled={!hasSelection}
|
||||
style={{
|
||||
...pillButtonStyle,
|
||||
background: "#e5e7eb",
|
||||
color: hasSelection ? "#111827" : "#6b7280",
|
||||
cursor: hasSelection ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageSelectionToolbar;
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import type { PageRef } from '../pdf/pdfTypes';
|
||||
import React, { useRef, useState } from "react";
|
||||
import type { PageRef } from "../pdf/pdfTypes";
|
||||
import CopyPagesDialog from "./PageWorkspace/CopyPagesDialog";
|
||||
import PageGrid from "./PageWorkspace/PageGrid";
|
||||
import PageSelectionToolbar from "./PageWorkspace/PageSelectionToolbar";
|
||||
|
||||
interface ReorderPanelProps {
|
||||
pages: PageRef[];
|
||||
@@ -17,7 +20,7 @@ interface ReorderPanelProps {
|
||||
onToggleSelect: (
|
||||
pageId: string,
|
||||
visualIndex: number,
|
||||
e: React.MouseEvent<HTMLButtonElement>
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
onSelectAll: () => void;
|
||||
|
||||
@@ -48,13 +51,11 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
|
||||
const [isCopyDragging, setIsCopyDragging] = useState(false);
|
||||
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
|
||||
const [copyTargetPosition, setCopyTargetPosition] = useState('');
|
||||
const [copyTargetPosition, setCopyTargetPosition] = useState("");
|
||||
const [copyDialogError, setCopyDialogError] = useState<string | null>(null);
|
||||
|
||||
const dragGhostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const isSelected = (pageId: string) => selectedPageIds.includes(pageId);
|
||||
|
||||
const cleanupDragGhost = () => {
|
||||
if (dragGhostRef.current && dragGhostRef.current.parentNode) {
|
||||
dragGhostRef.current.parentNode.removeChild(dragGhostRef.current);
|
||||
@@ -71,7 +72,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
if (!draggedPage) return [];
|
||||
|
||||
const selectedInVisualOrder = pages.filter((page) =>
|
||||
selectedPageIds.includes(page.id)
|
||||
selectedPageIds.includes(page.id),
|
||||
);
|
||||
|
||||
const draggingIsSelected =
|
||||
@@ -84,20 +85,20 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
const createDragGhost = (e: React.DragEvent, count: number) => {
|
||||
cleanupDragGhost();
|
||||
|
||||
const ghost = document.createElement('div');
|
||||
ghost.textContent = count === 1 ? '1 page' : `${count} pages`;
|
||||
const ghost = document.createElement("div");
|
||||
ghost.textContent = count === 1 ? "1 page" : `${count} pages`;
|
||||
|
||||
ghost.style.position = 'fixed';
|
||||
ghost.style.top = '0';
|
||||
ghost.style.left = '0';
|
||||
ghost.style.padding = '4px 8px';
|
||||
ghost.style.borderRadius = '999px';
|
||||
ghost.style.background = '#111827';
|
||||
ghost.style.color = '#e5e7eb';
|
||||
ghost.style.fontSize = '12px';
|
||||
ghost.style.position = "fixed";
|
||||
ghost.style.top = "0";
|
||||
ghost.style.left = "0";
|
||||
ghost.style.padding = "4px 8px";
|
||||
ghost.style.borderRadius = "999px";
|
||||
ghost.style.background = "#111827";
|
||||
ghost.style.color = "#e5e7eb";
|
||||
ghost.style.fontSize = "12px";
|
||||
ghost.style.fontFamily =
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
ghost.style.zIndex = '9999';
|
||||
ghost.style.zIndex = "9999";
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
dragGhostRef.current = ghost;
|
||||
@@ -106,6 +107,13 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2);
|
||||
};
|
||||
|
||||
const resetDragState = () => {
|
||||
cleanupDragGhost();
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
setIsCopyDragging(false);
|
||||
};
|
||||
|
||||
const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => {
|
||||
setDraggingIndex(visualIndex);
|
||||
setDropIndex(visualIndex);
|
||||
@@ -113,19 +121,16 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
const copying = isCopyModifierPressed(e);
|
||||
setIsCopyDragging(copying);
|
||||
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
||||
e.dataTransfer.setData('text/plain', String(visualIndex));
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.dropEffect = copying ? "copy" : "move";
|
||||
e.dataTransfer.setData("text/plain", String(visualIndex));
|
||||
|
||||
const draggedPages = getDraggedPages(visualIndex);
|
||||
createDragGhost(e, draggedPages.length);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
cleanupDragGhost();
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
setIsCopyDragging(false);
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => {
|
||||
@@ -136,7 +141,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
const copying = isCopyModifierPressed(e);
|
||||
setIsCopyDragging(copying);
|
||||
|
||||
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
||||
e.dataTransfer.dropEffect = copying ? "copy" : "move";
|
||||
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
@@ -153,7 +158,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
const copying = isCopyModifierPressed(e);
|
||||
setIsCopyDragging(copying);
|
||||
|
||||
e.dataTransfer.dropEffect = copying ? 'copy' : 'move';
|
||||
e.dataTransfer.dropEffect = copying ? "copy" : "move";
|
||||
|
||||
setDropIndex(pages.length);
|
||||
};
|
||||
@@ -172,7 +177,7 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
if (shouldCopy) {
|
||||
onCopyPagesToSlot(
|
||||
draggedPages.map((page) => page.id),
|
||||
dropIndex
|
||||
dropIndex,
|
||||
);
|
||||
|
||||
setDraggingIndex(null);
|
||||
@@ -207,35 +212,23 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
setIsCopyDragging(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (pageId: string) => () => {
|
||||
const handleDeleteClick = (pageId: string) => {
|
||||
onDelete(pageId);
|
||||
setDraggingIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const handleRotateClickClockwise = (pageId: string) => () => {
|
||||
onRotateClockwise(pageId);
|
||||
};
|
||||
|
||||
const handleRotateClickCounterclockwise = (pageId: string) => () => {
|
||||
onRotateCounterclockwise(pageId);
|
||||
};
|
||||
|
||||
const handleCardClick = (pageId: string) => () => {
|
||||
onOpenPreview(pageId);
|
||||
};
|
||||
|
||||
const handleCheckboxClick =
|
||||
(pageId: string, visualIndex: number) =>
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation(); // don't trigger preview
|
||||
onToggleSelect(pageId, visualIndex, e);
|
||||
};
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect(pageId, visualIndex, e);
|
||||
};
|
||||
|
||||
const handleCopySelectedClick = () => {
|
||||
if (selectedPageIds.length === 0) return;
|
||||
|
||||
setCopyTargetPosition(String(pages.length + 1)); // default: after last page
|
||||
setCopyTargetPosition(String(pages.length + 1));
|
||||
setCopyDialogError(null);
|
||||
setCopyDialogOpen(true);
|
||||
};
|
||||
@@ -245,11 +238,16 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
setCopyDialogError(null);
|
||||
};
|
||||
|
||||
const handleCopyTargetPositionChange = (value: string) => {
|
||||
setCopyTargetPosition(value);
|
||||
setCopyDialogError(null);
|
||||
};
|
||||
|
||||
const handleCopyDialogConfirm = (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (selectedPageIds.length === 0) {
|
||||
setCopyDialogError('No pages selected.');
|
||||
setCopyDialogError("No pages selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -267,23 +265,6 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
setCopyDialogError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!copyDialogOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCopyDialogCancel();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [copyDialogOpen]);
|
||||
|
||||
if (!hasPdf) {
|
||||
return (
|
||||
<div className="card">
|
||||
@@ -293,554 +274,66 @@ const ReorderPanel: React.FC<ReorderPanelProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const showLeftLine = (visualIndex: number) =>
|
||||
dropIndex !== null && dropIndex === visualIndex && draggingIndex !== null;
|
||||
|
||||
const showRightLine = (visualIndex: number) =>
|
||||
dropIndex !== null &&
|
||||
dropIndex === visualIndex + 1 &&
|
||||
draggingIndex !== null;
|
||||
|
||||
const showEndLine = () =>
|
||||
dropIndex !== null && dropIndex === pages.length && draggingIndex !== null;
|
||||
|
||||
// For highlighting the whole selection while dragging it
|
||||
const draggingPage = draggingIndex != null ? pages[draggingIndex] : null;
|
||||
const draggingSelectionActive =
|
||||
draggingPage != null &&
|
||||
selectedPageIds.length > 0 &&
|
||||
selectedPageIds.includes(draggingPage.id);
|
||||
const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb';
|
||||
const dropIndicatorColor = isCopyDragging ? "#16a34a" : "#2563eb";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>Pages</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
<p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
|
||||
Tap/click a page to preview it. Use the checkbox to select pages
|
||||
(Shift for ranges). Drag to reorder; dragging a selected page moves the
|
||||
whole selection. Hold Ctrl/⌘ while dropping to copy instead of move.
|
||||
Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages, Esc clears
|
||||
selection.
|
||||
(Shift for ranges). Drag to reorder; dragging a selected page moves
|
||||
the whole selection. Hold Ctrl/⌘ while dropping to copy instead of
|
||||
move. Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages,
|
||||
Esc clears selection.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Selected: <strong>{selectedPageIds.length}</strong>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{selectedPageIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySelectedClick}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title={'Copy selected pages to another position'}
|
||||
>
|
||||
Copy selected
|
||||
</button>
|
||||
)}
|
||||
{selectedPageIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteSelected}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#fee2e2',
|
||||
color: '#b91c1c',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Delete selected
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectAll}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#8dcd8d',
|
||||
color: '#111827',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: selectedPageIds.length === 0 ? '#6b7280' : '#111827',
|
||||
cursor: selectedPageIds.length === 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageSelectionToolbar
|
||||
selectedCount={selectedPageIds.length}
|
||||
onCopySelected={handleCopySelectedClick}
|
||||
onDeleteSelected={onDeleteSelected}
|
||||
onSelectAll={onSelectAll}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
<PageGrid
|
||||
pages={pages}
|
||||
thumbnails={thumbnails}
|
||||
selectedPageIds={selectedPageIds}
|
||||
isBusy={isBusy}
|
||||
draggingIndex={draggingIndex}
|
||||
dropIndex={dropIndex}
|
||||
draggingSelectionActive={draggingSelectionActive}
|
||||
isCopyDragging={isCopyDragging}
|
||||
dropIndicatorColor={dropIndicatorColor}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onCardDragOver={handleCardDragOver}
|
||||
onEndSlotDragOver={handleEndSlotDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{pages.map((page, visualIndex) => {
|
||||
const thumb = thumbnails[page.id];
|
||||
const rotation = page.rotation;
|
||||
const selected = isSelected(page.id);
|
||||
|
||||
const isDraggingCard =
|
||||
draggingIndex != null &&
|
||||
((draggingSelectionActive && selected) ||
|
||||
(!draggingSelectionActive && visualIndex === draggingIndex));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={page.id}
|
||||
draggable
|
||||
onDragStart={handleDragStart(visualIndex)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleCardDragOver(visualIndex)}
|
||||
onClick={handleCardClick(page.id)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '162px',
|
||||
padding: '0.4rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: isDraggingCard
|
||||
? isCopyDragging
|
||||
? '#dcfce7'
|
||||
: '#dbeafe'
|
||||
: selected
|
||||
? '#eff6ff'
|
||||
: '#f9fafb',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
cursor: isBusy ? 'default' : isCopyDragging ? 'copy' : 'grab',
|
||||
opacity: isBusy ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{/* selection checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCheckboxClick(page.id, visualIndex)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
left: '4px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '0.4rem',
|
||||
border: '1px solid #9ca3af',
|
||||
background: selected ? '#2563eb' : 'rgba(255,255,255,0.9)',
|
||||
color: selected ? 'white' : 'transparent',
|
||||
fontSize: '0.8rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Select page"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
|
||||
{/* left drop indicator */}
|
||||
{showLeftLine(visualIndex) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-4px',
|
||||
top: '4px',
|
||||
bottom: '4px',
|
||||
width: '3px',
|
||||
borderRadius: '999px',
|
||||
background: dropIndicatorColor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* right drop indicator */}
|
||||
{showRightLine(visualIndex) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-4px',
|
||||
top: '4px',
|
||||
bottom: '4px',
|
||||
width: '3px',
|
||||
borderRadius: '999px',
|
||||
background: dropIndicatorColor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '110px',
|
||||
height: '90px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{thumb ? (
|
||||
<img
|
||||
src={thumb}
|
||||
alt={`Page ${page.sourcePageIndex + 1}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '80px',
|
||||
borderRadius: '0.25rem',
|
||||
border: '1px dashed #d1d5db',
|
||||
background: '#f3f4f6',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: '0.8rem' }}>Page {page.sourcePageIndex + 1}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: '#6b7280' }}>
|
||||
Pos {visualIndex + 1} · Rot {rotation}°
|
||||
</span>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRotateClickClockwise(page.id)();
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
background: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↻ 90°
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRotateClickCounterclockwise(page.id)();
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
background: '#e5e7eb',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
↺ 90°
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(page.id)();
|
||||
}}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
padding: '0.15rem 0.4rem',
|
||||
fontSize: '0.75rem',
|
||||
background: '#fecaca',
|
||||
color: '#b91c1c',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Remove this page from the exported PDF"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* end slot for dropping after the last card */}
|
||||
{pages.length > 0 && (
|
||||
<div
|
||||
onDragOver={handleEndSlotDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '120px',
|
||||
position: 'relative',
|
||||
alignSelf: 'stretch',
|
||||
}}
|
||||
>
|
||||
{showEndLine() && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '8px',
|
||||
top: '4px',
|
||||
bottom: '4px',
|
||||
width: '3px',
|
||||
borderRadius: '999px',
|
||||
background: dropIndicatorColor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
onOpenPreview={onOpenPreview}
|
||||
onToggleSelect={handleCheckboxClick}
|
||||
onRotateClockwise={onRotateClockwise}
|
||||
onRotateCounterclockwise={onRotateCounterclockwise}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{copyDialogOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="copy-pages-dialog-title"
|
||||
onPointerDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleCopyDialogCancel();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: 'rgba(15, 23, 42, 0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleCopyDialogConfirm}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
background: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="copy-pages-dialog-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
Copy selected pages
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyDialogCancel}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
width: '1.8rem',
|
||||
height: '1.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
aria-label="Close copy dialog"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.9rem',
|
||||
color: '#4b5563',
|
||||
}}
|
||||
>
|
||||
Copy{' '}
|
||||
<strong>
|
||||
{selectedPageIds.length === 1
|
||||
? '1 selected page'
|
||||
: `${selectedPageIds.length} selected pages`}
|
||||
</strong>{' '}
|
||||
to a new position.
|
||||
</p>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Insert before position
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={pages.length + 1}
|
||||
value={copyTargetPosition}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setCopyTargetPosition(e.target.value);
|
||||
setCopyDialogError(null);
|
||||
}}
|
||||
style={{
|
||||
padding: '0.45rem 0.55rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #d1d5db',
|
||||
fontSize: '0.95rem',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#6b7280',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<div>1 = before the first page</div>
|
||||
<div>{pages.length + 1} = after the last page</div>
|
||||
</div>
|
||||
|
||||
{copyDialogError && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#b91c1c',
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{copyDialogError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyDialogCancel}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
background: '#e5e7eb',
|
||||
color: '#111827',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.45rem 0.8rem',
|
||||
background: '#16a34a',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Copy pages
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<CopyPagesDialog
|
||||
selectedCount={selectedPageIds.length}
|
||||
pageCount={pages.length}
|
||||
targetPosition={copyTargetPosition}
|
||||
error={copyDialogError}
|
||||
onTargetPositionChange={handleCopyTargetPositionChange}
|
||||
onCancel={handleCopyDialogCancel}
|
||||
onConfirm={handleCopyDialogConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,327 +1,327 @@
|
||||
import React from 'react';
|
||||
import type { WorkspaceSummary } from '../workspace/workspaceTypes';
|
||||
import type { WorkspaceCommandRecord } from '../workspace/workspaceCommands';
|
||||
import React from "react";
|
||||
import type { WorkspaceSummary } from "../workspace/workspaceTypes";
|
||||
import type { WorkspaceCommandRecord } from "../workspace/workspaceCommands";
|
||||
|
||||
interface WorkspacePanelProps {
|
||||
hasPdf: boolean;
|
||||
isBusy: boolean;
|
||||
hasPdf: boolean;
|
||||
isBusy: boolean;
|
||||
|
||||
activeWorkspaceId: string | null;
|
||||
workspaceName: string;
|
||||
workspaceDirty: boolean;
|
||||
workspaceMessage: string | null;
|
||||
activeWorkspaceId: string | null;
|
||||
workspaceName: string;
|
||||
workspaceDirty: boolean;
|
||||
workspaceMessage: string | null;
|
||||
|
||||
workspaces: WorkspaceSummary[];
|
||||
history: WorkspaceCommandRecord[];
|
||||
redoHistory: WorkspaceCommandRecord[];
|
||||
workspaces: WorkspaceSummary[];
|
||||
history: WorkspaceCommandRecord[];
|
||||
redoHistory: WorkspaceCommandRecord[];
|
||||
|
||||
onWorkspaceNameChange: (value: string) => void;
|
||||
onSaveWorkspace: () => void;
|
||||
onLoadWorkspace: (workspaceId: string) => void;
|
||||
onDeleteWorkspace: (workspaceId: string) => void;
|
||||
onRefreshWorkspaces: () => void;
|
||||
onResetWorkspace: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onWorkspaceNameChange: (value: string) => void;
|
||||
onSaveWorkspace: () => void;
|
||||
onLoadWorkspace: (workspaceId: string) => void;
|
||||
onDeleteWorkspace: (workspaceId: string) => void;
|
||||
onRefreshWorkspaces: () => void;
|
||||
onResetWorkspace: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
}
|
||||
|
||||
const WorkspacePanel: React.FC<WorkspacePanelProps> = ({
|
||||
hasPdf,
|
||||
isBusy,
|
||||
activeWorkspaceId,
|
||||
workspaceName,
|
||||
workspaceDirty,
|
||||
workspaceMessage,
|
||||
workspaces,
|
||||
history,
|
||||
redoHistory,
|
||||
onWorkspaceNameChange,
|
||||
onSaveWorkspace,
|
||||
onLoadWorkspace,
|
||||
onDeleteWorkspace,
|
||||
onRefreshWorkspaces,
|
||||
onResetWorkspace,
|
||||
onUndo,
|
||||
onRedo,
|
||||
hasPdf,
|
||||
isBusy,
|
||||
activeWorkspaceId,
|
||||
workspaceName,
|
||||
workspaceDirty,
|
||||
workspaceMessage,
|
||||
workspaces,
|
||||
history,
|
||||
redoHistory,
|
||||
onWorkspaceNameChange,
|
||||
onSaveWorkspace,
|
||||
onLoadWorkspace,
|
||||
onDeleteWorkspace,
|
||||
onRefreshWorkspaces,
|
||||
onResetWorkspace,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}) => {
|
||||
const canUndo = history.length > 0;
|
||||
const canRedo = redoHistory.length > 0;
|
||||
const canUndo = history.length > 0;
|
||||
const canRedo = redoHistory.length > 0;
|
||||
|
||||
const latestUndo = history[history.length - 1];
|
||||
const latestRedo = redoHistory[redoHistory.length - 1];
|
||||
const latestUndo = history[history.length - 1];
|
||||
const latestRedo = redoHistory[redoHistory.length - 1];
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Workspace</h2>
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Workspace</h2>
|
||||
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280' }}>
|
||||
Save named workspaces in this browser. PDF binaries are stored in
|
||||
IndexedDB; nothing is uploaded.
|
||||
</p>
|
||||
<p style={{ fontSize: "0.85rem", color: "#6b7280" }}>
|
||||
Save named workspaces in this browser. PDF binaries are stored in
|
||||
IndexedDB; nothing is uploaded.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={workspaceName}
|
||||
onChange={(e) => onWorkspaceNameChange(e.target.value)}
|
||||
placeholder="Workspace name"
|
||||
disabled={!hasPdf || isBusy}
|
||||
style={{
|
||||
flex: "1 1 220px",
|
||||
minWidth: 0,
|
||||
padding: "0.45rem 0.55rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid #d1d5db",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onUndo}
|
||||
disabled={!hasPdf || isBusy || !canUndo}
|
||||
title={latestUndo ? `Undo: ${latestUndo.label}` : "Nothing to undo"}
|
||||
>
|
||||
↶ Undo
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onRedo}
|
||||
disabled={!hasPdf || isBusy || !canRedo}
|
||||
title={latestRedo ? `Redo: ${latestRedo.label}` : "Nothing to redo"}
|
||||
>
|
||||
↷ Redo
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onSaveWorkspace}
|
||||
disabled={!hasPdf || isBusy}
|
||||
title={!hasPdf ? "Open a PDF first" : "Save workspace"}
|
||||
>
|
||||
💾 {activeWorkspaceId ? "Save" : "Save as"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onResetWorkspace}
|
||||
disabled={!hasPdf || isBusy}
|
||||
title={
|
||||
!hasPdf ? "No active workspace" : "Close the current workspace"
|
||||
}
|
||||
>
|
||||
Reset workspace
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onRefreshWorkspaces}
|
||||
disabled={isBusy}
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspaceDirty && hasPdf && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.8rem",
|
||||
color: "#92400e",
|
||||
}}
|
||||
>
|
||||
Unsaved workspace changes.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
color: "#166534",
|
||||
}}
|
||||
>
|
||||
{workspaceMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaces.length > 0 && (
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<strong style={{ fontSize: "0.9rem" }}>Saved workspaces</strong>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.4rem",
|
||||
marginTop: "0.4rem",
|
||||
}}
|
||||
>
|
||||
{workspaces.map((workspace) => {
|
||||
const active = workspace.id === activeWorkspaceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
style={{
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
padding: "0.5rem",
|
||||
background: active ? "#eff6ff" : "#f9fafb",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: "0.9rem" }}>
|
||||
<strong>{workspace.name}</strong>
|
||||
{active && (
|
||||
<span style={{ color: "#2563eb" }}> · active</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
||||
{workspace.pdfName} · source pages:{" "}
|
||||
{workspace.sourcePageCount} · workspace pages:{" "}
|
||||
{workspace.workspacePageCount} · undo:{" "}
|
||||
{workspace.historyCount} · redo: {workspace.redoCount} ·
|
||||
updated {new Date(workspace.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.35rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onLoadWorkspace(workspace.id)}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onDeleteWorkspace(workspace.id)}
|
||||
style={{
|
||||
background: "#fee2e2",
|
||||
color: "#991b1b",
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(history.length > 0 || redoHistory.length > 0) && (
|
||||
<details style={{ marginTop: "0.75rem" }} open>
|
||||
<summary style={{ cursor: "pointer", fontSize: "0.9rem" }}>
|
||||
Command history ({history.length} undo / {redoHistory.length} redo)
|
||||
</summary>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{history.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "#374151",
|
||||
borderLeft: "3px solid #2563eb",
|
||||
paddingLeft: "0.45rem",
|
||||
paddingTop: "0.2rem",
|
||||
paddingBottom: "0.2rem",
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Undo {history.length - index}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: "#6b7280" }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={workspaceName}
|
||||
onChange={(e) => onWorkspaceNameChange(e.target.value)}
|
||||
placeholder="Workspace name"
|
||||
disabled={!hasPdf || isBusy}
|
||||
style={{
|
||||
flex: '1 1 220px',
|
||||
minWidth: 0,
|
||||
padding: '0.45rem 0.55rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #d1d5db',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
/>
|
||||
style={{
|
||||
margin: "0.25rem 0",
|
||||
borderRadius: "999px",
|
||||
background: "#ecfdf5",
|
||||
color: "#166534",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
alignSelf: "flex-start",
|
||||
border: "2px solid #166534",
|
||||
width: "100%",
|
||||
}}
|
||||
></div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onUndo}
|
||||
disabled={!hasPdf || isBusy || !canUndo}
|
||||
title={latestUndo ? `Undo: ${latestUndo.label}` : 'Nothing to undo'}
|
||||
>
|
||||
↶ Undo
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onRedo}
|
||||
disabled={!hasPdf || isBusy || !canRedo}
|
||||
title={latestRedo ? `Redo: ${latestRedo.label}` : 'Nothing to redo'}
|
||||
>
|
||||
↷ Redo
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onSaveWorkspace}
|
||||
disabled={!hasPdf || isBusy}
|
||||
title={!hasPdf ? 'Open a PDF first' : 'Save workspace'}
|
||||
>
|
||||
💾 {activeWorkspaceId ? 'Save' : 'Save as'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onResetWorkspace}
|
||||
disabled={!hasPdf || isBusy}
|
||||
title={!hasPdf ? 'No active workspace' : 'Close the current workspace'}
|
||||
>
|
||||
Reset workspace
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onRefreshWorkspaces}
|
||||
disabled={isBusy}
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspaceDirty && hasPdf && (
|
||||
{redoHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, index) => (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
color: '#92400e',
|
||||
}}
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "#9ca3af",
|
||||
borderLeft: "3px solid #d1d5db",
|
||||
paddingLeft: "0.45rem",
|
||||
paddingTop: "0.2rem",
|
||||
paddingBottom: "0.2rem",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
>
|
||||
Unsaved workspace changes.
|
||||
<strong>
|
||||
Redo {index + 1}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: "#9ca3af" }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaceMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.85rem',
|
||||
color: '#166534',
|
||||
}}
|
||||
>
|
||||
{workspaceMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaces.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Saved workspaces</strong>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.4rem',
|
||||
marginTop: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{workspaces.map((workspace) => {
|
||||
const active = workspace.id === activeWorkspaceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
background: active ? '#eff6ff' : '#f9fafb',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<strong>{workspace.name}</strong>
|
||||
{active && (
|
||||
<span style={{ color: '#2563eb' }}> · active</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.75rem', color: '#6b7280' }}>
|
||||
{workspace.pdfName} · source pages:{' '}
|
||||
{workspace.sourcePageCount} · workspace pages:{' '}
|
||||
{workspace.workspacePageCount} · undo:{' '}
|
||||
{workspace.historyCount} · redo: {workspace.redoCount} · updated{' '}
|
||||
{new Date(workspace.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.35rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onLoadWorkspace(workspace.id)}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onDeleteWorkspace(workspace.id)}
|
||||
style={{
|
||||
background: '#fee2e2',
|
||||
color: '#991b1b',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(history.length > 0 || redoHistory.length > 0) && (
|
||||
<details style={{ marginTop: '0.75rem' }} open>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
|
||||
Command history ({history.length} undo / {redoHistory.length} redo)
|
||||
</summary>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
{history.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#374151',
|
||||
borderLeft: '3px solid #2563eb',
|
||||
paddingLeft: '0.45rem',
|
||||
paddingTop: '0.2rem',
|
||||
paddingBottom: '0.2rem',
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Undo {history.length - index}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: '#6b7280' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
margin: '0.25rem 0',
|
||||
borderRadius: '999px',
|
||||
background: '#ecfdf5',
|
||||
color: '#166534',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
alignSelf: 'flex-start',
|
||||
border: '2px solid #166534',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
</div>
|
||||
|
||||
{redoHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#9ca3af',
|
||||
borderLeft: '3px solid #d1d5db',
|
||||
paddingLeft: '0.45rem',
|
||||
paddingTop: '0.2rem',
|
||||
paddingBottom: '0.2rem',
|
||||
opacity: 0.75,
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
Redo {index + 1}. {entry.label}
|
||||
</strong>
|
||||
<br />
|
||||
<span style={{ color: '#9ca3af' }}>
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspacePanel;
|
||||
export default WorkspacePanel;
|
||||
|
||||
Reference in New Issue
Block a user