Skip to content

Commit 1e631ac

Browse files
rushelexclaude
andauthored
fix(vscode): bridge clipboard and forward keystrokes in webview (#970)
The Plannotator app runs in a nested cross-origin iframe inside the VS Code webview, where two input paths break: - Clipboard: the iframe document never holds focus, so the async Clipboard API is rejected ("Document is not focused") and native copy/cut/paste events never fire. Copy, cut, and paste all silently fail. - Keybindings: keydown events don't cross the iframe boundary, so VS Code never sees global shortcuts (Cmd+P, etc.) while a Plannotator tab is focused. Fix both off the one input signal the iframe still receives — keydown: - Route clipboard reads/writes through the extension host, which owns the system clipboard via vscode.env.clipboard. The injected script overrides navigator.clipboard and handles Cmd+C/X/V, relaying through the wrapper to an onDidReceiveMessage handler and back. - Forward all other keystrokes to the wrapper, which re-dispatches a synthetic KeyboardEvent on the webview document so VS Code can resolve its keybindings. Undo/redo/select-all stay native. Fixes #864 Fixes #969 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e1efea8 commit 1e631ac

5 files changed

Lines changed: 140 additions & 29 deletions

File tree

apps/vscode-extension/mocks/vscode.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,14 @@ export const commands = {
7474
async executeCommand(_command: string, ..._args: unknown[]) {},
7575
};
7676

77+
export interface Webview {
78+
html: string;
79+
onDidReceiveMessage(listener: (message: unknown) => void): { dispose(): void };
80+
postMessage(message: unknown): Thenable<boolean>;
81+
}
82+
7783
export interface WebviewPanel {
78-
webview: { html: string };
84+
webview: Webview;
7985
iconPath?: Uri;
8086
reveal(viewColumn?: number): void;
8187
dispose(): void;
@@ -105,11 +111,15 @@ export const window = {
105111
_viewType: string,
106112
_title: string,
107113
_showOptions: number,
108-
_options?: { enableScripts?: boolean },
114+
_options?: { enableScripts?: boolean; retainContextWhenHidden?: boolean },
109115
): WebviewPanel {
110116
let disposeListener: (() => void) | null = null;
111117
return {
112-
webview: { html: "" },
118+
webview: {
119+
html: "",
120+
onDidReceiveMessage() { return { dispose() {} }; },
121+
postMessage() { return Promise.resolve(true); },
122+
},
113123
reveal() {},
114124
dispose() {
115125
disposeListener?.();
@@ -138,6 +148,12 @@ export const env = {
138148
async asExternalUri(uri: Uri): Promise<Uri> {
139149
return uri;
140150
},
151+
clipboard: {
152+
async readText(): Promise<string> {
153+
return "";
154+
},
155+
async writeText(_value: string): Promise<void> {},
156+
},
141157
};
142158

143159
export const comments = {

apps/vscode-extension/src/cookie-proxy.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ describe("createCookieProxy", () => {
105105
// Should contain the virtual cookie store with saved cookies
106106
expect(html).toContain('"plannotator-identity":"tater-42"');
107107
expect(html).toContain('"other-cookie":"ignore"');
108+
// Should forward keystrokes to the parent for VS Code keybindings
109+
expect(html).toContain('type:"plannotator-keydown"');
110+
expect(html).toContain('window.addEventListener("keydown"');
108111
// Should still contain original content
109112
expect(html).toContain("<title>Test</title>");
110113
expect(html).toContain("Hello");

apps/vscode-extension/src/cookie-proxy.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,50 @@ function injectScript(html: string, savedCookies: string): string {
182182
setTimeout(sc,500);setInterval(sc,2000);
183183
var ci=setInterval(function(){if(document.body&&document.body.textContent.indexOf("Your response has been sent")!==-1){clearInterval(ci);sc();fetch("/___ext/close",{method:"POST"});}},500);
184184
try{window.parent.postMessage("plannotator-ready","*");}catch(e){}
185+
// Clipboard bridge: inside a nested cross-origin webview iframe the
186+
// document never holds focus, so the async Clipboard API is rejected and
187+
// native copy/cut/paste events never fire. Route reads and writes through
188+
// the extension host (which owns the system clipboard) and drive them off
189+
// keydown, the only input signal the iframe still receives.
190+
var readSeq=0,readPending={};
191+
function bridgeWrite(text){window.parent.postMessage({type:"plannotator-clipboard-write",text:String(text==null?"":text)},"*");}
192+
function bridgeRead(){return new Promise(function(resolve){var id=++readSeq;readPending[id]=resolve;window.parent.postMessage({type:"plannotator-clipboard-read",id:id},"*");});}
193+
try{Object.defineProperty(navigator,"clipboard",{configurable:true,value:{
194+
writeText:function(t){bridgeWrite(t);return Promise.resolve();},
195+
readText:function(){return bridgeRead();}
196+
}});}catch(err){}
197+
function fieldSelection(el){
198+
if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA")&&el.selectionStart!=null&&el.selectionEnd>el.selectionStart){
199+
return el.value.slice(el.selectionStart,el.selectionEnd);
200+
}
201+
return (window.getSelection&&window.getSelection().toString())||"";
202+
}
203+
window.addEventListener("message",function(e){var d=e.data;if(d&&d.type==="plannotator-clipboard-data"){var cb=readPending[d.id];if(cb){delete readPending[d.id];cb(d.text||"");}}});
204+
window.addEventListener("keydown",function(e){
205+
var k=(e.key||"").toLowerCase();
206+
if((e.metaKey||e.ctrlKey)&&!e.altKey){
207+
if(k==="c"||k==="x"){
208+
var sel=fieldSelection(document.activeElement);
209+
if(sel){
210+
e.preventDefault();
211+
bridgeWrite(sel);
212+
if(k==="x")document.execCommand("delete");
213+
}
214+
return;
215+
}
216+
if(k==="v"){
217+
e.preventDefault();
218+
bridgeRead().then(function(text){if(text)document.execCommand("insertText",false,text);});
219+
return;
220+
}
221+
// Undo/redo/select-all stay native; forwarding them would hijack them.
222+
if(k==="a"||k==="z"||k==="y")return;
223+
}
224+
try{window.parent.postMessage({type:"plannotator-keydown",event:{
225+
key:e.key,code:e.code,keyCode:e.keyCode,which:e.which,location:e.location,
226+
ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,metaKey:e.metaKey,repeat:e.repeat
227+
}},"*");}catch(err){}
228+
});
185229
})();</script>`;
186230

187231
const headMatch = html.match(/<head(\s[^>]*)?>/) ;

apps/vscode-extension/src/panel-manager.test.ts

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,19 @@ describe("PanelManager", () => {
1515
spies.length = 0;
1616
});
1717

18-
it("sets iframe src in webview html", async () => {
19-
let capturedHtml = "";
18+
// Stubs createWebviewPanel and returns a handle whose `.html` reflects the
19+
// HTML the manager assigns to the panel's webview.
20+
function stubWebviewPanel(): { html: string } {
21+
const captured = { html: "" };
2022
const spy = spyOn(vscode.window, "createWebviewPanel");
2123
spy.mockImplementation((() => {
2224
let disposeListener: (() => void) | null = null;
2325
return {
2426
webview: {
25-
get html() { return capturedHtml; },
26-
set html(v: string) { capturedHtml = v; },
27+
get html() { return captured.html; },
28+
set html(v: string) { captured.html = v; },
29+
onDidReceiveMessage() { return { dispose() {} }; },
30+
postMessage() { return Promise.resolve(true); },
2731
},
2832
reveal() {},
2933
dispose() { disposeListener?.(); },
@@ -34,46 +38,53 @@ describe("PanelManager", () => {
3438
} as unknown as vscode.WebviewPanel;
3539
}) as typeof vscode.window.createWebviewPanel);
3640
spies.push(spy);
41+
return captured;
42+
}
43+
44+
it("sets iframe src in webview html", async () => {
45+
const captured = stubWebviewPanel();
3746

3847
await manager.open("http://127.0.0.1:9999/review?id=42");
3948

40-
expect(capturedHtml).toContain(
49+
expect(captured.html).toContain(
4150
'src="http://127.0.0.1:9999/review?id=42"',
4251
);
4352
});
4453

54+
it("re-dispatches keystrokes forwarded from the app iframe", async () => {
55+
const captured = stubWebviewPanel();
56+
57+
await manager.open("http://127.0.0.1:9999/review?id=42");
58+
59+
expect(captured.html).toContain('d.type === "plannotator-keydown"');
60+
expect(captured.html).toContain('new KeyboardEvent("keydown", d.event)');
61+
});
62+
63+
it("relays clipboard messages between the app iframe and the extension host", async () => {
64+
const captured = stubWebviewPanel();
65+
66+
await manager.open("http://127.0.0.1:9999/review?id=42");
67+
68+
expect(captured.html).toContain("acquireVsCodeApi()");
69+
expect(captured.html).toContain('"plannotator-clipboard-write"');
70+
expect(captured.html).toContain('"plannotator-clipboard-data"');
71+
});
72+
4573
it("uses asExternalUri resolved URL in iframe and CSP", async () => {
4674
const envSpy = spyOn(vscode.env, "asExternalUri");
4775
envSpy.mockImplementation(async (_uri: vscode.Uri) => {
4876
return vscode.Uri.parse("https://localhost:8443/review?id=42");
4977
});
5078
spies.push(envSpy);
5179

52-
let capturedHtml = "";
53-
const panelSpy = spyOn(vscode.window, "createWebviewPanel");
54-
panelSpy.mockImplementation((() => {
55-
let disposeListener: (() => void) | null = null;
56-
return {
57-
webview: {
58-
get html() { return capturedHtml; },
59-
set html(v: string) { capturedHtml = v; },
60-
},
61-
reveal() {},
62-
dispose() { disposeListener?.(); },
63-
onDidDispose(listener: () => void) {
64-
disposeListener = listener;
65-
return { dispose() {} };
66-
},
67-
} as unknown as vscode.WebviewPanel;
68-
}) as typeof vscode.window.createWebviewPanel);
69-
spies.push(panelSpy);
80+
const captured = stubWebviewPanel();
7081

7182
await manager.open("http://127.0.0.1:9999/review?id=42");
7283

7384
expect(envSpy).toHaveBeenCalled();
74-
expect(capturedHtml).toContain(
85+
expect(captured.html).toContain(
7586
'src="https://localhost:8443/review?id=42"',
7687
);
77-
expect(capturedHtml).toContain("frame-src https://localhost:8443;");
88+
expect(captured.html).toContain("frame-src https://localhost:8443;");
7889
});
7990
});

apps/vscode-extension/src/panel-manager.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import * as vscode from "vscode";
22
import * as path from "path";
33
import { buildWrapperThemeScript } from "./vscode-theme";
44

5+
// Messages the app iframe sends up to the extension host (see the clipboard
6+
// bridge injected in cookie-proxy.ts).
7+
type ClipboardWriteMessage = { type: "plannotator-clipboard-write"; text: string };
8+
type ClipboardReadMessage = { type: "plannotator-clipboard-read"; id: number };
9+
type WebviewMessage = ClipboardWriteMessage | ClipboardReadMessage;
10+
511
export class PanelManager {
612
private panels: Set<vscode.WebviewPanel> = new Set();
713
private extensionPath: string = "";
@@ -25,8 +31,20 @@ export class PanelManager {
2531
);
2632
const origin = `${resolved.scheme}://${resolved.authority}`;
2733
panel.webview.html = getHtml(resolvedUrl, origin);
34+
35+
const messageSub = panel.webview.onDidReceiveMessage(async (raw: unknown) => {
36+
const msg = raw as WebviewMessage;
37+
if (msg.type === "plannotator-clipboard-write") {
38+
await vscode.env.clipboard.writeText(msg.text ?? "");
39+
} else if (msg.type === "plannotator-clipboard-read") {
40+
const text = await vscode.env.clipboard.readText();
41+
panel.webview.postMessage({ type: "plannotator-clipboard-data", id: msg.id, text });
42+
}
43+
});
44+
2845
this.panels.add(panel);
2946
panel.onDidDispose(() => {
47+
messageSub.dispose();
3048
this.panels.delete(panel);
3149
});
3250
return panel;
@@ -60,8 +78,27 @@ function getHtml(url: string, origin: string): string {
6078
(function() {
6179
var ready = false;
6280
var reloads = 0;
81+
var vscodeApi = acquireVsCodeApi();
6382
window.addEventListener("message", function(e) {
64-
if (e.data === "plannotator-ready") { ready = true; }
83+
var d = e.data;
84+
if (d === "plannotator-ready") { ready = true; return; }
85+
if (d && d.type === "plannotator-keydown") {
86+
// Re-dispatch keystrokes forwarded from the nested app iframe so VS
87+
// Code's keybinding service (which listens on the webview document)
88+
// can resolve global shortcuts like Cmd+P while the app is focused.
89+
window.dispatchEvent(new KeyboardEvent("keydown", d.event));
90+
return;
91+
}
92+
// Relay clipboard requests up to the extension host (owns the system
93+
// clipboard) and responses back down to the app iframe.
94+
if (d && (d.type === "plannotator-clipboard-write" || d.type === "plannotator-clipboard-read")) {
95+
vscodeApi.postMessage(d);
96+
return;
97+
}
98+
if (d && d.type === "plannotator-clipboard-data") {
99+
var f = document.getElementById("pn-frame");
100+
if (f && f.contentWindow) f.contentWindow.postMessage(d, "*");
101+
}
65102
});
66103
setTimeout(function() {
67104
if (!ready && reloads < 1) {

0 commit comments

Comments
 (0)