The editor
The screen a person sees between pressing your button and the file being sent.
The editor
Turn it on and the user gets a markup pass before anything is sent. Eleven tools, each on a number key:
- Arrow, line, box, ellipse, freehand, highlighter, text — the usual. Hold Shift to snap an arrow to 45° or keep a box square.
- Numbered steps — auto-incrementing badges for “do this, then this”. Delete one and the rest renumber, because a sequence with a gap in it defeats the point.
- Spotlight — dims everything outside a region. Where an arrow says “look here”, this says “and ignore all of that”.
- Redact — pixelates irreversibly, before the image is encoded.
- Crop, and a caption field.
Changing an annotation after you draw it
Pick select and click a shape. It gets an outline, eight grips and a rotate handle above it:
- Drag the shape to move it, or press
Deleteto remove it. - Drag a grip to resize. Stroke weight and text size scale with the shape, so a box dragged to twice the size doesn’t end up hairline. Hold
Shiftto keep the proportions — numbered badges and text do that anyway, since a stretched circle or squashed label just looks broken. - Drag the top handle to rotate.
Shiftsnaps to 15°. - Click a colour or move the stroke slider and it applies to the selected shape, not just the next one you draw. Every annotation stays editable for as long as the editor is open.
⌘Z / ⇧⌘Z undo and redo all of it, and the copy button puts the annotated image straight on the clipboard.
Turning the capture, and the theme
The two buttons beside the zoom controls turn the whole capture in 90° steps — for a screenshot that arrived on its side, or a tall page that reads better landscape. It applies to the exported file as well as the preview, and rotating four times is exactly where you started: annotations are held in the capture’s own frame and the turn is applied when the image is drawn, so nothing is resampled.
Next to it, Light / Dark / Match system. The project’s theme setting decides what somebody sees the first time; after that their own choice is remembered in their browser. Match system follows the OS live, so an editor left open at sunset changes with everything else.
const s2a = createClient({
publishableKey: 'pk_live_…',
editor: {
enabled: true,
// The last word somebody reads before their screen leaves their machine.
// Worth saying where it goes, not just that it goes.
confirmLabel: 'Attach to ticket',
},
})If they close it without confirming, capture() resolves to null — a cancel isn’t an error, so you don’t need a try/catch around it.
const result = await s2a.capture({ target: '#report' })
if (!result) return // user backed outYou can also flip the editor on from the dashboard without redeploying, since the SDK reads project settings at runtime.
The Save a copy menu in the header downloads the result without uploading it, and offers exactly the formats the call asked for — request ['png', 'pdf'] and that is what the menu lists. `formats` is a statement about what a capture is for, and the menu should not contradict it by handing your user an SVG of something you only ever meant to be a picture. Two of those depend on what’s happened to the capture: SVG and HTML are the vector document, and the reason to want them is that the text stays selectable, so they switch off the moment anything is drawn or cropped rather than handing you an .svg with a flat bitmap inside it. CSV is unaffected either way — it came from the DOM, not the pixels.
The caption
The caption is often the most useful part of a capture — it is the sentence that says what was wrong — so it is a small rich text field rather than a single-line input: bold, italic, underline, strikethrough, bulleted and numbered lists, and links. Deliberately no images or embeds; a caption travels to a dashboard, a webhook and a PDF footer, and a pasted data URL would be megabytes arriving where a sentence was expected. Pasting is taken as plain text, so a caption cannot inherit a font or a background from wherever it was copied.
You get both versions, and they are additional rather than alternative:
const result = await s2a.capture({ target: '#chart' })
result.caption // 'The VAT line is wrong' — plain text, always present
result.captionHtml // 'The <b>VAT</b> line is <i>wrong</i>' — only if formattedThe webhook carries the same pair as caption and caption_html. Anything expecting a string keeps working, and the PDF footer and filename use the plain version, because markup in either would render as tag soup.
captionHtml is sanitized twice — in the SDK before it is sent, and again on our server before it is stored. The second is the one that counts, since the SDK is the part an attacker replaces. What survives is a short list of formatting tags with no attributes at all, except href on a link limited to http, https, mailto and tel, rewritten with rel="noopener noreferrer nofollow". Scripts, styles, iframes and event handlers are dropped along with their contents. You can render caption_html directly; it is still your call whether you trust it, and caption is there if you would rather not.
Who finishes the capture
By default the editor shows a confirm button and the save menu is a side door — right when the user is reporting something and has no reason to want a copy.
editor: { enabled: true, finishOn: 'download' }With finishOn: 'download' the confirm button goes and saving a copy is what completes the capture. The user picks what to keep — one format or all of them — and every format the call asked for is uploaded and delivered regardless. What they take away and what your webhook receives are separate questions; tying them together would mean a webhook that gets less because somebody only wanted a PNG for an email.
The menu still offers Upload without saving for the person who reviewed the capture and does not want a file, so nobody is forced to download to finish.
The labels change with the mode, deliberately. In download mode the menu reads Upload & save as rather than Save as, because a control labelled “save a copy” that also puts the capture on somebody else’s server is a small dishonesty, and the person pressing it should know before rather than after. The confirm button says Upload for the same reason — confirmLabel overrides it where a different word is the honest one.
Putting your own name on it
The header credits screen2api by default. On Pro and Business you can change it or remove it — from project settings, which needs no redeploy, or in code:
editor: {
enabled: true,
branding: {
prefix: 'Reports by', // '' to drop it
label: 'Acme',
href: 'https://acme.com', // null for no link
// or replace the lot with your own markup:
html: '<img src="data:image/svg+xml;base64,…" style="height:14px" /> <strong>Acme</strong>',
// or take it away entirely:
// show: false,
},
}html is for a logo beside styled text, and inline styles work. Scripts, event handlers and javascript: URLs are stripped before it renders — this markup can arrive from project settings over the network, and nothing we deliver should be able to run code on your page. Images have to be data URLs, since the editor loads nothing externally.
Entitlement is checked when the config is served, not only when it’s saved. If a subscription lapses the credit comes back, and the settings are kept rather than erased, so resubscribing restores them.
Comparing two captures
Visual regression, run in the browser that already has both images. The usual way to do this needs a headless renderer, a CI runner and a service to hold baselines. Here the render already happened on the user’s machine, so the comparison is a loop over two pixel buffers.
import { diffCanvases, canvasFromUrl } from '@screen2api/sdk'
const baseline = await canvasFromUrl(storedBaselineUrl)
const current = await s2a.toCanvas({ target: '#invoice' })
const diff = diffCanvases(baseline, current, {
threshold: 0.1, // 0-1, default 0.1
ignore: [{ x: 290, y: 240, w: 110, h: 50 }], // the clock
minRegion: 24, // ignore specks
})
if (!diff.identical) {
console.log(diff.regions) // [{ x: 16, y: 96, w: 176, h: 32, pixels: 934 }]
showToUser(diff.canvas) // unchanged dimmed, changes boxed in red
}Two things make the difference between a check people act on and one they mute.
- The comparison is perceptual, not exact. Identical pages produce non-identical bytes constantly — antialiasing, subpixel text, a gradient dithered differently. Pixels are compared in a luminance-weighted space with a tolerance, so nudging every pixel by 2/255 reports nothing changed, which is the honest answer.
- You get places, not a percentage. “0.78% of pixels changed” is not actionable; “a 176×32 box at 16,96” is. Changed pixels are clustered into rectangles and sorted biggest first.
Different sizes are reported as sizeMismatch rather than resized into agreement: a layout that changed height is the regression, and scaling one to match would hide it and then call every pixel changed.