VScode extension key events and context menu (#498)

- Forward key-events to vscode when vibe-kanban is hosted in an iframe.
- Create a context-menu for copy/paste operation in iframe mode.
This commit is contained in:
Solomon
2025-08-15 18:12:29 +01:00
committed by GitHub
parent c1e26a24f2
commit 598d32a254
4 changed files with 563 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ import * as Sentry from '@sentry/react';
import { Loader } from '@/components/ui/loader';
import { GitHubLoginDialog } from '@/components/GitHubLoginDialog';
import { AppWithStyleOverride } from '@/utils/style-override';
import { WebviewContextMenu } from '@/vscode/ContextMenu';
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
@@ -139,6 +140,8 @@ function AppContent() {
<ThemeProvider initialTheme={config?.theme || ThemeMode.SYSTEM}>
<AppWithStyleOverride>
<div className="h-screen flex flex-col bg-background">
{/* Custom context menu and VS Code-friendly interactions when embedded in iframe */}
<WebviewContextMenu />
<GitHubLoginDialog
open={showGitHubLogin}
onOpenChange={handleGitHubLoginComplete}

View File

@@ -4,6 +4,8 @@ import App from './App.tsx';
import './styles/index.css';
import { ClickToComponent } from 'click-to-react-component';
import * as Sentry from '@sentry/react';
// Install VS Code iframe keyboard bridge when running inside an iframe
import './vscode/bridge';
import {
useLocation,

View File

@@ -0,0 +1,277 @@
import React, { useEffect, useRef, useState } from 'react';
import {
readClipboardViaBridge,
writeClipboardViaBridge,
} from '@/vscode/bridge';
type Point = { x: number; y: number };
function inIframe(): boolean {
try {
return window.self !== window.top;
} catch {
return true;
}
}
function isEditable(
target: EventTarget | null
): target is
| HTMLInputElement
| HTMLTextAreaElement
| (HTMLElement & { isContentEditable: boolean }) {
const el = target as HTMLElement | null;
if (!el) return false;
const tag = el.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea') return true;
return !!el.isContentEditable;
}
async function readClipboardText(): Promise<string> {
return await readClipboardViaBridge();
}
async function writeClipboardText(text: string): Promise<boolean> {
return await writeClipboardViaBridge(text);
}
function getSelectedText(): string {
const sel = window.getSelection();
return sel ? sel.toString() : '';
}
function cutFromInput(el: HTMLInputElement | HTMLTextAreaElement) {
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
if (end > start) {
const selected = el.value.slice(start, end);
void writeClipboardText(selected);
const before = el.value.slice(0, start);
const after = el.value.slice(end);
el.value = before + after;
el.setSelectionRange(start, start);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
}
function pasteIntoInput(
el: HTMLInputElement | HTMLTextAreaElement,
text: string
) {
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const before = el.value.slice(0, start);
const after = el.value.slice(end);
el.value = before + text + after;
const caret = start + text.length;
el.setSelectionRange(caret, caret);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
export const WebviewContextMenu: React.FC = () => {
const [visible, setVisible] = useState(false);
const [pos, setPos] = useState<Point>({ x: 0, y: 0 });
const [adjustedPos, setAdjustedPos] = useState<Point | null>(null);
const [canCut, setCanCut] = useState<boolean>(false);
const [canPaste, setCanPaste] = useState<boolean>(false);
const targetRef = useRef<EventTarget | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!inIframe()) return;
const onContext = (e: MouseEvent) => {
e.preventDefault();
targetRef.current = e.target;
setPos({ x: e.clientX, y: e.clientY });
// Decide whether Cut should be shown: only for editable targets with a selection
const tgt = e.target as HTMLElement | null;
let cut = false;
let paste = false;
if (tgt && (tgt as HTMLInputElement).selectionStart !== undefined) {
const el = tgt as HTMLInputElement | HTMLTextAreaElement;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
cut = end > start && !el.readOnly && !el.disabled;
paste = !el.readOnly && !el.disabled;
} else if (isEditable(tgt)) {
const sel = window.getSelection();
cut = !!sel && sel.toString().length > 0;
paste = true;
} else {
cut = false;
paste = false;
}
setCanCut(cut);
setCanPaste(paste);
setVisible(true);
};
const onClick = () => setVisible(false);
document.addEventListener('contextmenu', onContext);
document.addEventListener('click', onClick);
window.addEventListener('blur', onClick);
return () => {
document.removeEventListener('contextmenu', onContext);
document.removeEventListener('click', onClick);
window.removeEventListener('blur', onClick);
};
}, []);
// When menu becomes visible, adjust position to stay within viewport
useEffect(() => {
if (!visible) {
setAdjustedPos(null);
return;
}
const el = menuRef.current;
if (!el) return;
// Use a microtask to ensure layout is ready
const id = requestAnimationFrame(() => {
const menuW = el.offsetWidth;
const menuH = el.offsetHeight;
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 4;
let x = pos.x;
let y = pos.y;
if (x + menuW + margin > vw) x = Math.max(margin, vw - menuW - margin);
if (y + menuH + margin > vh) y = Math.max(margin, vh - menuH - margin);
setAdjustedPos({ x, y });
});
return () => cancelAnimationFrame(id);
}, [visible, pos]);
const close = () => setVisible(false);
const onCopy = async () => {
const tgt = targetRef.current as HTMLElement | null;
let copied = false;
if (tgt && (tgt as HTMLInputElement).selectionStart !== undefined) {
const el = tgt as HTMLInputElement | HTMLTextAreaElement;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
if (end > start) {
const selected = el.value.slice(start, end);
copied = await writeClipboardText(selected);
}
}
if (!copied) {
const sel = getSelectedText();
if (sel) copied = await writeClipboardText(sel);
}
if (!copied) {
try {
document.execCommand('copy');
} catch {
/* empty */
}
}
close();
};
const onCut = async () => {
const tgt = targetRef.current as HTMLElement | null;
if (
tgt &&
(tgt as HTMLInputElement).selectionStart !== undefined &&
!(tgt as HTMLInputElement).readOnly &&
!(tgt as HTMLInputElement).disabled
) {
cutFromInput(tgt as HTMLInputElement | HTMLTextAreaElement);
} else if (isEditable(tgt)) {
// contentEditable: emulate cut by copying selection, then deleting via execCommand
const sel = getSelectedText();
if (sel) {
await writeClipboardText(sel);
try {
document.execCommand('delete');
} catch {
/* empty */
}
}
} else {
// Read-only content: treat Cut as Copy for usability
const sel = getSelectedText();
if (sel) await writeClipboardText(sel);
}
close();
};
const onPaste = async () => {
const text = await readClipboardText();
const tgt = targetRef.current as HTMLElement | null;
if (tgt && (tgt as HTMLInputElement).selectionStart !== undefined) {
(tgt as HTMLElement).focus();
pasteIntoInput(tgt as HTMLInputElement | HTMLTextAreaElement, text);
} else if (isEditable(tgt)) {
(tgt as HTMLElement).focus();
document.execCommand('insertText', false, text);
}
close();
};
const onUndo = () => {
try {
document.execCommand('undo');
} catch {
/* empty */
}
close();
};
const onRedo = () => {
try {
document.execCommand('redo');
} catch {
/* empty */
}
close();
};
const onSelectAll = () => {
try {
document.execCommand('selectAll');
} catch {
/* empty */
}
close();
};
if (!visible) return null;
return (
<div
ref={menuRef}
style={{
position: 'fixed',
left: (adjustedPos ?? pos).x,
top: (adjustedPos ?? pos).y,
zIndex: 99999,
}}
className="min-w-[160px] rounded-md border border-gray-300 bg-white text-gray-900 shadow-lg dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
onContextMenu={(e) => e.preventDefault()}
>
<MenuItem label="Copy" onClick={onCopy} />
{canCut && <MenuItem label="Cut" onClick={onCut} />}
{canPaste && <MenuItem label="Paste" onClick={onPaste} />}
<Divider />
<MenuItem label="Undo" onClick={onUndo} />
<MenuItem label="Redo" onClick={onRedo} />
<Divider />
<MenuItem label="Select All" onClick={onSelectAll} />
</div>
);
};
const MenuItem: React.FC<{ label: string; onClick: () => void }> = ({
label,
onClick,
}) => (
<button
className="block w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={onClick}
type="button"
>
{label}
</button>
);
const Divider: React.FC = () => (
<div className="my-1 h-px bg-gray-200 dark:bg-gray-700" />
);

View File

@@ -0,0 +1,281 @@
// VS Code Webview iframe keyboard bridge
// Forwards key events to the parent window so the VS Code webview can re-dispatch
// them and preserve editor/global shortcuts when focused inside the iframe.
function inIframe(): boolean {
try {
return window.self !== window.top;
} catch {
return true;
}
}
type KeyPayload = {
key: string;
code: string;
altKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
metaKey: boolean;
repeat: boolean;
isComposing: boolean;
location: number;
};
function serializeKeyEvent(e: KeyboardEvent): KeyPayload {
return {
key: e.key,
code: e.code,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
repeat: e.repeat,
isComposing: e.isComposing,
location: e.location ?? 0,
};
}
function isMac() {
return navigator.platform.toUpperCase().includes('MAC');
}
function isCopy(e: KeyboardEvent) {
return (
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'c'
);
}
function isCut(e: KeyboardEvent) {
return (
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'x'
);
}
function isPaste(e: KeyboardEvent) {
return (
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'v'
);
}
function activeEditable():
| HTMLInputElement
| HTMLTextAreaElement
| (HTMLElement & { isContentEditable: boolean })
| null {
const el = document.activeElement as HTMLElement | null;
if (!el) return null;
const tag = el.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea')
return el as HTMLInputElement | HTMLTextAreaElement;
if (el.isContentEditable)
return el as HTMLElement & { isContentEditable: boolean };
return null;
}
async function writeClipboardText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
return document.execCommand('copy');
} catch {
return false;
}
}
}
async function readClipboardText(): Promise<string> {
try {
return await navigator.clipboard.readText();
} catch {
return '';
}
}
function getSelectedText(): string {
const el = activeEditable() as
| HTMLInputElement
| HTMLTextAreaElement
| (HTMLElement & { isContentEditable: boolean })
| null;
if (el && (el as HTMLInputElement).selectionStart !== undefined) {
const input = el as HTMLInputElement | HTMLTextAreaElement;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
return start < end ? input.value.slice(start, end) : '';
}
const sel = window.getSelection();
return sel ? sel.toString() : '';
}
function cutFromInput(el: HTMLInputElement | HTMLTextAreaElement) {
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
if (end > start) {
const selected = el.value.slice(start, end);
void writeClipboardText(selected);
const before = el.value.slice(0, start);
const after = el.value.slice(end);
el.value = before + after;
el.setSelectionRange(start, start);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
}
function pasteIntoInput(
el: HTMLInputElement | HTMLTextAreaElement,
text: string
) {
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const before = el.value.slice(0, start);
const after = el.value.slice(end);
el.value = before + text + after;
const caret = start + text.length;
el.setSelectionRange(caret, caret);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
const pasteResolvers: Record<string, (text: string) => void> = {};
export function parentClipboardWrite(text: string) {
try {
window.parent.postMessage(
{ type: 'vscode-iframe-clipboard-copy', text },
'*'
);
} catch (_err) {
void 0;
}
}
export function parentClipboardRead(): Promise<string> {
return new Promise((resolve) => {
const requestId = Math.random().toString(36).slice(2);
pasteResolvers[requestId] = (text: string) => resolve(text);
try {
window.parent.postMessage(
{ type: 'vscode-iframe-clipboard-paste-request', requestId },
'*'
);
} catch {
resolve('');
}
});
}
type IframeMessage = {
type: string;
event?: KeyPayload;
text?: string;
requestId?: string;
};
window.addEventListener('message', (e: MessageEvent) => {
const data: unknown = e?.data;
if (!data || typeof data !== 'object') return;
const msg = data as IframeMessage;
if (msg.type === 'vscode-iframe-clipboard-paste-result' && msg.requestId) {
const fn = pasteResolvers[msg.requestId];
if (fn) {
fn(msg.text || '');
delete pasteResolvers[msg.requestId];
}
}
});
export function installVSCodeIframeKeyboardBridge() {
if (!inIframe()) return;
const forward = (type: string, e: KeyboardEvent) => {
try {
window.parent.postMessage({ type, event: serializeKeyEvent(e) }, '*');
} catch (_err) {
void 0;
}
};
const onKeyDown = async (e: KeyboardEvent) => {
// Handle clipboard combos locally so OS shortcuts work inside the iframe
if (isCopy(e)) {
const text = getSelectedText();
if (text) {
e.preventDefault();
e.stopPropagation();
const ok = await writeClipboardText(text);
if (!ok) parentClipboardWrite(text);
return;
}
} else if (isCut(e)) {
const el = activeEditable() as
| HTMLInputElement
| HTMLTextAreaElement
| null;
if (el) {
e.preventDefault();
e.stopPropagation();
cutFromInput(el);
return;
}
} else if (isPaste(e)) {
const el = activeEditable() as
| HTMLInputElement
| HTMLTextAreaElement
| (HTMLElement & { isContentEditable: boolean })
| null;
if (el) {
e.preventDefault();
e.stopPropagation();
let text = await readClipboardText();
if (!text) text = await parentClipboardRead();
if ((el as HTMLInputElement).selectionStart !== undefined)
pasteIntoInput(el as HTMLInputElement | HTMLTextAreaElement, text);
else document.execCommand('insertText', false, text);
return;
}
}
// Forward everything else so VS Code can handle global shortcuts
forward('vscode-iframe-keydown', e);
};
const onKeyUp = (e: KeyboardEvent) => forward('vscode-iframe-keyup', e);
const onKeyPress = (e: KeyboardEvent) => forward('vscode-iframe-keypress', e);
// Capture phase to run before app handlers
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('keypress', onKeyPress, true);
document.addEventListener('keydown', onKeyDown, true);
document.addEventListener('keyup', onKeyUp, true);
document.addEventListener('keypress', onKeyPress, true);
}
export async function writeClipboardViaBridge(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
parentClipboardWrite(text);
return false;
}
}
export async function readClipboardViaBridge(): Promise<string> {
try {
return await navigator.clipboard.readText();
} catch {
return await parentClipboardRead();
}
}
// Auto-install on import to make it robust
installVSCodeIframeKeyboardBridge();