From de5d1935a24740a669d706f295570de2c6d8b8ac Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Wed, 13 May 2026 23:14:53 -0700 Subject: [PATCH 1/2] Add HTML notebook cells Signed-off-by: Jeremy lewi --- app/src/components/Actions/Actions.test.tsx | 42 +++ app/src/components/Actions/Actions.tsx | 159 +++++++-- app/src/components/Actions/HtmlCell.tsx | 214 ++++++++++++ app/src/lib/cellContent.ts | 10 + .../serializeNotebookToMarkdown.test.ts | 16 + .../markdown/serializeNotebookToMarkdown.ts | 28 +- app/src/routes/run.tsx | 15 +- app/test/browser/run-cuj-scenarios.ts | 1 + app/test/browser/test-scenario-html-cell.ts | 326 ++++++++++++++++++ app/vite.config.mts | 1 + app/vitest.config.mts | 3 +- docs-dev/design/20260513_html_cells.md | 273 +++++++++++++++ 12 files changed, 1056 insertions(+), 32 deletions(-) create mode 100644 app/src/components/Actions/HtmlCell.tsx create mode 100644 app/src/lib/cellContent.ts create mode 100644 app/test/browser/test-scenario-html-cell.ts create mode 100644 docs-dev/design/20260513_html_cells.md diff --git a/app/src/components/Actions/Actions.test.tsx b/app/src/components/Actions/Actions.test.tsx index daf33fe6..0ca166b4 100644 --- a/app/src/components/Actions/Actions.test.tsx +++ b/app/src/components/Actions/Actions.test.tsx @@ -281,6 +281,48 @@ 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(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..233dfd85 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); } @@ -457,7 +473,7 @@ export function ActionOutputItems({ outputs }: { outputs: parser_pb.CellOutput[] ) { return null; } - if (!(item.data instanceof Uint8Array)) { + if (normalizeBinaryData(item.data).length === 0) { return null; } return ( @@ -528,6 +544,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 +685,8 @@ export function Action({ const editorLanguage = useMemo(() => { switch (selectedLanguage) { + case "html": + return "html"; case "markdown": return "markdown"; case "javascript": @@ -773,6 +792,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) { @@ -892,21 +920,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 +977,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 +1077,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/HtmlCell.tsx b/app/src/components/Actions/HtmlCell.tsx new file mode 100644 index 00000000..f9b21387 --- /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( + () => ( +