Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions assets/js/src/core/components/code-editor/code-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import React from 'react'
import ReactCodeMirror, { type ReactCodeMirrorProps, EditorView } from '@uiw/react-codemirror'
import { getPresetExtensions } from '@Pimcore/components/code-editor/helpers'
import { useCodeMirrorThemeExtensions } from '@Pimcore/components/code-editor/use-code-mirror-theme'
import { useStyles } from './code-editor.styles'

export type CodeEditorPreset = 'text' | 'yaml' | 'html' | 'json'
Expand All @@ -23,17 +24,22 @@ export interface CodeEditorProps extends Omit<ReactCodeMirrorProps, 'extensions'
lineWrapping?: boolean
}

export const CodeEditor = ({ preset, extensions, value, onChange, lineWrapping = false, ...props }: CodeEditorProps): React.JSX.Element => {
export const CodeEditor = ({ preset, extensions, value, onChange, lineWrapping = false, theme, ...props }: CodeEditorProps): React.JSX.Element => {
const { styles } = useStyles()
const themeExtensions = useCodeMirrorThemeExtensions()

// A caller that supplies its own `theme` keeps it, and keeps control of the visuals:
// the theme built from the Studio tokens is not applied on that path.
const isThemeDerived = theme === undefined

// Combine preset extensions with custom extensions
const combinedExtensions = React.useMemo(() => {
const presetExtensions = preset !== null && preset !== undefined ? getPresetExtensions(preset) : []
const customExtensions = extensions ?? []
const wrappingExtensions = lineWrapping ? [EditorView.lineWrapping] : []
const studioTheme = isThemeDerived ? themeExtensions : []

return [...presetExtensions, ...customExtensions, ...wrappingExtensions]
}, [preset, extensions, lineWrapping])
return [...studioTheme, ...presetExtensions, ...customExtensions, ...wrappingExtensions]
}, [preset, extensions, lineWrapping, isThemeDerived, themeExtensions])

