Skip to content

Commit e9e1e39

Browse files
ochafikclaude
andcommitted
pdf-server: add clipboard support (copy/cut/paste annotations and images)
- Request clipboardWrite permission in resource metadata - Ctrl/Cmd+C: copy selected annotations as JSON to clipboard - Ctrl/Cmd+X: cut (copy + delete) selected annotations - Ctrl/Cmd+V / paste event: paste annotations from clipboard JSON, or paste images from clipboard (e.g. screenshots, copied images) - Refactor drag-drop image creation into shared addImageFromFile() - Pasted annotations get new IDs and slight offset to avoid overlap - Pasted images are centered on the current page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9819649 commit e9e1e39

2 files changed

Lines changed: 235 additions & 64 deletions

File tree

examples/pdf-server/server.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2144,7 +2144,16 @@ Example — add a signature image and a stamp, then screenshot to verify:
21442144
);
21452145
return {
21462146
contents: [
2147-
{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
2147+
{
2148+
uri: RESOURCE_URI,
2149+
mimeType: RESOURCE_MIME_TYPE,
2150+
text: html,
2151+
_meta: {
2152+
ui: {
2153+
permissions: { clipboardWrite: {} },
2154+
},
2155+
},
2156+
},
21482157
],
21492158
};
21502159
},

examples/pdf-server/src/mcp-app.ts

