fix(ipynb): keep cells pixel-stable when they switch to editing

The preview and the live editor disagreed on four things, measured over CDP:
- Font: the excerpt painted the bare "SF Mono" name (or --font-mono via its
  row class), while Monaco appended its own fallbacks and landed on Menlo.
  Both now use resolveEditorFontStack, the editor font plus the terminal
  fallback chain.
- Line height: 20px rows vs Monaco's 21px. Both read CODE_EXCERPT_LAYOUT.
- Gutter: a 48px line-number column plus 12px inset vs Monaco's 25px gutter.
  Notebook cells drop line numbers (the Jupyter and VS Code notebook
  default) and Monaco's decorations lane is the same 12px inset.
- Rows: colorized blank lines collapsed to 0px, and a trailing newline had
  no preview row. Rows are fixed-height and a trailing newline opens an
  empty last line, matching the Monaco model.

The [n] prompt and run icon now share one grid cell, so the hover swap keeps
the label's box and centre. The commented-line tint moves to a theme token.
This commit is contained in:
Jinwoo-H
2026-09-23 02:49:03 -04:00
parent ff0eda23d4
commit 172b4f7937
6 changed files with 74 additions and 35 deletions
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import Editor, { type OnMount } from '@monaco-editor/react'
import type { Components } from 'react-markdown'
import { monaco } from '@/lib/monaco-setup'
import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
import { computeEditorFontSize, resolveEditorFontStack } from '@/lib/editor-font-zoom'
import { useAppStore } from '@/store'
import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts'
import {
@@ -11,13 +11,10 @@ import {
} from './ipynb-code-cell-lines'
import type { IpynbCell } from './ipynb-parse'
import { MarkdownPreviewBody } from './MarkdownPreviewBody'
import MonacoCodeExcerpt from './MonacoCodeExcerpt'
import MonacoCodeExcerpt, { CODE_EXCERPT_LAYOUT } from './MonacoCodeExcerpt'
import { useDocumentDarkTheme } from './use-document-dark-theme'
const NO_MARKDOWN_COMPONENTS: Components = {}
// Matches MonacoCodeExcerpt's `leading-5 py-1`, so activating a cell does not shift the layout.
const SOURCE_LINE_HEIGHT_PX = 20
const SOURCE_VERTICAL_PADDING_PX = 4
export function IpynbMarkdownCell({ source }: { source: string }): React.JSX.Element {
const isDark = useDocumentDarkTheme()
@@ -88,6 +85,7 @@ export function IpynbCellSource(props: IpynbCellSourceProps): React.JSX.Element
highlightedStartLine={-1}
highlightedEndLine={-1}
language={cell.language}
showLineNumbers={false}
/>
</div>
)}
@@ -112,11 +110,11 @@ function IpynbSourceEditor({
onSaveRequestRef.current = onSaveRequest
}, [onDeactivate, onSaveRequest])
const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel)
const lineHeight = Math.max(SOURCE_LINE_HEIGHT_PX, Math.ceil(fontSize * 1.5))
const { lineHeight, paddingX, paddingY } = CODE_EXCERPT_LAYOUT
const maxHeight = IPYNB_CODE_CELL_PREVIEW_MAX_LINES * lineHeight
// Seeds the first frame only; Monaco reports the real content height after mount.
const [contentHeight, setContentHeight] = useState(
() => getIpynbCodeCellPreviewLines(source).length * lineHeight + 2 * SOURCE_VERTICAL_PADDING_PX
() => getIpynbCodeCellPreviewLines(source).length * lineHeight + 2 * paddingY
)
const handleMount: OnMount = useCallback((editorInstance, monacoInstance) => {
editorInstance.focus()
@@ -159,12 +157,15 @@ function IpynbSourceEditor({
onChange={(value) => onChange(value ?? '')}
options={{
automaticLayout: true,
fontFamily: resolveEditorFontFamily(settings),
fontFamily: resolveEditorFontStack(settings),
fontSize,
// Why: same box as the excerpt it replaces. No gutter, so the decorations lane is the inset.
lineHeight,
padding: { top: SOURCE_VERTICAL_PADDING_PX, bottom: SOURCE_VERTICAL_PADDING_PX },
padding: { top: paddingY, bottom: paddingY },
lineNumbers: 'off',
glyphMargin: false,
lineNumbersMinChars: 3,
folding: false,
lineDecorationsWidth: paddingX,
minimap: { enabled: false },
overviewRulerLanes: 0,
renderLineHighlight: 'none',
@@ -24,6 +24,7 @@ import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { IpynbCellKind } from './ipynb-parse'
const CELL_KINDS: readonly IpynbCellKind[] = ['code', 'markdown', 'raw']
@@ -77,7 +78,7 @@ export function IpynbToolbarButton({
)
}
/** Jupyter-style `[n]` prompt that turns into a run button on hover or focus. */
/** Jupyter-style `[n]` prompt that turns into the run button on hover or focus. */
export function IpynbRunPrompt({
executionCount,
running,
@@ -87,19 +88,29 @@ export function IpynbRunPrompt({
running: boolean
onRun: () => void
}): React.JSX.Element {
if (running) {
return <Loader2 className="m-1.5 size-3 animate-spin text-muted-foreground" />
}
return (
<IpynbToolbarButton
label={translate('auto.components.editor.IpynbViewer.859bf9fc21', 'Run cell')}
size="xs"
disabled={running}
onClick={onRun}
>
<span className="font-mono text-[11px] text-muted-foreground group-focus-within:hidden group-hover:hidden">
[{executionCount ?? ' '}]
{/* Both states share one grid cell, so the slot keeps the label's width and centre. */}
<span className="grid place-items-center *:[grid-area:1/1]">
<span
className={cn(
'font-mono text-[11px] text-muted-foreground',
running ? 'invisible' : 'group-focus-within:invisible group-hover:invisible'
)}
>
[{executionCount ?? ' '}]
</span>
{running ? (
<Loader2 className="animate-spin" />
) : (
<Play className="invisible group-focus-within:visible group-hover:visible" />
)}
</span>
<Play className="hidden group-focus-within:block group-hover:block" />
</IpynbToolbarButton>
)
}
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'
import { monaco } from '@/lib/monaco-setup'
import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
import { computeEditorFontSize, resolveEditorFontStack } from '@/lib/editor-font-zoom'
import { resolveDocumentTheme } from '@/lib/document-theme'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
@@ -30,12 +30,18 @@ async function ensureColorizationLanguage(language: string): Promise<void> {
await pythonLanguageRegistrationPromise
}
/** Box metrics a live Monaco editor must reuse to swap in for an excerpt without shifting. */
export const CODE_EXCERPT_LAYOUT = { lineHeight: 20, paddingY: 4, paddingX: 12 } as const
// Why: preflight gives <code> its own mono stack; inherit so the editor font setting applies.
const CODE_STYLE = { paddingInline: CODE_EXCERPT_LAYOUT.paddingX, fontFamily: 'inherit' } as const
type MonacoCodeExcerptProps = {
lines: string[]
firstLineNumber: number
highlightedStartLine: number
highlightedEndLine: number
language: string
showLineNumbers?: boolean
}
export default function MonacoCodeExcerpt({
@@ -43,7 +49,8 @@ export default function MonacoCodeExcerpt({
firstLineNumber,
highlightedStartLine,
highlightedEndLine,
language
language,
showLineNumbers = true
}: MonacoCodeExcerptProps): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
@@ -51,7 +58,7 @@ export default function MonacoCodeExcerpt({
settings?.terminalFontSize ?? 13,
editorFontZoomLevel
)
const fontFamily = resolveEditorFontFamily(settings)
const fontFamily = resolveEditorFontStack(settings)
const isDark = resolveDocumentTheme(settings?.theme ?? 'system')
const code = useMemo(() => lines.join('\n'), [lines])
const [htmlLines, setHtmlLines] = useState<string[]>(() => lines.map(() => ''))
@@ -88,8 +95,14 @@ export default function MonacoCodeExcerpt({
return (
<div
className="overflow-x-auto py-1 text-[12px] leading-5"
style={{ fontFamily, fontSize: editorFontSize }}
className="overflow-x-auto"
style={{
fontFamily,
fontSize: editorFontSize,
lineHeight: `${CODE_EXCERPT_LAYOUT.lineHeight}px`,
paddingBlock: CODE_EXCERPT_LAYOUT.paddingY,
letterSpacing: 0
}}
>
{lines.map((codeLine, index) => {
const lineNumber = firstLineNumber + index
@@ -99,18 +112,23 @@ export default function MonacoCodeExcerpt({
return (
<div
key={lineNumber}
className={cn('flex font-mono', isCommentedLine && 'bg-emerald-500/10')}
className={cn('flex', isCommentedLine && 'bg-workspace-status-review/10')}
// Why: colorized blank lines are empty spans; a fixed row keeps them one line tall.
style={{ height: CODE_EXCERPT_LAYOUT.lineHeight }}
>
<span className="w-12 shrink-0 select-none border-r border-border/40 px-2 text-right text-muted-foreground tabular-nums">
{lineNumber}
</span>
{showLineNumbers ? (
<span className="w-12 shrink-0 select-none border-r border-border/40 px-2 text-right text-muted-foreground tabular-nums">
{lineNumber}
</span>
) : null}
{html ? (
<code
className="min-w-max flex-1 whitespace-pre px-3 text-foreground"
className="min-w-max flex-1 whitespace-pre text-foreground"
style={CODE_STYLE}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<code className="min-w-max flex-1 whitespace-pre px-3 text-foreground">
<code className="min-w-max flex-1 whitespace-pre text-foreground" style={CODE_STYLE}>
{codeLine || ' '}
</code>
)}
@@ -13,8 +13,9 @@ afterEach(() => {
describe('notebook code cell line derivation', () => {
it('derives preview lines including CRLF content', () => {
expect(getIpynbCodeCellPreviewLines('')).toEqual([''])
expect(getIpynbCodeCellPreviewLines('one\ntwo\n')).toEqual(['one', 'two'])
expect(getIpynbCodeCellPreviewLines('one\r\ntwo\r\n')).toEqual(['one', 'two'])
expect(getIpynbCodeCellPreviewLines('one\ntwo')).toEqual(['one', 'two'])
expect(getIpynbCodeCellPreviewLines('one\ntwo\n')).toEqual(['one', 'two', ''])
expect(getIpynbCodeCellPreviewLines('one\r\ntwo\r\n')).toEqual(['one', 'two', ''])
})
it('caps newline-heavy cells without splitting or walking the full payload', () => {
@@ -25,11 +25,9 @@ export function getIpynbCodeCellPreviewLines(source: string): string[] {
lineStart = index + 1
}
if (lineStart < scanLength) {
lines.push(sliceIpynbCodeCellPreviewLine(source, lineStart, scanLength))
}
return lines.length > 0 ? lines : ['']
// A trailing newline still opens an empty last line, as it does in the Monaco model.
lines.push(sliceIpynbCodeCellPreviewLine(source, lineStart, scanLength))
return lines
}
function sliceIpynbCodeCellPreviewLine(source: string, lineStart: number, lineEnd: number): string {
+10
View File
@@ -1,3 +1,5 @@
import { buildFontFamily } from '@/components/terminal-pane/layout-serialization'
const EDITOR_FONT_ZOOM_MIN = -6
const EDITOR_FONT_ZOOM_MAX = 18
const EDITOR_FONT_ZOOM_STEP = 1
@@ -43,3 +45,11 @@ export type EditorFontFamilySettings = {
export function resolveEditorFontFamily(settings?: EditorFontFamilySettings | null): string {
return settings?.editorFontFamily?.trim() || settings?.terminalFontFamily || 'monospace'
}
/**
* Fallback-backed stack for code painted outside Monaco. Monaco appends its own fallbacks to a
* bare name; a plain element does not, so an unresolvable name like "SF Mono" drops to serif.
*/
export function resolveEditorFontStack(settings?: EditorFontFamilySettings | null): string {
return buildFontFamily(settings?.editorFontFamily?.trim() || settings?.terminalFontFamily || '')
}