Iframe communication (vibe-kanban e1be4a05)

As well as triggering the context menu to open, when the user clicks on a react item we should communicate with the parent page (this is all loaded in an iframe).

As it's a dev environment we need to keep security low, so don't check domains.
This commit is contained in:
Louis Knight-Webb
2025-09-26 09:40:49 +01:00
parent 4faf9f2284
commit fe6ae809ff
3 changed files with 12379 additions and 9568 deletions

View File

@@ -8,7 +8,9 @@ import { html } from 'htm/react'
import * as React from 'react'
import { ContextMenu } from './ContextMenu.js'
import { getDisplayNameForInstance } from './getDisplayNameFromReactInstance.js'
import { getPathToSource } from './getPathToSource.js'
import { getPropsForInstance } from './getPropsForInstance.js'
import { getReactInstancesForElement } from './getReactInstancesForElement.js'
import { getSourceForInstance } from './getSourceForInstance.js'
import { getUrl } from './getUrl.js'
@@ -24,6 +26,123 @@ export const Trigger = /** @type {const} */ ({
BUTTON: 'button',
})
// Message source and version for iframe communication
const MESSAGE_SOURCE = 'click-to-component'
const MESSAGE_VERSION = 1
/**
* Extract component instances data for a target element
* @param {HTMLElement} target
* @param {Function} pathModifier
* @returns {Array}
*/
function getComponentInstances(target, pathModifier) {
if (!target) return []
const instances = getReactInstancesForElement(target).filter((instance) =>
getSourceForInstance(instance)
)
return instances.map((instance) => {
const name = getDisplayNameForInstance(instance)
const source = getSourceForInstance(instance)
const path = getPathToSource(source, pathModifier)
const props = getPropsForInstance(instance)
return {
name,
props,
source: {
fileName: source.fileName,
lineNumber: source.lineNumber,
columnNumber: source.columnNumber
},
pathToSource: path
}
})
}
/**
* Send a message to the parent window when opening in editor.
* No-ops when not inside an iframe.
* @param {Object} args
* @param {string} args.editor
* @param {string} args.pathToSource
* @param {string} args.url
* @param {'alt-click'|'context-menu'} args.trigger
* @param {MouseEvent} [args.event]
* @param {HTMLElement} [args.element]
* @param {Function} [args.pathModifier]
* @param {string} [args.selectedComponent] - Name of the selected component
*/
function postOpenToParent({ editor, pathToSource, url, trigger, event, element, pathModifier, selectedComponent }) {
try {
const el = element || (event && event.target instanceof HTMLElement ? event.target : null)
// Get all component instances for the clicked element
const allComponents = el ? getComponentInstances(el, pathModifier) : []
// Find the selected component in the list (or use the first one)
const selected = selectedComponent
? allComponents.find(comp => comp.name === selectedComponent)
: allComponents.find(comp => comp.pathToSource === pathToSource) || allComponents[0]
const elementInfo = el
? {
tag: el.tagName?.toLowerCase?.() || undefined,
id: el.id || undefined,
className:
typeof el.className === 'string'
? el.className
: String(el.className || ''),
role: el.getAttribute('role') || undefined,
dataset: { ...el.dataset },
}
: undefined
const message = {
source: MESSAGE_SOURCE,
version: MESSAGE_VERSION,
type: 'open-in-editor',
payload: {
selected: selected ? {
editor,
pathToSource: selected.pathToSource,
url,
name: selected.name,
props: selected.props,
source: selected.source
} : {
editor,
pathToSource,
url,
name: selectedComponent || 'Unknown',
props: {},
source: {}
},
components: allComponents,
trigger,
coords: event
? { x: event.clientX ?? undefined, y: event.clientY ?? undefined }
: undefined,
clickedElement: elementInfo,
},
}
if (
typeof window !== 'undefined' &&
window.parent &&
window.parent !== window &&
typeof window.parent.postMessage === 'function'
) {
window.parent.postMessage(message, '*') // dev-only, permissive
}
} catch (err) {
// Never break product flows due to messaging
console.warn('[click-to-component] postMessage failed', err)
}
}
/**
* @param {Props} props
*/
@@ -185,6 +304,19 @@ export function ClickToComponent({ editor = 'vscode', port, pathModifier }) {
})
event.preventDefault()
// Notify parent window via postMessage
postOpenToParent({
editor,
pathToSource: path,
url,
trigger: 'alt-click',
event,
element: target,
pathModifier,
selectedComponent: getDisplayNameForInstance(instance)
})
window.location.assign(url)
setState(State.IDLE)
@@ -202,13 +334,23 @@ export function ClickToComponent({ editor = 'vscode', port, pathModifier }) {
pathToSource: returnValue,
})
// Notify parent window via postMessage
postOpenToParent({
editor,
pathToSource: returnValue,
url,
trigger: 'context-menu',
element: target,
pathModifier,
})
window.location.assign(url)
}
setState(State.IDLE)
setTrigger(null)
},
[editor]
[editor, target, pathModifier]
)
const onKeyDown = React.useCallback(
@@ -329,6 +471,29 @@ export function ClickToComponent({ editor = 'vscode', port, pathModifier }) {
[state, target, trigger]
)
// Send ready message to parent when component mounts
React.useEffect(function sendReadyMessage() {
if (
typeof window !== 'undefined' &&
window.parent &&
window.parent !== window &&
typeof window.parent.postMessage === 'function'
) {
try {
window.parent.postMessage(
{
source: MESSAGE_SOURCE,
version: MESSAGE_VERSION,
type: 'ready'
},
'*'
)
} catch (err) {
console.warn('[click-to-component] ready message failed', err)
}
}
}, [])
React.useEffect(
function addEventListenersToWindow() {
window.addEventListener('click', onClick, { capture: true })

105
parent-example.html Normal file
View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html>
<head>
<title>Parent Page - Iframe Communication Example</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
#messages {
background: #f5f5f5;
border: 1px solid #ddd;
padding: 10px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
}
iframe {
width: 100%;
height: 500px;
border: 1px solid #ccc;
margin: 10px 0;
}
</style>
</head>
<body>
<h1>Parent Page - Click-to-Component Iframe Communication</h1>
<p>This page demonstrates iframe communication with the click-to-component tool.</p>
<iframe src="your-app-with-click-to-component.html" id="dev-iframe"></iframe>
<h2>Messages from iframe:</h2>
<div id="messages"></div>
<script>
const messagesDiv = document.getElementById('messages');
function logMessage(message) {
const timestamp = new Date().toLocaleTimeString();
messagesDiv.textContent += `[${timestamp}] ${JSON.stringify(message, null, 2)}\n\n`;
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
// Listen for messages from the iframe
window.addEventListener('message', (event) => {
const data = event.data;
// Only handle messages from our click-to-component tool
if (!data || data.source !== 'click-to-component') {
return;
}
switch (data.type) {
case 'ready':
console.log('Click-to-Component iframe ready');
logMessage({
type: 'ready',
message: 'Click-to-Component tool loaded and ready'
});
break;
case 'open-in-editor':
const { selected, components, trigger, coords, clickedElement } = data.payload;
console.log('Open in editor:', data.payload);
logMessage({
type: 'open-in-editor',
trigger: trigger,
selected: {
component: selected.name,
file: selected.pathToSource,
editor: selected.editor
},
allComponents: components.map(c => ({
name: c.name,
file: c.pathToSource,
props: Object.keys(c.props)
})),
clickPosition: coords,
htmlElement: {
tag: clickedElement?.tag,
id: clickedElement?.id,
className: clickedElement?.className
}
});
break;
default:
console.log('Unknown message type:', data.type);
logMessage({
type: 'unknown',
data: data
});
}
});
// Example: You could also send messages TO the iframe if needed
function sendMessageToIframe(message) {
const iframe = document.getElementById('dev-iframe');
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(message, '*');
}
}
</script>
</body>
</html>

21675
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff