{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "code-editor", "title": "Code Editor", "description": "An editable Monaco code editor with Shiki tokenization and CSS variable theming.", "dependencies": [ "shiki", "monaco-editor-core", "@shikijs/monaco", "lucide-react" ], "registryDependencies": [ "https://ds.formance.com/r/code-themes.json", "https://ds.formance.com/r/code-navigator.json" ], "files": [ { "path": "registry/default/ui/code/code-editor.tsx", "content": "'use client';\n\nimport { Check, Copy } from 'lucide-react';\nimport type React from 'react';\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n} from 'react';\n\nimport { cn } from '@/lib/utils';\nimport { Button } from '@/registry/default/ui/button';\nimport { CodeNavigator } from '@/registry/default/ui/code/code-navigator';\nimport {\n buildMonacoThemeFromCSSVars,\n CODE_LANGUAGES,\n getHighlighter,\n MONACO_EDITOR_OPTIONS,\n setupMonacoEnvironment,\n type TCodeLanguage,\n} from '@/registry/default/ui/code/code-themes';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype TDiagnostic = {\n startLineNumber: number;\n startColumn: number;\n endLineNumber: number;\n endColumn: number;\n message: string;\n severity: 'error' | 'warning' | 'info' | 'hint';\n};\n\ntype TDiagnosticsConfig = {\n validate?: (value: string) => Promise | TDiagnostic[];\n};\n\ntype TMonacoEditorInstance = {\n revealLineInCenter: (line: number) => void;\n setPosition: (position: { lineNumber: number; column: number }) => void;\n focus: () => void;\n};\n\ntype TCodeEditorProps = {\n value: string;\n defaultValue?: string;\n language: TCodeLanguage;\n onChange?: (value: string) => void;\n /** Fixed height (ignored when `fill` or `adaptiveHeight` is true). @default 400 */\n height?: number | string;\n isReadonly?: boolean;\n canCopy?: boolean;\n onCtrlEnter?: VoidFunction;\n onDidPaste?: (value: string) => void;\n /** Height adapts to content (up to 1000 px). Ignored when `fill` is true. @default true */\n adaptiveHeight?: boolean;\n /** Fill parent container height. Takes precedence over `adaptiveHeight`/`height`. */\n fill?: boolean;\n /** Unfold all code regions by default. @default true */\n defaultUnfoldAll?: boolean;\n bordered?: boolean;\n /** Override dark mode detection. When omitted, auto-detects from `document.documentElement.class`. */\n isDark?: boolean;\n diagnostics?: TDiagnosticsConfig;\n onEditorReady?: (editor: TMonacoEditorInstance) => void;\n /** Show a breadcrumb navigator toolbar for JSON/YAML content. */\n withNavigator?: boolean;\n} & Omit, 'onChange'>;\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst SEVERITY_MAP = { error: 8, warning: 4, info: 2, hint: 1 } as const;\nconst VALIDATION_DEBOUNCE_MS = 500;\nconst CTRL_ENTER_EVENT = 'formance:code-editor:ctrl-enter';\nconst MONACO_THEME_BASE = 'formance-css-vars';\n\n// Guard: Monaco global setup (language registration + Shiki wiring) must run\n// exactly once. Without this, React StrictMode's double-mount causes\n// \"Cannot register two commands with the same id\" errors.\ntype TMonacoSetupResult = {\n monaco: any;\n /** Original setTheme before shikiToMonaco's override */\n setTheme: (name: string) => void;\n};\n\nlet _monacoSetupPromise: Promise | null = null;\n\n// Monaco only runs in the browser, so the editor is gated on hydration.\nconst subscribeToNothing = () => () => {};\nconst getIsClientSnapshot = () => true;\nconst getIsServerSnapshot = () => false;\n\nfunction ensureMonacoSetup(): Promise {\n if (_monacoSetupPromise) return _monacoSetupPromise;\n\n _monacoSetupPromise = (async () => {\n const [monaco, { shikiToMonaco }] = await Promise.all([\n import('monaco-editor-core'),\n import('@shikijs/monaco'),\n ]);\n\n setupMonacoEnvironment();\n\n const highlighter = await getHighlighter();\n\n CODE_LANGUAGES.forEach((lang) => {\n monaco.languages.register({ id: lang });\n });\n\n // Save the original setTheme before shikiToMonaco overrides it.\n // We need it to apply the resolved CSS-variables theme later,\n // which isn't registered with Shiki.\n const originalSetTheme = monaco.editor.setTheme.bind(monaco.editor);\n\n // shikiToMonaco intercepts setTheme/create and needs real hex colors\n // in the Shiki color map — the CSS-variables theme would break Monaco.\n highlighter.setTheme('formance-monaco-fallback');\n shikiToMonaco(highlighter, monaco);\n\n return { monaco, setTheme: originalSetTheme };\n })();\n\n return _monacoSetupPromise;\n}\n\n// ---------------------------------------------------------------------------\n// Height helper\n// ---------------------------------------------------------------------------\n\nfunction getHeightStyle(\n fill: boolean,\n adaptive: boolean,\n height: number | string\n): string {\n if (fill) return '100%';\n if (adaptive) return 'auto';\n\n return typeof height === 'number' ? `${height}px` : height;\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\ntype TUseMonacoThemeArgs = {\n isInitialized: boolean;\n isDark?: boolean;\n monacoRef: React.RefObject;\n containerRef: React.RefObject;\n setThemeRef: React.RefObject<((name: string) => void) | null>;\n};\n\n/**\n * Re-applies the theme when dark/light changes. When `isDark` is provided it\n * reacts to prop changes; when it is undefined the document class is observed.\n */\nfunction useMonacoTheme({\n isInitialized,\n isDark,\n monacoRef,\n containerRef,\n setThemeRef,\n}: TUseMonacoThemeArgs) {\n useEffect(() => {\n const monaco = monacoRef.current;\n if (!monaco || !isInitialized || !containerRef.current) return;\n\n const applyTheme = () => {\n if (!monaco || !containerRef.current) return;\n const theme = buildMonacoThemeFromCSSVars(containerRef.current);\n\n monaco.editor.defineTheme(MONACO_THEME_BASE, theme as any);\n setThemeRef.current?.(MONACO_THEME_BASE);\n };\n\n applyTheme();\n\n if (isDark === undefined) {\n const observer = new MutationObserver(applyTheme);\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: ['class'],\n });\n\n return () => observer.disconnect();\n }\n }, [isInitialized, isDark, monacoRef, containerRef, setThemeRef]);\n}\n\nfunction CodeEditor({\n value,\n defaultValue,\n language,\n onChange,\n height = 400,\n isReadonly = false,\n canCopy = true,\n onCtrlEnter,\n onDidPaste,\n adaptiveHeight: adaptiveHeightProp = true,\n fill = false,\n defaultUnfoldAll = true,\n bordered = true,\n isDark,\n diagnostics,\n onEditorReady,\n withNavigator = false,\n className,\n ...htmlProps\n}: TCodeEditorProps) {\n const adaptiveHeight = !fill && adaptiveHeightProp;\n\n const containerRef = useRef(null);\n\n const editorRef = useRef(null);\n\n const monacoRef = useRef(null);\n const setThemeRef = useRef<((name: string) => void) | null>(null);\n const validationTimeoutRef = useRef | null>(\n null\n );\n const diagnosticsRef = useRef(diagnostics);\n const onDidPasteRef = useRef(onDidPaste);\n const onCtrlEnterRef = useRef(onCtrlEnter);\n\n const isClient = useSyncExternalStore(\n subscribeToNothing,\n getIsClientSnapshot,\n getIsServerSnapshot\n );\n const [isInitialized, setIsInitialized] = useState(false);\n const [copied, setCopied] = useState(false);\n const [navigatorEditorRef, setNavigatorEditorRef] =\n useState(null);\n\n const currentValue = value ?? defaultValue ?? '';\n const isEmpty = currentValue === '{}' || currentValue === '';\n\n // Keep diagnostics ref current\n useEffect(() => {\n diagnosticsRef.current = diagnostics;\n }, [diagnostics]);\n\n useEffect(() => {\n onDidPasteRef.current = onDidPaste;\n }, [onDidPaste]);\n\n useEffect(() => {\n onCtrlEnterRef.current = onCtrlEnter;\n }, [onCtrlEnter]);\n\n useEffect(() => {\n const handleCtrlEnter = () => onCtrlEnterRef.current?.();\n window.addEventListener(CTRL_ENTER_EVENT, handleCtrlEnter);\n\n return () => window.removeEventListener(CTRL_ENTER_EVENT, handleCtrlEnter);\n }, []);\n\n // Adaptive height\n const updateHeight = useCallback(() => {\n if (!containerRef.current || !editorRef.current || !adaptiveHeight) return;\n const h = Math.min(1000, editorRef.current.getContentHeight());\n containerRef.current.style.height = `${h}px`;\n editorRef.current.layout({\n width: containerRef.current.clientWidth,\n height: h,\n });\n }, [adaptiveHeight]);\n\n // Validation\n const runValidation = useCallback(async (content: string) => {\n const monaco = monacoRef.current;\n const editor = editorRef.current;\n const validate = diagnosticsRef.current?.validate;\n if (!monaco || !editor || !validate) return;\n\n const model = editor.getModel();\n if (!model) return;\n\n const results = await validate(content);\n const markers = results.map((d: TDiagnostic) => ({\n startLineNumber: d.startLineNumber,\n startColumn: d.startColumn,\n endLineNumber: d.endLineNumber,\n endColumn: d.endColumn,\n message: d.message,\n severity: SEVERITY_MAP[d.severity],\n }));\n\n if (editor.getModel() === model) {\n monaco.editor.setModelMarkers(model, 'custom-validation', markers);\n }\n }, []);\n\n const triggerValidation = useCallback(\n (content: string) => {\n if (validationTimeoutRef.current)\n clearTimeout(validationTimeoutRef.current);\n validationTimeoutRef.current = setTimeout(\n () => runValidation(content),\n VALIDATION_DEBOUNCE_MS\n );\n },\n [runValidation]\n );\n\n // ---------------------------------------------------------------------------\n // Editor setup\n // ---------------------------------------------------------------------------\n\n const setupValues = useRef({\n currentValue,\n isReadonly,\n defaultUnfoldAll,\n onChange,\n adaptiveHeight,\n updateHeight,\n isEmpty,\n onEditorReady,\n });\n\n useEffect(() => {\n setupValues.current = {\n currentValue,\n isReadonly,\n defaultUnfoldAll,\n onChange,\n adaptiveHeight,\n updateHeight,\n isEmpty,\n onEditorReady,\n };\n });\n\n useEffect(() => {\n if (!isClient || !containerRef.current) return;\n\n let disposed = false;\n\n (async () => {\n const { monaco, setTheme } = await ensureMonacoSetup();\n\n if (disposed || !containerRef.current) return;\n monacoRef.current = monaco;\n setThemeRef.current = setTheme;\n\n const {\n currentValue: initialValue,\n isReadonly: readOnly,\n defaultUnfoldAll: unfoldAll,\n adaptiveHeight: withAdaptiveHeight,\n updateHeight: applyHeight,\n isEmpty: startsEmpty,\n onEditorReady: notifyEditorReady,\n } = setupValues.current;\n\n // Create the editor with the Shiki-compatible fallback theme.\n // shikiToMonaco intercepts create() and calls its own setTheme()\n // which needs real hex colors in the Shiki color map.\n const editor = monaco.editor.create(containerRef.current, {\n ...MONACO_EDITOR_OPTIONS,\n value: initialValue,\n language,\n theme: 'formance-monaco-fallback',\n readOnly,\n });\n\n // Apply the resolved CSS-variables theme for proper brand colors.\n const resolvedTheme = buildMonacoThemeFromCSSVars(containerRef.current);\n monaco.editor.defineTheme(\n MONACO_THEME_BASE,\n\n resolvedTheme as any\n );\n setTheme(MONACO_THEME_BASE);\n\n if (disposed) {\n editor.dispose();\n\n return;\n }\n\n editorRef.current = editor;\n\n if (!unfoldAll) {\n editor.getAction('editor.foldAll')?.run();\n }\n\n editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {\n window.dispatchEvent(new CustomEvent(CTRL_ENTER_EVENT));\n });\n\n editor.onDidChangeModelContent(() => {\n const v = editor.getValue();\n setupValues.current.onChange?.(v);\n triggerValidation(v);\n });\n\n editor.onDidPaste(() => {\n onDidPasteRef.current?.(editor.getValue());\n });\n\n if (withAdaptiveHeight) {\n editor.onDidContentSizeChange(() => setupValues.current.updateHeight());\n }\n if (!startsEmpty) applyHeight();\n\n runValidation(initialValue);\n setIsInitialized(true);\n\n const instance: TMonacoEditorInstance = {\n revealLineInCenter: (line) => editor.revealLineInCenter(line),\n setPosition: (pos) => editor.setPosition(pos),\n focus: () => editor.focus(),\n };\n setNavigatorEditorRef(instance);\n notifyEditorReady?.(instance);\n })();\n\n return () => {\n disposed = true;\n if (validationTimeoutRef.current)\n clearTimeout(validationTimeoutRef.current);\n editorRef.current?.dispose();\n editorRef.current = null;\n };\n }, [isClient, language, triggerValidation, runValidation]);\n\n // Sync external value\n useEffect(() => {\n const editor = editorRef.current;\n if (!editor || !isInitialized) return;\n if (editor.getValue() !== currentValue) editor.setValue(currentValue);\n }, [currentValue, isInitialized]);\n\n useMonacoTheme({\n isInitialized,\n isDark,\n monacoRef,\n containerRef,\n setThemeRef,\n });\n\n // Sync readonly\n useEffect(() => {\n if (!editorRef.current || !isInitialized) return;\n editorRef.current.updateOptions({ readOnly: isReadonly });\n }, [isReadonly, isInitialized]);\n\n const handleCopy = async () => {\n await navigator.clipboard.writeText(currentValue.trim());\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n };\n\n if (!isClient) return null;\n\n return (\n \n {withNavigator && (\n \n )}\n
\n \n\n {canCopy && !isEmpty && (\n \n {copied ? : }\n \n )}\n
\n \n );\n}\n\nexport {\n CodeEditor,\n type TCodeEditorProps,\n type TDiagnostic,\n type TDiagnosticsConfig,\n type TMonacoEditorInstance,\n};\n", "type": "registry:ui", "target": "components/code/code-editor.tsx" } ], "type": "registry:ui" }