mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-22 18:45:19 +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 { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||||
import { ExternalLink, Globe, X } from 'lucide-react'
|
import { ExternalLink, Globe, X } from 'lucide-react'
|
||||||
import { blocks } from '@x/shared'
|
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 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 = [
|
const DEFAULT_IFRAME_ALLOW = [
|
||||||
'accelerometer',
|
'accelerometer',
|
||||||
'autoplay',
|
'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 }) {
|
function IframeBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||||
const raw = node.attrs.data as string
|
const raw = node.attrs.data as string
|
||||||
let config: blocks.IframeBlock | null = null
|
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 meta = getIframeMeta(config.url)
|
||||||
const title = config.title || meta?.host || 'Embedded page'
|
const title = config.title || meta?.host || 'Embedded page'
|
||||||
const allow = config.allow || DEFAULT_IFRAME_ALLOW
|
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 (
|
return (
|
||||||
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
|
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
|
||||||
|
|
@ -91,12 +203,31 @@ function IframeBlockView({ node, deleteNode }: { node: { attrs: Record<string, u
|
||||||
Open
|
Open
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
src={config.url}
|
src={config.url}
|
||||||
title={title}
|
title={title}
|
||||||
className="iframe-block-frame"
|
className="iframe-block-frame"
|
||||||
loading="lazy"
|
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}
|
allow={allow}
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads"
|
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: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: height 0.18s ease;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, color-mix(in srgb, var(--primary) 14%, transparent), transparent 45%),
|
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));
|
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 {
|
.tiptap-editor .ProseMirror .iframe-block-frame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
background: #fff;
|
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 {
|
.tiptap-editor .ProseMirror .iframe-block-caption {
|
||||||
|
|
@ -1056,6 +1103,15 @@
|
||||||
font-size: 13px;
|
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 */
|
/* Chart block */
|
||||||
.tiptap-editor .ProseMirror .chart-block-title {
|
.tiptap-editor .ProseMirror .chart-block-title {
|
||||||
font-size: 14px;
|
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 LOCAL_SITES_DIR = path.join(WorkDir, 'sites');
|
||||||
const SITE_SLUG_RE = /^[a-z0-9][a-z0-9-_]*$/i;
|
const SITE_SLUG_RE = /^[a-z0-9][a-z0-9-_]*$/i;
|
||||||
|
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height';
|
||||||
const TEXT_EXTENSIONS = new Set([
|
const TEXT_EXTENSIONS = new Set([
|
||||||
'.css',
|
'.css',
|
||||||
'.html',
|
'.html',
|
||||||
|
|
@ -40,6 +41,82 @@ const MIME_TYPES: Record<string, string> = {
|
||||||
'.webp': 'image/webp',
|
'.webp': 'image/webp',
|
||||||
'.xml': 'application/xml; charset=utf-8',
|
'.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 localSitesServer: Server | null = null;
|
||||||
let startPromise: Promise<void> | 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> {
|
async function respondWithFile(res: express.Response, filePath: string, method: string): Promise<void> {
|
||||||
const extension = path.extname(filePath).toLowerCase();
|
const extension = path.extname(filePath).toLowerCase();
|
||||||
const mimeType = MIME_TYPES[extension] || 'application/octet-stream';
|
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)) {
|
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);
|
res.end(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ You can embed a local site in a note with:
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- The app serves each site with SPA-friendly routing, so client-side routers work
|
- 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
|
- Put an \`index.html\` file at the site root
|
||||||
- Remote APIs still need to allow browser requests from a local page
|
- Remote APIs still need to allow browser requests from a local page
|
||||||
`,
|
`,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue