From 7b1598e5af6ae8a294a4305705bcf65dbc62b167 Mon Sep 17 00:00:00 2001 From: Canopix Date: Thu, 5 Jun 2025 13:06:02 -0300 Subject: [PATCH] Add Rich Text editor and data type --- .gitignore | 1 - package.json | 10 + public/assets/icons/arrow-clockwise.svg | 4 + .../assets/icons/arrow-counterclockwise.svg | 4 + public/assets/icons/chat-square-quote.svg | 4 + public/assets/icons/chevron-down.svg | 3 + public/assets/icons/code.svg | 3 + public/assets/icons/journal-code.svg | 5 + public/assets/icons/journal-text.svg | 5 + public/assets/icons/justify.svg | 3 + public/assets/icons/link.svg | 4 + public/assets/icons/list-ol.svg | 4 + public/assets/icons/list-ul.svg | 3 + public/assets/icons/pencil-fill.svg | 3 + public/assets/icons/text-center.svg | 3 + public/assets/icons/text-left.svg | 3 + public/assets/icons/text-paragraph.svg | 3 + public/assets/icons/text-right.svg | 3 + public/assets/icons/type-bold.svg | 3 + public/assets/icons/type-h1.svg | 3 + public/assets/icons/type-h2.svg | 3 + public/assets/icons/type-h3.svg | 3 + public/assets/icons/type-italic.svg | 3 + public/assets/icons/type-strikethrough.svg | 3 + public/assets/icons/type-underline.svg | 3 + src/components/container-form/attribute.tsx | 4 + src/components/container-form/messages.ts | 5 + .../custom-object-form/attribute-input.tsx | 22 + .../custom-object-form/editor-styles.css | 757 ++++++++++++++++++ .../lexical-editor-field.tsx | 119 +++ .../plugins/AutoLinkPlugin.tsx | 44 + .../plugins/CodeHighlightPlugin.tsx | 13 + .../plugins/ListMaxIndentLevelPlugin.tsx | 75 ++ .../custom-object-form/plugins/SvgIcon.tsx | 26 + .../plugins/ToolbarPlugin.tsx | 642 +++++++++++++++ .../plugins/TreeViewPlugin.tsx | 19 + .../custom-object-form/themes/ExampleTheme.ts | 69 ++ src/constants.ts | 2 + src/globals.d.ts | 8 + yarn.lock | 492 +++++++++++- 40 files changed, 2383 insertions(+), 3 deletions(-) create mode 100644 public/assets/icons/arrow-clockwise.svg create mode 100644 public/assets/icons/arrow-counterclockwise.svg create mode 100644 public/assets/icons/chat-square-quote.svg create mode 100644 public/assets/icons/chevron-down.svg create mode 100644 public/assets/icons/code.svg create mode 100644 public/assets/icons/journal-code.svg create mode 100644 public/assets/icons/journal-text.svg create mode 100644 public/assets/icons/justify.svg create mode 100644 public/assets/icons/link.svg create mode 100644 public/assets/icons/list-ol.svg create mode 100644 public/assets/icons/list-ul.svg create mode 100644 public/assets/icons/pencil-fill.svg create mode 100644 public/assets/icons/text-center.svg create mode 100644 public/assets/icons/text-left.svg create mode 100644 public/assets/icons/text-paragraph.svg create mode 100644 public/assets/icons/text-right.svg create mode 100644 public/assets/icons/type-bold.svg create mode 100644 public/assets/icons/type-h1.svg create mode 100644 public/assets/icons/type-h2.svg create mode 100644 public/assets/icons/type-h3.svg create mode 100644 public/assets/icons/type-italic.svg create mode 100644 public/assets/icons/type-strikethrough.svg create mode 100644 public/assets/icons/type-underline.svg create mode 100644 src/components/custom-object-form/editor-styles.css create mode 100644 src/components/custom-object-form/lexical-editor-field.tsx create mode 100644 src/components/custom-object-form/plugins/AutoLinkPlugin.tsx create mode 100644 src/components/custom-object-form/plugins/CodeHighlightPlugin.tsx create mode 100644 src/components/custom-object-form/plugins/ListMaxIndentLevelPlugin.tsx create mode 100644 src/components/custom-object-form/plugins/SvgIcon.tsx create mode 100644 src/components/custom-object-form/plugins/ToolbarPlugin.tsx create mode 100644 src/components/custom-object-form/plugins/TreeViewPlugin.tsx create mode 100644 src/components/custom-object-form/themes/ExampleTheme.ts diff --git a/.gitignore b/.gitignore index 1b51f32..44fa69d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ node_modules/ dist/ coverage/ -public/ csp.json env.json .env.local diff --git a/package.json b/package.json index 750d919..355e9fa 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@commercetools-uikit/data-table": "^18.4.0", "@commercetools-uikit/date-input": "^18.4.0", "@commercetools-uikit/date-time-input": "^18.4.0", + "@commercetools-uikit/dropdown-menu": "^20.0.0", "@commercetools-uikit/flat-button": "^18.4.0", "@commercetools-uikit/grid": "^18.4.0", "@commercetools-uikit/hooks": "^18.4.0", @@ -65,6 +66,14 @@ "@dnd-kit/sortable": "^10.0.0", "@formatjs/cli": "^6.2.7", "@jest/types": "27.5.1", + "@lexical/code": "^0.32.1", + "@lexical/history": "^0.32.1", + "@lexical/html": "^0.32.1", + "@lexical/link": "^0.32.1", + "@lexical/list": "^0.32.1", + "@lexical/markdown": "^0.32.1", + "@lexical/react": "^0.32.1", + "@lexical/rich-text": "^0.32.1", "@manypkg/cli": "^0.21.3", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.5", @@ -80,6 +89,7 @@ "jest": "27.5.1", "jest-runner-eslint": "^2.2.0", "jest-watch-typeahead": "1.1.0", + "lexical": "^0.32.1", "lodash": "^4.17.21", "lodash.omit": "4.5.0", "msw": "^2.2.3", diff --git a/public/assets/icons/arrow-clockwise.svg b/public/assets/icons/arrow-clockwise.svg new file mode 100644 index 0000000..b072eb0 --- /dev/null +++ b/public/assets/icons/arrow-clockwise.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/assets/icons/arrow-counterclockwise.svg b/public/assets/icons/arrow-counterclockwise.svg new file mode 100644 index 0000000..b0b23b9 --- /dev/null +++ b/public/assets/icons/arrow-counterclockwise.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/assets/icons/chat-square-quote.svg b/public/assets/icons/chat-square-quote.svg new file mode 100644 index 0000000..40893f4 --- /dev/null +++ b/public/assets/icons/chat-square-quote.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/assets/icons/chevron-down.svg b/public/assets/icons/chevron-down.svg new file mode 100644 index 0000000..1f0b8bc --- /dev/null +++ b/public/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/code.svg b/public/assets/icons/code.svg new file mode 100644 index 0000000..079f5c6 --- /dev/null +++ b/public/assets/icons/code.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/journal-code.svg b/public/assets/icons/journal-code.svg new file mode 100644 index 0000000..82098b9 --- /dev/null +++ b/public/assets/icons/journal-code.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/assets/icons/journal-text.svg b/public/assets/icons/journal-text.svg new file mode 100644 index 0000000..9b66f43 --- /dev/null +++ b/public/assets/icons/journal-text.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/assets/icons/justify.svg b/public/assets/icons/justify.svg new file mode 100644 index 0000000..009bd72 --- /dev/null +++ b/public/assets/icons/justify.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/link.svg b/public/assets/icons/link.svg new file mode 100644 index 0000000..df35bc8 --- /dev/null +++ b/public/assets/icons/link.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/assets/icons/list-ol.svg b/public/assets/icons/list-ol.svg new file mode 100644 index 0000000..5782568 --- /dev/null +++ b/public/assets/icons/list-ol.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/assets/icons/list-ul.svg b/public/assets/icons/list-ul.svg new file mode 100644 index 0000000..217d153 --- /dev/null +++ b/public/assets/icons/list-ul.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/pencil-fill.svg b/public/assets/icons/pencil-fill.svg new file mode 100644 index 0000000..59d2830 --- /dev/null +++ b/public/assets/icons/pencil-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/text-center.svg b/public/assets/icons/text-center.svg new file mode 100644 index 0000000..2887a99 --- /dev/null +++ b/public/assets/icons/text-center.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/text-left.svg b/public/assets/icons/text-left.svg new file mode 100644 index 0000000..0452611 --- /dev/null +++ b/public/assets/icons/text-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/text-paragraph.svg b/public/assets/icons/text-paragraph.svg new file mode 100644 index 0000000..9779bea --- /dev/null +++ b/public/assets/icons/text-paragraph.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/text-right.svg b/public/assets/icons/text-right.svg new file mode 100644 index 0000000..34686b0 --- /dev/null +++ b/public/assets/icons/text-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/type-bold.svg b/public/assets/icons/type-bold.svg new file mode 100644 index 0000000..276d133 --- /dev/null +++ b/public/assets/icons/type-bold.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/type-h1.svg b/public/assets/icons/type-h1.svg new file mode 100644 index 0000000..4c89181 --- /dev/null +++ b/public/assets/icons/type-h1.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/type-h2.svg b/public/assets/icons/type-h2.svg new file mode 100644 index 0000000..b6ab765 --- /dev/null +++ b/public/assets/icons/type-h2.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/type-h3.svg b/public/assets/icons/type-h3.svg new file mode 100644 index 0000000..154c293 --- /dev/null +++ b/public/assets/icons/type-h3.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/type-italic.svg b/public/assets/icons/type-italic.svg new file mode 100644 index 0000000..3ac6b09 --- /dev/null +++ b/public/assets/icons/type-italic.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/type-strikethrough.svg b/public/assets/icons/type-strikethrough.svg new file mode 100644 index 0000000..1c940e4 --- /dev/null +++ b/public/assets/icons/type-strikethrough.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/type-underline.svg b/public/assets/icons/type-underline.svg new file mode 100644 index 0000000..c299b8b --- /dev/null +++ b/public/assets/icons/type-underline.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/container-form/attribute.tsx b/src/components/container-form/attribute.tsx index 4fedb04..7f55143 100644 --- a/src/components/container-form/attribute.tsx +++ b/src/components/container-form/attribute.tsx @@ -65,6 +65,10 @@ const typeOptions = [ label: , value: TYPES.Reference, }, + { + label: , + value: TYPES.RichText, + }, ]; type Props = { diff --git a/src/components/container-form/messages.ts b/src/components/container-form/messages.ts index 28f25d0..f1aaa7e 100644 --- a/src/components/container-form/messages.ts +++ b/src/components/container-form/messages.ts @@ -187,4 +187,9 @@ export default defineMessages({ description: 'The error message for required fields', defaultMessage: 'This field is required. Provide a value.', }, + richTextLabel: { + id: 'Container.form.type.label.richText', + description: 'Label for attributes rich text value', + defaultMessage: 'Rich Text', + }, }); diff --git a/src/components/custom-object-form/attribute-input.tsx b/src/components/custom-object-form/attribute-input.tsx index 9c0b6ca..c071f7a 100644 --- a/src/components/custom-object-form/attribute-input.tsx +++ b/src/components/custom-object-form/attribute-input.tsx @@ -18,6 +18,7 @@ import Spacings from '@commercetools-uikit/spacings'; import { TYPES } from '../../constants'; import nestedStyles from '../container-form/nested-attributes.module.css'; import AttributeField from './attribute-field'; // eslint-disable-line import/no-cycle +import LexicalEditorField from './lexical-editor-field'; type Props = { type: string; @@ -254,6 +255,27 @@ const AttributeInput: FC = ({ ); } + case TYPES.RichText: + return ( + + { + onChange({ target: { name: fieldName, value: newValue } }); + }} + onBlur={() => { + if (onBlur) { + onBlur({ target: { name } }); + } + }} + /> + {touched && errors && ( + {errors} + )} + + ); + case TYPES.Object: return (
diff --git a/src/components/custom-object-form/editor-styles.css b/src/components/custom-object-form/editor-styles.css new file mode 100644 index 0000000..25eae6a --- /dev/null +++ b/src/components/custom-object-form/editor-styles.css @@ -0,0 +1,757 @@ +body { + margin: 0; + background: #eee; + font-family: system-ui, -apple-system, BlinkMacSystemFont, ".SFNSText-Regular", + sans-serif; + font-weight: 500; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.other h2 { + font-size: 18px; + color: #444; + margin-bottom: 7px; +} + +.other a { + color: #777; + text-decoration: underline; + font-size: 14px; +} + +.other ul { + padding: 0; + margin: 0; + list-style-type: none; +} + +.App { + font-family: sans-serif; + text-align: center; +} + +h1 { + font-size: 24px; + color: #333; +} + +.ltr { + text-align: left; +} + +.rtl { + text-align: right; +} + +.editor-container { + margin: 20px auto 20px auto; + border-radius: 2px; + max-width: 100%; + width: 100%; + color: #000; + position: relative; + line-height: 20px; + font-weight: 400; + text-align: left; + border-top-left-radius: 10px; + border-top-right-radius: 10px; +} + +.editor-inner { + background: #fff; + position: relative; +} + +.editor-input { + min-height: 150px; + resize: none; + font-size: 15px; + caret-color: rgb(5, 5, 5); + position: relative; + tab-size: 1; + outline: 0; + padding: 15px 10px; + caret-color: #444; +} + +.editor-placeholder { + color: #999; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 15px; + left: 10px; + font-size: 15px; + user-select: none; + display: inline-block; + pointer-events: none; +} + +.editor-text-bold { + font-weight: bold; +} + +.editor-text-italic { + font-style: italic; +} + +.editor-text-underline { + text-decoration: underline; +} + +.editor-text-strikethrough { + text-decoration: line-through; +} + +.editor-text-underlineStrikethrough { + text-decoration: underline line-through; +} + +.editor-text-code { + background-color: rgb(240, 242, 245); + padding: 1px 0.25rem; + font-family: Menlo, Consolas, Monaco, monospace; + font-size: 94%; +} + +.editor-link { + color: rgb(33, 111, 219); + text-decoration: none; +} + +.tree-view-output { + display: block; + background: #222; + color: #fff; + padding: 5px; + font-size: 12px; + white-space: pre-wrap; + margin: 1px auto 10px auto; + max-height: 250px; + position: relative; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + overflow: auto; + line-height: 14px; +} + +.editor-code { + background-color: rgb(240, 242, 245); + font-family: Menlo, Consolas, Monaco, monospace; + display: block; + padding: 8px 8px 8px 52px; + line-height: 1.53; + font-size: 13px; + margin: 0; + margin-top: 8px; + margin-bottom: 8px; + tab-size: 2; + /* white-space: pre; */ + overflow-x: auto; + position: relative; +} + +.editor-code:before { + content: attr(data-gutter); + position: absolute; + background-color: #eee; + left: 0; + top: 0; + border-right: 1px solid #ccc; + padding: 8px; + color: #777; + white-space: pre-wrap; + text-align: right; + min-width: 25px; +} +.editor-code:after { + content: attr(data-highlight-language); + top: 0; + right: 3px; + padding: 3px; + font-size: 10px; + text-transform: uppercase; + position: absolute; + color: rgba(0, 0, 0, 0.5); +} + +.editor-tokenComment { + color: slategray; +} + +.editor-tokenPunctuation { + color: #999; +} + +.editor-tokenProperty { + color: #905; +} + +.editor-tokenSelector { + color: #690; +} + +.editor-tokenOperator { + color: #9a6e3a; +} + +.editor-tokenAttr { + color: #07a; +} + +.editor-tokenVariable { + color: #e90; +} + +.editor-tokenFunction { + color: #dd4a68; +} + +.editor-paragraph { + margin: 0; + margin-bottom: 8px; + position: relative; +} + +.editor-paragraph:last-child { + margin-bottom: 0; +} + +.editor-heading-h1 { + font-size: 24px; + color: rgb(5, 5, 5); + font-weight: 400; + margin: 0; + margin-bottom: 12px; + padding: 0; +} + +.editor-heading-h2 { + font-size: 15px; + color: rgb(101, 103, 107); + font-weight: 700; + margin: 0; + margin-top: 10px; + padding: 0; + text-transform: uppercase; +} + +.editor-quote { + margin: 0; + margin-left: 20px; + font-size: 15px; + color: rgb(101, 103, 107); + border-left-color: rgb(206, 208, 212); + border-left-width: 4px; + border-left-style: solid; + padding-left: 16px; +} + +.editor-list-ol { + padding: 0; + margin: 0; + margin-left: 16px; +} + +.editor-list-ul { + padding: 0; + margin: 0; + margin-left: 16px; +} + +.editor-listitem { + margin: 8px 32px 8px 32px; +} + +.editor-nested-listitem { + list-style-type: none; +} + +pre::-webkit-scrollbar { + background: transparent; + width: 10px; +} + +pre::-webkit-scrollbar-thumb { + background: #999; +} + +.debug-timetravel-panel { + overflow: hidden; + padding: 0 0 10px 0; + margin: auto; + display: flex; +} + +.debug-timetravel-panel-slider { + padding: 0; + flex: 8; +} + +.debug-timetravel-panel-button { + padding: 0; + border: 0; + background: none; + flex: 1; + color: #fff; + font-size: 12px; +} + +.debug-timetravel-panel-button:hover { + text-decoration: underline; +} + +.debug-timetravel-button { + border: 0; + padding: 0; + font-size: 12px; + top: 10px; + right: 15px; + position: absolute; + background: none; + color: #fff; +} + +.debug-timetravel-button:hover { + text-decoration: underline; +} + +.emoji { + color: transparent; + background-size: 16px 16px; + background-position: center; + background-repeat: no-repeat; + vertical-align: middle; + margin: 0 -1px; +} + +.emoji-inner { + padding: 0 0.15em; +} + +.emoji-inner::selection { + color: transparent; + background-color: rgba(150, 150, 150, 0.4); +} + +.emoji-inner::moz-selection { + color: transparent; + background-color: rgba(150, 150, 150, 0.4); +} + +.emoji.happysmile { + /* background-image: url(./images/emoji/1F642.png); */ /* Path needs adjustment if images are used */ +} + +.toolbar { + display: flex; + margin-bottom: 1px; + background: #fff; + padding: 4px; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + vertical-align: middle; +} + +.toolbar button.toolbar-item { + border: 0; + display: flex; + background: none; + border-radius: 10px; + padding: 8px; + cursor: pointer; + vertical-align: middle; +} + +.toolbar button.toolbar-item:disabled { + cursor: not-allowed; +} + +.toolbar button.toolbar-item.spaced { + margin-right: 2px; +} + +.toolbar button.toolbar-item i.format { + background-size: contain; + display: inline-block; + height: 18px; + width: 18px; + margin-top: 2px; + vertical-align: -0.25em; + display: flex; + opacity: 0.6; +} + +.toolbar button.toolbar-item:disabled i.format { + opacity: 0.2; +} + +.toolbar button.toolbar-item.active { + background-color: rgba(223, 232, 250, 0.3); +} + +.toolbar button.toolbar-item.active i { + opacity: 1; +} + +.toolbar .toolbar-item:hover:not([disabled]) { + background-color: #eee; +} + +.toolbar .divider { + width: 1px; + background-color: #eee; + margin: 0 4px; +} + +.toolbar select.toolbar-item { + border: 0; + display: flex; + background: none; + border-radius: 10px; + padding: 8px; + vertical-align: middle; + -webkit-appearance: none; + -moz-appearance: none; + width: 70px; + font-size: 14px; + color: #777; + text-overflow: ellipsis; +} + +.toolbar select.code-language { + text-transform: capitalize; + width: 130px; +} + +.toolbar .toolbar-item .text { + display: flex; + line-height: 20px; + width: 200px; + vertical-align: middle; + font-size: 14px; + color: #777; + text-overflow: ellipsis; + width: 70px; + overflow: hidden; + height: 20px; + text-align: left; +} + +.toolbar .toolbar-item .icon { + display: flex; + width: 20px; + height: 20px; + user-select: none; + margin-right: 8px; + line-height: 16px; + background-size: contain; +} + +.toolbar i.chevron-down { + margin-top: 3px; + width: 16px; + height: 16px; + display: flex; + user-select: none; + /* background-image: url(images/icons/chevron-down.svg); */ /* Path needs adjustment - REMOVED */ +} + +.toolbar i.chevron-down.inside { + width: 16px; + height: 16px; + display: flex; + margin-left: -25px; + margin-top: 11px; + margin-right: 10px; + pointer-events: none; +} + +i.chevron-down { + background-color: transparent; + background-size: contain; + display: inline-block; + height: 8px; + width: 8px; + /* background-image: url(images/icons/chevron-down.svg); */ /* Path needs adjustment - REMOVED */ +} + +#block-controls button:hover { + background-color: #efefef; +} + +#block-controls button:focus-visible { + border-color: blue; +} + +#block-controls span.block-type { + background-size: contain; + display: block; + width: 18px; + height: 18px; + margin: 2px; +} + +/* Commenting out icon paths, as they are not available in this project */ +/* #block-controls span.block-type.paragraph { + background-image: url(images/icons/text-paragraph.svg); +} + +#block-controls span.block-type.h1 { + background-image: url(images/icons/type-h1.svg); +} + +#block-controls span.block-type.h2 { + background-image: url(images/icons/type-h2.svg); +} + +#block-controls span.block-type.quote { + background-image: url(images/icons/chat-square-quote.svg); +} + +#block-controls span.block-type.ul { + background-image: url(images/icons/list-ul.svg); +} + +#block-controls span.block-type.ol { + background-image: url(images/icons/list-ol.svg); +} + +#block-controls span.block-type.code { + background-image: url(images/icons/code.svg); +} */ + +.dropdown { + z-index: 5; + display: block; + position: absolute; + box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1), + inset 0 0 0 1px rgba(255, 255, 255, 0.5); + border-radius: 8px; + min-width: 100px; + min-height: 40px; + background-color: #fff; +} + +.dropdown .item { + margin: 0 8px 0 8px; + padding: 8px; + color: #050505; + cursor: pointer; + line-height: 16px; + font-size: 15px; + display: flex; + align-content: center; + flex-direction: row; + flex-shrink: 0; + justify-content: space-between; + background-color: #fff; + border-radius: 8px; + border: 0; + min-width: 268px; +} + +.dropdown .item .active { + display: flex; + width: 20px; + height: 20px; + background-size: contain; +} + +.dropdown .item:first-child { + margin-top: 8px; +} + +.dropdown .item:last-child { + margin-bottom: 8px; +} + +.dropdown .item:hover { + background-color: #eee; +} + +.dropdown .item .text { + display: flex; + line-height: 20px; + flex-grow: 1; + width: 200px; +} + +.dropdown .item .icon { + display: flex; + width: 20px; + height: 20px; + user-select: none; + margin-right: 12px; + line-height: 16px; + background-size: contain; +} + +.link-editor { + position: absolute; + z-index: 100; + top: -10000px; + left: -10000px; + margin-top: -6px; + max-width: 300px; + width: 100%; + opacity: 0; + background-color: #fff; + box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3); + border-radius: 8px; + transition: opacity 0.5s; +} + +.link-editor .link-input { + display: block; + width: calc(100% - 24px); + box-sizing: border-box; + margin: 8px 12px; + padding: 8px 12px; + border-radius: 15px; + background-color: #eee; + font-size: 15px; + color: rgb(5, 5, 5); + border: 0; + outline: 0; + position: relative; + font-family: inherit; +} + +.link-editor div.link-edit { + /* background-image: url(images/icons/pencil-fill.svg); */ /* Path needs adjustment - REMOVED */ + background-size: 16px; + background-position: center; + background-repeat: no-repeat; + width: 35px; + vertical-align: -0.25em; + position: absolute; + right: 0; + top: 0; + bottom: 0; + cursor: pointer; +} + +.link-editor .link-input a { + color: rgb(33, 111, 219); + text-decoration: none; + display: block; + white-space: nowrap; + overflow: hidden; + margin-right: 30px; + text-overflow: ellipsis; +} + +.link-editor .link-input a:hover { + text-decoration: underline; +} + +.link-editor .button { + width: 20px; + height: 20px; + display: inline-block; + padding: 6px; + border-radius: 8px; + cursor: pointer; + margin: 0 2px; +} + +.link-editor .button.hovered { + width: 20px; + height: 20px; + display: inline-block; + background-color: #eee; +} + +.link-editor .button i, +.actions i { + background-size: contain; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: -0.25em; +} + +/* Icon classes - commenting out as image paths are not available */ +/* +i.undo { + background-image: url(images/icons/arrow-counterclockwise.svg); +} + +i.redo { + background-image: url(images/icons/arrow-clockwise.svg); +} + +.icon.paragraph { + background-image: url(images/icons/text-paragraph.svg); +} + +.icon.large-heading, +.icon.h1 { + background-image: url(images/icons/type-h1.svg); +} + +.icon.small-heading, +.icon.h2 { + background-image: url(images/icons/type-h2.svg); +} + +.icon.bullet-list, +.icon.ul { + background-image: url(images/icons/list-ul.svg); +} + +.icon.numbered-list, +.icon.ol { + background-image: url(images/icons/list-ol.svg); +} + +.icon.quote { + background-image: url(images/icons/chat-square-quote.svg); +} + +.icon.code { + background-image: url(images/icons/code.svg); +} + +i.bold { + background-image: url(images/icons/type-bold.svg); +} + +i.italic { + background-image: url(images/icons/type-italic.svg); +} + +i.underline { + background-image: url(images/icons/type-underline.svg); +} + +i.strikethrough { + background-image: url(images/icons/type-strikethrough.svg); +} + +i.code { + background-image: url(images/icons/code.svg); +} + +i.link { + background-image: url(images/icons/link.svg); +} + +i.left-align { + background-image: url(images/icons/text-left.svg); +} + +i.center-align { + background-image: url(images/icons/text-center.svg); +} + +i.right-align { + background-image: url(images/icons/text-right.svg); +} + +i.justify-align { + background-image: url(images/icons/justify.svg); +} +*/ \ No newline at end of file diff --git a/src/components/custom-object-form/lexical-editor-field.tsx b/src/components/custom-object-form/lexical-editor-field.tsx new file mode 100644 index 0000000..e3ad061 --- /dev/null +++ b/src/components/custom-object-form/lexical-editor-field.tsx @@ -0,0 +1,119 @@ +import { FC, ReactNode, ReactElement } from 'react'; +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { EditorState, LexicalEditor } from 'lexical'; +import { HeadingNode, QuoteNode } from '@lexical/rich-text'; +import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; +import { ListItemNode, ListNode } from '@lexical/list'; +import { CodeHighlightNode, CodeNode } from '@lexical/code'; +import { AutoLinkNode, LinkNode } from '@lexical/link'; +import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; +import { ListPlugin } from '@lexical/react/LexicalListPlugin'; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TRANSFORMERS } from "@lexical/markdown"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; + +import ExampleTheme from "./themes/ExampleTheme"; +import ToolbarPlugin from "./plugins/ToolbarPlugin"; +import TreeViewPlugin from "./plugins/TreeViewPlugin"; +import ListMaxIndentLevelPlugin from "./plugins/ListMaxIndentLevelPlugin"; +import CodeHighlightPlugin from "./plugins/CodeHighlightPlugin"; +import AutoLinkPlugin from "./plugins/AutoLinkPlugin"; + +import './editor-styles.css'; // Import the new styles + +// Simple Loading Component for RichTextPlugin ErrorBoundary +const LoadingComponent: FC = () => { + return
Loading...
; +}; + +function Placeholder() { + return
Enter some rich text...
; +} + + +type LexicalEditorFieldProps = { + name: string; + initialValue?: string; + onChange: (fieldName: string, value: string) => void; + onBlur?: (fieldName: string, touched: boolean) => void; +}; + +const LexicalEditorField: FC = ({ name, initialValue, onChange, onBlur }) => { + const initialEditorConfig = { + namespace: `LexicalEditor-${name}`, + theme: ExampleTheme, + onError: (error: Error) => { + console.error("Lexical Error:", error); + // Potentially delegate to a global error handler or display a user-facing message + throw error; + }, + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + CodeHighlightNode, + TableNode, + TableCellNode, + TableRowNode, + AutoLinkNode, + LinkNode + ], + editorState: initialValue + ? (editor: LexicalEditor) => { + try { + const parsedEditorState = editor.parseEditorState(initialValue); + editor.setEditorState(parsedEditorState); + } catch (e) { + // If JSON is invalid or not an editor state, Lexical will start with an empty state. + console.warn("Invalid initial value for editor", e) + } + } + : undefined, + }; + + const handleLexicalChange = (editorState: EditorState) => { + const jsonString = JSON.stringify(editorState.toJSON()); + onChange(name, jsonString); + }; + + const handleLexicalBlur = () => { + if (onBlur) { + onBlur(name, true); + } + }; + + + return ( + +
+ +
+ } + placeholder={} + ErrorBoundary={LoadingComponent} + /> + {/* + */} + + + + + + + + +
+
+
+ ); +}; + +export default LexicalEditorField; \ No newline at end of file diff --git a/src/components/custom-object-form/plugins/AutoLinkPlugin.tsx b/src/components/custom-object-form/plugins/AutoLinkPlugin.tsx new file mode 100644 index 0000000..cf17b18 --- /dev/null +++ b/src/components/custom-object-form/plugins/AutoLinkPlugin.tsx @@ -0,0 +1,44 @@ +import { AutoLinkPlugin as LexicalAutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin"; +import { FC } from "react"; + +// Define matchers with RegExp literals +const URL_MATCHER = new RegExp( + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/ +); + +const EMAIL_MATCHER = new RegExp( + /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ +); + +const MATCHERS = [ + (text: string) => { + const match = URL_MATCHER.exec(text); + if (match === null) { + return null; + } + return { + index: match.index, + length: match[0].length, + text: match[0], + url: match[0], + }; + }, + (text: string) => { + const match = EMAIL_MATCHER.exec(text); + if (match === null) { + return null; + } + return { + index: match.index, + length: match[0].length, + text: match[0], + url: `mailto:${match[0]}`, + }; + }, +]; + +const AutoLinkPlugin: FC = () => { + return ; +} + +export default AutoLinkPlugin; \ No newline at end of file diff --git a/src/components/custom-object-form/plugins/CodeHighlightPlugin.tsx b/src/components/custom-object-form/plugins/CodeHighlightPlugin.tsx new file mode 100644 index 0000000..b9946fe --- /dev/null +++ b/src/components/custom-object-form/plugins/CodeHighlightPlugin.tsx @@ -0,0 +1,13 @@ +import { registerCodeHighlighting } from "@lexical/code"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { FC, useEffect } from "react"; + +const CodeHighlightPlugin: FC = () => { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + return null; +} + +export default CodeHighlightPlugin; \ No newline at end of file diff --git a/src/components/custom-object-form/plugins/ListMaxIndentLevelPlugin.tsx b/src/components/custom-object-form/plugins/ListMaxIndentLevelPlugin.tsx new file mode 100644 index 0000000..b81b194 --- /dev/null +++ b/src/components/custom-object-form/plugins/ListMaxIndentLevelPlugin.tsx @@ -0,0 +1,75 @@ +import { $getListDepth, $isListItemNode, $isListNode } from "@lexical/list"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $getSelection, + $isElementNode, + $isRangeSelection, + INDENT_CONTENT_COMMAND, + COMMAND_PRIORITY_HIGH, + LexicalEditor, + RangeSelection, + ElementNode +} from "lexical"; +import { FC, useEffect } from "react"; + +function getElementNodesInSelection(selection: RangeSelection): Set { + const nodesInSelection = selection.getNodes(); + + if (nodesInSelection.length === 0) { + return new Set([ + selection.anchor.getNode().getParentOrThrow(), + selection.focus.getNode().getParentOrThrow() + ]); + } + + return new Set( + nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())) + ); +} + +function isIndentPermitted(maxDepth: number): boolean { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return false; + } + + const elementNodesInSelection = getElementNodesInSelection(selection); + + let totalDepth = 0; + + for (const elementNode of elementNodesInSelection) { + if ($isListNode(elementNode)) { + totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth); + } else if ($isListItemNode(elementNode)) { + const parent = elementNode.getParent(); + if (!$isListNode(parent)) { + throw new Error( + "ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent." + ); + } + totalDepth = Math.max($getListDepth(parent) + 1, totalDepth); + } + } + return totalDepth <= maxDepth; +} + +interface ListMaxIndentLevelPluginProps { + maxDepth?: number; +} + +const ListMaxIndentLevelPlugin: FC = ({ maxDepth }) => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerCommand( + INDENT_CONTENT_COMMAND, + () => !isIndentPermitted(maxDepth ?? 7), + COMMAND_PRIORITY_HIGH + ); + }, [editor, maxDepth]); + + return null; +} + +export default ListMaxIndentLevelPlugin; \ No newline at end of file diff --git a/src/components/custom-object-form/plugins/SvgIcon.tsx b/src/components/custom-object-form/plugins/SvgIcon.tsx new file mode 100644 index 0000000..9b41529 --- /dev/null +++ b/src/components/custom-object-form/plugins/SvgIcon.tsx @@ -0,0 +1,26 @@ +import React, { FC } from 'react'; + +interface SvgIconProps { + iconName: string; + alt?: string; + className?: string; + width?: string | number; + height?: string | number; +} + +const SvgIcon: FC = ({ iconName, alt, className, width = 20, height = 20 }) => { + const iconPath = '/assets/icons/' + iconName + '.svg'; + + return ( + {alt + ); +}; + +export default SvgIcon; \ No newline at end of file diff --git a/src/components/custom-object-form/plugins/ToolbarPlugin.tsx b/src/components/custom-object-form/plugins/ToolbarPlugin.tsx new file mode 100644 index 0000000..a82e06b --- /dev/null +++ b/src/components/custom-object-form/plugins/ToolbarPlugin.tsx @@ -0,0 +1,642 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + REDO_COMMAND, + UNDO_COMMAND, + SELECTION_CHANGE_COMMAND, + FORMAT_TEXT_COMMAND, + FORMAT_ELEMENT_COMMAND, + $getSelection, + $isRangeSelection, + $createParagraphNode, + $getNodeByKey, + LexicalEditor, + RangeSelection, + NodeKey, + EditorState +} from "lexical"; +import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"; +import { + $isParentElementRTL, + $wrapNodes, + $isAtNodeEnd +} from "@lexical/selection"; +import { $getNearestNodeOfType, mergeRegister } from "@lexical/utils"; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + REMOVE_LIST_COMMAND, + $isListNode, + ListNode +} from "@lexical/list"; +import { createPortal } from "react-dom"; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, + HeadingTagType +} from "@lexical/rich-text"; +import { + $createCodeNode, + $isCodeNode, + getDefaultCodeLanguage, + getCodeLanguages +} from "@lexical/code"; +import DropdownMenu from '@commercetools-uikit/dropdown-menu'; +import SvgIcon from './SvgIcon'; + +const LowPriority = 1; + +const supportedBlockTypes = new Set([ + "paragraph", + "quote", + "code", + "h1", + "h2", + "ul", + "ol" +]); + +const blockTypeToBlockName: Record = { + code: "Code Block", + h1: "Large Heading", + h2: "Small Heading", + h3: "Heading", + h4: "Heading", + h5: "Heading", + ol: "Numbered List", + paragraph: "Normal", + quote: "Quote", + ul: "Bulleted List" +}; + +const Divider: FC = () => { + return
; +} + +function positionEditorElement(editor: HTMLElement, rect: DOMRect | null) { + if (rect === null) { + editor.style.opacity = "0"; + editor.style.top = "-1000px"; + editor.style.left = "-1000px"; + } else { + editor.style.opacity = "1"; + editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`; + editor.style.left = `${rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2 + }px`; + } +} + +interface FloatingLinkEditorProps { + editor: LexicalEditor; +} + +const FloatingLinkEditor: FC = ({ editor }) => { + const editorRef = useRef(null); + const inputRef = useRef(null); + const mouseDownRef = useRef(false); + const [linkUrl, setLinkUrl] = useState(""); + const [isEditMode, setEditMode] = useState(false); + const [lastSelection, setLastSelection] = useState(null); + + const updateLinkEditor = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + const parent = node.getParent(); + if ($isLinkNode(parent)) { + setLinkUrl(parent.getURL()); + } else if ($isLinkNode(node)) { + setLinkUrl(node.getURL()); + } else { + setLinkUrl(""); + } + } + const editorElem = editorRef.current; + const nativeSelection = window.getSelection(); + const activeElement = document.activeElement; + + if (editorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + if ( + selection !== null && + nativeSelection && + !nativeSelection.isCollapsed && + rootElement !== null && + rootElement.contains(nativeSelection.anchorNode) + ) { + const domRange = nativeSelection.getRangeAt(0); + let rect; + if (nativeSelection.anchorNode === rootElement) { + let inner = rootElement; + while (inner.firstElementChild != null) { + inner = inner.firstElementChild as HTMLElement; + } + rect = inner.getBoundingClientRect(); + } else { + rect = domRange.getBoundingClientRect(); + } + + if (!mouseDownRef.current) { + positionEditorElement(editorElem, rect); + } + setLastSelection(selection as RangeSelection | null); + } else if (!activeElement || activeElement.className !== "link-input") { + positionEditorElement(editorElem, null); + setLastSelection(null); + setEditMode(false); + setLinkUrl(""); + } + + return true; + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }: { editorState: EditorState }) => { + editorState.read(() => { + updateLinkEditor(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateLinkEditor(); + return true; + }, + LowPriority + ) + ); + }, [editor, updateLinkEditor]); + + useEffect(() => { + editor.getEditorState().read(() => { + updateLinkEditor(); + }); + }, [editor, updateLinkEditor]); + + useEffect(() => { + if (isEditMode && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditMode]); + + return ( +
+ {isEditMode ? ( + { + setLinkUrl(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (lastSelection !== null) { + if (linkUrl !== "") { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl); + } + setEditMode(false); + } + } else if (event.key === "Escape") { + event.preventDefault(); + setEditMode(false); + } + }} + /> + ) : ( + <> +
+ + {linkUrl} + +
event.preventDefault()} + onClick={() => { + setEditMode(true); + }} + /> +
+ + )} +
+ ); +} + +interface SelectProps { + onChange: (event: React.ChangeEvent) => void; + className: string; + options: string[]; + value: string; +} + +const Select: FC = ({ onChange, className, options, value }) => { + return ( + + ); +} + +function getSelectedNode(selection: RangeSelection) { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) { + return anchorNode; + } + const isBackward = selection.isBackward(); + if (isBackward) { + return $isAtNodeEnd(focus) ? anchorNode : focusNode; + } else { + return $isAtNodeEnd(anchor) ? focusNode : anchorNode; + } +} + +const ToolbarPlugin: FC = () => { + const [editor] = useLexicalComposerContext(); + const toolbarRef = useRef(null); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [blockType, setBlockType] = useState("paragraph"); + const [selectedElementKey, setSelectedElementKey] = useState(null); + const [codeLanguage, setCodeLanguage] = useState(""); + const [isRTL, setIsRTL] = useState(false); + const [isLink, setIsLink] = useState(false); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isCode, setIsCode] = useState(false); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + const element = + anchorNode.getKey() === "root" + ? anchorNode + : anchorNode.getTopLevelElementOrThrow(); + const elementKey = element.getKey(); + const elementDOM = editor.getElementByKey(elementKey); + if (elementDOM !== null) { + setSelectedElementKey(elementKey); + if ($isListNode(element)) { + const parentList = $getNearestNodeOfType(anchorNode, ListNode); + const type = parentList ? parentList.getTag() : element.getTag(); + setBlockType(type); + } else { + const type = $isHeadingNode(element) + ? element.getTag() + : element.getType(); + setBlockType(type); + if ($isCodeNode(element)) { + setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage()); + } + } + } + // Update text format + setIsBold(selection.hasFormat("bold")); + setIsItalic(selection.hasFormat("italic")); + setIsUnderline(selection.hasFormat("underline")); + setIsStrikethrough(selection.hasFormat("strikethrough")); + setIsCode(selection.hasFormat("code")); + setIsRTL($isParentElementRTL(selection)); + + // Update links + const node = getSelectedNode(selection); + const parent = node.getParent(); + if ($isLinkNode(parent) || $isLinkNode(node)) { + setIsLink(true); + } else { + setIsLink(false); + } + } + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }: { editorState: EditorState }) => { + editorState.read(() => { + updateToolbar(); + }); + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, newEditor) => { + updateToolbar(); + return false; + }, + LowPriority + ), + editor.registerCommand( + CAN_UNDO_COMMAND, + (payload: boolean) => { + setCanUndo(payload); + return false; + }, + LowPriority + ), + editor.registerCommand( + CAN_REDO_COMMAND, + (payload: boolean) => { + setCanRedo(payload); + return false; + }, + LowPriority + ) + ); + }, [editor, updateToolbar]); + + const codeLanguages = useMemo(() => getCodeLanguages(), []); + const onCodeLanguageSelect = useCallback( + (e: React.ChangeEvent) => { + editor.update(() => { + if (selectedElementKey !== null) { + const node = $getNodeByKey(selectedElementKey); + if ($isCodeNode(node)) { + node.setLanguage(e.target.value); + } + } + }); + }, + [editor, selectedElementKey] + ); + + const insertLink = useCallback(() => { + if (!isLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://"); + } else { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } + }, [editor, isLink]); + + const formatParagraph = () => { + if (blockType !== "paragraph") { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createParagraphNode()); + } + }); + } + }; + + const formatLargeHeading = () => { + if (blockType !== "h1") { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createHeadingNode("h1")); + } + }); + } + }; + + const formatSmallHeading = () => { + if (blockType !== "h2") { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createHeadingNode("h2")); + } + }); + } + }; + + const formatBulletList = () => { + if (blockType !== "ul") { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + } else { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + } + }; + + const formatNumberedList = () => { + if (blockType !== "ol") { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + } else { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + } + }; + + const formatQuote = () => { + if (blockType !== "quote") { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createQuoteNode()); + } + }); + } + }; + + const formatCode = () => { + if (blockType !== "code") { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $wrapNodes(selection, () => $createCodeNode()); + } + }); + } + }; + + const triggerElement = ( + + ); + + return ( +
+ + + + {supportedBlockTypes.has(blockType) && ( + <> + + + Normal + + + Large Heading + + + Small Heading + + + Bullet List + + + Numbered List + + + Quote + + + Code Block + + + + + )} + {blockType === "code" ? ( + <> +