// Handle onChange to ensure it matches Ant Design Form expectations
const handleChange = React.useCallback((val: string) => {
Expand All @@ -46,6 +52,7 @@ export const CodeEditor = ({ preset, extensions, value, onChange, lineWrapping =
className={ styles.editor }
extensions={ combinedExtensions }
onChange={ handleChange }
theme={ theme ?? 'none' }
value={ value ?? '' }
/>
)
Expand Down
143 changes: 143 additions & 0 deletions assets/js/src/core/components/code-editor/use-code-mirror-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

import { useMemo } from 'react'
import { theme as antdThemeApi } from 'antd'
import { EditorView, oneDarkHighlightStyle, type Extension } from '@uiw/react-codemirror'
import { syntaxHighlighting } from '@codemirror/language'
import { isDarkSurface } from '@Pimcore/utils/color'

/**
* CodeMirror ships its own colours and defaults to the light set, which paints an opaque
* white surface while the text colour is inherited from the Studio theme -- on a dark
* theme that leaves an editor white on white.
*
* This is expressed as a CodeMirror theme rather than as a stylesheet on the wrapper.
* `EditorView.baseTheme` registers at `Prec.lowest` and is mounted before every other
* theme, so a normal-precedence theme wins any specificity tie against the built-in
* rules it replaces. A stylesheet has no such guarantee: it has to out-specify selectors
* it does not own, which has to be re-checked on every CodeMirror upgrade.
*
* `&` resolves to the editor root, so a bare `.cm-x` selector becomes two classes -- the
* same depth as the `&dark .cm-x` rule it replaces. The focused selection is the one
* built-in rule written deeper than that, so it is matched shape for shape.
*/
export const useCodeMirrorThemeExtensions = (): Extension[] => {
const { token } = antdThemeApi.useToken()

return useMemo(() => {
const isDark = isDarkSurface(token.colorBgContainer)

// A selection band has to move away from the surface, and which direction that is
// depends on the surface: on a dark one it has to lift, on a light one it has to
// deepen. No single token does both -- `colorPrimary` is itself dark, so it gives
// 1.1:1 on `pimcore-dark` and drops text to 2.39:1 on the light theme. Measured
// against the surface, these two beat the `#233` / `#d7d4f0` they replace on both:
// 1.6:1 vs 1.26:1 dark, 1.89:1 vs 1.44:1 light, with text at 8.0:1 and 8.7:1.
const selectionBackground = isDark ? token.colorFill : token.colorPrimaryBorder

const editorTheme = EditorView.theme({
'&': {
backgroundColor: token.colorBgContainer,
color: token.colorText
},

// The base theme hard-codes a black caret and a mid-grey placeholder.
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: token.colorText
},
'.cm-placeholder': {
color: token.colorTextPlaceholder
},

'.cm-gutters': {
backgroundColor: token.colorFillQuaternary,
color: token.colorTextSecondary,
borderRight: `${token.lineWidth}px ${token.lineType} ${token.colorBorderSecondary}`
},
'.cm-activeLine': {
backgroundColor: token.colorFillQuaternary
},
'.cm-activeLineGutter': {
backgroundColor: token.colorFillTertiary,
color: token.colorText
},

// `&dark.cm-focused > .cm-scroller > …` is five classes deep, so the focused
// variant is restated at the same depth rather than relying on the shorter one.
'.cm-selectionBackground': {
backgroundColor: selectionBackground
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: selectionBackground
},
'.cm-selectionMatch': {
backgroundColor: token.colorFillSecondary
},

// A hit and the current hit have to be told apart. These use border-strength fills
// from two different hues rather than two steps of one ramp: the `*Bg` steps are
// container tints that read as barely present, and two tints of the same hue leave
// you unable to see which hit the search is on.
'.cm-searchMatch': {
backgroundColor: token.colorWarningBorder
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: token.colorPrimaryBorder
},

'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
backgroundColor: token.colorFillSecondary
},
'.cm-foldPlaceholder': {
backgroundColor: token.colorFillTertiary,
border: 'none',
color: token.colorTextSecondary
},

'.cm-tooltip': {
backgroundColor: token.colorBgElevated,
color: token.colorText,
border: `${token.lineWidth}px ${token.lineType} ${token.colorBorderSecondary}`
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: token.controlItemBgActive,
color: token.colorText
},

// The search panel and its controls, which otherwise keep CodeMirror's own greys.
'.cm-panels': {
backgroundColor: token.colorBgElevated,
color: token.colorText
},
'.cm-button': {
backgroundColor: token.colorFillQuaternary,
backgroundImage: 'none',
color: token.colorText,
border: `${token.lineWidth}px ${token.lineType} ${token.colorBorder}`,
borderRadius: `${token.borderRadiusSM}px`
},
'.cm-textfield': {
backgroundColor: token.colorBgContainer,
color: token.colorText,
border: `${token.lineWidth}px ${token.lineType} ${token.colorBorder}`,
borderRadius: `${token.borderRadiusSM}px`
}
}, { dark: isDark })

// Syntax colours are the one part a theme cannot carry: CodeMirror generates opaque
// class names for highlight tags. Only One Dark's highlight style is taken, never the
// whole `oneDark` bundle -- that also carries `oneDarkTheme`, whose chrome would win
// over everything above.
return isDark
? [editorTheme, syntaxHighlighting(oneDarkHighlightStyle)]
: [editorTheme]
}, [token])
}
10 changes: 9 additions & 1 deletion assets/js/src/core/components/text-editor/text-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import React from 'react'
import ReactCodeMirror from '@uiw/react-codemirror'
import cn from 'classnames'
import { getLanguageExtensions, type SupportedLanguage } from '@Pimcore/components/text-editor/detect-language'
import { useCodeMirrorThemeExtensions } from '@Pimcore/components/code-editor/use-code-mirror-theme'
import { useStyle } from './text-editor.styles'

interface TextEditorProps {
Expand All @@ -30,15 +31,22 @@ export const TextEditor = ({
setTextValue
}: TextEditorProps): React.JSX.Element => {
const { styles } = useStyle()
const themeExtensions = useCodeMirrorThemeExtensions()

const extensions = React.useMemo(
() => [...themeExtensions, ...getLanguageExtensions(language)],
[themeExtensions, language]
)

return (
<ReactCodeMirror
basicSetup={ {
lineNumbers
} }
className={ cn(styles.editor, className) }
extensions={ getLanguageExtensions(language) }
extensions={ extensions }
onChange={ (value) => { setTextValue(value) } }
theme="none"
value={ textValue }
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { Content } from '@Pimcore/components/content/content'
import { getLanguageExtensions } from '@Pimcore/components/text-editor/detect-language'
import { useCodeMirrorThemeExtensions } from '@Pimcore/components/code-editor/use-code-mirror-theme'
import { type EmailLog, useEmailLogGetTextQuery } from '@Pimcore/modules/email/emails-api-slice.gen'
import ReactCodeMirror from '@uiw/react-codemirror'
import { isUndefined } from 'lodash'
Expand All @@ -24,6 +25,12 @@ interface TextPreviewProps {
export const TextPreview = ({ email, hasTextLog }: TextPreviewProps): React.JSX.Element => {
const { data, isLoading } = useEmailLogGetTextQuery({ id: email.id }, { skip: !hasTextLog })
const { styles } = useStyles()
const themeExtensions = useCodeMirrorThemeExtensions()

const extensions = React.useMemo(
() => [...themeExtensions, ...getLanguageExtensions('html')],
[themeExtensions]
)

return (
<Content
Expand All @@ -37,8 +44,9 @@ export const TextPreview = ({ email, hasTextLog }: TextPreviewProps): React.JSX.
searchKeymap: true
} }
className={ styles.codeEditor }
extensions={ getLanguageExtensions('html') }
extensions={ extensions }
readOnly
theme="none"
value={ data?.data ?? '' }
/>
</Content>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { Content } from '@Pimcore/components/content/content'
import { getLanguageExtensions } from '@Pimcore/components/text-editor/detect-language'
import { useCodeMirrorThemeExtensions } from '@Pimcore/components/code-editor/use-code-mirror-theme'
import ReactCodeMirror from '@uiw/react-codemirror'
import React from 'react'
import { useStyles } from './translation-html-preview.styles'
Expand All @@ -24,6 +25,12 @@ export const TranslationHtmlPreview = ({
onChange
}: TranslationHtmlEditorProps): React.JSX.Element => {
const { styles } = useStyles()
const themeExtensions = useCodeMirrorThemeExtensions()

const extensions = React.useMemo(
() => [...themeExtensions, ...getLanguageExtensions('html')],
[themeExtensions]
)

return (
<Content
Expand All @@ -36,8 +43,9 @@ export const TranslationHtmlPreview = ({
searchKeymap: true
} }
className={ styles.codeEditor }
extensions={ getLanguageExtensions('html') }
extensions={ extensions }
onChange={ onChange }
theme="none"
value={ value }
/>
</Content>
Expand Down
65 changes: 65 additions & 0 deletions assets/js/src/core/utils/color.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

import { isDarkSurface } from './color'

describe('isDarkSurface', () => {
it('reports the shipped theme surfaces correctly', () => {
// colorBgContainer as pimcore-dark and studio-default-light resolve it.
expect(isDarkSurface('#1e1e24')).toBe(true)
expect(isDarkSurface('#ffffff')).toBe(false)
})

it('accepts opaque colours in every format theme tokens use', () => {
expect(isDarkSurface('#000')).toBe(true)
expect(isDarkSurface('#FFF')).toBe(false)
expect(isDarkSurface('rgb(30, 30, 36)')).toBe(true)
expect(isDarkSurface('rgb(255 255 255)')).toBe(false)
expect(isDarkSurface('rgba(30, 30, 36, 1)')).toBe(true)
expect(isDarkSurface(' #1E1E24 ')).toBe(true)
})

it('judges by luminance rather than by channel values', () => {
// Saturated mid-tones: green reads light, blue reads dark, at equal channel value.
expect(isDarkSurface('#00ff00')).toBe(false)
expect(isDarkSurface('#0000ff')).toBe(true)
})

it('switches where contrast against white equals contrast against black', () => {
// Mid greys are light surfaces: #777 already reads better with dark content.
expect(isDarkSurface('#767676')).toBe(false)
expect(isDarkSurface('#b0b0b0')).toBe(false)
// …and the crossover is not somewhere up in the light greys.
expect(isDarkSurface('#5a5a5a')).toBe(true)
})

it('rejects translucent colours, whose appearance depends on the backdrop', () => {
// Black at zero alpha renders as whatever is behind it, so it must not read as dark
// merely because its channels are zero -- and must agree with `transparent`.
expect(isDarkSurface('rgba(0, 0, 0, 0)')).toBe(false)
expect(isDarkSurface('rgba(0, 0, 0, 0.1)')).toBe(false)
expect(isDarkSurface('rgba(255, 255, 255, 0.09)')).toBe(false)
expect(isDarkSurface('rgb(0 0 0 / 50%)')).toBe(false)
})

it('treats an unparseable colour as light, matching the antd default', () => {
expect(isDarkSurface('')).toBe(false)
expect(isDarkSurface('transparent')).toBe(false)
expect(isDarkSurface('var(--surface)')).toBe(false)
expect(isDarkSurface('#12345')).toBe(false)
})

it('does not read a malformed rgb() as black, which would report dark', () => {
expect(isDarkSurface('rgb(a, b, c)')).toBe(false)
expect(isDarkSurface('rgb(30, 30)')).toBe(false)
expect(isDarkSurface('rgb(1, 2, 3, 4, 5)')).toBe(false)
expect(isDarkSurface('rgb()')).toBe(false)
})
})
Loading
Loading