Skip to content

PDF Editor: the edits themselves cannot be cancelled #21

Description

@ogfrench

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

  1. 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.

  2. 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.

  3. 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.

  4. 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

  • A merge or watermark in progress shows a cancel control; pressing it returns to the workspace with the batch intact, within roughly one page or one file of work.
  • Escape reaches the same path.
  • A cancelled edit is never reported as an error, and never offers the support contact.
  • The tab stays responsive during a long watermark (the spinner keeps animating).
  • src/tools/ gains no UI imports.
  • Tests: a signal aborted mid-loop stops early and leaves no partial file; an un-aborted run is byte-identical to today's output.

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions