mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 || '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user