Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 123 additions & 2 deletions app/components/IsolatedFrameDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { isDarkMode as detectDarkMode } from "@/lib/dark-mode";
import {
type IframeToParentMessage,
IsolatedFrame,
type IsolatedFrameHandle,
IsolatedRendererProvider,
Expand Down Expand Up @@ -78,8 +80,46 @@ const interactiveContent = `
</button>
`;

// Content with repeatable terms for search demonstration
const searchContent = `
<style>
.report { color: var(--text-primary); line-height: 1.6; }
.report h2 { margin: 0 0 12px 0; font-size: 18px; }
.report p { margin: 0 0 12px 0; }
.report table { width: 100%; margin-top: 16px; }
</style>
<div class="report">
<h2>Machine Learning Model Comparison</h2>
<p>
The <strong>Random Forest</strong> model achieved 94% accuracy on the test dataset.
This model uses ensemble learning to combine multiple decision trees. The training
process completed in 45 seconds with default hyperparameters.
</p>
<p>
The <strong>XGBoost</strong> model improved to 96% accuracy with optimized hyperparameters.
Gradient boosting helped reduce overfitting compared to the Random Forest approach.
Training time was slightly longer at 62 seconds due to hyperparameter tuning.
</p>
<p>
The <strong>Neural Network</strong> reached 97% accuracy after training for 100 epochs.
The model architecture includes 3 hidden layers with dropout regularization.
Total training time was 180 seconds on GPU-accelerated hardware.
</p>
<table>
<thead>
<tr><th>Model</th><th>Accuracy</th><th>Training Time</th></tr>
</thead>
<tbody>
<tr><td>Random Forest</td><td>94%</td><td>45s</td></tr>
<tr><td>XGBoost</td><td>96%</td><td>62s</td></tr>
<tr><td>Neural Network</td><td>97%</td><td>180s</td></tr>
</tbody>
</table>
</div>
`;

interface IsolatedFrameDemoProps {
variant?: "table" | "styled" | "interactive" | "theme";
variant?: "table" | "styled" | "interactive" | "theme" | "search";
}

export function IsolatedFrameDemo({
Expand All @@ -90,6 +130,12 @@ export function IsolatedFrameDemo({
const [darkMode, setDarkMode] = useState(() => detectDarkMode());
const [ready, setReady] = useState(false);

// Search state (for search variant)
const [searchQuery, setSearchQuery] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [matchCount, setMatchCount] = useState(0);
const [currentMatch, setCurrentMatch] = useState(0);

// Observe page theme changes (fumadocs adds/removes 'dark' class on html)
useEffect(() => {
const observer = new MutationObserver(() => {
Expand All @@ -104,6 +150,37 @@ export function IsolatedFrameDemo({
return () => observer.disconnect();
}, []);

// Handle search results from iframe
const handleMessage = useCallback((msg: IframeToParentMessage) => {
if (msg.type === "search_results") {
setMatchCount(msg.payload.count);
setCurrentMatch(msg.payload.count > 0 ? 0 : -1);
}
}, []);

// Debounced search when query or case sensitivity changes
useEffect(() => {
if (variant !== "search") return;
const timer = setTimeout(() => {
frameRef.current?.search(searchQuery, caseSensitive);
}, 150);
return () => clearTimeout(timer);
}, [searchQuery, caseSensitive, variant]);

const handlePrevMatch = () => {
if (matchCount === 0) return;
const prev = currentMatch > 0 ? currentMatch - 1 : matchCount - 1;
setCurrentMatch(prev);
frameRef.current?.searchNavigate(prev);
};

const handleNextMatch = () => {
if (matchCount === 0) return;
const next = (currentMatch + 1) % matchCount;
setCurrentMatch(next);
frameRef.current?.searchNavigate(next);
};

const getContent = () => {
switch (variant) {
case "table":
Expand All @@ -114,6 +191,8 @@ export function IsolatedFrameDemo({
return interactiveContent;
case "theme":
return htmlTableContent;
case "search":
return searchContent;
default:
return htmlTableContent;
}
Expand All @@ -139,6 +218,47 @@ export function IsolatedFrameDemo({
</span>
</div>
)}
{variant === "search" && (
<div className="flex flex-wrap items-center gap-3">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search in content..."
className="rounded border border-gray-300 bg-background px-3 py-1.5 text-sm dark:border-gray-600"
/>
<label className="flex items-center gap-1.5 text-sm">
<input
type="checkbox"
checked={caseSensitive}
onChange={(e) => setCaseSensitive(e.target.checked)}
className="rounded"
/>
Case sensitive
</label>
<div className="flex items-center gap-1">
<button
type="button"
onClick={handlePrevMatch}
disabled={matchCount === 0}
className="rounded p-1 hover:bg-gray-100 disabled:opacity-40 dark:hover:bg-gray-800"
>
<ChevronLeft className="size-4" />
</button>
<span className="w-14 text-center text-sm tabular-nums">
{matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : "0/0"}
</span>
<button
type="button"
onClick={handleNextMatch}
disabled={matchCount === 0}
className="rounded p-1 hover:bg-gray-100 disabled:opacity-40 dark:hover:bg-gray-800"
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
)}
<div className="overflow-hidden rounded-lg border">
<IsolatedFrame
ref={frameRef}
Expand All @@ -155,6 +275,7 @@ export function IsolatedFrameDemo({
console.log("Link clicked:", url, newTab);
window.open(url, newTab ? "_blank" : "_self");
}}
onMessage={variant === "search" ? handleMessage : undefined}
/>
</div>
{!ready && <p className="text-sm text-muted-foreground">Loading...</p>}
Expand Down
40 changes: 40 additions & 0 deletions content/docs/outputs/isolated-frame.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,41 @@ Try editing the HTML below to see live updates via the `render()` method:

This demo combines `CodeMirrorEditor` with `IsolatedFrame` to create a live HTML playground. Changes are debounced (300ms) and sent to the iframe via `frameRef.current.render()`.

### Global Find (Search)

Search within iframe content with highlighting and navigation:

<div className="my-4 rounded border p-4">
<IsolatedFrameDemo variant="search" />
</div>

```tsx
const frameRef = useRef<IsolatedFrameHandle>(null);
const [matchCount, setMatchCount] = useState(0);

// Search for text (case-insensitive)
frameRef.current?.search("accuracy", false);

// Navigate between matches
frameRef.current?.searchNavigate(0); // first match
frameRef.current?.searchNavigate(1); // second match

// Clear search highlights
frameRef.current?.search("");

// Listen for search results
<IsolatedFrame
ref={frameRef}
onMessage={(msg) => {
if (msg.type === "search_results") {
setMatchCount(msg.payload.count);
}
}}
/>
```

Search matches are highlighted in yellow, and the active match (after `searchNavigate`) is highlighted in orange and scrolled into view.

## Architecture

Communication between parent and iframe uses `postMessage`:
Expand All @@ -220,13 +255,16 @@ Communication between parent and iframe uses `postMessage`:
- `theme` - Update dark/light mode
- `eval` - Execute JavaScript (for bootstrapping)
- `clear` - Clear all content
- `search` - Search for text in content
- `search_navigate` - Navigate to a specific search match
- `comm_*` - Widget communication

**Iframe → Parent:**
- `ready` - Iframe loaded
- `resize` - Content height changed
- `link_click` - User clicked a link
- `error` - JavaScript error occurred
- `search_results` - Number of matches found
- `widget_*` - Widget state updates

## Props
Expand Down Expand Up @@ -258,6 +296,8 @@ interface IsolatedFrameHandle {
eval: (code: string) => void;
setTheme: (isDark: boolean) => void;
clear: () => void;
search: (query: string, caseSensitive?: boolean) => void;
searchNavigate: (matchIndex: number) => void;
isReady: boolean;
isIframeReady: boolean;
}
Expand Down