toes/src/client/tool-iframes.ts
2026-02-02 15:50:40 -08:00

194 lines
5.9 KiB
TypeScript

import { theme } from './themes'
// Iframe cache - these never get recreated once loaded
// Use a global to survive hot reloads
const iframes: Map<string, { iframe: HTMLIFrameElement; port: number; contentHeight?: number }> =
(window as any).__toolIframes ??= new Map()
// Track current state to avoid unnecessary DOM updates
// Also stored on window to survive hot reloads
let currentTool: string | null = (window as any).__currentTool ?? null
// Get the stable container (outside Hono-managed DOM)
const getContainer = () => document.getElementById('tool-iframes')
// Listen for messages from iframes
function setupMessageListener() {
if ((window as any).__toolIframeMessageListener) return
; (window as any).__toolIframeMessageListener = true
window.addEventListener('message', (event) => {
const { type } = event.data || {}
if (type === 'resize-iframe') {
const height = event.data.height
if (!height || typeof height !== 'number') return
// Find which iframe sent this message
for (const [, cached] of iframes) {
if (cached.iframe.contentWindow === event.source) {
cached.contentHeight = height
cached.iframe.style.height = `${height}px`
return
}
}
}
})
}
// Initialize the container styles
export function initToolIframes() {
const container = getContainer()
if (!container) return
setupMessageListener()
// Restore iframe cache from DOM if module was hot-reloaded
if (iframes.size === 0) {
const existingIframes = container.querySelectorAll('iframe')
existingIframes.forEach(iframe => {
try {
const url = new URL(iframe.src)
const port = parseInt(url.port, 10)
const toolName = iframe.dataset.toolName
const appName = iframe.dataset.appName
if (port && toolName) {
const cacheKey = appName ? `${toolName}:${appName}` : toolName
iframes.set(cacheKey, { iframe, port })
}
} catch {
// Invalid URL, skip
}
})
}
container.style.cssText = `
display: none;
position: fixed;
background: ${theme('colors-bg')};
overflow: auto;
`
}
// Build URL with params using TOES_URL base
function buildToolUrl(port: number, params: Record<string, string>): string {
const base = new URL(window.location.origin)
base.port = String(port)
const searchParams = new URLSearchParams(params)
const query = searchParams.toString()
return query ? `${base.origin}?${query}` : base.origin
}
// Build cache key from tool name and params
function buildCacheKey(toolName: string, params: Record<string, string>): string {
const parts = [toolName]
const sortedKeys = Object.keys(params).sort()
for (const key of sortedKeys) {
parts.push(`${key}=${params[key]}`)
}
return parts.join(':')
}
// Reset a tool iframe to its home page
export function resetToolIframe(toolName: string, selectedApp: string | null) {
const params: Record<string, string> = {}
if (selectedApp) params.app = selectedApp
const cacheKey = buildCacheKey(toolName, params)
const cached = iframes.get(cacheKey)
if (cached) {
// For code app, include empty file param to clear its localStorage
const urlParams = toolName === 'code' ? { ...params, file: '' } : params
cached.iframe.src = buildToolUrl(cached.port, urlParams)
}
}
// Update which iframe is visible based on selected tab and tool state
export function updateToolIframes(
selectedTab: string,
tools: Array<{ name: string; port?: number; state: string }>,
selectedApp: string | null
) {
const container = getContainer()
if (!container) return
// Find the selected tool
const selectedTool = tools.find(t => t.name === selectedTab)
const showIframe = selectedTool?.state === 'running' && selectedTool?.port
if (!showIframe) {
if (currentTool !== null) {
container.style.display = 'none'
currentTool = null
;(window as any).__currentTool = null
}
return
}
const tool = selectedTool!
// Build params and cache key
const params: Record<string, string> = {}
if (selectedApp) params.app = selectedApp
const cacheKey = buildCacheKey(tool.name, params)
// Skip if nothing changed
if (currentTool === cacheKey) {
const tabContent = document.querySelector('[data-tool-target]')
if (tabContent) {
const rect = tabContent.getBoundingClientRect()
container.style.top = `${rect.top}px`
container.style.left = `${rect.left}px`
container.style.width = `${rect.width}px`
container.style.height = `calc(100vh - ${rect.top}px)`
}
return
}
// Position container over the tab content area
const tabContent = document.querySelector('[data-tool-target]')
if (!tabContent) {
container.style.display = 'none'
currentTool = null
return
}
const rect = tabContent.getBoundingClientRect()
container.style.cssText = `
display: block;
position: fixed;
top: ${rect.top}px;
left: ${rect.left}px;
width: ${rect.width}px;
height: calc(100vh - ${rect.top}px);
background: ${theme('colors-bg')};
z-index: 100;
overflow: auto;
`
// Get or create the iframe
let cached = iframes.get(cacheKey)
if (!cached || cached.port !== tool.port) {
const iframe = document.createElement('iframe')
iframe.src = buildToolUrl(tool.port!, params)
iframe.dataset.toolName = tool.name
iframe.dataset.appName = selectedApp ?? ''
iframe.style.cssText = `width: 100%; border: none;`
cached = { iframe, port: tool.port! }
iframes.set(cacheKey, cached)
container.appendChild(iframe)
}
// Show only the selected iframe
for (const [key, { iframe }] of iframes) {
const shouldShow = key === cacheKey
if (shouldShow && iframe.parentElement !== container) {
container.appendChild(iframe)
}
iframe.style.display = shouldShow ? 'block' : 'none'
}
currentTool = cacheKey
;(window as any).__currentTool = cacheKey
}