Implement custom CodeMirror themes and enhance CodeBlockEditor layout
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Added farmControlLight and farmControlDark themes for CodeMirror integration, improving syntax highlighting and overall editor aesthetics.
- Updated CodeBlockEditor to utilize the new themes and introduced a layout theme for better responsiveness.
- Enhanced styling in App.css for the code block editor, ensuring proper display and overflow handling.
- Refactored CodeDiffViewer to support the new themes, maintaining visual consistency across code comparison components.
This commit is contained in:
Tom Butcher 2026-08-20 00:12:22 +01:00
parent 84adf58542
commit 6a4074674d
6 changed files with 399 additions and 76 deletions

View File

@ -47,7 +47,8 @@
} }
.ant-typography code, .ant-typography code,
.ant-typography pre { .ant-typography pre,
.ͼ1 .cm-scroller {
font-family: 'DM Mono'; font-family: 'DM Mono';
} }
@ -965,6 +966,17 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
margin: 0; margin: 0;
} }
.code-block-editor {
display: flex;
flex-direction: column;
min-height: 0;
}
.code-block-editor > div {
flex: 1 1 auto;
min-height: 0;
}
.ant-splitter-panel:not(:first-child) .info-collapse .info-collapse-label { .ant-splitter-panel:not(:first-child) .info-collapse .info-collapse-label {
margin-left: 4px; margin-left: 4px;
} }
@ -1316,31 +1328,3 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
background-color: #85858541; background-color: #85858541;
border-radius: 2px; border-radius: 2px;
} }
.ͼo {
color: var(--color-text);
}
.ͼu {
color: var(--color-warning);
}
.ͼq {
color: var(--color-error);
}
.ͼ13 {
color: var(--color-success);
}
.ͼp {
color: var(--color-purple);
}
.ͼv {
color: var(--color-cyan);
}
.ͼt {
color: var(--color-magenta);
}

View File

