mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-30 10:56:29 +02:00
improvements
This commit is contained in:
parent
a5fc7faa9b
commit
5dc592bfab
4 changed files with 280 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue