diff --git a/app/src/components/Actions/Actions.test.tsx b/app/src/components/Actions/Actions.test.tsx index daf33fe..6722f70 100644 --- a/app/src/components/Actions/Actions.test.tsx +++ b/app/src/components/Actions/Actions.test.tsx @@ -372,4 +372,29 @@ describe("Action component", () => { expect(runnerSelect).toBeNull(); expect(kernelSelect).toBeTruthy(); }); + + it("ignores focus on rendered markdown controls that are outside a focus-role surface", () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-md-rendered-controls", + kind: parser_pb.CellKind.MARKUP, + languageId: "markdown", + outputs: [], + metadata: {}, + value: "hello", + }); + const stub = new StubCellData(cell); + const onFocusStateChange = vi.fn(); + + render( + , + ); + + fireEvent.focus(screen.getByRole("button", { name: "Delete cell" })); + + expect(onFocusStateChange).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/components/Actions/Actions.tsx b/app/src/components/Actions/Actions.tsx index 565206b..8ad0500 100644 --- a/app/src/components/Actions/Actions.tsx +++ b/app/src/components/Actions/Actions.tsx @@ -29,6 +29,14 @@ import Editor from "./Editor"; import MarkdownCell from "./MarkdownCell"; import { IOPUB_INCOMPLETE_METADATA_KEY } from "../../lib/ipykernel"; import { appLogger } from "../../lib/logging/runtime"; +import { + createNotebookActiveCellState, + loadNotebookActiveCellMap, + persistNotebookActiveCellMap, + type CellFocusRole, + type NotebookActiveCellMap, + type NotebookActiveCellState, +} from "../../lib/notebookActiveCellState"; import { copyNotebookShareUrl } from "../../lib/shareLinks"; import { PlayIcon, @@ -481,12 +489,20 @@ export function ActionOutputItems({ outputs }: { outputs: parser_pb.CellOutput[] export function Action({ cellData, - docUri, + docUri = "", isFirst, + isActiveCell = false, + activeFocusRole = "editor", + isWindowFocused = false, + onFocusStateChange, }: { cellData: CellData; - docUri: string; + docUri?: string; isFirst: boolean; + isActiveCell?: boolean; + activeFocusRole?: CellFocusRole; + isWindowFocused?: boolean; + onFocusStateChange?: (state: NotebookActiveCellState) => void; }) { const { store } = useNotebookStore(); const { listRunners, defaultRunnerName } = useRunners(); @@ -621,6 +637,48 @@ export function Action({ [], ); + const handleFocusCapture = useCallback( + (event: React.FocusEvent) => { + if (!cell?.refId || !onFocusStateChange) { + return; + } + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + const focusRoleElement = target.closest( + "[data-cell-focus-role]", + ); + if (!focusRoleElement) { + return; + } + const focusRole = + focusRoleElement.dataset.cellFocusRole === "rendered" + ? "rendered" + : "editor"; + const nextState = createNotebookActiveCellState(cell.refId, focusRole); + if (!nextState) { + return; + } + onFocusStateChange(nextState); + }, + [cell?.refId, onFocusStateChange], + ); + + const handleMarkdownFocusRoleChange = useCallback( + (focusRole: CellFocusRole) => { + if (!cell?.refId || !onFocusStateChange) { + return; + } + const nextState = createNotebookActiveCellState(cell.refId, focusRole); + if (!nextState) { + return; + } + onFocusStateChange(nextState); + }, + [cell?.refId, onFocusStateChange], + ); + const handleRemoveCell = useCallback(() => { cellData.remove(); setContextMenu(null); @@ -963,7 +1021,9 @@ export function Action({ id={`markdown-action-${cell.refId}`} className="group/cell relative flex min-w-0" onContextMenu={handleContextMenu} + onFocusCapture={handleFocusCapture} data-testid="markdown-action" + data-cell-ref-id={cell.refId} > {/* Left gutter: top + bottom add-cell buttons */}
@@ -994,6 +1054,10 @@ export function Action({ languageOptions={LANGUAGE_OPTIONS} onLanguageChange={handleLanguageChange} forceEditRequest={markdownEditRequest} + isActiveCell={isActiveCell} + activeFocusRole={activeFocusRole} + isWindowFocused={isWindowFocused} + onFocusRoleChange={handleMarkdownFocusRoleChange} /> {/* Trash icon on the right, visible on hover */}