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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
277
frontend/src/vscode/ContextMenu.tsx
Normal file
277
frontend/src/vscode/ContextMenu.tsx
Normal 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" />
|
||||
);
|
||||
281
frontend/src/vscode/bridge.ts
Normal file
281
frontend/src/vscode/bridge.ts
Normal 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();
|
||||
Reference in New Issue
Block a user