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
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"noSvgWithoutTitle": "off",
"useButtonType": "off",
"useFocusableInteractive": "off",
"useKeyWithClickEvents": "off"
"useKeyWithClickEvents": "off",
"noAutofocus": "off"
},
"suspicious": {
"noArrayIndexKey": "off",
Expand Down
16 changes: 15 additions & 1 deletion registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,8 @@
"@nteract/isolated-frame",
"@nteract/widget-store",
"@nteract/markdown-output",
"@nteract/error-boundary"
"@nteract/error-boundary",
"@nteract/highlight-text"
],
"files": [
{
Expand Down Expand Up @@ -948,6 +949,19 @@
}
]
},
{
"name": "highlight-text",
"type": "registry:lib",
"title": "Highlight Text",
"description": "DOM utility for highlighting search matches in text content with customizable styling.",
"files": [
{
"path": "registry/lib/highlight-text.ts",
"type": "registry:lib",
"target": "lib/highlight-text.ts"
}
]
},
{
"name": "isolated-renderer",
"type": "registry:component",
Expand Down
6 changes: 3 additions & 3 deletions registry/cell/CellContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const CellContainer = forwardRef<HTMLDivElement, CellContainerProps>(
ribbonColor,
)}
/>
<div className="min-w-0 flex-1 py-3 pl-5 pr-3">{codeContent}</div>
<div className="min-w-0 flex-1 py-3 pl-6 pr-3">{codeContent}</div>
</div>
{/* Output row - ribbon + content together */}
{hasOutput && (
Expand All @@ -107,7 +107,7 @@ export const CellContainer = forwardRef<HTMLDivElement, CellContainerProps>(
/>
<div
className={cn(
"min-w-0 flex-1 py-2 pl-5 pr-3 transition-opacity duration-150",
"min-w-0 flex-1 py-2 pl-6 pr-3 transition-opacity duration-150",
!isFocused && "opacity-70",
)}
>
Expand All @@ -125,7 +125,7 @@ export const CellContainer = forwardRef<HTMLDivElement, CellContainerProps>(
ribbonColor,
)}
/>
<div className="min-w-0 flex-1 py-3 pl-5 pr-3">{children}</div>
<div className="min-w-0 flex-1 py-3 pl-6 pr-3">{children}</div>
</div>
)}
{/* Right margin - pt-3 aligns with left gutter, appears on hover/focus */}
Expand Down
2 changes: 0 additions & 2 deletions registry/cell/CellTypeSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import { Bot, ChevronDown, Code, Database, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Expand Down
2 changes: 0 additions & 2 deletions registry/cell/CollaboratorAvatars.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import * as React from "react";
import {
Avatar,
Expand Down
2 changes: 1 addition & 1 deletion registry/cell/CompactExecutionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export function CompactExecutionButton({
return (
<button
type="button"
data-testid="execute-button"
onClick={handleClick}
className={cn(
"group/exec inline-flex items-center font-mono text-sm tabular-nums",
Expand All @@ -47,6 +46,7 @@ export function CompactExecutionButton({
className,
)}
title={isExecuting ? "Stop execution" : "Run cell"}
data-testid="execute-button"
>
<span className="opacity-60">[</span>
<span className="relative inline-flex min-w-4 items-center justify-center">
Expand Down
111 changes: 81 additions & 30 deletions registry/cell/OutputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { isDarkMode as detectDarkMode } from "@/lib/dark-mode";
import { cn } from "@/lib/utils";
import { ErrorBoundary } from "@/registry/lib/error-boundary";
import { highlightTextInDom } from "@/registry/lib/highlight-text";
import { OutputErrorFallback } from "@/registry/lib/output-error-fallback";
import {
AnsiErrorOutput,
Expand Down Expand Up @@ -107,6 +108,16 @@ interface OutputAreaProps {
* @deprecated Use the comm bridge instead for full widget support
*/
onWidgetUpdate?: (commId: string, state: Record<string, unknown>) => void;
/**
* Search query to highlight in iframe outputs.
* Empty string or undefined clears highlights.
*/
searchQuery?: string;
/**
* Callback reporting how many search matches were found in this cell's outputs.
* Called when iframe reports search_results or in-DOM highlighting completes.
*/
onSearchMatchCount?: (count: number) => void;
}

/**
Expand Down Expand Up @@ -279,10 +290,15 @@ export function OutputArea({
preloadIframe = false,
onLinkClick,
onWidgetUpdate,
searchQuery,
onSearchMatchCount,
}: OutputAreaProps) {
const id = useId();
const frameRef = useRef<IsolatedFrameHandle>(null);
const bridgeRef = useRef<CommBridgeManager | null>(null);
const inDomOutputRef = useRef<HTMLDivElement>(null);
const searchQueryRef = useRef(searchQuery);
searchQueryRef.current = searchQuery;

// Track dark mode state and observe changes
const [darkMode, setDarkMode] = useState(() => detectDarkMode());
Expand Down Expand Up @@ -319,7 +335,6 @@ export function OutputArea({
const shouldUseBridge = shouldIsolate && hasWidgets && widgetContext !== null;

const hasCollapseControl = onToggleCollapse !== undefined;
const outputCount = outputs.length;

// Handle messages from iframe, routing widget messages to comm bridge
const handleIframeMessage = useCallback(
Expand All @@ -333,8 +348,13 @@ export function OutputArea({
if (message.type === "widget_update" && onWidgetUpdate) {
onWidgetUpdate(message.payload.commId, message.payload.state);
}

// Capture search result count from iframe
if (message.type === "search_results") {
onSearchMatchCount?.(message.payload.count);
}
},
[onWidgetUpdate],
[onWidgetUpdate, onSearchMatchCount],
);

// Callback when frame is ready - set up bridge and render outputs
Expand Down Expand Up @@ -399,6 +419,11 @@ export function OutputArea({
});
}
});

// Re-apply search highlights after rendering new content
if (searchQueryRef.current) {
frameRef.current?.search(searchQueryRef.current);
}
}, [outputs, priority, shouldUseBridge, widgetContext]);

// Clean up bridge on unmount
Expand All @@ -418,6 +443,29 @@ export function OutputArea({
}
}, [handleFrameReady]);

// Forward search query to the iframe (for isolated outputs)
useEffect(() => {
if (frameRef.current?.isIframeReady) {
frameRef.current.search(searchQuery || "");
}
}, [searchQuery]);

// Highlight search matches in in-DOM outputs
// Re-run when outputs array ref changes so new content gets highlighted
useEffect(() => {
if (shouldIsolate) return; // iframe reports its own count via search_results
if (!searchQuery || !inDomOutputRef.current || outputs.length === 0) {
// Only report 0 if we were previously tracking matches for this cell
if (searchQuery) onSearchMatchCount?.(0);
return;
}
const cleanup = highlightTextInDom(inDomOutputRef.current, searchQuery);
const count =
inDomOutputRef.current.querySelectorAll(".global-find-match").length;
onSearchMatchCount?.(count);
return cleanup;
}, [searchQuery, shouldIsolate, outputs, onSearchMatchCount]);

// Empty state: render nothing (unless preloading iframe)
if (outputs.length === 0 && !showPreloadedIframe) {
return null;
Expand Down Expand Up @@ -447,7 +495,7 @@ export function OutputArea({
)}
<span>
{collapsed
? `Show ${outputCount} output${outputCount > 1 ? "s" : ""}`
? `Show ${outputs.length} output${outputs.length > 1 ? "s" : ""}`
: "Hide outputs"}
</span>
</button>
Expand Down Expand Up @@ -480,34 +528,37 @@ export function OutputArea({
)}

{/* In-DOM outputs (when not using isolation) */}
{!shouldIsolate &&
outputs.map((output, index) => (
<div
key={`output-${index}`}
data-slot="output-item"
data-output-index={index}
>
<ErrorBoundary
resetKeys={[JSON.stringify(output)]}
fallback={(error, reset) => (
<OutputErrorFallback
error={error}
outputIndex={index}
onRetry={reset}
/>
)}
onError={(error, errorInfo) => {
console.error(
`[OutputArea] Error rendering output ${index}:`,
error,
errorInfo.componentStack,
);
}}
{!shouldIsolate && (
<div ref={inDomOutputRef}>
{outputs.map((output, index) => (
<div
key={`output-${index}`}
data-slot="output-item"
data-output-index={index}
>
{renderOutput(output, index, renderers, priority)}
</ErrorBoundary>
</div>
))}
<ErrorBoundary
resetKeys={[JSON.stringify(output)]}
fallback={(error, reset) => (
<OutputErrorFallback
error={error}
outputIndex={index}
onRetry={reset}
/>
)}
onError={(error, errorInfo) => {
console.error(
`[OutputArea] Error rendering output ${index}:`,
error,
errorInfo.componentStack,
);
}}
>
{renderOutput(output, index, renderers, priority)}
</ErrorBoundary>
</div>
))}
</div>
)}
</div>
)}
</div>
Expand Down
2 changes: 0 additions & 2 deletions registry/cell/PresenceBookmarks.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import type * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Expand Down
5 changes: 5 additions & 0 deletions registry/editor/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export const notebookEditorTheme = EditorView.theme({
"&.cm-focused": {
outline: "none",
},
// Reset line padding so code aligns with output areas
// (CodeMirror's base theme adds "padding: 0 2px 0 6px" to .cm-line)
".cm-line": {
paddingLeft: "0",
},
// Mobile-friendly padding
"@media (max-width: 640px)": {
".cm-content": {
Expand Down
66 changes: 66 additions & 0 deletions registry/lib/highlight-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Highlight all occurrences of a search query within a DOM container.
*
* Walks text nodes using TreeWalker, wraps matches in <mark> elements.
* Returns a cleanup function that removes all marks and restores text nodes.
*/
export function highlightTextInDom(
container: HTMLElement,
query: string,
): () => void {
if (!query) return () => {};

const marks: HTMLElement[] = [];
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
);
const lowerQuery = query.toLowerCase();

// Collect matches first (to avoid mutating DOM while walking)
const matches: { node: Text; offset: number; length: number }[] = [];
let node = walker.nextNode();
while (node) {
const text = node.nodeValue || "";
const lowerText = text.toLowerCase();
let pos = lowerText.indexOf(lowerQuery, 0);
while (pos !== -1) {
matches.push({ node: node as Text, offset: pos, length: query.length });
pos = lowerText.indexOf(lowerQuery, pos + query.length);
}
node = walker.nextNode();
}

// Apply highlights in reverse order to preserve offsets
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
try {
const range = document.createRange();
range.setStart(m.node, m.offset);
range.setEnd(m.node, m.offset + m.length);
const mark = document.createElement("mark");
mark.className = "global-find-match";
mark.style.cssText =
"background: #fbbf24; color: #000; border-radius: 2px; padding: 0;";
range.surroundContents(mark);
marks.unshift(mark);
} catch {
// surroundContents can fail if range crosses element boundaries
}
}

// Return cleanup function
return () => {
for (const mark of marks) {
const parent = mark.parentNode;
if (parent) {
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark);
}
parent.removeChild(mark);
parent.normalize();
}
}
};
}
2 changes: 0 additions & 2 deletions registry/lib/widget-error-fallback.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import { cn } from "@/lib/utils";

export interface WidgetErrorFallbackProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ function createMockFrame(): {
eval: vi.fn(),
setTheme: vi.fn(),
clear: vi.fn(),
search: vi.fn(),
searchNavigate: vi.fn(),
isReady: true,
isIframeReady: true,
};
Expand Down
6 changes: 4 additions & 2 deletions registry/outputs/isolated/comm-bridge-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,9 +370,11 @@ export class CommBridgeManager {

const unsubscribe = this.store.subscribeToCustomMessage(
commId,
(content, buffers) => {
(content: Record<string, unknown>, buffers?: DataView[]) => {
// Convert DataView[] to ArrayBuffer[] for postMessage
const arrayBuffers = buffers?.map((dv) => dv.buffer as ArrayBuffer);
const arrayBuffers = buffers?.map(
(dv: DataView) => dv.buffer as ArrayBuffer,
);
// Forward custom message to iframe
this.sendCommMsg(commId, "custom", content, arrayBuffers);
},
Expand Down
Loading