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:
@@ -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;
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
}, [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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user