feat(ui): add CodeBlock and CodeEditor components with Monaco and hljs

- add CodeBlock for read-only syntax-highlighted display (hljs, js/json)
- add CodeEditor wrapping Monaco editor with theme sync, focus state, and error state
- replace code textareas in snippet, credential, and step pages with CodeEditor
- replace code <pre> blocks in snippet and credential detail pages with CodeBlock
- make form cards full-width on pages with code editors
- add resize:vertical support to CodeEditor wrapper with automaticLayout
This commit is contained in:
2026-04-10 15:53:26 +03:00
parent 2046e64428
commit 1c13e068ad
21 changed files with 391 additions and 76 deletions
@@ -0,0 +1,76 @@
.pre {
margin: 0;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.code {
font-family: var(--font-mono, ui-monospace, 'Cascadia Code', 'Fira Code', monospace);
font-size: var(--font-size-sm);
line-height: 1.6;
display: block;
background: transparent !important;
color: var(--color-text);
}
/* ── highlight.js token overrides (theme-aware) ─────────────────────── */
.code :global(.hljs-comment),
.code :global(.hljs-quote) {
color: var(--hl-comment, #6A8072);
font-style: italic;
}
.code :global(.hljs-keyword),
.code :global(.hljs-selector-tag),
.code :global(.hljs-built_in),
.code :global(.hljs-name),
.code :global(.hljs-tag) {
color: var(--hl-keyword, #A67DCC);
}
.code :global(.hljs-string),
.code :global(.hljs-title),
.code :global(.hljs-section),
.code :global(.hljs-attribute),
.code :global(.hljs-literal),
.code :global(.hljs-template-tag),
.code :global(.hljs-template-variable),
.code :global(.hljs-type),
.code :global(.hljs-addition) {
color: var(--hl-string, #9BB870);
}
.code :global(.hljs-number),
.code :global(.hljs-regexp),
.code :global(.hljs-link) {
color: var(--hl-number, #E8A85F);
}
.code :global(.hljs-function),
.code :global(.hljs-title.hljs-function_) {
color: var(--hl-function, #5BB8D4);
}
.code :global(.hljs-variable),
.code :global(.hljs-params) {
color: var(--hl-variable, #D4A0A0);
}
.code :global(.hljs-property) {
color: var(--hl-property, #79C0C8);
}
.code :global(.hljs-deletion),
.code :global(.hljs-meta) {
color: var(--hl-meta, #A9A3C9);
}
.code :global(.hljs-emphasis) {
font-style: italic;
}
.code :global(.hljs-strong) {
font-weight: 700;
}
+27
View File
@@ -0,0 +1,27 @@
import { useMemo } from 'react';
import hljs, { escapeHtml } from '../../lib/hljs';
import styles from './CodeBlock.module.css';
export interface CodeBlockProps {
code: string;
language?: string;
}
export function CodeBlock({ code, language = 'javascript' }: CodeBlockProps) {
const highlighted = useMemo(() => {
try {
return hljs.highlight(code, { language }).value;
} catch {
return code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}, [code, language]);
return (
<pre className={styles.pre}>
<code
className={`${styles.code} hljs language-${language}`}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
</pre>
);
}
@@ -0,0 +1,18 @@
.wrapper {
width: 100%;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
resize: vertical;
min-height: 80px;
transition: border-color var(--transition), box-shadow var(--transition);
}
.focused {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.hasError {
border-color: var(--color-danger);
}
+83
View File
@@ -0,0 +1,83 @@
import { useState, useEffect } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import styles from './CodeEditor.module.css';
export interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
language?: string;
rows?: number;
error?: boolean;
}
function useMonacoTheme() {
const [theme, setTheme] = useState(() =>
document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light'
);
useEffect(() => {
const observer = new MutationObserver(() => {
setTheme(
document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light'
);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, []);
return theme;
}
export function CodeEditor({
value,
onChange,
language = 'javascript',
rows = 6,
error = false,
}: CodeEditorProps) {
const theme = useMonacoTheme();
const [focused, setFocused] = useState(false);
const height = rows * 20 + 16;
const handleMount: OnMount = (editor) => {
editor.onDidFocusEditorWidget(() => setFocused(true));
editor.onDidBlurEditorWidget(() => setFocused(false));
};
return (
<div
className={[
styles.wrapper,
focused ? styles.focused : '',
error ? styles.hasError : '',
]
.filter(Boolean)
.join(' ')}
style={{ height }}
>
<Editor
height="100%"
theme={theme}
language={language}
value={value}
onChange={(v) => onChange(v ?? '')}
onMount={handleMount}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
lineHeight: 20,
wordWrap: 'on',
padding: { top: 8, bottom: 8 },
folding: false,
renderLineHighlight: 'line',
scrollbar: { vertical: 'auto', horizontal: 'hidden' },
automaticLayout: true,
}}
/>
</div>
);
}
+6
View File
@@ -36,6 +36,12 @@ export type { PaginationProps } from './Pagination/Pagination';
export { Search } from './Search/Search';
export type { SearchProps } from './Search/Search';
export { CodeBlock } from './CodeBlock/CodeBlock';
export type { CodeBlockProps } from './CodeBlock/CodeBlock';
export { CodeEditor } from './CodeEditor/CodeEditor';
export type { CodeEditorProps } from './CodeEditor/CodeEditor';
export { ContextMenu } from './ContextMenu/ContextMenu';
export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu';