Added iframe block
This commit is contained in:
arkml 2026-04-18 12:10:40 +05:30 committed by GitHub
parent 1f58c1f6cb
commit acc655172d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1642 additions and 1 deletions

View file

@ -25,6 +25,7 @@ import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup";
@ -291,6 +292,11 @@ app.whenReady().then(async () => {
// start chrome extension sync server
initChromeSync();
// start local sites server for iframe dashboards and other mini apps
initLocalSites().catch((error) => {
console.error('[LocalSites] Failed to start:', error);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
@ -309,4 +315,7 @@ app.on("before-quit", () => {
stopWorkspaceWatcher();
stopRunsWatcher();
stopServicesWatcher();
shutdownLocalSites().catch((error) => {
console.error('[LocalSites] Failed to shut down cleanly:', error);
});
});

View file

@ -13,6 +13,7 @@ import { TrackBlockExtension } from '@/extensions/track-block'
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block'
import { IframeBlockExtension } from '@/extensions/iframe-block'
import { ChartBlockExtension } from '@/extensions/chart-block'
import { TableBlockExtension } from '@/extensions/table-block'
import { CalendarBlockExtension } from '@/extensions/calendar-block'
@ -177,6 +178,8 @@ function blockToMarkdown(node: JsonNode): string {
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'embedBlock':
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'iframeBlock':
return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'chartBlock':
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
case 'tableBlock':
@ -676,6 +679,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
TrackTargetCloseExtension,
ImageBlockExtension,
EmbedBlockExtension,
IframeBlockExtension,
ChartBlockExtension,
TableBlockExtension,
CalendarBlockExtension,

View file

@ -0,0 +1,256 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { Globe, X } from 'lucide-react'
import { blocks } from '@x/shared'
import { useEffect, useRef, useState } from 'react'
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height'
const IFRAME_HEIGHT_CACHE_PREFIX = 'rowboat:iframe-height:'
const DEFAULT_IFRAME_HEIGHT = 560
const MIN_IFRAME_HEIGHT = 240
const HEIGHT_UPDATE_THRESHOLD = 4
const AUTO_RESIZE_SETTLE_MS = 160
const LOAD_FALLBACK_READY_MS = 180
const DEFAULT_IFRAME_ALLOW = [
'accelerometer',
'autoplay',
'camera',
'clipboard-read',
'clipboard-write',
'display-capture',
'encrypted-media',
'fullscreen',
'geolocation',
'microphone',
].join('; ')
function getIframeHeightCacheKey(url: string): string {
return `${IFRAME_HEIGHT_CACHE_PREFIX}${url}`
}
function readCachedIframeHeight(url: string, fallbackHeight: number): number {
try {
const raw = window.localStorage.getItem(getIframeHeightCacheKey(url))
if (!raw) return fallbackHeight
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed)) return fallbackHeight
return Math.max(MIN_IFRAME_HEIGHT, parsed)
} catch {
return fallbackHeight
}
}
function writeCachedIframeHeight(url: string, height: number): void {
try {
window.localStorage.setItem(getIframeHeightCacheKey(url), String(height))
} catch {
// ignore storage failures
}
}
function parseIframeHeightMessage(event: MessageEvent): { height: number } | null {
const data = event.data
if (!data || typeof data !== 'object') return null
const candidate = data as { type?: unknown; height?: unknown }
if (candidate.type !== IFRAME_HEIGHT_MESSAGE) return null
if (typeof candidate.height !== 'number' || !Number.isFinite(candidate.height)) return null
return {
height: Math.max(MIN_IFRAME_HEIGHT, Math.ceil(candidate.height)),
}
}
function IframeBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const raw = node.attrs.data as string
let config: blocks.IframeBlock | null = null
try {
config = blocks.IframeBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
if (!config) {
return (
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
<div className="iframe-block-card iframe-block-error">
<Globe size={16} />
<span>Invalid iframe block</span>
</div>
</NodeViewWrapper>
)
}
const visibleTitle = config.title?.trim() || ''
const title = visibleTitle || 'Embedded page'
const allow = config.allow || DEFAULT_IFRAME_ALLOW
const initialHeight = config.height ?? DEFAULT_IFRAME_HEIGHT
const [frameHeight, setFrameHeight] = useState(() => readCachedIframeHeight(config.url, initialHeight))
const [frameReady, setFrameReady] = useState(false)
const iframeRef = useRef<HTMLIFrameElement | null>(null)
const loadFallbackTimerRef = useRef<number | null>(null)
const autoResizeReadyTimerRef = useRef<number | null>(null)
const frameReadyRef = useRef(false)
useEffect(() => {
setFrameHeight(readCachedIframeHeight(config.url, initialHeight))
setFrameReady(false)
frameReadyRef.current = false
if (loadFallbackTimerRef.current !== null) {
window.clearTimeout(loadFallbackTimerRef.current)
loadFallbackTimerRef.current = null
}
if (autoResizeReadyTimerRef.current !== null) {
window.clearTimeout(autoResizeReadyTimerRef.current)
autoResizeReadyTimerRef.current = null
}
}, [config.url, initialHeight, raw])
useEffect(() => {
frameReadyRef.current = frameReady
}, [frameReady])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const iframeWindow = iframeRef.current?.contentWindow
if (!iframeWindow || event.source !== iframeWindow) return
const message = parseIframeHeightMessage(event)
if (!message) return
if (loadFallbackTimerRef.current !== null) {
window.clearTimeout(loadFallbackTimerRef.current)
loadFallbackTimerRef.current = null
}
if (autoResizeReadyTimerRef.current !== null) {
window.clearTimeout(autoResizeReadyTimerRef.current)
}
writeCachedIframeHeight(config.url, message.height)
setFrameHeight((currentHeight) => (
Math.abs(currentHeight - message.height) < HEIGHT_UPDATE_THRESHOLD ? currentHeight : message.height
))
if (!frameReadyRef.current) {
autoResizeReadyTimerRef.current = window.setTimeout(() => {
setFrameReady(true)
frameReadyRef.current = true
autoResizeReadyTimerRef.current = null
}, AUTO_RESIZE_SETTLE_MS)
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [config.url])
useEffect(() => {
return () => {
if (loadFallbackTimerRef.current !== null) {
window.clearTimeout(loadFallbackTimerRef.current)
}
if (autoResizeReadyTimerRef.current !== null) {
window.clearTimeout(autoResizeReadyTimerRef.current)
}
}
}, [])
return (
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
<div className="iframe-block-card">
<button
className="iframe-block-delete"
onClick={deleteNode}
aria-label="Delete iframe block"
>
<X size={14} />
</button>
{visibleTitle && <div className="iframe-block-title">{visibleTitle}</div>}
<div
className={`iframe-block-frame-shell${frameReady ? ' iframe-block-frame-shell-ready' : ' iframe-block-frame-shell-loading'}`}
style={{ height: frameHeight }}
>
{!frameReady && (
<div className="iframe-block-loading-overlay" aria-hidden="true">
<div className="iframe-block-loading-bar" />
<div className="iframe-block-loading-copy">Loading embed</div>
</div>
)}
<iframe
ref={iframeRef}
src={config.url}
title={title}
className="iframe-block-frame"
loading="lazy"
onLoad={() => {
if (loadFallbackTimerRef.current !== null) {
window.clearTimeout(loadFallbackTimerRef.current)
}
loadFallbackTimerRef.current = window.setTimeout(() => {
setFrameReady(true)
loadFallbackTimerRef.current = null
}, LOAD_FALLBACK_READY_MS)
}}
allow={allow}
allowFullScreen
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads"
/>
</div>
</div>
</NodeViewWrapper>
)
}
export const IframeBlockExtension = Node.create({
name: 'iframeBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-iframe')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'iframe-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(IframeBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```iframe\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {},
},
}
},
})