Lines changed: 225 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3842,6 +3842,45 @@ document.addEventListener("keydown", (e) => {
38423842
return;
38433843
}
38443844

3845+
// Ctrl/Cmd+C: copy selected annotations
3846+
if ((e.ctrlKey || e.metaKey) && e.key === "c" && !e.shiftKey) {
3847+
if (
3848+
document.activeElement instanceof HTMLInputElement ||
3849+
document.activeElement instanceof HTMLTextAreaElement
3850+
) {
3851+
return;
3852+
}
3853+
if (selectedAnnotationIds.size > 0) {
3854+
e.preventDefault();
3855+
copySelectedAnnotations();
3856+
}
3857+
return;
3858+
}
3859+
3860+
// Ctrl/Cmd+X: cut selected annotations (copy + delete)
3861+
if ((e.ctrlKey || e.metaKey) && e.key === "x" && !e.shiftKey) {
3862+
if (
3863+
document.activeElement instanceof HTMLInputElement ||
3864+
document.activeElement instanceof HTMLTextAreaElement
3865+
) {
3866+
return;
3867+
}
3868+
if (selectedAnnotationIds.size > 0) {
3869+
e.preventDefault();
3870+
copySelectedAnnotations().then((copied) => {
3871+
if (copied) {
3872+
const ids = [...selectedAnnotationIds];
3873+
selectAnnotation(null);
3874+
for (const id of ids) {
3875+
removeAnnotation(id);
3876+
}
3877+
persistAnnotations();
3878+
}
3879+
});
3880+
}
3881+
return;
3882+
}
3883+
38453884
// Ctrl/Cmd+S: save (for local files)
38463885
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
38473886
e.preventDefault();
@@ -4607,6 +4646,89 @@ app.connect().then(() => {
46074646
updateAnnotationsBadge();
46084647
});
46094648

4649+
// =============================================================================
4650+
// Image from File (shared by drag-drop and paste)
4651+
// =============================================================================
4652+
4653+
/**
4654+
* Create an image annotation from a File/Blob at the given screen position.
4655+
* If no position is given, places the image at the center of the current page.
4656+
*/
4657+
function addImageFromFile(
4658+
file: File | Blob,
4659+
screenX?: number,
4660+
screenY?: number,
4661+
): void {
4662+
const reader = new FileReader();
4663+
reader.onload = () => {
4664+
const dataUrl = reader.result as string;
4665+
const base64 = dataUrl.split(",")[1];
4666+
const mimeType =
4667+
file.type || (base64.startsWith("/9j/") ? "image/jpeg" : "image/png");
4668+
4669+
const img = new Image();
4670+
img.onload = () => {
4671+
const maxWidth = 200; // PDF points
4672+
const aspectRatio = img.naturalHeight / img.naturalWidth;
4673+
const width = Math.min(img.naturalWidth, maxWidth);
4674+
const height = width * aspectRatio;
4675+
4676+
// Convert screen position to PDF internal coords, or default to page center
4677+
let pdfX: number;
4678+
let pdfInternalY: number;
4679+
if (screenX != null && screenY != null) {
4680+
pdfX = screenX / scale;
4681+
pdfInternalY = (containerHtmlEl.clientHeight - screenY) / scale;
4682+
} else {
4683+
// Center on the visible page area
4684+
const pageW = containerHtmlEl.clientWidth / scale;
4685+
const pageH = containerHtmlEl.clientHeight / scale;
4686+
pdfX = pageW / 2 - width / 2;
4687+
pdfInternalY = pageH / 2 + height / 2;
4688+
}
4689+
4690+
const id = `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
4691+
const def: ImageAnnotation = {
4692+
type: "image",
4693+
id,
4694+
page: currentPage,
4695+
x: pdfX,
4696+
y: pdfInternalY,
4697+
width,
4698+
height,
4699+
imageData: base64,
4700+
mimeType,
4701+
};
4702+
4703+
// Downscale if base64 data is too large (> ~300KB)
4704+
if (base64.length > 400_000) {
4705+
const canvas = document.createElement("canvas");
4706+
const maxDim = 800;
4707+
let w = img.naturalWidth;
4708+
let h = img.naturalHeight;
4709+
if (w > maxDim || h > maxDim) {
4710+
const ratio = Math.min(maxDim / w, maxDim / h);
4711+
w = Math.round(w * ratio);
4712+
h = Math.round(h * ratio);
4713+
}
4714+
canvas.width = w;
4715+
canvas.height = h;
4716+
const ctx = canvas.getContext("2d")!;
4717+
ctx.drawImage(img, 0, 0, w, h);
4718+
const quality = mimeType === "image/jpeg" ? 0.7 : undefined;
4719+
const downscaledUrl = canvas.toDataURL(mimeType, quality);
4720+
def.imageData = downscaledUrl.split(",")[1];
4721+
}
4722+
4723+
addAnnotation(def);
4724+
selectAnnotation(def.id);
4725+
persistAnnotations();
4726+
};
4727+
img.src = dataUrl;
4728+
};
4729+
reader.readAsDataURL(file);
4730+
}
4731+
46104732
// =============================================================================
46114733
// Image Drag & Drop
46124734
// =============================================================================
@@ -4623,72 +4745,112 @@ containerHtmlEl.addEventListener("drop", async (e: DragEvent) => {
46234745
e.stopPropagation();
46244746
if (!e.dataTransfer?.files.length) return;
46254747

4748+
const containerRect = containerHtmlEl.getBoundingClientRect();
4749+
const dropX = e.clientX - containerRect.left;
4750+
const dropY = e.clientY - containerRect.top;
4751+
46264752
for (const file of e.dataTransfer.files) {
46274753
if (!file.type.startsWith("image/")) continue;
4754+
addImageFromFile(file, dropX, dropY);
4755+
}
4756+
});
46284757

4629-
const reader = new FileReader();
4630-
reader.onload = async () => {
4631-
const dataUrl = reader.result as string;
4632-
// Strip the data:mime;base64, prefix
4633-
const base64 = dataUrl.split(",")[1];
4634-
const mimeType = file.type;
4635-
4636-
// Determine drop position in PDF coordinates
4637-
const containerRect = containerHtmlEl.getBoundingClientRect();
4638-
const dropX = e.clientX - containerRect.left;
4639-
const dropY = e.clientY - containerRect.top;
4640-
const pdfX = dropX / scale;
4641-
4642-
// Get natural image dimensions to determine aspect ratio
4643-
const img = new Image();
4644-
img.onload = () => {
4645-
const maxWidth = 200; // PDF points
4646-
const aspectRatio = img.naturalHeight / img.naturalWidth;
4647-
const width = Math.min(img.naturalWidth, maxWidth);
4648-
const height = width * aspectRatio;
4649-
4650-
// Convert screen drop point to PDF internal coords
4651-
// The drop Y is in screen space (top-left origin), convert to PDF space (bottom-left)
4652-
const pdfInternalY = (containerHtmlEl.clientHeight - dropY) / scale;
4653-
4654-
const id = `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
4655-
const def: ImageAnnotation = {
4656-
type: "image",
4657-
id,
4658-
page: currentPage,
4659-
x: pdfX,
4660-
y: pdfInternalY,
4661-
width,
4662-
height,
4663-
imageData: base64,
4664-
mimeType,
4665-
};
4758+
// =============================================================================
4759+
// Clipboard: Copy / Cut / Paste
4760+
// =============================================================================
46664761

4667-
// Downscale if base64 data is too large (> ~300KB)
4668-
if (base64.length > 400_000) {
4669-
const canvas = document.createElement("canvas");
4670-
const maxDim = 800;
4671-
let w = img.naturalWidth;
4672-
let h = img.naturalHeight;
4673-
if (w > maxDim || h > maxDim) {
4674-
const ratio = Math.min(maxDim / w, maxDim / h);
4675-
w = Math.round(w * ratio);
4676-
h = Math.round(h * ratio);
4677-
}
4678-
canvas.width = w;
4679-
canvas.height = h;
4680-
const ctx = canvas.getContext("2d")!;
4681-
ctx.drawImage(img, 0, 0, w, h);
4682-
const quality = mimeType === "image/jpeg" ? 0.7 : undefined;
4683-
const downscaledUrl = canvas.toDataURL(mimeType, quality);
4684-
def.imageData = downscaledUrl.split(",")[1];
4685-
}
4762+
/** Clipboard format identifier so we can recognize our own data on paste. */
4763+
const CLIPBOARD_FORMAT = "pdf-annotations/v1";
46864764

4687-
addAnnotation(def);
4688-
persistAnnotations();
4689-
};
4690-
img.src = dataUrl;
4691-
};
4692-
reader.readAsDataURL(file);
4765+
/** Copy selected annotations to clipboard as JSON. Returns true if anything was copied. */
4766+
async function copySelectedAnnotations(): Promise<boolean> {
4767+
if (selectedAnnotationIds.size === 0) return false;
4768+
const defs: PdfAnnotationDef[] = [];
4769+
for (const id of selectedAnnotationIds) {
4770+
const tracked = annotationMap.get(id);
4771+
if (tracked) defs.push({ ...tracked.def });
46934772
}
4694-
});
4773+
if (defs.length === 0) return false;
4774+
4775+
const payload = JSON.stringify({
4776+
format: CLIPBOARD_FORMAT,
4777+
annotations: defs,
4778+
});
4779+
try {
4780+
await navigator.clipboard.writeText(payload);
4781+
return true;
4782+
} catch {
4783+
return false;
4784+
}
4785+
}
4786+
4787+
/** Try to parse clipboard text as our annotation format. */
4788+
function parseAnnotationClipboard(text: string): PdfAnnotationDef[] | null {
4789+
try {
4790+
const parsed = JSON.parse(text);
4791+
if (
4792+
parsed?.format === CLIPBOARD_FORMAT &&
4793+
Array.isArray(parsed.annotations)
4794+
) {
4795+
return parsed.annotations;
4796+
}
4797+
} catch {
4798+
// Not our format
4799+
}
4800+
return null;
4801+
}
4802+
4803+
/** Paste annotations or images from clipboard. */
4804+
function handlePaste(e: ClipboardEvent): void {
4805+
// Don't intercept paste in inputs
4806+
if (
4807+
document.activeElement instanceof HTMLInputElement ||
4808+
document.activeElement instanceof HTMLTextAreaElement ||
4809+
document.activeElement instanceof HTMLSelectElement
4810+
) {
4811+
return;
4812+
}
4813+
4814+
const clipboardData = e.clipboardData;
4815+
if (!clipboardData) return;
4816+
4817+
// Check for image files first
4818+
for (const item of clipboardData.items) {
4819+
if (item.type.startsWith("image/")) {
4820+
e.preventDefault();
4821+
const file = item.getAsFile();
4822+
if (file) addImageFromFile(file);
4823+
return;
4824+
}
4825+
}
4826+
4827+
// Check for text that might be our annotation format
4828+
const text = clipboardData.getData("text/plain");
4829+
if (!text) return;
4830+
4831+
const annotations = parseAnnotationClipboard(text);
4832+
if (!annotations || annotations.length === 0) return;
4833+
4834+
e.preventDefault();
4835+
4836+
// Paste with new IDs and a slight offset so they don't overlap originals
4837+
const offset = 10; // PDF points
4838+
selectAnnotation(null);
4839+
for (const def of annotations) {
4840+
def.id = `paste_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
4841+
def.page = currentPage;
4842+
if ("x" in def && typeof def.x === "number") def.x += offset;
4843+
if ("y" in def && typeof def.y === "number") def.y += offset;
4844+
if ("rects" in def && Array.isArray(def.rects)) {
4845+
for (const r of def.rects) {
4846+
r.x += offset;
4847+
r.y += offset;
4848+
}
4849+
}
4850+
addAnnotation(def);
4851+
selectAnnotation(def.id, true);
4852+
}
4853+
persistAnnotations();
4854+
}
4855+
4856+
document.addEventListener("paste", handlePaste);

0 commit comments

Comments
 (0)