improvements

This commit is contained in:
Arjun 2026-04-16 00:32:34 +05:30
parent a5fc7faa9b
commit 5dc592bfab
4 changed files with 280 additions and 3 deletions

View file

@ -2,8 +2,15 @@ import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { ExternalLink, 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',
@ -29,6 +36,43 @@ function getIframeMeta(url: string): { host: string; path: string } | null {
}
}
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
@ -53,7 +97,75 @@ function IframeBlockView({ node, deleteNode }: { node: { attrs: Record<string, u
const meta = getIframeMeta(config.url)
const title = config.title || meta?.host || 'Embedded page'
const allow = config.allow || DEFAULT_IFRAME_ALLOW
const height = config.height ?? DEFAULT_IFRAME_HEIGHT
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">
@ -91,12 +203,31 @@ function IframeBlockView({ node, deleteNode }: { node: { attrs: Record<string, u
Open
</a>
</div>
<div className="iframe-block-frame-shell" style={{ height }}>
<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"

View file

@ -1030,16 +1030,63 @@
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-caption {
@ -1056,6 +1103,15 @@
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;

View file

@ -11,6 +11,7 @@ export const LOCAL_SITES_BASE_URL = `http://localhost:${LOCAL_SITES_PORT}`;
const LOCAL_SITES_DIR = path.join(WorkDir, 'sites');
const SITE_SLUG_RE = /^[a-z0-9][a-z0-9-_]*$/i;
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height';
const TEXT_EXTENSIONS = new Set([
'.css',
'.html',
@ -40,6 +41,82 @@ const MIME_TYPES: Record<string, string> = {
'.webp': 'image/webp',
'.xml': 'application/xml; charset=utf-8',
};
const IFRAME_AUTOSIZE_BOOTSTRAP = String.raw`<script>
(() => {
if (window.parent === window || typeof window.parent?.postMessage !== 'function') return;
const MESSAGE_TYPE = '__ROWBOAT_IFRAME_HEIGHT_MESSAGE__';
const MIN_HEIGHT = 240;
let animationFrameId = 0;
let lastHeight = 0;
const applyEmbeddedStyles = () => {
const root = document.documentElement;
if (root) root.style.overflowY = 'hidden';
if (document.body) document.body.style.overflowY = 'hidden';
};
const measureHeight = () => {
const root = document.documentElement;
const body = document.body;
return Math.max(
root?.scrollHeight ?? 0,
root?.offsetHeight ?? 0,
root?.clientHeight ?? 0,
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
body?.clientHeight ?? 0,
);
};
const publishHeight = () => {
animationFrameId = 0;
applyEmbeddedStyles();
const nextHeight = Math.max(MIN_HEIGHT, Math.ceil(measureHeight()));
if (Math.abs(nextHeight - lastHeight) < 2) return;
lastHeight = nextHeight;
window.parent.postMessage({
type: MESSAGE_TYPE,
height: nextHeight,
href: window.location.href,
}, '*');
};
const schedulePublish = () => {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(publishHeight);
};
const resizeObserver = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(schedulePublish)
: null;
if (resizeObserver && document.documentElement) resizeObserver.observe(document.documentElement);
if (resizeObserver && document.body) resizeObserver.observe(document.body);
const mutationObserver = new MutationObserver(schedulePublish);
if (document.documentElement) {
mutationObserver.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
}
window.addEventListener('load', schedulePublish);
window.addEventListener('resize', schedulePublish);
if (document.fonts?.addEventListener) {
document.fonts.addEventListener('loadingdone', schedulePublish);
}
for (const delay of [0, 50, 150, 300, 600, 1200]) {
setTimeout(schedulePublish, delay);
}
schedulePublish();
})();
</script>`;
let localSitesServer: Server | null = null;
let startPromise: Promise<void> | null = null;
@ -110,6 +187,14 @@ async function ensureLocalSiteScaffold(): Promise<void> {
);
}
function injectIframeAutosizeBootstrap(html: string): string {
const bootstrap = IFRAME_AUTOSIZE_BOOTSTRAP.replace('__ROWBOAT_IFRAME_HEIGHT_MESSAGE__', IFRAME_HEIGHT_MESSAGE)
if (/<\/body>/i.test(html)) {
return html.replace(/<\/body>/i, `${bootstrap}\n</body>`)
}
return `${html}\n${bootstrap}`
}
async function respondWithFile(res: express.Response, filePath: string, method: string): Promise<void> {
const extension = path.extname(filePath).toLowerCase();
const mimeType = MIME_TYPES[extension] || 'application/octet-stream';
@ -126,7 +211,11 @@ async function respondWithFile(res: express.Response, filePath: string, method:
}
if (TEXT_EXTENSIONS.has(extension)) {
const text = await fsp.readFile(filePath, 'utf8');
let text = await fsp.readFile(filePath, 'utf8');
if (extension === '.html') {
text = injectIframeAutosizeBootstrap(text);
}
res.setHeader('Content-Length', String(Buffer.byteLength(text)));
res.end(text);
return;
}

View file

@ -19,6 +19,7 @@ You can embed a local site in a note with:
Notes:
- The app serves each site with SPA-friendly routing, so client-side routers work
- Local HTML pages auto-expand inside Rowboat iframe blocks to fit their content height
- Put an \`index.html\` file at the site root
- Remote APIs still need to allow browser requests from a local page
`,