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:
@@ -14,6 +14,8 @@
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\" \".storybook/**/*.{ts,tsx}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^26.0.4",
|
||||
"lucide-react": "^1.7.0",
|
||||
"moment": "^2.30.1",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('json', json);
|
||||
|
||||
export default hljs;
|
||||
|
||||
export function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -196,6 +196,10 @@
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.formCardFull {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.formFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { credentials } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, Textarea } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function CreateCredentialPage() {
|
||||
@@ -58,7 +58,7 @@ export function CreateCredentialPage() {
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCard}>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('credentials.form_name')}
|
||||
@@ -72,17 +72,23 @@ export function CreateCredentialPage() {
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Textarea
|
||||
label={t('credentials.form_data')}
|
||||
placeholder={t('credentials.form_data_placeholder')}
|
||||
value={data}
|
||||
onChange={(e) => {
|
||||
setData(e.target.value);
|
||||
setDataError('');
|
||||
}}
|
||||
error={dataError || undefined}
|
||||
rows={6}
|
||||
/>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.fieldLabel}>
|
||||
{t('credentials.form_data')}
|
||||
<span className={styles.requiredMark}> *</span>
|
||||
</label>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={data}
|
||||
onChange={(v) => {
|
||||
setData(v);
|
||||
setDataError('');
|
||||
}}
|
||||
rows={6}
|
||||
error={!!dataError}
|
||||
/>
|
||||
{dataError && <span className={styles.fieldError}>{dataError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formActions}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Upload, Pencil, Trash2 } from 'lucide-react';
|
||||
import { CodeBlock } from '../../ui';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { credentials } from '../../api';
|
||||
import type { Credential } from '../../api';
|
||||
@@ -55,7 +56,7 @@ export function CredentialDetailPage() {
|
||||
{credential && (
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||
<Download size={14} />
|
||||
<Upload size={14} />
|
||||
{t('credentials.action_export')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -116,15 +117,16 @@ export function CredentialDetailPage() {
|
||||
<>
|
||||
<h2 className={styles.sectionHeading}>{t('credentials.section_data')}</h2>
|
||||
<Card>
|
||||
<pre className={styles.codeBlock}>
|
||||
{(() => {
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(credential.data), null, 2);
|
||||
} catch {
|
||||
return credential.data;
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Pencil, Trash2, Plus, Upload } from 'lucide-react';
|
||||
import { Settings, Pencil, Trash2, Plus, Download } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { credentials } from '../../api';
|
||||
import type { Credential } from '../../api';
|
||||
@@ -141,7 +141,7 @@ export function CredentialsPage() {
|
||||
onChange={handleImport}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={14} />
|
||||
<Download size={14} />
|
||||
{t('credentials.action_import')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { credentials } from '../../api';
|
||||
import type { Credential } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, Textarea } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function EditCredentialPage() {
|
||||
@@ -84,7 +84,7 @@ export function EditCredentialPage() {
|
||||
|
||||
{!loading && credential && (
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCard}>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('credentials.form_name')}
|
||||
@@ -98,17 +98,23 @@ export function EditCredentialPage() {
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Textarea
|
||||
label={t('credentials.form_data')}
|
||||
placeholder={t('credentials.form_data_placeholder')}
|
||||
value={data}
|
||||
onChange={(e) => {
|
||||
setData(e.target.value);
|
||||
setDataError('');
|
||||
}}
|
||||
error={dataError || undefined}
|
||||
rows={6}
|
||||
/>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.fieldLabel}>
|
||||
{t('credentials.form_data')}
|
||||
<span className={styles.requiredMark}> *</span>
|
||||
</label>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={data}
|
||||
onChange={(v) => {
|
||||
setData(v);
|
||||
setDataError('');
|
||||
}}
|
||||
rows={6}
|
||||
error={!!dataError}
|
||||
/>
|
||||
{dataError && <span className={styles.fieldError}>{dataError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formActions}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { scenarios, steps } from '../../api';
|
||||
import type { Scenario, StepType } from '../../api';
|
||||
import type { CreateStepPayload } from '../../api/client';
|
||||
import { Breadcrumbs, Button, Card, Input, Select } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, Input, Select, CodeEditor } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
const STEP_TYPES: StepType[] = ['login', 'exec', 'sign'];
|
||||
@@ -75,7 +75,7 @@ export function CreateStepPage() {
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCard}>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('steps.form_order')}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { scenarios, steps } from '../../api';
|
||||
import type { Scenario, ScenarioStep, StepType } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, Select } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, Input, Select, CodeEditor } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
const STEP_TYPES: StepType[] = ['login', 'exec', 'sign'];
|
||||
@@ -91,7 +91,7 @@ export function EditStepPage() {
|
||||
|
||||
{!loading && step && (
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCard}>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('steps.form_order')}
|
||||
@@ -118,24 +118,18 @@ export function EditStepPage() {
|
||||
/>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
rows={6}
|
||||
<CodeEditor
|
||||
value={execCode}
|
||||
onChange={(e) => setExecCode(e.target.value)}
|
||||
placeholder={t('steps.form_exec_code_placeholder')}
|
||||
spellCheck={false}
|
||||
onChange={setExecCode}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
rows={6}
|
||||
<CodeEditor
|
||||
value={validateCode}
|
||||
onChange={(e) => setValidateCode(e.target.value)}
|
||||
placeholder={t('steps.form_validate_code_placeholder')}
|
||||
spellCheck={false}
|
||||
onChange={setValidateCode}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Pencil, Plus, History, Trash2, Download } from 'lucide-react';
|
||||
import { Play, Pencil, Plus, History, Trash2, Upload } from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
||||
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
|
||||
@@ -227,7 +227,7 @@ export function ScenarioDetailPage() {
|
||||
{t('scenarios.action_runs')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||
<Download size={14} />
|
||||
<Upload size={14} />
|
||||
{t('scenarios.action_export')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Plus, Trash2, Upload } from 'lucide-react';
|
||||
import { Play, Plus, Trash2, Download } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { scenarios } from '../../api';
|
||||
import type { Scenario } from '../../api';
|
||||
@@ -99,7 +99,7 @@ export function ScenariosPage() {
|
||||
onChange={handleImport}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={14} />
|
||||
<Download size={14} />
|
||||
{t('scenarios.action_import')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate('/scenarios/new')}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { snippets } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input, Textarea } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function CreateSnippetPage() {
|
||||
@@ -59,7 +59,7 @@ export function CreateSnippetPage() {
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCard}>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('snippets.form_name')}
|
||||
@@ -84,16 +84,14 @@ export function CreateSnippetPage() {
|
||||
{t('snippets.form_code')}
|
||||
<span className={styles.requiredMark}> *</span>
|
||||
</label>
|
||||
<textarea
|
||||
className={[styles.textarea, codeError ? styles.textareaError : ''].filter(Boolean).join(' ')}
|
||||
rows={14}
|
||||
<CodeEditor
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCode(e.target.value);
|
||||
onChange={(v) => {
|
||||
setCode(v);
|
||||
setCodeError('');
|
||||
}}
|
||||
placeholder={t('snippets.form_code_placeholder')}
|
||||
spellCheck={false}
|
||||
rows={14}
|
||||
error={!!codeError}
|
||||
/>
|
||||
{codeError && <span className={styles.fieldError}>{codeError}</span>}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { snippets } from '../../api';
|
||||
import type { Snippet } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, Input } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function EditSnippetPage() {
|
||||
@@ -83,7 +83,7 @@ export function EditSnippetPage() {
|
||||
|
||||
{!loading && snippet && (
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCard}>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('snippets.form_name')}
|
||||
@@ -108,16 +108,14 @@ export function EditSnippetPage() {
|
||||
{t('snippets.form_code')}
|
||||
<span className={styles.requiredMark}> *</span>
|
||||
</label>
|
||||
<textarea
|
||||
className={[styles.textarea, codeError ? styles.textareaError : ''].filter(Boolean).join(' ')}
|
||||
rows={14}
|
||||
<CodeEditor
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
setCode(e.target.value);
|
||||
onChange={(v) => {
|
||||
setCode(v);
|
||||
setCodeError('');
|
||||
}}
|
||||
placeholder={t('snippets.form_code_placeholder')}
|
||||
spellCheck={false}
|
||||
rows={14}
|
||||
error={!!codeError}
|
||||
/>
|
||||
{codeError && <span className={styles.fieldError}>{codeError}</span>}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Upload, Pencil, Trash2 } from 'lucide-react';
|
||||
import { CodeBlock } from '../../ui';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { snippets } from '../../api';
|
||||
import type { Snippet } from '../../api';
|
||||
@@ -55,7 +56,7 @@ export function SnippetDetailPage() {
|
||||
{snippet && (
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||
<Download size={14} />
|
||||
<Upload size={14} />
|
||||
{t('snippets.action_export')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -114,7 +115,7 @@ export function SnippetDetailPage() {
|
||||
<h2 className={styles.sectionHeading}>{t('snippets.section_code')}</h2>
|
||||
</div>
|
||||
<Card>
|
||||
<pre className={styles.codeBlock}>{snippet.code}</pre>
|
||||
<CodeBlock code={snippet.code} />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Code, Pencil, Trash2, Plus, Upload } from 'lucide-react';
|
||||
import { Code, Pencil, Trash2, Plus, Download } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { snippets } from '../../api';
|
||||
import type { Snippet } from '../../api';
|
||||
@@ -134,7 +134,7 @@ export function SnippetsPage() {
|
||||
onChange={handleImport}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={14} />
|
||||
<Download size={14} />
|
||||
{t('snippets.action_import')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Generated
+82
@@ -15,6 +15,8 @@
|
||||
"client": {
|
||||
"name": "liqa-client",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^26.0.4",
|
||||
"lucide-react": "^1.7.0",
|
||||
"moment": "^2.30.1",
|
||||
@@ -3063,6 +3065,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mozilla/readability": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz",
|
||||
@@ -4642,6 +4667,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/validator": {
|
||||
"version": "13.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||
@@ -6947,6 +6980,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
@@ -8564,6 +8607,15 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.12",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
@@ -10350,6 +10402,19 @@
|
||||
"tmpl": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -10532,6 +10597,17 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.55.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -12315,6 +12391,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
||||
Reference in New Issue
Block a user