Plugins
Plugins add optional features to the MarkUp SDK without inflating the base bundle. You pass them to render(); the SDK wires them up alongside its built-in UI and tears them down when you call destroy() — including any host-page UI a plugin mounted, such as the screenshot plugin’s element picker, and any event subscription it took out through host.events.
The only shipped plugin today is the Screenshot plugin. Load it when you want users to attach a picture of what they’re commenting on.
Screenshot plugin
Adds a screenshot button to the new-comment action row. The user picks an element on the page, and the SDK captures it to a PNG and attaches it to the draft comment — same upload pipeline as the built-in paperclip button, so it lands on the thread when the comment is submitted.
Two modes:
- Manual (default) — the user clicks the screenshot button, then clicks the element they want to capture.
- Auto — the moment the user places a pin, the SDK captures the element under the pin automatically. The composer opens immediately; the screenshot arrives as a pending upload shortly after (~300 ms) so the user can start typing without waiting.
The plugin ships as its own bundle so pages that don’t use it never include it in the bundle.
Install the plugin
npm
The plugin is a subpath of the same @ceros-dev/markup-sdk package you already installed — nothing new to add to package.json:
import {MarkUpSDK} from "@ceros-dev/markup-sdk/ui";
import {screenshotPlugin} from "@ceros-dev/markup-sdk/plugins/screenshot";
MarkUpSDK.init({publicKey: "<YOUR_PUBLIC_KEY>", markupId: "<YOUR_MARKUP_ID>"}).render({
plugins: [screenshotPlugin()]
});import {MarkUpSDK} from "@ceros-dev/markup-sdk/ui";
import {screenshotPlugin} from "@ceros-dev/markup-sdk/plugins/screenshot";
MarkUpSDK.init({publicKey: "<YOUR_PUBLIC_KEY>", markupId: "<YOUR_MARKUP_ID>"}).render({
plugins: [screenshotPlugin({auto: true})]
});Options
Everything is optional — screenshotPlugin() with no arguments gives you the button with sensible defaults.
| Option | Type | |
|---|---|---|
| auto optional | boolean | When |
| label optional | string | Tooltip and aria-label for the screenshot button. Default: |
| filename optional | string | Filename the captured Blob is uploaded as. Default: |
| selectionHint optional | string | Hint text shown at the top of the viewport during element picking. Default: a sensible built-in hint. |
| mimeType optional | string | MIME type for the resulting Blob. Default: |
| backgroundColor optional | string | Fill color used for regions the target element leaves transparent. When unset, the plugin walks up the target's ancestors and picks the first opaque background color, falling back to white. Set this only to override that heuristic (e.g. force a dark background). |
| scale optional | number | Output scale multiplier. |
| embedFonts optional | boolean | Whether custom typefaces are inlined into the screenshot so it renders with the same fonts the user sees. Turn off for a small performance boost if the target uses only system fonts, or if font requests are being blocked by the network. Default: |
| proxyUrl optional | string | URL prefix for a CORS-forwarding proxy you run on your own origin. When set, cross-origin images, background images, and |
Sensible defaults
The plugin picks the two options you’d almost always want:
backgroundColoris computed by walking up the ancestor chain from the target element and picking the first non-transparent background color. Most page elements are transparent, so without this the screenshot would come out on a plain white background regardless of what the user actually sees behind the element.embedFonts: true— custom typefaces used by the target are inlined into the screenshot so it renders with exactly the fonts the user sees on the page.
Both can be overridden:
markup.render({
plugins: [
screenshotPlugin({
backgroundColor: "#000000",
scale: 2
})
]
});What ends up on the thread
The captured Blob flows through the same S3 upload pipeline as any other attachment — the user submits the comment and the screenshot lands on the thread as an image attachment. No new API surface, no separate endpoint. If the user removes the pending screenshot before submitting (via the X on the thumbnail), it never uploads.
If the capture fails — most often because the plugin can’t reach a cross-origin asset on the page — the SDK’s built-in snackbar surfaces the error, the composer stays open, and the user can retry or submit without the screenshot.
Handling cross-origin assets
Images, background images, and @font-face files served from another origin without permissive CORS headers can’t be inlined into the screenshot — the browser blocks the fetch and the plugin falls back to a blank rectangle.
Because the SDK runs on your own origin, the practical fix is a small CORS-forwarding proxy that lives on that same origin. Configure it via proxyUrl and the plugin routes cross-origin asset requests through it automatically.
The proxy contract
proxyUrl is a URL prefix. Given an asset URL https://cdn.other.com/pic.png, the plugin fetches ${proxyUrl}${encodeURIComponent("https://cdn.other.com/pic.png")}. Your proxy needs to:
- Read the URL from the query (or path — whatever your prefix format is).
- Fetch it server-side.
- Stream the bytes back with an
Access-Control-Allow-Originheader that permits your app’s origin (or*for a locked-down internal proxy).
A minimal Node/Express sketch — treat it as a starting point, not production-ready code:
app.get("/api/cors-proxy", async (req, res) => {
const url = String(req.query.url ?? "");
// Lock the proxy down — SEE THE SECURITY NOTE BELOW.
if (!isAllowed(url)) {
return res.status(403).end();
}
const upstream = await fetch(url);
res.set("Access-Control-Allow-Origin", "https://myapp.example.com");
res.set("Content-Type", upstream.headers.get("content-type") ?? "application/octet-stream");
upstream.body.pipe(res);
});And in the plugin:
markup.render({
plugins: [
screenshotPlugin({
auto: true,
proxyUrl: "https://myapp.example.com/api/cors-proxy?url="
})
]
});Security note — treat the proxy as a potential SSRF vector
An unconstrained URL proxy lets an attacker fetch arbitrary URLs through your backend, including your internal network and cloud metadata endpoints. Before shipping the proxy:
- Allowlist domains you know you need to inline (the CDNs / origins your app actually uses).
- Reject private IP ranges (RFC1918, link-local, loopback) and the cloud metadata IPs (
169.254.169.254, etc.). - Require authentication — a signed request from your app, a session cookie, or an origin check — so unauthenticated visitors can’t hit it.
- Rate-limit by IP and by user to keep it from becoming a free bandwidth relay.
If you’re not comfortable owning that surface, ship the plugin without proxyUrl — cross-origin assets will just be blank in the screenshot, which is fine for a lot of use cases.
Auto-capture vs manual
| Manual | Auto ({auto: true}) | |
|---|---|---|
| Trigger | User clicks the screenshot button | User places a pin |
| What’s captured | The element the user picks after clicking the button | The element that was highlighted when the user placed the pin |
| Composer wait | Composer already open before the picker runs | Composer opens immediately, screenshot arrives as a pending upload shortly after |
| Button visible? | Yes | Yes — for retake / additional captures |
| Best for | Occasional / precise screenshots of a specific region | ”Every comment gets a screenshot” workflows — bug reports, design feedback |
Known limitations
- Cross-origin iframes inside the captured element render blank in the resulting PNG. The plugin operates on the top-frame DOM only. Same-origin iframes are captured normally.
- Cross-origin assets without CORS headers (images, background images, fonts) render blank in the PNG unless
proxyUrlis configured. Rare for fonts — most font CDNs send permissive CORS — more common for images. - First capture on a page is slower while the plugin fetches each
@font-facefile. Subsequent captures on the same page are fast — the plugin caches font data internally for the lifetime of the page. - Chrome / Safari / Firefox / Edge at the same versions the SDK supports (Chrome 90+, Firefox 88+, Safari 14+, Edge 90+).
Bundle size
The screenshot plugin bundle is roughly 155 KB minified (~50 KB gzipped) — dominated by the DOM-to-image capture engine it bundles. Because it ships as a separate entry (@ceros-dev/markup-sdk/plugins/screenshot on npm), it doesn’t affect the size of the core SDK bundle for consumers who don’t use it.