View file

@ -764,6 +764,7 @@
/* Shared block styles (image, embed, chart, table) */
.tiptap-editor .ProseMirror .image-block-wrapper,
.tiptap-editor .ProseMirror .embed-block-wrapper,
.tiptap-editor .ProseMirror .iframe-block-wrapper,
.tiptap-editor .ProseMirror .chart-block-wrapper,
.tiptap-editor .ProseMirror .table-block-wrapper,
.tiptap-editor .ProseMirror .calendar-block-wrapper,
@ -775,6 +776,7 @@
.tiptap-editor .ProseMirror .image-block-card,
.tiptap-editor .ProseMirror .embed-block-card,
.tiptap-editor .ProseMirror .iframe-block-card,
.tiptap-editor .ProseMirror .chart-block-card,
.tiptap-editor .ProseMirror .table-block-card,
.tiptap-editor .ProseMirror .calendar-block-card,
@ -793,6 +795,7 @@
.tiptap-editor .ProseMirror .image-block-card:hover,
.tiptap-editor .ProseMirror .embed-block-card:hover,
.tiptap-editor .ProseMirror .iframe-block-card:hover,
.tiptap-editor .ProseMirror .chart-block-card:hover,
.tiptap-editor .ProseMirror .table-block-card:hover,
.tiptap-editor .ProseMirror .calendar-block-card:hover,
@ -805,6 +808,7 @@
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
.tiptap-editor .ProseMirror .iframe-block-wrapper.ProseMirror-selectednode .iframe-block-card,
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card,
.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card,
@ -817,6 +821,7 @@
.tiptap-editor .ProseMirror .image-block-delete,
.tiptap-editor .ProseMirror .embed-block-delete,
.tiptap-editor .ProseMirror .iframe-block-delete,
.tiptap-editor .ProseMirror .chart-block-delete,
.tiptap-editor .ProseMirror .table-block-delete,
.tiptap-editor .ProseMirror .calendar-block-delete,
@ -843,6 +848,7 @@
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
.tiptap-editor .ProseMirror .iframe-block-card:hover .iframe-block-delete,
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete,
.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete,
@ -854,6 +860,7 @@
.tiptap-editor .ProseMirror .image-block-delete:hover,
.tiptap-editor .ProseMirror .embed-block-delete:hover,
.tiptap-editor .ProseMirror .iframe-block-delete:hover,
.tiptap-editor .ProseMirror .chart-block-delete:hover,
.tiptap-editor .ProseMirror .table-block-delete:hover,
.tiptap-editor .ProseMirror .calendar-block-delete:hover,
@ -943,6 +950,103 @@
font-size: 13px;
}
/* Iframe block */
.tiptap-editor .ProseMirror .iframe-block-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.tiptap-editor .ProseMirror .iframe-block-title {
font-size: 15px;
font-weight: 600;
color: var(--foreground);
padding-right: 28px;
}
.tiptap-editor .ProseMirror .iframe-block-frame-shell {
position: relative;
width: 100%;
min-height: 240px;
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: 12px;
overflow: hidden;
transition: height 0.18s ease;
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--primary) 14%, transparent), transparent 45%),
linear-gradient(180deg, color-mix(in srgb, var(--muted) 65%, transparent), color-mix(in srgb, var(--background) 95%, transparent));
}
.tiptap-editor .ProseMirror .iframe-block-loading-overlay {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--primary) 10%, transparent), transparent 42%),
linear-gradient(180deg, color-mix(in srgb, var(--muted) 88%, transparent), color-mix(in srgb, var(--background) 98%, transparent));
color: color-mix(in srgb, var(--foreground) 60%, transparent);
pointer-events: none;
opacity: 1;
transition: opacity 0.18s ease;
}
.tiptap-editor .ProseMirror .iframe-block-loading-bar {
width: min(220px, 46%);
height: 7px;
border-radius: 999px;
background:
linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--primary) 60%, transparent) 50%, transparent 100%),
color-mix(in srgb, var(--foreground) 8%, transparent);
background-size: 180px 100%, auto;
background-repeat: no-repeat;
animation: iframe-block-loading-sweep 1.05s linear infinite;
}
.tiptap-editor .ProseMirror .iframe-block-loading-copy {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.01em;
}
.tiptap-editor .ProseMirror .iframe-block-frame-shell-ready .iframe-block-loading-overlay {
opacity: 0;
}
.tiptap-editor .ProseMirror .iframe-block-frame {
width: 100%;
height: 100%;
border: none;
background: #fff;
opacity: 1;
transition: opacity 0.18s ease;
}
.tiptap-editor .ProseMirror .iframe-block-frame-shell-loading .iframe-block-frame {
opacity: 0;
}
.tiptap-editor .ProseMirror .iframe-block-error {
display: flex;
align-items: center;
gap: 6px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
font-size: 13px;
}
@keyframes iframe-block-loading-sweep {
from {
background-position: -180px 0, 0 0;
}
to {
background-position: calc(100% + 180px) 0, 0 0;
}
}
/* Chart block */
.tiptap-editor .ProseMirror .chart-block-title {
font-size: 14px;