@ -0,0 +1,230 @@
import { EditorView } from '@codemirror/view'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
const editorFontFamily = "'DM Mono', monospace"
const farmControlHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: 'var(--color-purple)' },
{
tag: [
tags.function(tags.variableName),
tags.labelName,
tags.definition(tags.function(tags.variableName))
],
color: 'var(--color-magenta)'
},
{
tag: [tags.string, tags.inserted, tags.special(tags.string)],
color: 'var(--color-success)'
},
{
tag: [
tags.number,
tags.bool,
tags.atom,
tags.changed,
tags.typeName,
tags.className,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
tags.color,
tags.constant(tags.name),
tags.standard(tags.name)
],
color: 'var(--color-warning)'
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link
],
color: 'var(--color-cyan)'
},
{ tag: tags.invalid, color: 'var(--color-error)' },
{
tag: [tags.meta, tags.comment],
color: 'color-mix(in srgb, var(--color-text) 55%, transparent)',
fontStyle: 'italic'
},
{
tag: [
tags.name,
tags.deleted,
tags.character,
tags.propertyName,
tags.macroName,
tags.definition(tags.name),
tags.separator,
tags.variableName
],
color: 'var(--color-text)'
},
{ tag: tags.heading, fontWeight: 'bold', color: 'var(--color-error)' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{
tag: tags.link,
color: 'var(--color-link)',
textDecoration: 'underline'
},
{
tag: [tags.processingInstruction],
color: 'var(--color-info)'
}
])
const farmControlLightTheme = EditorView.theme(
{
'&': {
color: 'var(--color-text)',
backgroundColor: 'var(--layout-modal-bg)',
fontFamily: editorFontFamily,
fontSize: '14px'
},
'.cm-content': {
caretColor: 'var(--color-primary)'
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-primary)'
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{
backgroundColor:
'color-mix(in srgb, var(--color-primary) 25%, transparent)'
},
'.cm-activeLine': {
backgroundColor:
'color-mix(in srgb, var(--color-primary) 8%, transparent)'
},
'.cm-selectionMatch': {
backgroundColor:
'color-mix(in srgb, var(--color-success) 15%, transparent)'
},
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
backgroundColor:
'color-mix(in srgb, var(--color-warning) 20%, transparent)'
},
'.cm-gutters': {
backgroundColor: 'var(--layout-modal-bg)',
color: 'color-mix(in srgb, var(--color-text) 45%, transparent)',
border: 'none'
},
'.cm-activeLineGutter': {
backgroundColor:
'color-mix(in srgb, var(--color-primary) 8%, transparent)'
},
'.cm-foldPlaceholder': {
backgroundColor: 'transparent',
border: 'none',
color: 'color-mix(in srgb, var(--color-text) 55%, transparent)'
},
'.cm-tooltip': {
border: 'none',
backgroundColor: 'var(--layout-modal-bg)',
color: 'var(--color-text)'
},
'.cm-tooltip .cm-tooltip-arrow:before': {
borderTopColor: 'transparent',
borderBottomColor: 'transparent'
},
'.cm-tooltip .cm-tooltip-arrow:after': {
borderTopColor: 'var(--layout-modal-bg)',
borderBottomColor: 'var(--layout-modal-bg)'
},
'.cm-tooltip-autocomplete': {
'& > ul > li[aria-selected]': {
backgroundColor:
'color-mix(in srgb, var(--color-primary) 12%, transparent)',
color: 'var(--color-text)'
}
}
},
{ dark: false }
)
const farmControlDarkTheme = EditorView.theme(
{
'&': {
color: 'var(--color-text)',
backgroundColor: 'var(--layout-modal-bg)',
fontFamily: editorFontFamily,
fontSize: '14px'
},
'.cm-content': {
caretColor: 'var(--color-primary)'
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-primary)'
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{
backgroundColor:
'color-mix(in srgb, var(--color-primary) 30%, transparent)'
},
'.cm-activeLine': {
backgroundColor:
'color-mix(in srgb, var(--color-primary) 10%, transparent)'
},
'.cm-selectionMatch': {
backgroundColor:
'color-mix(in srgb, var(--color-success) 18%, transparent)'
},
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
backgroundColor:
'color-mix(in srgb, var(--color-warning) 22%, transparent)'
},
'.cm-gutters': {
backgroundColor: 'var(--layout-modal-bg)',
color: 'color-mix(in srgb, var(--color-text) 45%, transparent)',
border: 'none'
},
'.cm-activeLineGutter': {
backgroundColor:
'color-mix(in srgb, var(--color-primary) 10%, transparent)'
},
'.cm-foldPlaceholder': {
backgroundColor: 'transparent',
border: 'none',
color: 'color-mix(in srgb, var(--color-text) 55%, transparent)'
},
'.cm-tooltip': {
border: 'none',
backgroundColor: 'var(--layout-modal-bg)',
color: 'var(--color-text)'
},
'.cm-tooltip .cm-tooltip-arrow:before': {
borderTopColor: 'transparent',
borderBottomColor: 'transparent'
},
'.cm-tooltip .cm-tooltip-arrow:after': {
borderTopColor: 'var(--layout-modal-bg)',
borderBottomColor: 'var(--layout-modal-bg)'
},
'.cm-tooltip-autocomplete': {
'& > ul > li[aria-selected]': {
backgroundColor:
'color-mix(in srgb, var(--color-primary) 15%, transparent)',
color: 'var(--color-text)'
}
}
},
{ dark: true }
)
export const farmControlLight = [
farmControlLightTheme,
syntaxHighlighting(farmControlHighlightStyle)
]
export const farmControlDark = [
farmControlDarkTheme,
syntaxHighlighting(farmControlHighlightStyle)
]

View File

