open as worspace implementation

This commit is contained in:
2026-05-17 02:31:50 +02:00
parent 07f4361573
commit a5dc70aabf
13 changed files with 445 additions and 162 deletions

View File

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

View File

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

View File

@@ -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.",
},
];

View File

@@ -1 +1 @@
export const APP_VERSION = "0.2.1";
export const APP_VERSION = "0.3.0";

View File

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

View 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'
);
});
});

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