Follow-up to #20, which closed the other half of this. PR #18 gave the PDF Editor's optional compression step a Skip compression control. The edits that run before it — merge, organize, watermark, extract — still have no way out.
The gap
runWithPopup() (PdfWorkspace.ts) opens with showPopup(wrap, true). persistent: true means ModalManager.handleEscape returns early, and no onEscape is supplied, so Escape does nothing and there is no button. The only exit is a page reload, which loses the loaded batch.
Smaller than the compression gap that #20 covered — these are local pdf-lib operations rather than a 16 MB fetch plus a WASM compile — but a watermark across several hundred pages, or a merge of large scans, still parks you on an undismissable spinner.
There is a second, related problem: these run synchronously on the main thread, so the tab is frozen while they work. Even the spinner stops animating. Fixing cancellation fixes this too, because the mechanism is the same.
Why it is tractable
Both slow operations are already loops:
organize() and extract() are single-document rebuilds and matter less, though they take the same treatment for free.
So this is textbook cooperative cancellation. No worker, no restructuring.
Sketch
-
A small shared helper in src/tools/:
export class PdfEditCancelled extends Error {}
/** Yield to the event loop, then abort if asked to. */
export async function checkpoint(signal?: AbortSignal): Promise<void> {
await new Promise(r => setTimeout(r, 0));
if (signal?.aborted) throw new PdfEditCancelled();
}
The yield is load-bearing twice over: it lets the click that sets aborted actually be processed (a synchronous loop never gives the event loop a chance to deliver it), and it unfreezes the UI so the spinner animates.
-
merge(files, signal?) awaits checkpoint(signal) per file; watermark(bytes, name, opts, signal?) every N pages — N tuned so the yield cost stays negligible on a small document.
-
runWithPopup takes an optional AbortController, renders a cancel button when one is supplied, and treats PdfEditCancelled as a neutral outcome rather than routing it through showError / appendSupportContact. A cancel is not a failure and must not be reported as one — the same rule that made Compress say stopped instead of failed.
-
src/tools/ stays UI-free (the discipline src/core/ follows): an AbortSignal in, no imports out.
Copy
These are main-thread handlers, so an in-flight page cannot be torn out mid-draw. The honest promise is "finishes the page it is on, then stops", which lands within one page of work. Do not reuse the Compress wording, which now promises an immediate stop and can deliver it.
Acceptance
Non-goals
- Moving PDF edits into a worker. Larger change, and cooperative cancellation solves the user-visible problem without it.
- Resuming a cancelled edit.
Related: #12 (Compress epic), #20 (cancellation hardening), PR #18.
Follow-up to #20, which closed the other half of this. PR #18 gave the PDF Editor's optional compression step a Skip compression control. The edits that run before it — merge, organize, watermark, extract — still have no way out.
The gap
runWithPopup()(PdfWorkspace.ts) opens withshowPopup(wrap, true).persistent: truemeansModalManager.handleEscapereturns early, and noonEscapeis supplied, so Escape does nothing and there is no button. The only exit is a page reload, which loses the loaded batch.Smaller than the compression gap that #20 covered — these are local pdf-lib operations rather than a 16 MB fetch plus a WASM compile — but a watermark across several hundred pages, or a merge of large scans, still parks you on an undismissable spinner.
There is a second, related problem: these run synchronously on the main thread, so the tab is frozen while they work. Even the spinner stops animating. Fixing cancellation fixes this too, because the mechanism is the same.
Why it is tractable
Both slow operations are already loops:
merge()(src/tools/pdfMerge.ts) iterates over source files, loading and copying pages from each.watermark()(src/tools/pdfWatermark.ts) iterates over pages, stamping each one.organize()andextract()are single-document rebuilds and matter less, though they take the same treatment for free.So this is textbook cooperative cancellation. No worker, no restructuring.
Sketch
A small shared helper in
src/tools/:The yield is load-bearing twice over: it lets the click that sets
abortedactually be processed (a synchronous loop never gives the event loop a chance to deliver it), and it unfreezes the UI so the spinner animates.merge(files, signal?)awaitscheckpoint(signal)per file;watermark(bytes, name, opts, signal?)every N pages — N tuned so the yield cost stays negligible on a small document.runWithPopuptakes an optionalAbortController, renders a cancel button when one is supplied, and treatsPdfEditCancelledas a neutral outcome rather than routing it throughshowError/appendSupportContact. A cancel is not a failure and must not be reported as one — the same rule that made Compress say stopped instead of failed.src/tools/stays UI-free (the disciplinesrc/core/follows): anAbortSignalin, no imports out.Copy
These are main-thread handlers, so an in-flight page cannot be torn out mid-draw. The honest promise is "finishes the page it is on, then stops", which lands within one page of work. Do not reuse the Compress wording, which now promises an immediate stop and can deliver it.
Acceptance
src/tools/gains no UI imports.Non-goals
Related: #12 (Compress epic), #20 (cancellation hardening), PR #18.