Text: Markdown toolbar (#129636)

This commit is contained in:
Adela Almasan
2026-08-03 09:24:55 -04:00
committed by GitHub
parent 0446e9d70d
commit 032e1dfcca
16 changed files with 902 additions and 37 deletions
+1
View File
@@ -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([]);
});
});
+3 -5
View File
@@ -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. */
+12
View File
@@ -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"
+1
View File
@@ -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"