@patternfly/chatbot 6.3.0-prerelease.16 → 6.3.0-prerelease.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/cjs/Message/CodeBlockMessage/CodeBlockMessage.d.ts +20 -2
  2. package/dist/cjs/Message/CodeBlockMessage/CodeBlockMessage.js +14 -3
  3. package/dist/cjs/Message/CodeBlockMessage/ExpandableSectionForSyntaxHighlighter.d.ts +62 -0
  4. package/dist/cjs/Message/CodeBlockMessage/ExpandableSectionForSyntaxHighlighter.js +136 -0
  5. package/dist/cjs/Message/Message.d.ts +16 -1
  6. package/dist/cjs/Message/Message.js +2 -1
  7. package/dist/cjs/Message/Message.test.js +30 -0
  8. package/dist/cjs/Message/Plugins/rehypeMoveImagesOutOfParagraphs.d.ts +2 -0
  9. package/dist/cjs/Message/Plugins/rehypeMoveImagesOutOfParagraphs.js +47 -0
  10. package/dist/css/main.css +10 -0
  11. package/dist/css/main.css.map +1 -1
  12. package/dist/esm/Message/CodeBlockMessage/CodeBlockMessage.d.ts +20 -2
  13. package/dist/esm/Message/CodeBlockMessage/CodeBlockMessage.js +15 -4
  14. package/dist/esm/Message/CodeBlockMessage/ExpandableSectionForSyntaxHighlighter.d.ts +62 -0
  15. package/dist/esm/Message/CodeBlockMessage/ExpandableSectionForSyntaxHighlighter.js +130 -0
  16. package/dist/esm/Message/Message.d.ts +16 -1
  17. package/dist/esm/Message/Message.js +2 -1
  18. package/dist/esm/Message/Message.test.js +30 -0
  19. package/dist/esm/Message/Plugins/rehypeMoveImagesOutOfParagraphs.d.ts +2 -0
  20. package/dist/esm/Message/Plugins/rehypeMoveImagesOutOfParagraphs.js +43 -0
  21. package/dist/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +3 -2
  23. package/patternfly-docs/content/extensions/chatbot/design-guidelines.md +10 -0
  24. package/patternfly-docs/content/extensions/chatbot/examples/Customizing Messages/Customizing Messages.md +51 -0
  25. package/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx +9 -0
  26. package/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx +9 -0
  27. package/patternfly-docs/content/extensions/chatbot/img/quick-response-confirmation.svg +67 -0
  28. package/src/Message/CodeBlockMessage/CodeBlockMessage.scss +7 -0
  29. package/src/Message/CodeBlockMessage/CodeBlockMessage.tsx +99 -12
  30. package/src/Message/CodeBlockMessage/ExpandableSectionForSyntaxHighlighter.tsx +220 -0
  31. package/src/Message/Message.test.tsx +39 -0
  32. package/src/Message/Message.tsx +19 -1
  33. package/src/Message/Plugins/rehypeMoveImagesOutOfParagraphs.ts +53 -0
@@ -80,3 +80,10 @@
80
80
  background-color: var(--pf-t--global--background--color--tertiary--default);
81
81
  font-size: var(--pf-t--global--font--size--body--default);
82
82
  }
83
+
84
+ .pf-chatbot__message-code-toggle {
85
+ .pf-v6-c-button.pf-m-link {
86
+ --pf-v6-c-button--m-link--Color: var(--pf-t--global--color--nonstatus--blue--default);
87
+ --pf-v6-c-button--hover--Color: var(--pf-t--global--color--nonstatus--blue--hover);
88
+ }
89
+ }
@@ -5,25 +5,68 @@ import { useState, useRef, useId, useCallback, useEffect } from 'react';
5
5
  import SyntaxHighlighter from 'react-syntax-highlighter';
6
6
  import { obsidian } from 'react-syntax-highlighter/dist/esm/styles/hljs';
7
7
  // Import PatternFly components
