Repository: Sage/carbon (ts)

Issue:
TextEditor to have a callback for onChange which returns JSON and HTML content of the editor
### Description

TextEditer  should have a callback for onChange which returns JSON and HTML content of the editor similar to how it works for onSave in the latest version. It would help in use cases where onSave is not used.


### Suggested solution

TextEditer  should have a callback for onChange which returns JSON and HTML content of the editor

### Demo URL

_No response_

### Alternatives

_No response_

### Additional context

_No response_

### Confidentiality

- [x] I confirm there is no confidential or commercially sensitive information included.

Pull request:
feat(text-editor): add support for `onChange` callback also being called with json and html values
fix #7285

### Proposed behaviour

<!--
A clear and concise description of what changes this PR makes. If applicable, include any UI screenshots to help explain your request. If you are a Sage contributor, please DO NOT share any commercially sensitive information, such as UI screenshots or code from Sage products.
-->
Adds a new argument to the `onChange` callback type so that it can be called with the string value and the HTML and JSON formatted values.

Moves the `createFromHTML` and `createEmpty` utils out of the `__internal__` directory as they are exported from the main index file

### Current behaviour

<!--
A clear and concise description of the behaviour before this change. If applicable, include any UI screenshots to help explain your request. If you are a Sage contributor, please DO NOT share any commercially sensitive information, such as UI screenshots or code from Sage products.
-->
The `onChange` callback is only called with the unformatted string value

The `createFromHTML` and `createEmpty` utils are part of the `__internal__` directory.

### Checklist

<!-- Each PR should include the following -->

- [x] Commits follow our style guide
- [x] Related issues linked in commit messages if required
- [x] Playwright automation tests added or updated if required
- [x] Storybook added or updated if required
- [x] Typescript `d.ts` file added or updated if required

#### QA

- [ ] Tested in provided StackBlitz sandbox/Storybook
- [ ] Add new Playwright test coverage if required
- [ ] Carbon implementation matches Design System/designs
- [ ] UI Tests GitHub check reviewed if required

### Additional context

<!-- Add any other context or links about the pull request here. -->

### Testing instructions

<!--
How can a reviewer test this PR?

If this PR addresses a pre-existing bug, please include a link to a sandbox that reproduces the original bug. A starter template has been provided to help you do this:
<https://stackblitz.com/fork/github/Parsium/carbon-starter>
-->

I have added a test story that renders the HTML and JSON values below the editor `text-editor-test--on-change-formatted-values`


Patch:
diff --git a/src/components/text-editor/__internal__/index.ts b/src/components/text-editor/__internal__/index.ts
index 7cbf07510..05c32803a 100644
--- a/src/components/text-editor/__internal__/index.ts
+++ b/src/components/text-editor/__internal__/index.ts
@@ -1,2 +1,1 @@
 export { default } from "./read-only-rte.component";
-export { createEmpty, createFromHTML } from "./utils";
diff --git a/src/components/text-editor/__internal__/plugins/Toolbar/buttons/save.component.tsx b/src/components/text-editor/__internal__/plugins/Toolbar/buttons/save.component.tsx
index 90dd63117..04020398d 100644
--- a/src/components/text-editor/__internal__/plugins/Toolbar/buttons/save.component.tsx
+++ b/src/components/text-editor/__internal__/plugins/Toolbar/buttons/save.component.tsx
@@ -20,7 +20,7 @@ interface SaveProps {
   children: SaveObjectProps[];
 }
 
