diff --git a/app/src/components/Actions/Actions.test.tsx b/app/src/components/Actions/Actions.test.tsx index daf33fe6..ee3864a3 100644 --- a/app/src/components/Actions/Actions.test.tsx +++ b/app/src/components/Actions/Actions.test.tsx @@ -231,6 +231,34 @@ describe("Action component", () => { expect(screen.queryByTestId("cell-console")).toBeNull(); }); + it("suppresses duplicate stdout output items while a live console stream is active", () => { + const stdoutOutput = create(parser_pb.CellOutputSchema, { + items: [ + create(parser_pb.CellOutputItemSchema, { + mime: "application/vnd.code.notebook.stdout", + type: "Buffer", + data: new TextEncoder().encode("prompt"), + }), + ], + }); + const cell = create(parser_pb.CellSchema, { + refId: "cell-live-stdout", + kind: parser_pb.CellKind.CODE, + languageId: "bash", + outputs: [stdoutOutput], + metadata: { + [RunmeMetadataKey.LastRunID]: "run-live-stdout", + }, + value: "echo hi", + }); + const stub = new StubCellData(cell) as unknown as CellData; + + render(); + + expect(screen.getByTestId("cell-console")).toBeTruthy(); + expect(screen.queryByText(/mime=application\/vnd\.code\.notebook\.stdout/)).toBeNull(); + }); + it("shows language selector in markdown edit mode and converts to code language", () => { const cell = create(parser_pb.CellSchema, { refId: "cell-md", @@ -281,6 +309,49 @@ describe("Action component", () => { expect(updatedCell.languageId).toBe("markdown"); }); + it("converts markdown cells to html code cells", () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-html-convert", + kind: parser_pb.CellKind.MARKUP, + languageId: "markdown", + outputs: [], + metadata: {}, + value: "", + }); + const stub = new StubCellData(cell); + + render(); + + const selector = screen.getByRole("combobox"); + fireEvent.change(selector, { target: { value: "html" } }); + + expect(stub.update).toHaveBeenCalledTimes(1); + const updatedCell = stub.update.mock.calls[0][0] as parser_pb.Cell; + expect(updatedCell.kind).toBe(parser_pb.CellKind.CODE); + expect(updatedCell.languageId).toBe("html"); + }); + + it("renders html cells in-place without the code run toolbar", () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-html-rendered", + kind: parser_pb.CellKind.CODE, + languageId: "html", + outputs: [], + metadata: {}, + value: "
Hello HTML
", + }); + const stub = new StubCellData(cell); + + render(); + + expect(screen.getByTestId("html-action")).toBeTruthy(); + expect(screen.getByTestId("html-rendered")).toBeTruthy(); + const frame = screen.getByTestId("html-preview-frame") as HTMLIFrameElement; + expect(frame.getAttribute("srcdoc")).toBe("
Hello HTML
"); + expect(frame.getAttribute("sandbox")).toBe(""); + expect(screen.queryByLabelText("Run code")).toBeNull(); + }); + it("shows browser/sandbox runner selector for javascript cells", () => { const cell = create(parser_pb.CellSchema, { refId: "cell-runner-select", diff --git a/app/src/components/Actions/Actions.tsx b/app/src/components/Actions/Actions.tsx index 565206b3..3812529a 100644 --- a/app/src/components/Actions/Actions.tsx +++ b/app/src/components/Actions/Actions.tsx @@ -26,10 +26,12 @@ import { useNotebookStore } from "../../contexts/NotebookStoreContext"; import { useOutput } from "../../contexts/OutputContext"; import CellConsole, { fontSettings } from "./CellConsole"; import Editor from "./Editor"; +import HtmlCell from "./HtmlCell"; import MarkdownCell from "./MarkdownCell"; import { IOPUB_INCOMPLETE_METADATA_KEY } from "../../lib/ipykernel"; import { appLogger } from "../../lib/logging/runtime"; import { copyNotebookShareUrl } from "../../lib/shareLinks"; +import { isHtmlLanguageId, isMarkdownLanguageId } from "../../lib/cellContent"; import { PlayIcon, PlusIcon, @@ -269,6 +271,7 @@ function RunActionButton({ // Action is an editor and an optional Runme console const LANGUAGE_OPTIONS = [ { label: "Markdown", value: "markdown" }, + { label: "HTML", value: "html" }, { label: "Bash", value: "bash" }, { label: "Jupyter", value: "jupyter" }, { label: "Python", value: "python" }, @@ -282,6 +285,7 @@ const JAVASCRIPT_RUNNER_OPTIONS = [ type SupportedLanguage = | "bash" + | "html" | "jupyter" | "javascript" | "markdown" @@ -290,6 +294,13 @@ type SupportedLanguage = const outputTextDecoder = new TextDecoder(); const ALWAYS_SKIP_MIMES = new Set([MimeType.StatefulRunmeTerminal]); +function normalizeBinaryData(data?: Uint8Array | ArrayLike | null): Uint8Array { + if (!data) { + return new Uint8Array(); + } + return data instanceof Uint8Array ? data : Uint8Array.from(data); +} + function isGoogleDriveFileUri(uri: string | null | undefined): uri is string { if (!uri) { return false; @@ -317,9 +328,12 @@ function normalizeLanguageId( switch (kind) { case parser_pb.CellKind.CODE: const normalized = (languageId ?? "").toLowerCase(); - if (normalized === "markdown") { + if (isMarkdownLanguageId(normalized)) { return "markdown"; } + if (isHtmlLanguageId(normalized)) { + return "html"; + } if (normalized === "python" || normalized === "py") { return "python"; } @@ -344,26 +358,28 @@ function normalizeLanguageId( } } -function decodeOutputText(data: Uint8Array): string { - if (!(data instanceof Uint8Array) || data.length === 0) { +function decodeOutputText(data?: Uint8Array | ArrayLike | null): string { + const normalized = normalizeBinaryData(data); + if (normalized.length === 0) { return ""; } try { - return outputTextDecoder.decode(data); + return outputTextDecoder.decode(normalized); } catch { return ""; } } -function uint8ArrayToBase64(data: Uint8Array): string { - if (!(data instanceof Uint8Array) || data.length === 0) { +function uint8ArrayToBase64(data?: Uint8Array | ArrayLike | null): string { + const normalized = normalizeBinaryData(data); + if (normalized.length === 0) { return ""; } let binary = ""; const chunkSize = 0x8000; - for (let i = 0; i < data.length; i += chunkSize) { - const chunk = data.subarray(i, i + chunkSize); + for (let i = 0; i < normalized.length; i += chunkSize) { + const chunk = normalized.subarray(i, i + chunkSize); binary += String.fromCharCode(...chunk); } @@ -436,7 +452,13 @@ function ActionOutputItemView({ ); } -export function ActionOutputItems({ outputs }: { outputs: parser_pb.CellOutput[] }) { +export function ActionOutputItems({ + outputs, + suppressStdText = false, +}: { + outputs: parser_pb.CellOutput[]; + suppressStdText?: boolean; +}) { const hasTerminalOutput = outputs.some((output) => (output.items ?? []).some((item) => item?.mime === MimeType.StatefulRunmeTerminal), ); @@ -452,12 +474,12 @@ export function ActionOutputItems({ outputs }: { outputs: parser_pb.CellOutput[] return null; } if ( - hasTerminalOutput && + (hasTerminalOutput || suppressStdText) && (mime === MimeType.VSCodeNotebookStdOut || mime === MimeType.VSCodeNotebookStdErr) ) { return null; } - if (!(item.data instanceof Uint8Array)) { + if (normalizeBinaryData(item.data).length === 0) { return null; } return ( @@ -528,6 +550,7 @@ export function Action({ y: number; } | null>(null); const [shareRemoteUri, setShareRemoteUri] = useState(null); + const [htmlEditRequest, setHtmlEditRequest] = useState(0); const [markdownEditRequest, setMarkdownEditRequest] = useState(0); const [pid, setPid] = useState(null); const [exitCode, setExitCode] = useState(null); @@ -668,6 +691,8 @@ export function Action({ const editorLanguage = useMemo(() => { switch (selectedLanguage) { + case "html": + return "html"; case "markdown": return "markdown"; case "javascript": @@ -773,6 +798,15 @@ export function Action({ } return; } + if (selectedLanguage === "html") { + if (initialRunnerName !== DEFAULT_RUNNER_PLACEHOLDER) { + cellData.setRunner(DEFAULT_RUNNER_PLACEHOLDER); + } + if (hasJupyterSelection) { + cellData.clearJupyterKernel(); + } + return; + } if (selectedLanguage === "jupyter" && isAppKernelRunnerName(initialRunnerName)) { cellData.setRunner(DEFAULT_RUNNER_PLACEHOLDER); if (hasJupyterSelection) { @@ -876,8 +910,13 @@ export function Action({ if (!cell?.outputs || cell.outputs.length === 0) { return null; } - return ; - }, [cell?.outputs]); + return ( + + ); + }, [cell?.outputs, cellData]); const handleLanguageChange = useCallback( (event: ChangeEvent) => { @@ -892,21 +931,26 @@ export function Action({ const updatedCell = create(parser_pb.CellSchema, cell); updatedCell.metadata ??= {}; - if (nextValue === "markdown") { - setMarkdownEditRequest((request) => request + 1); - updatedCell.kind = parser_pb.CellKind.MARKUP; - updatedCell.languageId = "markdown"; + const clearRuntimeMetadata = () => { delete updatedCell.metadata[RunmeMetadataKey.RunnerName]; delete updatedCell.metadata[RunmeMetadataKey.JupyterServerName]; delete updatedCell.metadata[RunmeMetadataKey.JupyterKernelID]; delete updatedCell.metadata[RunmeMetadataKey.JupyterKernelName]; + }; + if (nextValue === "markdown") { + setMarkdownEditRequest((request) => request + 1); + updatedCell.kind = parser_pb.CellKind.MARKUP; + updatedCell.languageId = "markdown"; + clearRuntimeMetadata(); + } else if (nextValue === "html") { + setHtmlEditRequest((request) => request + 1); + updatedCell.kind = parser_pb.CellKind.CODE; + updatedCell.languageId = "html"; + clearRuntimeMetadata(); } else if (nextValue === "jupyter") { updatedCell.kind = parser_pb.CellKind.CODE; updatedCell.languageId = "jupyter"; - delete updatedCell.metadata[RunmeMetadataKey.RunnerName]; - delete updatedCell.metadata[RunmeMetadataKey.JupyterServerName]; - delete updatedCell.metadata[RunmeMetadataKey.JupyterKernelID]; - delete updatedCell.metadata[RunmeMetadataKey.JupyterKernelName]; + clearRuntimeMetadata(); } else if (nextValue === "javascript") { updatedCell.kind = parser_pb.CellKind.CODE; updatedCell.languageId = "javascript"; @@ -944,11 +988,12 @@ export function Action({ // Determine if this cell is a markdown cell (either MARKUP kind or CODE with markdown language) const isMarkdownCell = useMemo(() => { if (!cell) return false; - // Check if cell kind is MARKUP if (cell.kind === parser_pb.CellKind.MARKUP) return true; - // Check if cell is CODE but with markdown language - const lang = (cell.languageId ?? "").toLowerCase(); - return lang === "markdown" || lang === "md"; + return isMarkdownLanguageId(cell.languageId); + }, [cell]); + const isHtmlCell = useMemo(() => { + if (!cell) return false; + return cell.kind === parser_pb.CellKind.CODE && isHtmlLanguageId(cell.languageId); }, [cell]); if (!cell) { @@ -1043,6 +1088,89 @@ export function Action({ ); } + if (isHtmlCell) { + return ( +
+
+ + +
+
+
+ + +
+
+ {adjustedContextMenu && ( +
event.preventDefault()} + > + {shareRemoteUri && ( + + )} + +
+ )} +
+ ); + } + // Render code cells as a unified Marimo-style card: editor + toolbar + output // are all inside one bordered container with a distinctive "paper" shadow. // The outer wrapper is a flex row: left gutter (add-cell buttons) + cell card. diff --git a/app/src/components/Actions/CellConsole.test.tsx b/app/src/components/Actions/CellConsole.test.tsx index 34d5d607..0ec93dee 100644 --- a/app/src/components/Actions/CellConsole.test.tsx +++ b/app/src/components/Actions/CellConsole.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { act } from "react"; import ReactDOM from "react-dom/client"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; // Mock runmedev/renderers to avoid registering the real web component, // which depends on adoptedStyleSheets and other browser-only APIs. @@ -19,9 +20,11 @@ import CellConsole from "./CellConsole"; class FakeCellData { snapshot: parser_pb.Cell; + private readonly stream: any; - constructor(cell: parser_pb.Cell) { + constructor(cell: parser_pb.Cell, stream?: any) { this.snapshot = cell; + this.stream = stream; } subscribe(_listener: () => void): () => void { @@ -29,7 +32,7 @@ class FakeCellData { } getStreams() { - return undefined; + return this.stream; } getRunID() { @@ -38,6 +41,32 @@ class FakeCellData { } } +function createSubject() { + const listeners = new Set<(value: T) => void>(); + return { + subscribe(listener: (value: T) => void) { + listeners.add(listener); + return { + unsubscribe: () => listeners.delete(listener), + }; + }, + emit(value: T) { + listeners.forEach((listener) => listener(value)); + }, + }; +} + +function createFakeStream() { + return { + stdout: createSubject(), + stderr: createSubject(), + pid: createSubject(), + exitCode: createSubject(), + sendExecuteRequest: vi.fn(), + setCallback: vi.fn(), + }; +} + // Minimal console-view stub so the component can construct it. class FakeConsoleView extends HTMLElement { initialContent = ""; @@ -62,6 +91,30 @@ class FakeConsoleView extends HTMLElement { } describe("CellConsole", () => { + const roots: Array = []; + + function renderConsole(cellData: FakeCellData) { + const div = document.createElement("div"); + document.body.appendChild(div); + const root = ReactDOM.createRoot(div); + roots.push(root); + act(() => { + root.render( + {}} onPid={() => {}} />, + ); + }); + return div; + } + + afterEach(() => { + act(() => { + while (roots.length > 0) { + roots.pop()?.unmount(); + } + }); + document.body.innerHTML = ""; + }); + it("renders existing stdout content from cell outputs", async () => { if (!customElements.get("console-view")) { customElements.define("console-view", FakeConsoleView); @@ -95,15 +148,10 @@ describe("CellConsole", () => { }); const cellData = new FakeCellData(cell); - const div = document.createElement("div"); - document.body.appendChild(div); + + const div = renderConsole(cellData); await act(async () => { - const root = ReactDOM.createRoot(div); - root.render( - {}} onPid={() => {}} />, - ); - // Let effects flush await Promise.resolve(); }); @@ -113,4 +161,72 @@ describe("CellConsole", () => { const texts = Array.from(spans).map((s) => s.textContent); expect(texts.join("")).toContain("hello world"); }); + + it("shows a stdin composer for active streams and sends a newline-terminated write", async () => { + const stream = createFakeStream(); + const cell = create(parser_pb.CellSchema, { + refId: "cell-stdin", + kind: parser_pb.CellKind.CODE, + languageId: "bash", + outputs: [], + metadata: { + [RunmeMetadataKey.LastRunID]: "run-stdin", + }, + }); + + await act(async () => { + renderConsole(new FakeCellData(cell, stream)); + await Promise.resolve(); + }); + + const input = screen.getByTestId("cell-stdin-input") as HTMLInputElement; + const submit = screen.getByTestId("cell-stdin-submit") as HTMLButtonElement; + expect(submit.disabled).toBe(true); + + await act(async () => { + fireEvent.change(input, { target: { value: "y" } }); + }); + + expect(submit.disabled).toBe(false); + + await act(async () => { + fireEvent.submit(screen.getByTestId("cell-stdin-form")); + }); + + expect(stream.sendExecuteRequest).toHaveBeenCalledTimes(1); + const request = stream.sendExecuteRequest.mock.calls[0][0] as { + inputData?: Uint8Array; + }; + expect(new TextDecoder().decode(request.inputData)).toBe("y\n"); + expect(input.value).toBe(""); + }); + + it("renders partial stdout chunks immediately while the process is still running", async () => { + const stream = createFakeStream(); + const cell = create(parser_pb.CellSchema, { + refId: "cell-live-prompt", + kind: parser_pb.CellKind.CODE, + languageId: "bash", + outputs: [], + metadata: { + [RunmeMetadataKey.LastRunID]: "run-live", + }, + }); + + let div: HTMLDivElement; + await act(async () => { + div = renderConsole(new FakeCellData(cell, stream)); + await Promise.resolve(); + }); + + await act(async () => { + stream.stdout.emit(new TextEncoder().encode("Password:")); + await Promise.resolve(); + }); + + const consoleEl = div!.querySelector("console-view") as FakeConsoleView | null; + const spans = consoleEl?.querySelectorAll(".xterm-rows span") ?? []; + const texts = Array.from(spans).map((s) => s.textContent).join(""); + expect(texts).toContain("Password:"); + }); }); diff --git a/app/src/components/Actions/CellConsole.tsx b/app/src/components/Actions/CellConsole.tsx index 615c5c60..25b50053 100644 --- a/app/src/components/Actions/CellConsole.tsx +++ b/app/src/components/Actions/CellConsole.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, + useState, useSyncExternalStore, type MutableRefObject, } from "react"; @@ -10,45 +11,16 @@ import { ExecuteRequestSchema, WinsizeSchema, } from "@buf/stateful_runme.bufbuild_es/runme/runner/v2/runner_pb"; -import { ClientMessages, setContext } from "@runmedev/renderers"; +import { ClientMessages } from "@runmedev/renderers"; import { MimeType, RunmeMetadataKey, parser_pb } from "../../runme/client"; import { CellData } from "../../lib/notebookData"; -import { maybeParseIPykernelMessage, type IPykernelMessage } from "../../lib/ipykernel"; export const fontSettings = { fontSize: 12.6, fontFamily: "Fira Mono, monospace", }; -/** - * Extract displayable text from an IPykernel message. - * - "stream" messages contain print() output in content.text - * - "error" messages contain exception info in content.ename/evalue/traceback - * Returns the text to write to the terminal, or null if the message - * is a control message that should be silently ignored. - */ -function extractIopubText(msg: IPykernelMessage): string | null { - const msgType = msg.header?.msg_type ?? (msg as any).msg_type; - - if (msgType === "stream") { - const text = typeof msg.content?.text === "string" ? msg.content.text : ""; - return text || null; - } - - if (msgType === "error") { - const ename = (msg.content as any)?.ename ?? ""; - const evalue = (msg.content as any)?.evalue ?? ""; - const traceback: string[] = Array.isArray((msg.content as any)?.traceback) - ? (msg.content as any).traceback - : []; - const text = [ename, evalue, ...traceback].filter(Boolean).join("\n"); - return text ? text + "\n" : null; - } - - return null; -} - const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); @@ -127,7 +99,7 @@ const CellConsole = ({ cellData, onExitCode, onPid }: CellConsoleProps) => { const pendingWrites = useRef([]); const winsizeRef = useRef<{ cols: number; rows: number }>({ cols: 0, rows: 0 }); const wroteInitialForRun = useRef(null); - const stdoutBufferRef = useRef(""); + const [stdinValue, setStdinValue] = useState(""); const cell = useSyncExternalStore( @@ -136,6 +108,7 @@ const CellConsole = ({ cellData, onExitCode, onPid }: CellConsoleProps) => { ) || undefined; const runID = cellData.getRunID(); + const stream = cellData.getStreams() as any; if (!cell || cell.kind !== parser_pb.CellKind.CODE) { return null; @@ -185,7 +158,6 @@ const CellConsole = ({ cellData, onExitCode, onPid }: CellConsoleProps) => { // Write any recovered output into the terminal once it is ready. consoleEl.initialContent = ""; - const stream = cellData.getStreams() as any; const disposers: Array<() => void> = []; const flushPending = () => { @@ -224,42 +196,9 @@ const CellConsole = ({ cellData, onExitCode, onPid }: CellConsoleProps) => { } }; - const flushStdoutBuffer = () => { - const pending = stdoutBufferRef.current; - if (!pending) { - return; - } - stdoutBufferRef.current = ""; - const parsed = maybeParseIPykernelMessage(pending); - if (parsed) { - const text = extractIopubText(parsed); - if (text) { - writeToTerminal(textEncoder.encode(text)); - } - } else { - writeToTerminal(textEncoder.encode(pending)); - } - }; - - if (stream) { + if (stream) { const stdoutSub = stream.stdout.subscribe((data: Uint8Array) => { - const chunkText = textDecoder.decode(data); - stdoutBufferRef.current += chunkText; - - const lines = stdoutBufferRef.current.split("\n"); - stdoutBufferRef.current = lines.pop() ?? ""; - - lines.forEach((line) => { - const parsed = maybeParseIPykernelMessage(line); - if (parsed) { - const text = extractIopubText(parsed); - if (text) { - writeToTerminal(textEncoder.encode(text)); - } - return; - } - writeToTerminal(textEncoder.encode(`${line}\n`)); - }); + writeToTerminal(data); }); disposers.push(() => stdoutSub.unsubscribe()); @@ -274,7 +213,6 @@ const CellConsole = ({ cellData, onExitCode, onPid }: CellConsoleProps) => { disposers.push(() => pidSub.unsubscribe()); const exitSub = stream.exitCode.subscribe((code: number) => { - flushStdoutBuffer(); onExitCode(code); }); disposers.push(() => exitSub.unsubscribe()); @@ -285,15 +223,61 @@ const CellConsole = ({ cellData, onExitCode, onPid }: CellConsoleProps) => { }; // Only recreate subscriptions/console when the run changes; callbacks are stable. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [runID]); + }, [cellData, onExitCode, onPid, runID, stream]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const activeStream = cellData.getStreams() as any; + if (!activeStream) { + return; + } + const input = stdinValue.endsWith("\n") ? stdinValue : `${stdinValue}\n`; + const req = create(ExecuteRequestSchema, { + inputData: textEncoder.encode(input), + }); + activeStream.sendExecuteRequest(req); + setStdinValue(""); + }; return ( -
+
+
+ {stream ? ( +
+
+ Waiting for input +
+
+ setStdinValue(event.currentTarget.value)} + placeholder="Type one response and press Enter" + type="text" + value={stdinValue} + /> + +
+
+ ) : null} +
); }; diff --git a/app/src/components/Actions/HtmlCell.tsx b/app/src/components/Actions/HtmlCell.tsx new file mode 100644 index 00000000..6a0c2dc3 --- /dev/null +++ b/app/src/components/Actions/HtmlCell.tsx @@ -0,0 +1,214 @@ +import { + type ChangeEvent, + memo, + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type FocusEvent, + type KeyboardEvent, +} from "react"; + +import { create } from "@bufbuild/protobuf"; +import { parser_pb } from "../../runme/client"; +import type { CellData } from "../../lib/notebookData"; +import Editor from "./Editor"; +import { fontSettings } from "./CellConsole"; + +interface HtmlCellProps { + cellData: CellData; + selectedLanguage: string; + languageSelectId: string; + languageOptions: readonly { label: string; value: string }[]; + onLanguageChange: (event: ChangeEvent) => void; + forceEditRequest?: number; +} + +const HtmlCell = memo( + ({ + cellData, + selectedLanguage, + languageSelectId, + languageOptions, + onLanguageChange, + forceEditRequest = 0, + }: HtmlCellProps) => { + const cell = useSyncExternalStore( + useCallback( + (listener) => cellData.subscribeToContentChange(listener), + [cellData], + ), + useCallback(() => cellData.snapshot, [cellData]), + useCallback(() => cellData.snapshot, [cellData]), + ); + + const [rendered, setRendered] = useState(() => { + const value = cell?.value ?? ""; + return value.trim().length > 0; + }); + + const value = cell?.value ?? ""; + + useEffect(() => { + if (!value.trim() && rendered) { + setRendered(false); + } + }, [rendered, value]); + + useEffect(() => { + if (forceEditRequest > 0) { + setRendered(false); + } + }, [forceEditRequest]); + + const handleRenderedKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.target !== event.currentTarget) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setRendered(false); + } + }, + [], + ); + + const handleBlur = useCallback( + (event: FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) { + return; + } + if (!value.trim()) { + return; + } + setRendered(true); + }, + [value], + ); + + const handleEditorKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape" && value.trim()) { + setRendered(true); + } + }, + [value], + ); + + const handleEditorChange = useCallback( + (newValue: string) => { + if (!cell) { + return; + } + const updated = create(parser_pb.CellSchema, cell); + updated.value = newValue; + cellData.update(updated); + }, + [cell, cellData], + ); + + const handlePreview = useCallback(() => { + if (value.trim()) { + setRendered(true); + } + }, [value]); + + const previewIframe = useMemo( + () => ( +