8
- import { CodeBlock, CodeBlockAction, CodeBlockCode, Button, Tooltip } from '@patternfly/react-core';
8
+ import {
9
+ CodeBlock,
10
+ CodeBlockAction,
11
+ CodeBlockCode,
12
+ Button,
13
+ Tooltip,
14
+ ExpandableSection,
15
+ ExpandableSectionToggle,
16
+ ExpandableSectionProps,
17
+ ExpandableSectionToggleProps,
18
+ ExpandableSectionVariant
19
+ } from '@patternfly/react-core';
9
20
 
10
21
  import { CheckIcon } from '@patternfly/react-icons/dist/esm/icons/check-icon';
11
22
  import { CopyIcon } from '@patternfly/react-icons/dist/esm/icons/copy-icon';
12
- import { ExtraProps } from 'react-markdown';
23
+ import { ExpandableSectionForSyntaxHighlighter } from './ExpandableSectionForSyntaxHighlighter';
24
+
25
+ export interface CodeBlockMessageProps {
26
+ /** Content rendered in code block */
27
+ children?: React.ReactNode;
28
+ /** Aria label applied to code block */
29
+ 'aria-label'?: string;
30
+ /** Class name applied to code block */
31
+ className?: string;
32
+ /** Whether code block is expandable */
33
+ isExpandable?: boolean;
34
+ /** Additional props passed to expandable section if isExpandable is applied */
35
+ expandableSectionProps?: Omit<ExpandableSectionProps, 'ref'>;
36
+ /** Additional props passed to expandable toggle if isExpandable is applied */
37
+ expandableSectionToggleProps?: ExpandableSectionToggleProps;
38
+ /** Link text applied to expandable toggle when expanded */
39
+ expandedText?: string;
40
+ /** Link text applied to expandable toggle when collapsed */
41
+ collapsedText?: string;
42
+ }
13
43
 