-export interface SaveCallbackProps {
+export interface EditorFormattedValues {
   htmlString?: string;
   json?: {
     root: {
@@ -38,7 +38,7 @@ interface SaveButtonProps {
   /** The namespace of the editor that this button belongs to */
   namespace: string;
   /** The callback to call when the save button is clicked */
-  onSave: (value: SaveCallbackProps) => void;
+  onSave: (value: EditorFormattedValues) => void;
 }
 
 // The `SaveButton` component is a button that saves the current state of the editor
diff --git a/src/components/text-editor/__internal__/plugins/Toolbar/toolbar.component.tsx b/src/components/text-editor/__internal__/plugins/Toolbar/toolbar.component.tsx
index 458e67023..c4d9f8f02 100644
--- a/src/components/text-editor/__internal__/plugins/Toolbar/toolbar.component.tsx
+++ b/src/components/text-editor/__internal__/plugins/Toolbar/toolbar.component.tsx
@@ -20,7 +20,7 @@ import useLocale from "../../../../../hooks/__internal__/useLocale";
 
 import { BoldButton, ItalicButton, ListControls } from "./buttons";
 
-import SaveButton, { SaveCallbackProps } from "./buttons/save.component";
+import SaveButton, { EditorFormattedValues } from "./buttons/save.component";
 
 interface ToolbarProps {
   /** The namespace of the editor that this toolbar belongs to */
@@ -28,7 +28,7 @@ interface ToolbarProps {
   /** The callback to call when the cancel button is clicked */
   onCancel?: () => void;
   /** The callback to call when the save button is clicked */
-  onSave?: (value: SaveCallbackProps) => void;
+  onSave?: (value: EditorFormattedValues) => void;
 }
 
 const Toolbar = ({ namespace, onCancel, onSave }: ToolbarProps) => {
diff --git a/src/components/text-editor/__internal__/read-only-rte.component.tsx b/src/components/text-editor/__internal__/read-only-rte.component.tsx
index ffd543bcb..d20273e4b 100644
--- a/src/components/text-editor/__internal__/read-only-rte.component.tsx
+++ b/src/components/text-editor/__internal__/read-only-rte.component.tsx
@@ -7,7 +7,7 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
 import React, { useMemo } from "react";
 
 import { TextEditorProps } from "../text-editor.component";
-import { createFromHTML } from "./utils";
+import { createFromHTML } from "../utils";
 import { markdownNodes, theme } from "./constants";
 
 const wrapLinksInAnchors = (value: string) => {
diff --git a/src/components/text-editor/index.ts b/src/components/text-editor/index.ts
index a2dc2bbc8..f368cb351 100644
--- a/src/components/text-editor/index.ts
+++ b/src/components/text-editor/index.ts
@@ -1,3 +1,6 @@
 export { default } from "./text-editor.component";
-export { createEmpty, createFromHTML } from "./__internal__";
-export type { TextEditorProps } from "./text-editor.component";
+export { createEmpty, createFromHTML } from "./utils";
+export type {
+  TextEditorProps,
+  EditorFormattedValues,
+} from "./text-editor.component";
diff --git a/src/components/text-editor/text-editor.component.tsx b/src/components/text-editor/text-editor.component.tsx
index 99d0c2a7c..b372ce2c9 100644
--- a/src/components/text-editor/text-editor.component.tsx
+++ b/src/components/text-editor/text-editor.component.tsx
@@ -18,6 +18,7 @@ import React, {
   useState,
 } from "react";
 import { MarginProps } from "styled-system";
+import { SerializeLexical, validateUrl } from "./__internal__/helpers";
 
 import Label from "../../__internal__/label";
 import useDebounce from "../../hooks/__internal__/useDebounce";
@@ -28,7 +29,6 @@ import {
   markdownNodes,
   theme,
 } from "./__internal__/constants";
-import { validateUrl } from "./__internal__/helpers";
 import {
   AutoLinkerPlugin,
   CharacterCounterPlugin,
@@ -45,12 +45,14 @@ import StyledTextEditor, {
   StyledValidationMessage,
   StyledWrapper,
 } from "./text-editor.style";
-import { SaveCallbackProps } from "./__internal__/plugins/Toolbar/buttons/save.component";
-import { createEmpty } from "./__internal__";
+import { EditorFormattedValues as SaveCallbackProps } from "./__internal__/plugins/Toolbar/buttons/save.component";
+import { createEmpty } from "./utils";
 import HintText from "../../__internal__/hint-text";
 import { filterStyledSystemMarginProps } from "../../style/utils";
 import tagComponent, { TagProps } from "../../__internal__/utils/helpers/tags";
 
+export type EditorFormattedValues = SaveCallbackProps;
+
 export interface TextEditorProps extends MarginProps, TagProps {
   /** The maximum number of characters allowed in the editor */
   characterLimit?: number;
@@ -67,7 +69,7 @@ export interface TextEditorProps extends MarginProps, TagProps {
   /** The callback to fire when the Cancel button within the editor is pressed */
   onCancel?: () => void;
   /** The callback to fire when a change is registered within the editor */
-  onChange?: (value: string) => void;
+  onChange?: (value: string, formattedValues: EditorFormattedValues) => void;
   /** The callback to fire when a link is added into the editor */
   onLinkAdded?: (link: string, state: string) => void;
   /** The callback to fire when the Save button within the editor is pressed */
@@ -135,7 +137,10 @@ export const TextEditor = ({
     const currentTextContent = newState.read(() => $getRoot().getTextContent());
 
     if (onChange) {
-      onChange?.(currentTextContent);
+      const formattedValues = editorRef.current
+        ? SerializeLexical(editorRef.current)
+        : {};
+      onChange?.(currentTextContent, formattedValues);
     }
 
     // If the character limit is set, check if the limit has been exceeded
diff --git a/src/components/text-editor/text-editor.mdx b/src/components/text-editor/text-editor.mdx
index b7a4ff55b..8c3905c08 100644
--- a/src/components/text-editor/text-editor.mdx
+++ b/src/components/text-editor/text-editor.mdx
@@ -85,7 +85,7 @@ the JSON and HTML content of the editor. The `onCancel` function callback will b
 
 To handle changes to the content of the editor, you can set the `onChange` property to a function. The function will be called whenever the
 editor state changes, and provides a string representation of the editor content. This function is debounced by 500ms to prevent excessive
-calls to the callback function.
+calls to the callback function. The second argument passed to the `onChange` callback contains the `htmlString` and `JSON` content of the editor.
 
 <Canvas of={TextEditorStories.OnChange} />
 
diff --git a/src/components/text-editor/text-editor.pw.tsx b/src/components/text-editor/text-editor.pw.tsx
index 848d96463..1fdf289e6 100644
--- a/src/components/text-editor/text-editor.pw.tsx
+++ b/src/components/text-editor/text-editor.pw.tsx
@@ -3,6 +3,7 @@ import { test, expect } from "../../../playwright/helpers/base-test";
 import { checkAccessibility } from "../../../playwright/support/helper";
 
 import TextEditorDefaultComponent from "./components.test-pw";
+import { EditorFormattedValues } from "./text-editor.component";
 
 const preformattedJSON = {
   root: {
@@ -351,11 +352,19 @@ test.describe("Functionality tests", () => {
       mount,
       page,
     }) => {
-      let callbackFired = false;
+      let _value;
+      let _htmlString;
+      let _json;
+
       await mount(
         <TextEditorDefaultComponent
-          onChange={() => {
-            callbackFired = true;
+          onChange={(
+            value: string,
+            { htmlString, json }: EditorFormattedValues,
+          ) => {
+            _value = value;
+            _htmlString = htmlString;
+            _json = json;
           }}
         />,
       );
@@ -369,7 +378,41 @@ test.describe("Functionality tests", () => {
       // eslint-disable-next-line playwright/no-wait-for-timeout
       await page.waitForTimeout(3000);
 
-      expect(callbackFired).toBe(true);
+      expect(_value).toBe(" This is some text");
+      expect(_htmlString).toBe(
+        '<p dir="ltr"><span style="white-space: pre-wrap;"> This is some text</span></p>',
+      );
+      expect(_json).toEqual({
+        root: {
+          children: [
+            {
+              children: [
+                {
+                  detail: 0,
+                  format: 0,
+                  mode: "normal",
+                  style: "",
+                  text: " This is some text",
+                  type: "text",
+                  version: 1,
+                },
+              ],
+              direction: "ltr",
+              format: "",
+              indent: 0,
+              type: "paragraph",
+              version: 1,
+              textFormat: 0,
+              textStyle: "",
+            },
+          ],
+          direction: "ltr",
+          format: "",
+          indent: 0,
+          type: "root",
+          version: 1,
+        },
+      });
     });
   });
 
diff --git a/src/components/text-editor/text-editor.stories.tsx b/src/components/text-editor/text-editor.stories.tsx
index 1bf893966..55f0715fc 100644
--- a/src/components/text-editor/text-editor.stories.tsx
+++ b/src/components/text-editor/text-editor.stories.tsx
@@ -11,8 +11,11 @@ import Typography from "../typography";
 
 import enGB from "../../locales/en-gb";
 
-import TextEditor, { createEmpty, createFromHTML } from ".";
-import { SaveCallbackProps } from "./__internal__/plugins/Toolbar/buttons/save.component";
+import TextEditor, {
+  createEmpty,
+  createFromHTML,
+  EditorFormattedValues,
+} from ".";
 import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props";
 
 const styledSystemProps = generateStyledSystemProps({
@@ -101,15 +104,27 @@ export const CommandButtons: Story = () => {
 CommandButtons.storyName = "Command Buttons";
 
 export const OnChange: Story = () => {
-  const [state, setState] = React.useState<string | undefined>(undefined);
+  const [valueString, setValueString] = React.useState<string | undefined>(
+    undefined,
+  );
+  const [valueHTML, setValueHTML] = React.useState<string | undefined>(
+    undefined,
+  );
   return (
     <>
       <TextEditor
         namespace="storybook-onchange"
         labelText="Text Editor"
-        onChange={setState}
+        onChange={(value, { htmlString }) => {
+          setValueString(value);
+          setValueHTML(htmlString);
+        }}
       />
-      <div>Content: {state || "No content"}</div>
+      <div>Unformatted content: {valueString || "No content"}</div>
+      <div>
+        HTML formatted content:{" "}
+        {valueHTML === "<p><br></p>" ? "No content" : valueHTML}
+      </div>
     </>
   );
 };
@@ -119,7 +134,7 @@ OnChange.parameters = {
 };
 
 export const OnSave: Story = () => {
-  const [data, setData] = useState<SaveCallbackProps>({
+  const [data, setData] = useState<EditorFormattedValues>({
     htmlString: "<p><br></p>",
     json: undefined,
   });
diff --git a/src/components/text-editor/__internal__/utils.ts b/src/components/text-editor/utils.ts
similarity index 90%
rename from src/components/text-editor/__internal__/utils.ts
rename to src/components/text-editor/utils.ts
index 420d4f4c4..2b0d10532 100644
--- a/src/components/text-editor/__internal__/utils.ts
+++ b/src/components/text-editor/utils.ts
@@ -1,4 +1,4 @@
-import { DeserializeHTML } from "./helpers";
+import { DeserializeHTML } from "./__internal__/helpers";
 
 const createFromHTML = (html: string) => {
   // DeserializeHTML is tested as part of the helper tests


Test patch:
diff --git a/src/components/text-editor/text-editor-test.stories.tsx b/src/components/text-editor/text-editor-test.stories.tsx
index 89c209a72..28afc6d0c 100644
--- a/src/components/text-editor/text-editor-test.stories.tsx
+++ b/src/components/text-editor/text-editor-test.stories.tsx
@@ -2,13 +2,16 @@
 import { Meta, StoryObj } from "@storybook/react";
 import React, { useCallback, useEffect, useState } from "react";
 
-import TextEditor, { createFromHTML, TextEditorProps } from ".";
+import TextEditor, {
+  createFromHTML,
+  TextEditorProps,
+  EditorFormattedValues,
+} from ".";
 import Box from "../box";
 import Typography from "../typography";
 
 import useDebounce from "../../hooks/__internal__/useDebounce";
 import ReadOnlyEditor from "./__internal__";
-import { SaveCallbackProps } from "./__internal__/plugins/Toolbar/buttons/save.component";
 
 const meta: Meta<typeof TextEditor> = {
   title: "Text Editor/Test",
@@ -55,9 +58,12 @@ export const Functions = ({ ...props }: Partial<TextEditorProps>) => {
   const handleCancel = useCallback(() => {
     console.log("Cancel");
   }, []);
-  const handleSave = useCallback(({ htmlString, json }: SaveCallbackProps) => {
-    console.log("Save", { htmlString, json });
-  }, []);
+  const handleSave = useCallback(
+    ({ htmlString, json }: EditorFormattedValues) => {
+      console.log("Save", { htmlString, json });
+    },
+    [],
+  );
   const handleLinkAdded = useCallback((value: string) => {
     console.log("Link Added", value);
   }, []);
@@ -122,3 +128,39 @@ export const WithMargin: Story = () => {
   );
 };
 WithMargin.storyName = "With Margin";
+
+export const OnChangeFormattedValues: Story = () => {
+  const [valueJSON, setValueJSON] = React.useState<string | undefined>(
+    undefined,
+  );
+  const [valueHTML, setValueHTML] = React.useState<string | undefined>(
+    undefined,
+  );
+  return (
+    <>
+      <TextEditor
+        namespace="storybook-onchange-formatted-values"
+        labelText="Text Editor"
+        onChange={(_, { htmlString, json }) => {
+          setValueJSON(JSON.stringify(json, null, 2));
+          setValueHTML(htmlString);
+        }}
+      />
+      <div>
+        <b>JSON formatted content:</b>
+        <br /> {valueJSON}
+      </div>
+      <br />
+      <br />
+      <div>
+        <b>HTML formatted content:</b>
+        <br />
+        {valueHTML}
+      </div>
+    </>
+  );
+};
+OnChangeFormattedValues.storyName = "Change Handler With Formatted Values";
+OnChangeFormattedValues.parameters = {
+  chromatic: { disableSnapshot: true },
+};