@ -1,7 +1,7 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { EditorView, Decoration } from '@codemirror/view' import { EditorView, Decoration, ViewPlugin } from '@codemirror/view'
import { StateField } from '@codemirror/state' import { StateField } from '@codemirror/state'
import { javascript } from '@codemirror/lang-javascript' import { javascript } from '@codemirror/lang-javascript'
import { python } from '@codemirror/lang-python' import { python } from '@codemirror/lang-python'
@ -17,13 +17,88 @@ import { go } from '@codemirror/lang-go'
import { php } from '@codemirror/lang-php' import { php } from '@codemirror/lang-php'
import { yaml } from '@codemirror/lang-yaml' import { yaml } from '@codemirror/lang-yaml'
import { xml } from '@codemirror/lang-xml' import { xml } from '@codemirror/lang-xml'
import { oneDark } from '@codemirror/theme-one-dark' import {
farmControlDark,
farmControlLight
} from '../../../codemirror/farmControlTheme'
import { fcTemplateLang } from '../../../codemirror/fcTemplateLang' import { fcTemplateLang } from '../../../codemirror/fcTemplateLang'
import { useThemeContext } from '../context/ThemeContext' import { useThemeContext } from '../context/ThemeContext'
import { Button, Modal, Typography } from 'antd' import ScrollBox from './ScrollBox'
import { Button, Modal, Typography, Card } from 'antd'
const { Link } = Typography const { Link } = Typography
const codeBlockEditorLayoutTheme = EditorView.theme({
'&': {
height: 'auto !important'
},
'.cm-scroller': {
overflow: 'visible !important'
},
'.cm-gutters': {
position: 'sticky',
left: '0',
zIndex: 2
}
})
function createScrollBoxSyncExtension() {
return ViewPlugin.fromClass(
class {
constructor(view) {
this.view = view
this.scrollEl = view.dom.closest('.simplebar-content-wrapper')
this.syncing = false
this.onExternalScroll = this.onExternalScroll.bind(this)
this.onEditorScroll = this.onEditorScroll.bind(this)
if (this.scrollEl) {
this.scrollEl.addEventListener('scroll', this.onExternalScroll, {
passive: true
})
view.scrollDOM.addEventListener('scroll', this.onEditorScroll, {
passive: true
})
}
}
syncFromExternal() {
const { view, scrollEl } = this
if (this.syncing || !scrollEl) return
this.syncing = true
view.scrollDOM.scrollTop = scrollEl.scrollTop
view.scrollDOM.scrollLeft = scrollEl.scrollLeft
view.scrollDOM.dispatchEvent(new Event('scroll'))
this.syncing = false
}
syncFromEditor() {
const { view, scrollEl } = this
if (this.syncing || !scrollEl) return
this.syncing = true
scrollEl.scrollTop = view.scrollDOM.scrollTop
scrollEl.scrollLeft = view.scrollDOM.scrollLeft
this.syncing = false
}
onExternalScroll() {
this.syncFromExternal()
}
onEditorScroll() {
this.syncFromEditor()
}
destroy() {
if (this.scrollEl) {
this.scrollEl.removeEventListener('scroll', this.onExternalScroll)
this.view.scrollDOM.removeEventListener('scroll', this.onEditorScroll)
}
}
}
)
}
export function toEditorCode(code, language = 'javascript') { export function toEditorCode(code, language = 'javascript') {
if (code == null) return '' if (code == null) return ''
if (typeof code === 'object' && language === 'json') { if (typeof code === 'object' && language === 'json') {
@ -148,6 +223,16 @@ export default function CodeBlockEditor({
return [createErrorLineExtension(errorLines)] return [createErrorLineExtension(errorLines)]
}, [errorLines]) }, [errorLines])
const editorExtensions = useMemo(
() => [
languageExtension,
...errorLineExtension,
codeBlockEditorLayoutTheme,
createScrollBoxSyncExtension()
],
[languageExtension, errorLineExtension]
)
const handleOnChange = (value) => { const handleOnChange = (value) => {
if (typeof code == 'object' && language == 'json') { if (typeof code == 'object' && language == 'json') {
onChange(JSON.parse(value)) onChange(JSON.parse(value))
@ -159,43 +244,56 @@ export default function CodeBlockEditor({
const codeMirror = ( const codeMirror = (
<div <div
style={{ style={{
border: '1px solid #85858541', ...(height !== 'auto' ? { height } : {}),
minHeight: 0,
overflow: 'visible',
...style ...style
}} }}
className={className} className={`code-block-editor h-100 ${className}`.trim()}
> >
<CodeMirror <Card
value={editorCode}
height={height}
theme={isDarkMode ? oneDark : 'light'}
extensions={[languageExtension, ...errorLineExtension]}
readOnly={readOnly || disabled}
onChange={handleOnChange}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: false,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: !readOnly,
rectangularSelection: false,
crosshairCursor: false,
highlightActiveLine: !readOnly,
highlightSelectionMatches: false,
closeBracketsKeymap: false,
searchKeymap: false,
foldKeymap: false,
completionKeymap: !readOnly,
lintKeymap: false
}}
style={{ style={{
fontSize: '14px', borderRadius: 0,
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace' height: '100%',
overflow: 'visible',
backgroundColor: 'var(--layout-modal-bg)'
}} }}
/> styles={{ body: { padding: 0, height: '100%', overflow: 'visible' } }}
>
<ScrollBox>
<CodeMirror
value={editorCode}
height='auto'
theme={isDarkMode ? farmControlDark : farmControlLight}
extensions={editorExtensions}
readOnly={readOnly || disabled}
onChange={handleOnChange}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: false,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: !readOnly,
rectangularSelection: false,
crosshairCursor: false,
highlightActiveLine: !readOnly,
highlightSelectionMatches: false,
closeBracketsKeymap: false,
searchKeymap: false,
foldKeymap: false,
completionKeymap: !readOnly,
lintKeymap: false
}}
style={{
fontSize: '14px'
}}
/>
</ScrollBox>
</Card>
</div> </div>
) )

View File

@ -3,7 +3,10 @@ import PropTypes from 'prop-types'
import { MergeView } from '@codemirror/merge' import { MergeView } from '@codemirror/merge'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { oneDark } from '@codemirror/theme-one-dark' import {
farmControlDark,
farmControlLight
} from '../../../codemirror/farmControlTheme'
import { basicSetup } from 'codemirror' import { basicSetup } from 'codemirror'
import { useThemeContext } from '../context/ThemeContext' import { useThemeContext } from '../context/ThemeContext'
import { import {
@ -29,20 +32,12 @@ export default function CodeDiffViewer({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!parentRef.current) return if (!parentRef.current) return
const editorTheme = EditorView.theme({
'&': {
fontSize: '14px',
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace'
}
})
const sharedExtensions = [ const sharedExtensions = [
basicSetup, basicSetup,
languageExtension, languageExtension,
editorTheme,
EditorView.editable.of(false), EditorView.editable.of(false),
EditorState.readOnly.of(true), EditorState.readOnly.of(true),
...(isDarkMode ? [oneDark] : []) ...(isDarkMode ? farmControlDark : farmControlLight)
] ]
const view = new MergeView({ const view = new MergeView({

View File

@ -913,6 +913,7 @@ const ObjectProperty = ({
minimal={minimal} minimal={minimal}
autoCompleteObject={autoCompleteObject} autoCompleteObject={autoCompleteObject}
errorLines={errorLines} errorLines={errorLines}
{...(onChange && !useFormItem ? { onChange } : {})}
{...inputProps} {...inputProps}
/> />
) )

View File

@ -240,17 +240,32 @@ const TemplateEditor = ({
<div <div
style={{ style={{
overflowY: 'scroll', width: '100%',
height: '100%', height: '100%',
flex: '1 1 auto' flex: '1 1 auto',
minHeight: 0
}} }}
> >
<ObjectProperty <ObjectProperty
style={{
width: '100%',
height: '100%',
flex: '1 1 auto',
minHeight: 0
}}
useFormItem={false}
name={'content'} name={'content'}
height='100%' height='100%'
type='codeBlock' type='codeBlock'
language={'fcTemplateLang'} language={'fcTemplateLang'}
objectData={objectData} objectData={objectData}
onChange={(value) => {
form?.setFieldValue?.('content', value)
setObjectData?.((prev) => ({
...prev,
content: value
}))
}}
isEditing={isEditing} isEditing={isEditing}
autoCompleteObject={objectData?.testObject} autoCompleteObject={objectData?.testObject}
errorLines={errorLines} errorLines={errorLines}