14
44
  const CodeBlockMessage = ({
15
45
  children,
16
46
  className,
17
47
  'aria-label': ariaLabel,
48
+ isExpandable = false,
49
+ expandableSectionProps,
50
+ expandableSectionToggleProps,
51
+ expandedText = 'Show less',
52
+ collapsedText = 'Show more',
18
53
  ...props
19
- }: Omit<JSX.IntrinsicElements['code'], 'ref'> & ExtraProps) => {
54
+ }: CodeBlockMessageProps) => {
20
55
  const [copied, setCopied] = useState(false);
56
+ const [isExpanded, setIsExpanded] = useState(false);
21
57
 
22
- const buttonRef = useRef(undefined);
58
+ const buttonRef = useRef();
23
59
  const tooltipID = useId();
60
+ const toggleId = useId();
61
+ const contentId = useId();
62
+ const codeBlockRef = useRef<HTMLDivElement>(null);
24
63
 
25
64
  const language = /language-(\w+)/.exec(className || '')?.[1];
26
65
 
66
+ const onToggle = (isExpanded) => {
67
+ setIsExpanded(isExpanded);
68
+ };
69
+
27
70
  // Handle clicking copy button
28
71
  const handleCopy = useCallback((event, text) => {
29
72
  navigator.clipboard.writeText(text.toString());
@@ -69,17 +112,61 @@ const CodeBlockMessage = ({
69
112
  );
70
113
 
71
114
  return (
72
- <div className="pf-chatbot__message-code-block">
115
+ <div className="pf-chatbot__message-code-block" ref={codeBlockRef}>
73
116
  <CodeBlock actions={actions}>
74
117
  <CodeBlockCode>
75
- {language ? (
76
- <SyntaxHighlighter {...props} language={language} style={obsidian} PreTag="div" CodeTag="div" wrapLongLines>
77
- {String(children).replace(/\n$/, '')}
78
- </SyntaxHighlighter>
79
- ) : (
80
- <>{children}</>
81
- )}
118
+ <>
119
+ {language ? (
120
+ // SyntaxHighlighter doesn't work with ExpandableSection because it targets the direct child
121
+ // Forked for now and adjusted to match what we need
122
+ <ExpandableSectionForSyntaxHighlighter
123
+ variant={ExpandableSectionVariant.truncate}
124
+ isExpanded={isExpanded}
125
+ isDetached
126
+ toggleId={toggleId}
127
+ contentId={contentId}
128
+ language={language}
129
+ {...expandableSectionProps}
130
+ >
131
+ <SyntaxHighlighter
132
+ {...props}
133
+ language={language}
134
+ style={obsidian}
135
+ PreTag="div"
136
+ CodeTag="div"
137
+ wrapLongLines
138
+ >
139
+ {String(children).replace(/\n$/, '')}
140
+ </SyntaxHighlighter>
141
+ </ExpandableSectionForSyntaxHighlighter>
142
+ ) : (
143
+ <ExpandableSection
144
+ variant={ExpandableSectionVariant.truncate}
145
+ isExpanded={isExpanded}
146
+ isDetached
147
+ toggleId={toggleId}
148
+ contentId={contentId}
149
+ {...expandableSectionProps}
150
+ >
151
+ {children}
152
+ </ExpandableSection>
153
+ )}
154
+ </>
82
155
  </CodeBlockCode>
156
+ {isExpandable && (
157
+ <ExpandableSectionToggle
158
+ isExpanded={isExpanded}
159
+ onToggle={onToggle}
160
+ direction="up"
161
+ toggleId={toggleId}
162
+ contentId={contentId}
163
+ hasTruncatedContent
164
+ className="pf-chatbot__message-code-toggle"
165
+ {...expandableSectionToggleProps}
166
+ >
167
+ {isExpanded ? expandedText : collapsedText}
168
+ </ExpandableSectionToggle>
169
+ )}
83
170
  </CodeBlock>
84
171
  </div>
85
172
  );
@@ -0,0 +1,220 @@
1
+ import { Component, createRef } from 'react';
2
+ import styles from '@patternfly/react-styles/css/components/ExpandableSection/expandable-section';
3
+ import { css } from '@patternfly/react-styles';
4
+ import lineClamp from '@patternfly/react-tokens/dist/esm/c_expandable_section_m_truncate__content_LineClamp';
5
+ import { debounce, getResizeObserver, getUniqueId, PickOptional } from '@patternfly/react-core';
6
+
7
+ export enum ExpandableSectionVariant {
8
+ default = 'default',
9
+ truncate = 'truncate'
10
+ }
11
+
12
+ /** The main expandable section component. */
13
+
14
+ export interface ExpandableSectionProps extends Omit<React.HTMLProps<HTMLDivElement>, 'onToggle'> {
15
+ /** Content rendered inside the expandable section. */
16
+ children?: React.ReactNode;
17
+ /** Additional classes added to the expandable section. */
18
+ className?: string;
19
+ /** Id of the content of the expandable section. When passing in the isDetached property, this
20
+ * property's value should match the contentId property of the expandable section toggle sub-component.
21
+ */
22
+ contentId?: string;
23
+ /** Id of the toggle of the expandable section, which provides an accessible name to the
24
+ * expandable section content via the aria-labelledby attribute. When the isDetached property
25
+ * is also passed in, the value of this property must match the toggleId property of the
26
+ * expandable section toggle sub-component.
27
+ */
28
+ toggleId?: string;
29
+ /** Display size variant. Set to "lg" for disclosure styling. */
30
+ displaySize?: 'default' | 'lg';
31
+ /** Indicates the expandable section has a detached toggle. */
32
+ isDetached?: boolean;
33
+ /** Flag to indicate if the content is expanded. */
34
+ isExpanded?: boolean;
35
+ /** Flag to indicate if the content is indented. */
36
+ isIndented?: boolean;
37
+ /** Flag to indicate the width of the component is limited. Set to "true" for disclosure styling. */
38
+ isWidthLimited?: boolean;
39
+ /** Truncates the expandable content to the specified number of lines when using the
40
+ * "truncate" variant.
41
+ */
42
+ truncateMaxLines?: number;
43
+ /** Determines the variant of the expandable section. When passing in "truncate" as the
44
+ * variant, the expandable content will be truncated after 3 lines by default.
45
+ */
46
+ variant?: 'default' | 'truncate';
47
+ language?: string;
48
+ }
49
+
50
+ interface ExpandableSectionState {
51
+ isExpanded: boolean;
52
+ hasToggle: boolean;
53
+ previousWidth: number | undefined;
54
+ }
55
+
56
+ const setLineClamp = (element: HTMLDivElement | null, lines?: number, language?: string, isExpanded?: boolean) => {
57
+ if (!element || !lines || lines < 1 || typeof isExpanded === 'undefined') {
58
+ return;
59
+ }
60
+
61
+ if (language) {
62
+ const selector = `.language-${language.toLowerCase()}`;
63
+ const childElement = element.querySelector(selector) as HTMLDivElement;
64
+
65
+ if (!childElement) {
66
+ return;
67
+ }
68
+ if (isExpanded) {
69
+ // Reset all truncation-related styles to their default values
70
+ childElement.style.removeProperty('-webkit-line-clamp');
71
+ childElement.style.removeProperty('display');
72
+ childElement.style.removeProperty('-webkit-box-orient');
73
+ childElement.style.removeProperty('overflow');
74
+ } else {
75
+ childElement.style.setProperty('-webkit-line-clamp', lines.toString());
76
+ childElement.style.setProperty('display', '-webkit-box');
77
+ childElement.style.setProperty('-webkit-box-orient', 'vertical');
78
+ childElement.style.setProperty('overflow', 'hidden');
79
+ }
80
+ }
81
+ };
82
+
83
+ class ExpandableSectionForSyntaxHighlighter extends Component<ExpandableSectionProps, ExpandableSectionState> {
84
+ static displayName = 'ExpandableSection';
85
+ constructor(props: ExpandableSectionProps) {
86
+ super(props);
87
+
88
+ this.state = {
89
+ isExpanded: props.isExpanded ?? false,
90
+ hasToggle: true,
91
+ previousWidth: undefined
92
+ };
93
+ }
94
+
95
+ expandableContentRef = createRef<HTMLDivElement>();
96
+ /* eslint-disable-next-line */
97
+ observer: any = () => {};
98
+
99
+ static defaultProps: PickOptional<ExpandableSectionProps> = {
100
+ className: '',
101
+ isDetached: false,
102
+ displaySize: 'default',
103
+ isWidthLimited: false,
104
+ isIndented: false,
105
+ variant: 'default'
106
+ };
107
+
108
+ componentDidMount() {
109
+ if (this.props.variant === ExpandableSectionVariant.truncate) {
110
+ const expandableContent = this.expandableContentRef.current;
111
+ if (expandableContent) {
112
+ this.setState({ previousWidth: expandableContent.offsetWidth });
113
+ this.observer = getResizeObserver(expandableContent, this.handleResize, false);
114
+
115
+ if (this.props.truncateMaxLines) {
116
+ setLineClamp(expandableContent, this.props.truncateMaxLines, this.props.language, this.state.isExpanded);
117
+ }
118
+ }
119
+
120
+ this.checkToggleVisibility();
121
+ }
122
+ }
123
+
124
+ componentDidUpdate(prevProps: ExpandableSectionProps) {
125
+ if (
126
+ this.props.variant === ExpandableSectionVariant.truncate &&
127
+ (prevProps.truncateMaxLines !== this.props.truncateMaxLines ||
128
+ prevProps.children !== this.props.children ||
129
+ prevProps.isExpanded !== this.props.isExpanded)
130
+ ) {
131
+ const expandableContent = this.expandableContentRef.current;
132
+ setLineClamp(expandableContent, this.props.truncateMaxLines, this.props.language, this.props.isExpanded);
133
+ this.checkToggleVisibility();
134
+ }
135
+ }
136
+
137
+ componentWillUnmount() {
138
+ if (this.props.variant === ExpandableSectionVariant.truncate) {
139
+ this.observer();
140
+ }
141
+ }
142
+
143
+ checkToggleVisibility = () => {
144
+ if (this.expandableContentRef?.current) {
145
+ const maxLines = this.props.truncateMaxLines || parseInt(lineClamp.value);
146
+ const totalLines =
147
+ this.expandableContentRef.current.scrollHeight /
148
+ parseInt(getComputedStyle(this.expandableContentRef.current).lineHeight);
149
+
150
+ this.setState({
151
+ hasToggle: totalLines > maxLines
152
+ });
153
+ }
154
+ };
155
+
156
+ resize = () => {
157
+ if (this.expandableContentRef.current) {
158
+ const { offsetWidth } = this.expandableContentRef.current;
159
+ if (this.state.previousWidth !== offsetWidth) {
160
+ this.setState({ previousWidth: offsetWidth });
161
+ this.checkToggleVisibility();
162
+ }
163
+ }
164
+ };
165
+ handleResize = debounce(this.resize, 250);
166
+
167
+ render() {
168
+ const {
169
+ className,
170
+ children,
171
+ isExpanded,
172
+ isDetached,
173
+ displaySize,
174
+ isWidthLimited,
175
+ isIndented,
176
+ contentId,
177
+ toggleId,
178
+ variant,
179
+ ...props
180
+ } = this.props;
181
+
182
+ if (isDetached && !toggleId) {
183
+ /* eslint-disable no-console */
184
+ console.warn(
185
+ 'ExpandableSection: The toggleId value must be passed in and must match the toggleId of the ExpandableSectionToggle.'
186
+ );
187
+ }
188
+
189
+ const uniqueContentId = contentId || getUniqueId('expandable-section-content');
190
+ const uniqueToggleId = toggleId || getUniqueId('expandable-section-toggle');
191
+
192
+ return (
193
+ <div
194
+ className={css(
195
+ styles.expandableSection,
196
+ isExpanded && styles.modifiers.expanded,
197
+ displaySize === 'lg' && styles.modifiers.displayLg,
198
+ isWidthLimited && styles.modifiers.limitWidth,
199
+ isIndented && styles.modifiers.indented,
200
+ variant === ExpandableSectionVariant.truncate && styles.modifiers.truncate,
201
+ className
202
+ )}
203
+ {...props}
204
+ >
205
+ <div
206
+ ref={this.expandableContentRef}
207
+ className={css(styles.expandableSectionContent)}
208
+ hidden={variant !== ExpandableSectionVariant.truncate && !isExpanded}
209
+ id={uniqueContentId}
210
+ aria-labelledby={uniqueToggleId}
211
+ role="region"
212
+ >
213
+ {children}
214
+ </div>
215
+ </div>
216
+ );
217
+ }
218
+ }
219
+
220
+ export { ExpandableSectionForSyntaxHighlighter };
@@ -142,6 +142,8 @@ const EMPTY_TABLE = `
142
142
 
143
143
  const IMAGE = `![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`;
144
144
 
145
+ const INLINE_IMAGE = `inline text ![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`;
146
+
145
147
  const ERROR = {
146
148
  title: 'Could not load chat',
147
149
  children: 'Wait a few minutes and check your network settings. If the issue persists: ',
@@ -499,6 +501,36 @@ describe('Message', () => {
499
501
  screen.getByText(/https:\/\/raw.githubusercontent.com\/Azure-Samples\/helm-charts\/master\/docs/i)
500
502
  ).toBeTruthy();
501
503
  });
504
+ it('should render expandable code correctly', () => {
505
+ render(
506
+ <Message avatar="./img" role="user" name="User" content={CODE_MESSAGE} codeBlockProps={{ isExpandable: true }} />
507
+ );
508
+ expect(screen.getByText('Here is some YAML code:')).toBeTruthy();
509
+ expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy();
510
+ expect(screen.getByText(/yaml/)).toBeTruthy();
511
+ expect(screen.getByText(/apiVersion/i)).toBeTruthy();
512
+ expect(screen.getByRole('button', { name: /Show more/i })).toBeTruthy();
513
+ });
514
+ it('should handle click on expandable code correctly', async () => {
515
+ render(
516
+ <Message avatar="./img" role="user" name="User" content={CODE_MESSAGE} codeBlockProps={{ isExpandable: true }} />
517
+ );
518
+ const button = screen.getByRole('button', { name: /Show more/i });
519
+ await userEvent.click(button);
520
+ expect(screen.getByRole('button', { name: /Show less/i })).toBeTruthy();
521
+ expect(screen.getByText(/yaml/)).toBeTruthy();
522
+ expect(screen.getByText(/apiVersion:/i)).toBeTruthy();
523
+ expect(screen.getByText(/helm.openshift.io\/v1beta1/i)).toBeTruthy();
524
+ expect(screen.getByText(/metadata:/i)).toBeTruthy();
525
+ expect(screen.getByText(/name:/i)).toBeTruthy();
526
+ expect(screen.getByText(/azure-sample-repo0oooo00ooo/i)).toBeTruthy();
527
+ expect(screen.getByText(/spec/i)).toBeTruthy();
528
+ expect(screen.getByText(/connectionConfig:/i)).toBeTruthy();
529
+ expect(screen.getByText(/url:/i)).toBeTruthy();
530
+ expect(
531
+ screen.getByText(/https:\/\/raw.githubusercontent.com\/Azure-Samples\/helm-charts\/master\/docs/i)
532
+ ).toBeTruthy();
533
+ });
502
534
  it('can click copy code button', async () => {
503
535
  // need explicit setup since RTL stubs clipboard if you do this
504
536
  const user = userEvent.setup();
@@ -787,6 +819,13 @@ describe('Message', () => {
787
819
  render(<Message avatar="./img" role="user" name="User" content={IMAGE} />);
788
820
  expect(screen.getByRole('img', { name: /Multi-colored wavy lines on a black background/i })).toBeTruthy();
789
821
  });
822
+ it('inline image parent should have class pf-chatbot__message-and-actions', () => {
823
+ render(<Message avatar="./img" role="user" name="User" content={INLINE_IMAGE} />);
824
+ expect(screen.getByRole('img', { name: /Multi-colored wavy lines on a black background/i })).toBeTruthy();
825
+ expect(
826
+ screen.getByRole('img', { name: /Multi-colored wavy lines on a black background/i }).parentElement
827
+ ).toHaveClass('pf-chatbot__message-and-actions');
828
+ });
790
829
  it('should handle external links correctly', () => {
791
830
  render(<Message avatar="./img" role="user" name="User" content={`[PatternFly](https://www.patternfly.org/)`} />);
792
831
  // we are mocking rehype libraries, so we can't test target _blank addition on links directly with RTL
@@ -11,6 +11,8 @@ import {
11
11
  AvatarProps,
12
12
  ButtonProps,
13
13
  ContentVariants,
14
+ ExpandableSectionProps,
15
+ ExpandableSectionToggleProps,
14
16
  FormProps,
15
17
  Label,
16
18
  LabelGroupProps,
@@ -46,6 +48,7 @@ import { PluggableList } from 'react-markdown/lib';
46
48
  import LinkMessage from './LinkMessage/LinkMessage';
47
49
  import ErrorMessage from './ErrorMessage/ErrorMessage';
48
50
  import MessageInput from './MessageInput';
51
+ import { rehypeMoveImagesOutOfParagraphs } from './Plugins/rehypeMoveImagesOutOfParagraphs';
49
52
 
50
53
  export interface MessageAttachment {
51
54
  /** Name of file attached to the message */
@@ -106,9 +109,24 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
106
109
  botWord?: string;
107
110
  /** Label for the English "Loading message," displayed to screenreaders when loading a message */
108
111
  loadingWord?: string;
112
+ /** Props for code blocks */
109
113
  codeBlockProps?: {
114
+ /** Aria label applied to code blocks */
110
115
  'aria-label'?: string;
116
+ /** Class name applied to code blocks */
111
117
  className?: string;
118
+ /** Whether code blocks are expandable */
119
+ isExpandable?: boolean;
120
+ /** Length of text initially shown in expandable code blocks; defaults to 10 characters */
121
+ maxLength?: number;
122
+ /** Additional props passed to expandable section if isExpandable is applied */
123
+ expandableSectionProps?: Omit<ExpandableSectionProps, 'ref'>;
124
+ /** Additional props passed to expandable toggle if isExpandable is applied */
125
+ expandableSectionToggleProps?: ExpandableSectionToggleProps;
126
+ /** Link text applied to expandable toggle when expanded */
127
+ expandedText?: string;
128
+ /** Link text applied to expandable toggle when collapsed */
129
+ collapsedText?: string;
112
130
  };
113
131
  /** Props for quick responses */
114
132
  quickResponses?: QuickResponse[];
@@ -212,7 +230,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
212
230
  }, [content]);
213
231
 
214
232
  const { beforeMainContent, afterMainContent, endContent } = extraContent || {};
215
- let rehypePlugins: PluggableList = [rehypeUnwrapImages];
233
+ let rehypePlugins: PluggableList = [rehypeUnwrapImages, rehypeMoveImagesOutOfParagraphs];
216
234
  if (openLinkInNewTab) {
217
235
  rehypePlugins = rehypePlugins.concat([[rehypeExternalLinks, { target: '_blank' }, rehypeSanitize]]);
218
236
  }
@@ -0,0 +1,53 @@
1
+ import { visit } from 'unist-util-visit';
2
+ import { Element } from 'hast';
3
+ import { Node } from 'unist';
4
+
5
+ // Rehype plugin to remove images from within p tags and put them as separate block-level elements.
6
+ // This allows us to avoid having a blue background on images - this is something Kayla requested.
7
+ export const rehypeMoveImagesOutOfParagraphs = () => (tree: Node) => {
8
+ const nodesToRemove: { parent: Element; index: number; node: Element }[] = [];
9
+
10
+ visit(tree, 'element', (node: Element, index: number | null, parent: Element | null) => {
11
+ if (node.tagName === 'p' && node.children) {
12
+ const imagesInParagraph: { node: Element; index: number }[] = [];
13
+
14
+ node.children.forEach((child: Node, childIndex: number) => {
15
+ if (child.type === 'element' && (child as Element).tagName === 'img') {
16
+ imagesInParagraph.push({ node: child as Element, index: childIndex });
17
+ }
18
+ });
19
+
20
+ if (imagesInParagraph.length > 0 && parent && index !== null) {
21
+ imagesInParagraph.forEach(({ node: imgNode, index: imgIndex }) => {
22
+ nodesToRemove.push({ parent: node, index: imgIndex, node: imgNode });
23
+ });
24
+
25
+ // To avoid issues with index shifting during removal, we process in reverse
26
+ for (let i = nodesToRemove.length - 1; i >= 0; i--) {
27
+ const { parent: pTag, index: imgIndexToRemove } = nodesToRemove[i];
28
+ if (pTag.children) {
29
+ pTag.children.splice(imgIndexToRemove, 1);
30
+ }
31
+ }
32
+
33
+ // Insert the removed images after the paragraph
34
+ const paragraphIndexInParent = parent.children.indexOf(node);
35
+ if (paragraphIndexInParent !== -1) {
36
+ imagesInParagraph.forEach(({ node: imgNode }) => {
37
+ parent.children.splice(paragraphIndexInParent + 1, 0, imgNode);
38
+ });
39
+ }
40
+
41
+ // Remove paragraph if it's now empty after image removal
42
+ if (node.children.length === 0) {
43
+ const paragraphIndexInParent = parent.children.indexOf(node);
44
+ if (paragraphIndexInParent !== -1) {
45
+ parent.children.splice(paragraphIndexInParent, 1);
46
+ }
47
+ }
48
+
49
+ nodesToRemove.length = 0;
50
+ }
51
+ }
52
+ });
53
+ };