mirror of
https://github.com/grafana/grafana.git
synced 2026-08-03 10:03:15 -05:00
Text: Markdown toolbar (#129636)
This commit is contained in:
@@ -261,6 +261,7 @@
|
||||
"@codemirror/lang-sql": "6.10.0",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@emotion/css": "11.13.5",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.2",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type CodeMirrorExtension,
|
||||
} from './types';
|
||||
import { useLanguageExtension } from './useLanguageExtension';
|
||||
import { useShallowStable, useStableCallback } from './useStableProps';
|
||||
|
||||
const getCompletionExtensions = (
|
||||
sources: readonly CodeMirrorCompletionSource[] | undefined,
|
||||
@@ -92,9 +93,9 @@ export const CodeEditor = memo(function CodeEditor({
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
completionSources,
|
||||
completionMode = 'merge',
|
||||
extensions: additionalExtensions,
|
||||
extensions: additionalExtensionsProp,
|
||||
theme: themeOverride,
|
||||
basicSetup,
|
||||
basicSetup: basicSetupProp,
|
||||
indentWithTab = true,
|
||||
readOnly = false,
|
||||
lineWrapping = false,
|
||||
@@ -103,24 +104,22 @@ export const CodeEditor = memo(function CodeEditor({
|
||||
const { extension: languageExtension, error: languageExtensionError } = useLanguageExtension(language, sqlDialect);
|
||||
const editorTheme = useMemo(() => createCodeEditorTheme(theme), [theme]);
|
||||
|
||||
// A new identity on any of these reconfigures the whole editor — see useStableProps.
|
||||
const additionalExtensions = useShallowStable(additionalExtensionsProp);
|
||||
const sources = useShallowStable(completionSources);
|
||||
const basicSetup = useShallowStable(basicSetupProp);
|
||||
const handleChange = useStableCallback(onChange);
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
autocompleteTabKeymap,
|
||||
...getAccessibilityExtensions(ariaLabel, ariaLabelledby),
|
||||
...(languageExtension ? [languageExtension] : []),
|
||||
...getCompletionExtensions(completionSources, completionMode),
|
||||
...getCompletionExtensions(sources, completionMode),
|
||||
...(lineWrapping ? [EditorView.lineWrapping] : []),
|
||||
...(additionalExtensions ?? []),
|
||||
],
|
||||
[
|
||||
ariaLabel,
|
||||
ariaLabelledby,
|
||||
languageExtension,
|
||||
completionSources,
|
||||
completionMode,
|
||||
lineWrapping,
|
||||
additionalExtensions,
|
||||
]
|
||||
[ariaLabel, ariaLabelledby, languageExtension, sources, completionMode, lineWrapping, additionalExtensions]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
@@ -137,7 +136,7 @@ export const CodeEditor = memo(function CodeEditor({
|
||||
value={value}
|
||||
height={height}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
onChange={handleChange}
|
||||
basicSetup={basicSetup}
|
||||
indentWithTab={indentWithTab}
|
||||
readOnly={readOnly}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useShallowStable, useStableCallback } from './useStableProps';
|
||||
|
||||
describe('useShallowStable', () => {
|
||||
it('keeps the first reference for a shallow-equal array', () => {
|
||||
const a = {};
|
||||
const b = {};
|
||||
const { result, rerender } = renderHook((props: unknown[]) => useShallowStable(props), {
|
||||
initialProps: [a, b],
|
||||
});
|
||||
const first = result.current;
|
||||
|
||||
rerender([a, b]);
|
||||
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
it('returns the new reference when a member changes', () => {
|
||||
const { result, rerender } = renderHook((props: unknown[]) => useShallowStable(props), {
|
||||
initialProps: [{}],
|
||||
});
|
||||
const first = result.current;
|
||||
const next = [{}];
|
||||
|
||||
rerender(next);
|
||||
|
||||
expect(result.current).toBe(next);
|
||||
expect(result.current).not.toBe(first);
|
||||
});
|
||||
|
||||
it('returns the new reference when the length changes', () => {
|
||||
const a = {};
|
||||
const { result, rerender } = renderHook((props: unknown[]) => useShallowStable(props), {
|
||||
initialProps: [a],
|
||||
});
|
||||
const first = result.current;
|
||||
|
||||
rerender([a, {}]);
|
||||
|
||||
expect(result.current).not.toBe(first);
|
||||
});
|
||||
|
||||
it('keeps the first reference for a shallow-equal object', () => {
|
||||
const { result, rerender } = renderHook((props: { lineNumbers: boolean }) => useShallowStable(props), {
|
||||
initialProps: { lineNumbers: true },
|
||||
});
|
||||
const first = result.current;
|
||||
|
||||
rerender({ lineNumbers: true });
|
||||
expect(result.current).toBe(first);
|
||||
|
||||
rerender({ lineNumbers: false });
|
||||
expect(result.current).toEqual({ lineNumbers: false });
|
||||
});
|
||||
|
||||
it('does not treat an array as equal to an object with matching indices', () => {
|
||||
const { result, rerender } = renderHook((props: unknown) => useShallowStable(props), {
|
||||
initialProps: ['a'] as unknown,
|
||||
});
|
||||
|
||||
rerender({ 0: 'a' });
|
||||
|
||||
expect(Array.isArray(result.current)).toBe(false);
|
||||
});
|
||||
|
||||
it('passes primitives and nullish values through', () => {
|
||||
const { result, rerender } = renderHook((props: unknown) => useShallowStable(props), {
|
||||
initialProps: false as unknown,
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
rerender(undefined);
|
||||
expect(result.current).toBeUndefined();
|
||||
|
||||
rerender(null);
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStableCallback', () => {
|
||||
it('keeps the same reference across renders', () => {
|
||||
const { result, rerender } = renderHook((cb: () => void) => useStableCallback(cb), {
|
||||
initialProps: () => {},
|
||||
});
|
||||
const first = result.current;
|
||||
|
||||
rerender(() => {});
|
||||
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
it('invokes the latest callback and returns its value', () => {
|
||||
const first = jest.fn().mockReturnValue('first');
|
||||
const second = jest.fn().mockReturnValue('second');
|
||||
const { result, rerender } = renderHook((cb: (value: string) => string) => useStableCallback(cb), {
|
||||
initialProps: first,
|
||||
});
|
||||
|
||||
expect(result.current('a')).toBe('first');
|
||||
expect(first).toHaveBeenCalledWith('a');
|
||||
|
||||
rerender(second);
|
||||
|
||||
expect(result.current('b')).toBe('second');
|
||||
expect(second).toHaveBeenCalledWith('b');
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useInsertionEffect, useRef } from 'react';
|
||||
|
||||
import { shallowCompare } from '@grafana/data';
|
||||
|
||||
function isShallowEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// An array must not compare equal to an object with matching indices.
|
||||
if (Array.isArray(a) !== Array.isArray(b)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return shallowCompare(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous reference whenever `value` is shallow-equal to it.
|
||||
*
|
||||
* A new `extensions` or `basicSetup` identity makes `@uiw/react-codemirror`
|
||||
* reconfigure the whole editor, discarding extension state such as an open
|
||||
* completion popup. Call sites pass inline literals, so identity alone is too
|
||||
* eager a signal.
|
||||
*/
|
||||
export function useShallowStable<T>(value: T): T {
|
||||
const stable = useRef(value);
|
||||
|
||||
// Safe during render: a repeated render derives the same reference.
|
||||
if (!isShallowEqual(stable.current, value)) {
|
||||
stable.current = value;
|
||||
}
|
||||
|
||||
return stable.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps `callback` in a reference that never changes but always invokes the
|
||||
* latest version.
|
||||
*
|
||||
* `onChange` reconfigures the editor too (see {@link useShallowStable}), and a
|
||||
* controlled editor re-renders its parent on every keystroke.
|
||||
*/
|
||||
export function useStableCallback<Args extends unknown[], Return>(
|
||||
callback: (...args: Args) => Return
|
||||
): (...args: Args) => Return {
|
||||
const latest = useRef(callback);
|
||||
|
||||
// Stands in for useEffectEvent until we are off React 18. Insertion effects run
|
||||
// before layout effects, so even a caller in another component's layout effect
|
||||
// gets the latest callback instead of the previous render's.
|
||||
useInsertionEffect(() => {
|
||||
latest.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
return useCallback((...args: Args) => latest.current(...args), []);
|
||||
}
|
||||
@@ -14,6 +14,18 @@ export function getMarkdownStyles(theme: GrafanaTheme2) {
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
|
||||
// GFM task lists get no class to hook on, so the checkbox stands in for the marker.
|
||||
'li:has(> input[type="checkbox"], > p > input[type="checkbox"])': {
|
||||
listStyleType: 'none',
|
||||
},
|
||||
|
||||
// Pull into the marker gutter so labels line up with sibling list items.
|
||||
'li > input[type="checkbox"], li > p > input[type="checkbox"]': {
|
||||
marginLeft: theme.spacing(-2.5),
|
||||
marginRight: theme.spacing(0.5),
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
|
||||
table: {
|
||||
marginBottom: theme.spacing(2),
|
||||
'td, th': {
|
||||
|
||||
@@ -61,6 +61,21 @@ describe('TextNGPanel', () => {
|
||||
expect(screen.getByTestId('TextNGPanel-converted-content').innerHTML.trim()).toBe('');
|
||||
});
|
||||
|
||||
// Markdown renders these to '', which DangerouslySetHtmlContent throws on.
|
||||
it.each(['\n', '\n\n', ' \n ', '<!-- just a comment -->'])(
|
||||
'renders empty content for markdown that renders to nothing: %j',
|
||||
(content) => {
|
||||
replaceVariablesMock.mockReturnValueOnce(content);
|
||||
const props = Object.assign({}, defaultProps, {
|
||||
options: { content, mode: TextMode.Markdown },
|
||||
});
|
||||
|
||||
setup(props);
|
||||
|
||||
expect(screen.getByTestId('TextNGPanel-converted-content').innerHTML.trim()).toBe('');
|
||||
}
|
||||
);
|
||||
|
||||
it('sanitizes content in html mode', () => {
|
||||
const contentTest = '<form><p>Form tags are sanitized.</p></form>\n<script>Script tags are sanitized.</script>';
|
||||
replaceVariablesMock.mockReturnValueOnce(contentTest);
|
||||
|
||||
@@ -7,6 +7,7 @@ import config from 'app/core/config';
|
||||
import { CodeLanguage, TextMode } from '../../panelcfg.gen';
|
||||
|
||||
import { PREVIEW_TEST_ID, TextNGEditor } from './TextNGEditor';
|
||||
import { FORMAT_TOOLBAR_TEST_ID } from './TextNGFormatToolbar';
|
||||
|
||||
// The real CodeMirrorEditor pulls in a heavy, lazily-loaded CodeMirror bundle;
|
||||
// stub it with a plain textarea so these tests stay fast and deterministic.
|
||||
@@ -176,14 +177,6 @@ describe('TextNGEditor', () => {
|
||||
expect(screen.getByRole('textbox')).toHaveValue('# Data center = $datacenter');
|
||||
});
|
||||
|
||||
it('does not render a formatting toolbar', async () => {
|
||||
setup('hello', TextMode.Markdown);
|
||||
await enterWriteMode();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Insert variable' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Bold' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards editor changes via a debounced onChange', async () => {
|
||||
const { onChange } = setup('initial', TextMode.Markdown);
|
||||
await enterWriteMode();
|
||||
@@ -252,6 +245,103 @@ describe('TextNGEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('content that renders to nothing', () => {
|
||||
// Deleting everything and pressing enter in Split view: markdown renders a
|
||||
// lone newline to '', which the space fallback keeps DangerouslySetHtmlContent
|
||||
// from throwing on.
|
||||
it.each(['\n', '\n\n', ' \n ', '<!-- just a comment -->'])(
|
||||
'renders the empty space fallback in the preview for %j',
|
||||
async (content) => {
|
||||
setup('# Hello', TextMode.Markdown);
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Split' }));
|
||||
|
||||
// fireEvent, because userEvent.type() does not reproduce a value that is
|
||||
// only whitespace. A throw here fails the test.
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: content } });
|
||||
|
||||
// Debounced preview settles from the '# Hello' <h1> to the space fallback.
|
||||
await waitFor(() => expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML.trim()).toBe(''));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('preview updates', () => {
|
||||
it('re-renders the preview once typing settles', async () => {
|
||||
setup('# Hello', TextMode.Markdown);
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Split' }));
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '## Updated' } });
|
||||
|
||||
// Still the old preview: the update is debounced.
|
||||
expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h1');
|
||||
await waitFor(() => expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h2'));
|
||||
});
|
||||
|
||||
it('shows the current draft immediately when switching view, without waiting for the debounce', async () => {
|
||||
setup('# Hello', TextMode.Markdown);
|
||||
await enterWriteMode();
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '## Updated' } });
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Preview' }));
|
||||
|
||||
expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h2');
|
||||
});
|
||||
|
||||
it('shows externally replaced content immediately, e.g. after a discard', async () => {
|
||||
const { rerender } = render(
|
||||
<TextNGEditor
|
||||
content="# Hello"
|
||||
mode={TextMode.Markdown}
|
||||
showLineNumbers={false}
|
||||
replaceVariables={(value: string) => value}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
rerender(
|
||||
<TextNGEditor
|
||||
content="## Reverted"
|
||||
mode={TextMode.Markdown}
|
||||
showLineNumbers={false}
|
||||
replaceVariables={(value: string) => value}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(PREVIEW_TEST_ID).innerHTML).toContain('<h2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting toolbar', () => {
|
||||
it('renders alongside the editor in Write view', async () => {
|
||||
setup('hello', TextMode.Markdown);
|
||||
await enterWriteMode();
|
||||
|
||||
expect(screen.getByTestId(FORMAT_TOOLBAR_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders in Split view', async () => {
|
||||
setup('hello', TextMode.Markdown);
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', { name: 'Split' }));
|
||||
|
||||
expect(screen.getByTestId(FORMAT_TOOLBAR_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is hidden in Preview view, where there is nothing to format', () => {
|
||||
setup('hello', TextMode.Markdown);
|
||||
|
||||
expect(screen.queryByTestId(FORMAT_TOOLBAR_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not rendered in Code mode', async () => {
|
||||
setup('const a = 1;', TextMode.Code, jest.fn(), false, CodeLanguage.Typescript);
|
||||
await enterWriteMode();
|
||||
|
||||
expect(screen.queryByTestId(FORMAT_TOOLBAR_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('line numbers', () => {
|
||||
it('never shows line numbers in Markdown mode', async () => {
|
||||
setup('# Hello', TextMode.Markdown, jest.fn(), true);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useDebounce } from 'react-use';
|
||||
|
||||
import { type GrafanaTheme2, type InterpolateFunction } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
import { RadioButtonGroup, Stack, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { CodeMirrorEditor, type CodeMirrorEditorLanguage } from '@grafana/ui/unstable';
|
||||
import config from 'app/core/config';
|
||||
|
||||
@@ -13,6 +13,8 @@ import { CodeLanguage, TextMode } from '../../panelcfg.gen';
|
||||
import { TextNGCodeView } from '../TextNGCodeView';
|
||||
import { getInterpolateFormat, transformContent, getCodeMirrorLanguage } from '../utils';
|
||||
|
||||
import { TextNGFormatToolbar } from './TextNGFormatToolbar';
|
||||
|
||||
type ViewMode = 'write' | 'split' | 'preview';
|
||||
|
||||
export const PREVIEW_TEST_ID = 'TextNGEditor-preview';
|
||||
@@ -27,6 +29,9 @@ export interface TextNGEditorProps {
|
||||
}
|
||||
|
||||
const COMMIT_DEBOUNCE_MS = 250;
|
||||
// Markdown, sanitization and the innerHTML reparse cost tens of milliseconds on
|
||||
// a large document, so the preview trails typing.
|
||||
const PREVIEW_DEBOUNCE_MS = 150;
|
||||
|
||||
export function TextNGEditor({
|
||||
content,
|
||||
@@ -36,6 +41,7 @@ export function TextNGEditor({
|
||||
replaceVariables,
|
||||
onChange,
|
||||
}: TextNGEditorProps) {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles);
|
||||
const [view, setView] = useState<ViewMode>(() => (content.trim().length === 0 ? 'write' : 'preview'));
|
||||
|
||||
@@ -43,6 +49,10 @@ export function TextNGEditor({
|
||||
// a blur can fire before React re-renders with the new draft.
|
||||
const draftRef = useRef(content);
|
||||
const committedContent = useRef(content);
|
||||
const editorContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Trails `draft`, except where waiting would show something stale.
|
||||
const [previewSource, setPreviewSource] = useState(content);
|
||||
|
||||
const [prevContent, setPrevContent] = useState(content);
|
||||
if (content !== prevContent) {
|
||||
@@ -51,9 +61,16 @@ export function TextNGEditor({
|
||||
committedContent.current = content;
|
||||
draftRef.current = content;
|
||||
setDraft(content);
|
||||
setPreviewSource(content);
|
||||
}
|
||||
}
|
||||
|
||||
const [prevView, setPrevView] = useState(view);
|
||||
if (prevView !== view) {
|
||||
setPrevView(view);
|
||||
setPreviewSource(draftRef.current);
|
||||
}
|
||||
|
||||
const handleDraftChange = (next: string) => {
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
@@ -71,8 +88,15 @@ export function TextNGEditor({
|
||||
// overwrite externally reverted options (e.g. Discard).
|
||||
useDebounce(commitDraft, COMMIT_DEBOUNCE_MS, [draft]);
|
||||
|
||||
useDebounce(() => setPreviewSource(draftRef.current), PREVIEW_DEBOUNCE_MS, [draft]);
|
||||
|
||||
const format = getInterpolateFormat(codeLanguage);
|
||||
const interpolatedContent = view === 'write' ? '' : replaceVariables(draft, {}, format);
|
||||
const showPreview = view !== 'write';
|
||||
|
||||
const interpolatedContent = useMemo(
|
||||
() => (showPreview ? replaceVariables(previewSource, {}, format) : ''),
|
||||
[showPreview, replaceVariables, previewSource, format]
|
||||
);
|
||||
|
||||
const previewHtml = useMemo(
|
||||
() => (mode === TextMode.Code ? '' : transformContent(mode, interpolatedContent, config.disableSanitizeHtml)),
|
||||
@@ -100,7 +124,6 @@ export function TextNGEditor({
|
||||
];
|
||||
|
||||
const showEditor = view !== 'preview';
|
||||
const showPreview = view !== 'write';
|
||||
|
||||
const renderOutput = (testId: string) =>
|
||||
mode === TextMode.Code ? (
|
||||
@@ -118,15 +141,16 @@ export function TextNGEditor({
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-testid="TextNGEditor">
|
||||
<div className={styles.toolbar}>
|
||||
<Stack gap={1} alignItems="center" wrap="wrap" minHeight={theme.components.height.md}>
|
||||
<RadioButtonGroup options={viewOptions} value={view} onChange={setView} size="sm" />
|
||||
</div>
|
||||
{showEditor && <TextNGFormatToolbar mode={mode} editorContainerRef={editorContainerRef} />}
|
||||
</Stack>
|
||||
|
||||
<div className={cx(styles.body, view === 'split' && styles.splitBody)}>
|
||||
{showEditor && (
|
||||
// Outside interactions (Save, Apply, Back) blur the editor on mousedown,
|
||||
// so a pending draft is committed before anything reads the options.
|
||||
<div className={cx(styles.pane, styles.editorPane)} onBlur={commitDraft}>
|
||||
<div ref={editorContainerRef} className={cx(styles.pane, styles.editorPane)} onBlur={commitDraft}>
|
||||
<CodeMirrorEditor
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
@@ -149,14 +173,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
label: 'textNGEditor',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}),
|
||||
toolbar: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.spacing(1),
|
||||
}),
|
||||
body: css({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { TextMode } from '../../panelcfg.gen';
|
||||
|
||||
import { TextNGFormatToolbar } from './TextNGFormatToolbar';
|
||||
|
||||
let view: EditorView | undefined;
|
||||
|
||||
/** Mounts a real EditorView so the toolbar can find it from the DOM. */
|
||||
function Harness({
|
||||
mode,
|
||||
doc,
|
||||
selection,
|
||||
}: {
|
||||
mode: TextMode;
|
||||
doc: string;
|
||||
selection?: { anchor: number; head?: number };
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
view = new EditorView({ parent: containerRef.current!, state: EditorState.create({ doc, selection }) });
|
||||
return () => view?.destroy();
|
||||
// The doc is fixed per test; re-creating the view would discard the edits
|
||||
// under assertion.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextNGFormatToolbar mode={mode} editorContainerRef={containerRef} />
|
||||
<div ref={containerRef} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const setup = (mode: TextMode, doc = '', selection?: { anchor: number; head?: number }) =>
|
||||
render(<Harness mode={mode} doc={doc} selection={selection} />);
|
||||
|
||||
const clickButton = (name: string) => userEvent.click(screen.getByRole('button', { name }));
|
||||
|
||||
afterEach(() => {
|
||||
view = undefined;
|
||||
});
|
||||
|
||||
describe('TextNGFormatToolbar', () => {
|
||||
describe('available actions', () => {
|
||||
it('offers the markdown actions in markdown mode', () => {
|
||||
setup(TextMode.Markdown);
|
||||
|
||||
for (const name of ['Heading', 'Bold', 'Italic', 'Link', 'Bullet list', 'Numbered list', 'Checklist', 'Table']) {
|
||||
expect(screen.getByRole('button', { name })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByRole('button', { name: 'Insert variable' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers only the tag-based actions in HTML mode', () => {
|
||||
setup(TextMode.HTML);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Bold' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Italic' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Link' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Insert variable' })).toBeInTheDocument();
|
||||
// Markdown-only syntax has no HTML equivalent worth a one-click insert.
|
||||
expect(screen.queryByRole('button', { name: 'Heading' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Table' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing in code mode', () => {
|
||||
setup(TextMode.Code);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Bold' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Insert variable' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown actions', () => {
|
||||
it('wraps the selection in bold markers', async () => {
|
||||
setup(TextMode.Markdown, 'hello world', { anchor: 0, head: 5 });
|
||||
|
||||
await clickButton('Bold');
|
||||
|
||||
expect(view!.state.doc.toString()).toBe('**hello** world');
|
||||
});
|
||||
|
||||
it('wraps the selection in italic markers', async () => {
|
||||
setup(TextMode.Markdown, 'hello', { anchor: 0, head: 5 });
|
||||
|
||||
await clickButton('Italic');
|
||||
|
||||
expect(view!.state.doc.toString()).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('turns the selection into a link', async () => {
|
||||
setup(TextMode.Markdown, 'Grafana', { anchor: 0, head: 7 });
|
||||
|
||||
await clickButton('Link');
|
||||
|
||||
expect(view!.state.doc.toString()).toBe('[Grafana](https://)');
|
||||
});
|
||||
|
||||
it('prefixes the selected lines for headings and lists', async () => {
|
||||
setup(TextMode.Markdown, 'one\ntwo', { anchor: 0, head: 7 });
|
||||
|
||||
await clickButton('Bullet list');
|
||||
|
||||
expect(view!.state.doc.toString()).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('inserts a table skeleton', async () => {
|
||||
setup(TextMode.Markdown, '');
|
||||
|
||||
await clickButton('Table');
|
||||
|
||||
expect(view!.state.doc.toString()).toContain('| Column | Column |');
|
||||
});
|
||||
|
||||
it('inserts a variable placeholder', async () => {
|
||||
setup(TextMode.Markdown, '');
|
||||
|
||||
await clickButton('Insert variable');
|
||||
|
||||
expect(view!.state.doc.toString()).toBe('${}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML actions', () => {
|
||||
it('wraps the selection in tags', async () => {
|
||||
setup(TextMode.HTML, 'hello', { anchor: 0, head: 5 });
|
||||
|
||||
await clickButton('Bold');
|
||||
|
||||
expect(view!.state.doc.toString()).toBe('<b>hello</b>');
|
||||
});
|
||||
|
||||
it('turns the selection into an anchor', async () => {
|
||||
setup(TextMode.HTML, 'Grafana', { anchor: 0, head: 7 });
|
||||
|
||||
await clickButton('Link');
|
||||
|
||||
expect(view!.state.doc.toString()).toBe('<a href="https://">Grafana</a>');
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing while the lazily-loaded editor has not mounted yet', async () => {
|
||||
const container = document.createElement('div');
|
||||
render(<TextNGFormatToolbar mode={TextMode.Markdown} editorContainerRef={{ current: container }} />);
|
||||
|
||||
await clickButton('Bold');
|
||||
|
||||
// No EditorView to find, so no edit is dispatched (and the click does not throw).
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('returns focus to the editor so typing continues where it left off', async () => {
|
||||
setup(TextMode.Markdown, 'hello', { anchor: 0, head: 5 });
|
||||
|
||||
await clickButton('Bold');
|
||||
|
||||
expect(view!.hasFocus).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { type ReactNode, type RefObject } from 'react';
|
||||
|
||||
import { type IconName } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Stack, ToolbarButton } from '@grafana/ui';
|
||||
|
||||
import { TextMode } from '../../panelcfg.gen';
|
||||
|
||||
import { insertAtCursor, prefixSelectedLines, surroundSelection } from './editorCommands';
|
||||
|
||||
const TABLE_SNIPPET = '\n| Column | Column |\n| ------ | ------ |\n| Value | Value |\n';
|
||||
|
||||
export const FORMAT_TOOLBAR_TEST_ID = 'TextNGEditor-format-toolbar';
|
||||
|
||||
interface FormatAction {
|
||||
key: string;
|
||||
tooltip: string;
|
||||
icon?: IconName;
|
||||
label?: ReactNode;
|
||||
run: (view: EditorView) => void;
|
||||
}
|
||||
|
||||
/** Spread into `surroundSelection`. */
|
||||
type Markers = readonly [before: string, after?: string];
|
||||
|
||||
interface InlineMarkers {
|
||||
bold: Markers;
|
||||
italic: Markers;
|
||||
link: Markers;
|
||||
}
|
||||
|
||||
const MARKDOWN_MARKERS: InlineMarkers = {
|
||||
bold: ['**'],
|
||||
italic: ['*'],
|
||||
link: ['[', '](https://)'],
|
||||
};
|
||||
|
||||
const HTML_MARKERS: InlineMarkers = {
|
||||
bold: ['<b>', '</b>'],
|
||||
italic: ['<i>', '</i>'],
|
||||
link: ['<a href="https://">', '</a>'],
|
||||
};
|
||||
|
||||
function getFormatActions(mode: TextMode): FormatAction[] {
|
||||
if (mode === TextMode.Code) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const isHtml = mode === TextMode.HTML;
|
||||
const markers = isHtml ? HTML_MARKERS : MARKDOWN_MARKERS;
|
||||
|
||||
const inlineActions: FormatAction[] = [
|
||||
{
|
||||
key: 'bold',
|
||||
tooltip: t('textng.editor.tooltip-bold', 'Bold'),
|
||||
label: <strong>{t('textng.editor.format-bold', 'B')}</strong>,
|
||||
run: (view) => surroundSelection(view, ...markers.bold),
|
||||
},
|
||||
{
|
||||
key: 'italic',
|
||||
tooltip: t('textng.editor.tooltip-italic', 'Italic'),
|
||||
label: <em>{t('textng.editor.format-italic', 'I')}</em>,
|
||||
run: (view) => surroundSelection(view, ...markers.italic),
|
||||
},
|
||||
{
|
||||
key: 'link',
|
||||
tooltip: t('textng.editor.tooltip-link', 'Link'),
|
||||
icon: 'link',
|
||||
run: (view) => surroundSelection(view, ...markers.link),
|
||||
},
|
||||
];
|
||||
|
||||
const insertVariable: FormatAction = {
|
||||
key: 'variable',
|
||||
tooltip: t('textng.editor.tooltip-insert-variable', 'Insert variable'),
|
||||
icon: 'brackets-curly',
|
||||
run: (view) => insertAtCursor(view, '${}'),
|
||||
};
|
||||
|
||||
if (isHtml) {
|
||||
return [...inlineActions, insertVariable];
|
||||
}
|
||||
|
||||
// Markdown only: the HTML equivalents are too verbose for a one-click insert.
|
||||
return [
|
||||
{
|
||||
key: 'heading',
|
||||
tooltip: t('textng.editor.tooltip-heading', 'Heading'),
|
||||
label: t('textng.editor.format-heading', 'H'),
|
||||
run: (view) => prefixSelectedLines(view, '# '),
|
||||
},
|
||||
...inlineActions,
|
||||
{
|
||||
key: 'bullet-list',
|
||||
tooltip: t('textng.editor.tooltip-bullet-list', 'Bullet list'),
|
||||
icon: 'list-ul',
|
||||
run: (view) => prefixSelectedLines(view, '- '),
|
||||
},
|
||||
{
|
||||
key: 'numbered-list',
|
||||
tooltip: t('textng.editor.tooltip-numbered-list', 'Numbered list'),
|
||||
icon: 'list-ol',
|
||||
run: (view) => prefixSelectedLines(view, '1. '),
|
||||
},
|
||||
{
|
||||
key: 'checklist',
|
||||
tooltip: t('textng.editor.tooltip-checklist', 'Checklist'),
|
||||
icon: 'check-square',
|
||||
run: (view) => prefixSelectedLines(view, '- [ ] '),
|
||||
},
|
||||
{
|
||||
key: 'table',
|
||||
tooltip: t('textng.editor.tooltip-table', 'Table'),
|
||||
icon: 'table',
|
||||
run: (view) => insertAtCursor(view, TABLE_SNIPPET),
|
||||
},
|
||||
insertVariable,
|
||||
];
|
||||
}
|
||||
|
||||
export interface TextNGFormatToolbarProps {
|
||||
mode: TextMode;
|
||||
/**
|
||||
* Wrapper the CodeMirror editor is mounted into. The lazily-loaded bundle does
|
||||
* not expose its `EditorView`, so it is looked up from this container's DOM.
|
||||
*/
|
||||
editorContainerRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function TextNGFormatToolbar({ mode, editorContainerRef }: TextNGFormatToolbarProps) {
|
||||
const actions = getFormatActions(mode);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runAction = (action: FormatAction) => {
|
||||
const container = editorContainerRef.current;
|
||||
const view = container ? EditorView.findFromDOM(container) : null;
|
||||
if (view) {
|
||||
action.run(view);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={0.5} wrap="wrap" alignItems="center" data-testid={FORMAT_TOOLBAR_TEST_ID}>
|
||||
{actions.map((action) => (
|
||||
<ToolbarButton key={action.key} icon={action.icon} tooltip={action.tooltip} onClick={() => runAction(action)}>
|
||||
{action.label}
|
||||
</ToolbarButton>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import { insertAtCursor, prefixSelectedLines, surroundSelection } from './editorCommands';
|
||||
|
||||
let views: EditorView[] = [];
|
||||
|
||||
function createView(doc: string, selection?: { anchor: number; head?: number }): EditorView {
|
||||
const view = new EditorView({
|
||||
parent: document.body,
|
||||
state: EditorState.create({ doc, selection }),
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
views.forEach((view) => view.destroy());
|
||||
views = [];
|
||||
});
|
||||
|
||||
describe('surroundSelection', () => {
|
||||
it('wraps the selection and keeps it selected', () => {
|
||||
const view = createView('hello world', { anchor: 0, head: 5 });
|
||||
|
||||
surroundSelection(view, '**');
|
||||
|
||||
expect(view.state.doc.toString()).toBe('**hello** world');
|
||||
const { from, to } = view.state.selection.main;
|
||||
expect(view.state.sliceDoc(from, to)).toBe('hello');
|
||||
});
|
||||
|
||||
it('places the caret between the markers when nothing is selected', () => {
|
||||
const view = createView('', { anchor: 0 });
|
||||
|
||||
surroundSelection(view, '**');
|
||||
|
||||
expect(view.state.doc.toString()).toBe('****');
|
||||
expect(view.state.selection.main.head).toBe(2);
|
||||
});
|
||||
|
||||
it('supports asymmetric markers', () => {
|
||||
const view = createView('Grafana', { anchor: 0, head: 7 });
|
||||
|
||||
surroundSelection(view, '[', '](https://)');
|
||||
|
||||
expect(view.state.doc.toString()).toBe('[Grafana](https://)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAtCursor', () => {
|
||||
it('inserts at the caret and moves it past the insertion', () => {
|
||||
const view = createView('ab', { anchor: 1 });
|
||||
|
||||
insertAtCursor(view, '${}');
|
||||
|
||||
expect(view.state.doc.toString()).toBe('a${}b');
|
||||
expect(view.state.selection.main.head).toBe(4);
|
||||
});
|
||||
|
||||
it('replaces the selection', () => {
|
||||
const view = createView('keep drop', { anchor: 5, head: 9 });
|
||||
|
||||
insertAtCursor(view, 'new');
|
||||
|
||||
expect(view.state.doc.toString()).toBe('keep new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefixSelectedLines', () => {
|
||||
it('prefixes the caret line', () => {
|
||||
const view = createView('one\ntwo', { anchor: 5 });
|
||||
|
||||
prefixSelectedLines(view, '- ');
|
||||
|
||||
expect(view.state.doc.toString()).toBe('one\n- two');
|
||||
});
|
||||
|
||||
it('prefixes every line touched by the selection', () => {
|
||||
const view = createView('one\ntwo\nthree', { anchor: 0, head: 7 });
|
||||
|
||||
prefixSelectedLines(view, '# ');
|
||||
|
||||
expect(view.state.doc.toString()).toBe('# one\n# two\nthree');
|
||||
});
|
||||
|
||||
it('leaves out the line a selection merely ends at the start of', () => {
|
||||
const view = createView('one\ntwo', { anchor: 0, head: 4 });
|
||||
|
||||
prefixSelectedLines(view, '- ');
|
||||
|
||||
expect(view.state.doc.toString()).toBe('- one\ntwo');
|
||||
});
|
||||
|
||||
it('keeps the caret after the inserted prefix', () => {
|
||||
const view = createView('one', { anchor: 0 });
|
||||
|
||||
prefixSelectedLines(view, '- [ ] ');
|
||||
|
||||
expect(view.state.doc.toString()).toBe('- [ ] one');
|
||||
expect(view.state.selection.main.head).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type EditorView } from '@codemirror/view';
|
||||
|
||||
/** Wraps the selection (or the caret) in `before`/`after`, keeping it selected. */
|
||||
export function surroundSelection(view: EditorView, before: string, after = before) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
const selected = view.state.sliceDoc(from, to);
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: `${before}${selected}${after}` },
|
||||
selection: { anchor: from + before.length, head: from + before.length + selected.length },
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function insertAtCursor(view: EditorView, text: string) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
/** Prefixes every line touched by the selection, e.g. for headings and lists. */
|
||||
export function prefixSelectedLines(view: EditorView, prefix: string) {
|
||||
const { state } = view;
|
||||
const range = state.selection.main;
|
||||
const startLine = state.doc.lineAt(range.from).number;
|
||||
// A selection ending exactly at a line start (Shift+Down, or dragging through a
|
||||
// trailing newline) does not touch that line, so it must not be prefixed.
|
||||
const endPos = !range.empty && range.to === state.doc.lineAt(range.to).from ? range.to - 1 : range.to;
|
||||
const endLine = state.doc.lineAt(endPos).number;
|
||||
|
||||
const changes = [];
|
||||
for (let n = startLine; n <= endLine; n++) {
|
||||
changes.push({ from: state.doc.line(n).from, insert: prefix });
|
||||
}
|
||||
|
||||
// Map the selection rightward so the caret lands after the inserted prefix.
|
||||
const changeSet = state.changes(changes);
|
||||
view.dispatch({ changes: changeSet, selection: state.selection.map(changeSet, 1) });
|
||||
view.focus();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { CodeLanguage, TextMode } from '../panelcfg.gen';
|
||||
|
||||
import { getCodeMirrorLanguage, getInterpolateFormat, transformContent } from './utils';
|
||||
|
||||
describe('transformContent', () => {
|
||||
describe('never returns an empty string', () => {
|
||||
// DangerouslySetHtmlContent throws on falsy html, so any of these would
|
||||
// crash the panel.
|
||||
const rendersToNothing = ['', ' ', '\n', '\n\n', ' \n ', '<!-- just a comment -->'];
|
||||
|
||||
it.each(rendersToNothing)('for markdown content %j', (content) => {
|
||||
expect(transformContent(TextMode.Markdown, content, false)).not.toBe('');
|
||||
});
|
||||
|
||||
it.each(rendersToNothing)('for HTML content %j', (content) => {
|
||||
expect(transformContent(TextMode.HTML, content, false)).not.toBe('');
|
||||
});
|
||||
|
||||
it.each(rendersToNothing)('for code content %j', (content) => {
|
||||
expect(transformContent(TextMode.Code, content, false)).not.toBe('');
|
||||
});
|
||||
|
||||
it('for markdown that survives sanitization only as empty', () => {
|
||||
expect(transformContent(TextMode.Markdown, '<!-- x -->', true)).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders markdown', () => {
|
||||
expect(transformContent(TextMode.Markdown, '# Title', false)).toContain('<h1');
|
||||
});
|
||||
|
||||
it('sanitizes HTML by default', () => {
|
||||
const html = transformContent(TextMode.HTML, '<script>alert(1)</script><p>safe</p>', false);
|
||||
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('safe');
|
||||
});
|
||||
|
||||
it('leaves HTML untouched when sanitization is disabled', () => {
|
||||
expect(transformContent(TextMode.HTML, '<form><p>kept</p></form>', true)).toContain('<form>');
|
||||
});
|
||||
|
||||
it('leaves code content unrendered', () => {
|
||||
expect(transformContent(TextMode.Code, '# not a heading', false)).toBe('# not a heading');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInterpolateFormat', () => {
|
||||
it('uses the json format for the json language so values stay valid json', () => {
|
||||
expect(getInterpolateFormat(CodeLanguage.Json)).toBe('json');
|
||||
expect(getInterpolateFormat(CodeLanguage.Plaintext)).toBe('html');
|
||||
expect(getInterpolateFormat(undefined)).toBe('html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCodeMirrorLanguage', () => {
|
||||
// An unmapped language silently loses syntax highlighting.
|
||||
it.each([
|
||||
[CodeLanguage.Go, 'go'],
|
||||
[CodeLanguage.Html, 'html'],
|
||||
[CodeLanguage.Json, 'json'],
|
||||
[CodeLanguage.Markdown, 'markdown'],
|
||||
[CodeLanguage.Sql, 'sql'],
|
||||
[CodeLanguage.Typescript, 'typescript'],
|
||||
[CodeLanguage.Xml, 'xml'],
|
||||
[CodeLanguage.Yaml, 'yaml'],
|
||||
])('maps %s to %s', (codeLanguage, expected) => {
|
||||
expect(getCodeMirrorLanguage(codeLanguage)).toBe(expected);
|
||||
});
|
||||
|
||||
it('has no language for plaintext or an unset language', () => {
|
||||
expect(getCodeMirrorLanguage(CodeLanguage.Plaintext)).toBeUndefined();
|
||||
expect(getCodeMirrorLanguage(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('covers every CodeLanguage option', () => {
|
||||
const unmapped = Object.values(CodeLanguage).filter(
|
||||
(codeLanguage) => codeLanguage !== CodeLanguage.Plaintext && !getCodeMirrorLanguage(codeLanguage)
|
||||
);
|
||||
|
||||
expect(unmapped).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,6 @@ export function getInterpolateFormat(codeLanguage?: CodeLanguage): 'json' | 'htm
|
||||
|
||||
/** Shared by the panel and the edit-time preview so they can't diverge. */
|
||||
export function transformContent(mode: TextMode, content: string, disableSanitizeHtml: boolean): string {
|
||||
if (!content) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case TextMode.Code:
|
||||
break;
|
||||
@@ -28,7 +24,9 @@ export function transformContent(mode: TextMode, content: string, disableSanitiz
|
||||
});
|
||||
}
|
||||
|
||||
return content;
|
||||
// DangerouslySetHtmlContent throws on falsy html, and markdown renders blank
|
||||
// lines or a lone comment to ''.
|
||||
return content || ' ';
|
||||
}
|
||||
|
||||
/** Maps the panel's CodeLanguage option to CodeMirrorEditor's lazy-loaded language names. */
|
||||
|
||||
@@ -15952,6 +15952,18 @@
|
||||
},
|
||||
"editor": {
|
||||
"aria-label-content": "Text content",
|
||||
"format-bold": "B",
|
||||
"format-heading": "H",
|
||||
"format-italic": "I",
|
||||
"tooltip-bold": "Bold",
|
||||
"tooltip-bullet-list": "Bullet list",
|
||||
"tooltip-checklist": "Checklist",
|
||||
"tooltip-heading": "Heading",
|
||||
"tooltip-insert-variable": "Insert variable",
|
||||
"tooltip-italic": "Italic",
|
||||
"tooltip-link": "Link",
|
||||
"tooltip-numbered-list": "Numbered list",
|
||||
"tooltip-table": "Table",
|
||||
"view-preview": "Preview",
|
||||
"view-split": "Split",
|
||||
"view-write": "Write"
|
||||
|
||||
@@ -18904,6 +18904,7 @@ __metadata:
|
||||
"@codemirror/lang-sql": "npm:6.10.0"
|
||||
"@codemirror/language": "npm:6.12.4"
|
||||
"@codemirror/state": "npm:6.7.1"
|
||||
"@codemirror/view": "npm:^6.41.0"
|
||||
"@emotion/css": "npm:11.13.5"
|
||||
"@emotion/eslint-plugin": "npm:11.12.0"
|
||||
"@emotion/react": "npm:11.14.0"
|
||||
|
||||
Reference in New Issue
Block a user