open as worspace implementation
This commit is contained in:
223
src/App.tsx
223
src/App.tsx
@@ -17,6 +17,7 @@ import type {
|
||||
import {
|
||||
createInitialPageRefs,
|
||||
createPageRefId,
|
||||
createPdfId,
|
||||
createWorkspaceId,
|
||||
defaultWorkspaceNameFromPdfName,
|
||||
normalizeRotation,
|
||||
@@ -37,6 +38,11 @@ import {
|
||||
} from "./pdf/pdfService";
|
||||
import { usePdfThumbnails } from "./pdf/usePdfThumbnails";
|
||||
import { usePdfGeneratedOutputs } from "./hooks/usePdfGeneratedOutputs";
|
||||
import {
|
||||
createSelectionPdfName,
|
||||
createSelectionWorkspaceName,
|
||||
getSelectedPagesInVisualOrder,
|
||||
} from './workspace/workspaceSelection';
|
||||
|
||||
function isEditableKeyboardTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
@@ -127,6 +133,21 @@ const App: React.FC = () => {
|
||||
onError: handleThumbnailError,
|
||||
});
|
||||
|
||||
const closeActionDialog = useCallback(() => {
|
||||
setActionDialog(null);
|
||||
}, []);
|
||||
|
||||
const openActionDialog = useCallback(
|
||||
(dialog: {
|
||||
title: string;
|
||||
content: React.ReactNode;
|
||||
actions: ActionDialogAction[];
|
||||
}) => {
|
||||
setActionDialog(dialog);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const refreshWorkspaces = async () => {
|
||||
try {
|
||||
const summaries = await listWorkspaces();
|
||||
@@ -668,7 +689,41 @@ const App: React.FC = () => {
|
||||
setLastSelectedVisualIndex(null);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const performDeleteSelected = useCallback(
|
||||
(pageIdsToDelete: string[]) => {
|
||||
if (pageIdsToDelete.length === 0) return;
|
||||
|
||||
const before = getCurrentCommandState();
|
||||
const selectedSet = new Set(pageIdsToDelete);
|
||||
|
||||
executeWorkspaceCommand(
|
||||
createWorkspaceCommand({
|
||||
type: 'pages.delete',
|
||||
label:
|
||||
pageIdsToDelete.length === 1
|
||||
? 'Deleted selected page'
|
||||
: `Deleted ${pageIdsToDelete.length} selected pages`,
|
||||
before,
|
||||
after: {
|
||||
pages: pages.filter((page) => !selectedSet.has(page.id)),
|
||||
selectedPageIds: [],
|
||||
lastSelectedVisualIndex: null,
|
||||
},
|
||||
details: {
|
||||
count: pageIdsToDelete.length,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[
|
||||
createWorkspaceCommand,
|
||||
executeWorkspaceCommand,
|
||||
getCurrentCommandState,
|
||||
pages,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
if (selectedPageIds.length === 0) return;
|
||||
|
||||
const idsToDelete = [...selectedPageIds];
|
||||
@@ -706,33 +761,12 @@ const App: React.FC = () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const performDeleteSelected = (pageIdsToDelete: string[]) => {
|
||||
if (pageIdsToDelete.length === 0) return;
|
||||
|
||||
const before = getCurrentCommandState();
|
||||
const selectedSet = new Set(pageIdsToDelete);
|
||||
|
||||
executeWorkspaceCommand(
|
||||
createWorkspaceCommand({
|
||||
type: "pages.delete",
|
||||
label:
|
||||
pageIdsToDelete.length === 1
|
||||
? "Deleted selected page"
|
||||
: `Deleted ${pageIdsToDelete.length} selected pages`,
|
||||
before,
|
||||
after: {
|
||||
pages: pages.filter((page) => !selectedSet.has(page.id)),
|
||||
selectedPageIds: [],
|
||||
lastSelectedVisualIndex: null,
|
||||
},
|
||||
details: {
|
||||
count: pageIdsToDelete.length,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
}, [
|
||||
closeActionDialog,
|
||||
openActionDialog,
|
||||
performDeleteSelected,
|
||||
selectedPageIds,
|
||||
]);
|
||||
|
||||
const handleCopyPagesToSlot = (pageIds: string[], insertSlot: number) => {
|
||||
if (!pdf || pageIds.length === 0) return;
|
||||
@@ -780,18 +814,6 @@ const App: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const closeActionDialog = () => {
|
||||
setActionDialog(null);
|
||||
};
|
||||
|
||||
const openActionDialog = (dialog: {
|
||||
title: string;
|
||||
content: React.ReactNode;
|
||||
actions: ActionDialogAction[];
|
||||
}) => {
|
||||
setActionDialog(dialog);
|
||||
};
|
||||
|
||||
const handleOpenPreview = (pageId: string) => {
|
||||
setPreviewPageId(pageId);
|
||||
};
|
||||
@@ -909,8 +931,13 @@ const App: React.FC = () => {
|
||||
setIsBusy(true);
|
||||
|
||||
try {
|
||||
const selectedSet = new Set(selectedPageIds);
|
||||
const selectedPages = pages.filter((page) => selectedSet.has(page.id));
|
||||
const selectedPages = getSelectedPagesInVisualOrder(
|
||||
pages,
|
||||
selectedPageIds
|
||||
);
|
||||
|
||||
if (selectedPages.length === 0) return;
|
||||
|
||||
const blob = await exportPages(pdf, selectedPages);
|
||||
const base = pdf.name.replace(/\.pdf$/i, "");
|
||||
const filename = `${base}_selected.pdf`;
|
||||
@@ -923,6 +950,117 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const performOpenSelectionAsWorkspace = async () => {
|
||||
if (!pdf || selectedPageIds.length === 0) return;
|
||||
|
||||
const selectedPages = getSelectedPagesInVisualOrder(pages, selectedPageIds);
|
||||
if (selectedPages.length === 0) return;
|
||||
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
|
||||
try {
|
||||
const selectedPageCount = selectedPages.length;
|
||||
const blob = await exportPages(pdf, selectedPages);
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const doc = await PDFDocument.load(arrayBuffer);
|
||||
const pdfName = createSelectionPdfName(pdf.name, selectedPageCount);
|
||||
const workspaceName = createSelectionWorkspaceName(
|
||||
pdf.name,
|
||||
selectedPageCount
|
||||
);
|
||||
|
||||
const extractedPdf: PdfFile = {
|
||||
id: createPdfId(),
|
||||
name: pdfName,
|
||||
doc,
|
||||
pageCount: doc.getPageCount(),
|
||||
arrayBuffer,
|
||||
};
|
||||
|
||||
setPdf(extractedPdf);
|
||||
replaceWorkspaceState({
|
||||
pages: createInitialPageRefs(extractedPdf.pageCount),
|
||||
selectedPageIds: [],
|
||||
lastSelectedVisualIndex: null,
|
||||
history: [],
|
||||
redoHistory: [],
|
||||
dirty: true,
|
||||
message: `Created a new workspace from ${selectedPageCount} selected ${
|
||||
selectedPageCount === 1 ? "page" : "pages"
|
||||
}.`,
|
||||
});
|
||||
|
||||
setActiveWorkspaceId(null);
|
||||
setWorkspaceName(workspaceName);
|
||||
setPreviewPageId(null);
|
||||
clearGeneratedOutputs();
|
||||
clearThumbnailCache();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Error while opening selection as a new workspace.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSelectionAsWorkspace = () => {
|
||||
if (!pdf || selectedPageIds.length === 0) return;
|
||||
|
||||
const selectedPages = getSelectedPagesInVisualOrder(pages, selectedPageIds);
|
||||
if (selectedPages.length === 0) return;
|
||||
|
||||
if (!workspaceDirty) {
|
||||
void performOpenSelectionAsWorkspace();
|
||||
return;
|
||||
}
|
||||
|
||||
openActionDialog({
|
||||
title: "Open selection as new workspace?",
|
||||
content: (
|
||||
<>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
This will replace the current in-memory workspace with a new
|
||||
workspace built from {selectedPages.length}{' '}
|
||||
{selectedPages.length === 1 ? "selected page" : "selected pages"}.
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
The current workspace has unsaved changes. Do you want to save it
|
||||
before opening the selection as a new workspace?
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: "Cancel",
|
||||
variant: "secondary",
|
||||
onClick: closeActionDialog,
|
||||
},
|
||||
{
|
||||
label: "Open without saving",
|
||||
variant: "danger",
|
||||
onClick: () => {
|
||||
closeActionDialog();
|
||||
void performOpenSelectionAsWorkspace();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Save and open",
|
||||
variant: "primary",
|
||||
autoFocus: true,
|
||||
onClick: async () => {
|
||||
closeActionDialog();
|
||||
const saved = await handleSaveWorkspace();
|
||||
if (saved) {
|
||||
await performOpenSelectionAsWorkspace();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportReordered = async () => {
|
||||
if (!pdf || pages.length === 0) return;
|
||||
setError(null);
|
||||
@@ -1102,6 +1240,7 @@ const App: React.FC = () => {
|
||||
selectedCount={selectedPageIds.length}
|
||||
onSplit={handleSplit}
|
||||
onExtractSelected={handleExtractSelected}
|
||||
onOpenSelectionAsWorkspace={handleOpenSelectionAsWorkspace}
|
||||
onExportReordered={handleExportReordered}
|
||||
splitDownloads={splitDownloads}
|
||||
subsetDownload={subsetDownload}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ActionsPanelProps {
|
||||
|
||||
onSplit: () => void;
|
||||
onExtractSelected: () => void;
|
||||
onOpenSelectionAsWorkspace: () => void;
|
||||
onExportReordered: () => void;
|
||||
|
||||
splitDownloads: SplitPdfDownload[];
|
||||
@@ -25,6 +26,7 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
selectedCount,
|
||||
onSplit,
|
||||
onExtractSelected,
|
||||
onOpenSelectionAsWorkspace,
|
||||
onExportReordered,
|
||||
splitDownloads,
|
||||
subsetDownload,
|
||||
@@ -37,6 +39,11 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
onExtractSelected();
|
||||
};
|
||||
|
||||
const handleOpenSelectionAsWorkspaceClick = () => {
|
||||
if (selectedCount === 0) return;
|
||||
onOpenSelectionAsWorkspace();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Tools</h2>
|
||||
@@ -72,6 +79,20 @@ const ActionsPanel: React.FC<ActionsPanelProps> = ({
|
||||
📤 Extract selected ({selectedCount})
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={disabled || selectedCount === 0}
|
||||
onClick={handleOpenSelectionAsWorkspaceClick}
|
||||
style={{ flex: '1 1 45%' }}
|
||||
title={
|
||||
selectedCount === 0
|
||||
? 'Select at least one page'
|
||||
: 'Open selected pages as a new unsaved workspace'
|
||||
}
|
||||
>
|
||||
🧩 Open selection as workspace
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -45,11 +45,15 @@ const tutorialSteps = [
|
||||
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",
|
||||
title: "4. Extract selected pages or branch into a new workspace",
|
||||
body: "Extract selected pages when you only need a download. Open the selection as a new workspace when you want to continue working on that subset.",
|
||||
},
|
||||
{
|
||||
title: "5. 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",
|
||||
title: "6. Use history deliberately",
|
||||
body: "Each workspace operation is stored as a command with label and timestamp. Undo and redo walk through that command history.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.2.1";
|
||||
export const APP_VERSION = "0.3.0";
|
||||
|
||||
@@ -23,6 +23,10 @@ export function createWorkspaceId(): string {
|
||||
return createId("workspace");
|
||||
}
|
||||
|
||||
export function createPdfId(): string {
|
||||
return createId('pdf');
|
||||
}
|
||||
|
||||
export function defaultWorkspaceNameFromPdfName(pdfName: string): string {
|
||||
return pdfName.replace(/\.pdf$/i, "") || "Untitled workspace";
|
||||
}
|
||||
|
||||
39
src/workspace/workspaceSelection.test.ts
Normal file
39
src/workspace/workspaceSelection.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PageRef } from '../pdf/pdfTypes';
|
||||
import {
|
||||
createSelectionPdfName,
|
||||
createSelectionWorkspaceName,
|
||||
getSelectedPagesInVisualOrder,
|
||||
} from './workspaceSelection';
|
||||
|
||||
function page(id: string, sourcePageIndex: number): PageRef {
|
||||
return { id, sourcePageIndex, rotation: 0 };
|
||||
}
|
||||
|
||||
describe('workspaceSelection', () => {
|
||||
it('returns selected pages in current visual order', () => {
|
||||
const pages = [page('page-3', 2), page('page-1', 0), page('page-2', 1)];
|
||||
|
||||
expect(getSelectedPagesInVisualOrder(pages, ['page-2', 'page-3'])).toEqual([
|
||||
pages[0],
|
||||
pages[2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores selected ids that are no longer present', () => {
|
||||
const pages = [page('page-1', 0), page('page-2', 1)];
|
||||
|
||||
expect(getSelectedPagesInVisualOrder(pages, ['missing', 'page-2'])).toEqual(
|
||||
[pages[1]]
|
||||
);
|
||||
});
|
||||
|
||||
it('creates readable derived workspace and PDF names', () => {
|
||||
expect(createSelectionWorkspaceName('contract.pdf', 3)).toBe(
|
||||
'contract - 3-page-selection'
|
||||
);
|
||||
expect(createSelectionPdfName('contract.pdf', 1)).toBe(
|
||||
'contract - 1-page-selection.pdf'
|
||||
);
|
||||
});
|
||||
});
|
||||
31
src/workspace/workspaceSelection.ts
Normal file
31
src/workspace/workspaceSelection.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { PageRef } from '../pdf/pdfTypes';
|
||||
|
||||
export function getSelectedPagesInVisualOrder(
|
||||
pages: PageRef[],
|
||||
selectedPageIds: string[]
|
||||
): PageRef[] {
|
||||
if (pages.length === 0 || selectedPageIds.length === 0) return [];
|
||||
|
||||
const selectedSet = new Set(selectedPageIds);
|
||||
return pages.filter((page) => selectedSet.has(page.id));
|
||||
}
|
||||
|
||||
export function createSelectionWorkspaceName(
|
||||
pdfName: string,
|
||||
selectedPageCount: number
|
||||
): string {
|
||||
const baseName = pdfName.replace(/\.pdf$/i, '') || 'Untitled';
|
||||
const suffix =
|
||||
selectedPageCount === 1
|
||||
? '1-page-selection'
|
||||
: `${selectedPageCount}-page-selection`;
|
||||
|
||||
return `${baseName} - ${suffix}`;
|
||||
}
|
||||
|
||||
export function createSelectionPdfName(
|
||||
pdfName: string,
|
||||
selectedPageCount: number
|
||||
): string {
|
||||
return `${createSelectionWorkspaceName(pdfName, selectedPageCount)}.pdf`;
|
||||
}
|
||||
Reference in New Issue
Block a user