@squiz/formatted-text-editor 2.2.1 → 2.2.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Change Log
2
2
 
3
+ ## 2.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ - e5b062a: AI Content Tools button now launches modal
8
+
3
9
  ## 2.2.1
4
10
 
5
11
  ### Patch Changes
package/demo/App.tsx CHANGED
@@ -7,6 +7,7 @@ import Button from '../src/ui/Button/Button';
7
7
  import TextFieldsOutlinedIcon from '@mui/icons-material/TextFieldsOutlined';
8
8
  import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
9
9
  import { VerticalDivider } from '@remirror/react-components';
10
+ import { Dialog, useDialogStore } from '@squiz/sds';
10
11
  const ComponentHandlers = () => (
11
12
  <div style={{ display: 'flex', justifyContent: 'flex-end', maxHeight: '2rem' }}>
12
13
  <Button icon={<TextFieldsOutlinedIcon />} onClick={(x) => x} />
@@ -24,6 +25,7 @@ function App() {
24
25
  const [editable, setEditable] = useState(true);
25
26
  const [enableTableTool, setEnableTableTool] = useState(true);
26
27
  const [border, setBorder] = useState(true);
28
+ const { activeDialog } = useDialogStore();
27
29
 
28
30
  const handleEditorChange: RemirrorEventListener<Extension> = (parameter) => {
29
31
  try {
@@ -105,6 +107,7 @@ function App() {
105
107
  <ReactDiffViewer oldValue={squizDoc} newValue={squizDoc} splitView={false} showDiffOnly={false} />
106
108
  </div>
107
109
  </div>
110
+ {activeDialog && <Dialog {...activeDialog} />}
108
111
  </div>
109
112
  );
110
113
  }
package/demo/index.scss CHANGED
@@ -31,3 +31,7 @@ h1 {
31
31
  .error {
32
32
  color: red;
33
33
  }
34
+
35
+ .sr-only {
36
+ display: none;
37
+ }
@@ -8,28 +8,94 @@ const AiIcon_1 = require("../../../Icons/AiIcon");
8
8
  const sds_1 = require("@squiz/sds");
9
9
  const dxp_ai_client_react_1 = require("@squiz/dxp-ai-client-react");
10
10
  const react_2 = require("@remirror/react");
11
+ const dxp_content_tools_modal_1 = require("@squiz/dxp-content-tools-modal");
12
+ const model_1 = require("@remirror/pm/model");
13
+ const prosemirror_model_1 = require("prosemirror-model");
11
14
  const ContentToolsDropdown = () => {
12
- const { contentTools } = (0, dxp_ai_client_react_1.useAiService)();
13
- const dropdownItems = contentTools?.map((item) => ({
14
- items: [
15
- {
16
- action: () => alert(JSON.stringify(item, null, 2)),
17
- key: item.id,
18
- label: react_1.default.createElement("span", null, item.name),
19
- },
20
- ],
21
- key: item.id,
22
- }));
15
+ const aiService = (0, dxp_ai_client_react_1.useAiService)();
16
+ const { contentTools } = aiService;
17
+ const { updateActiveDialog } = (0, sds_1.useDialogStore)();
18
+ const { isSelectionEmpty, getHTML } = (0, react_2.useHelpers)();
19
+ const { getState, view } = (0, react_2.useRemirrorContext)();
23
20
  // No content tools to show, don't show dropdown at all
24
21
  if (!contentTools || contentTools?.length === 0) {
25
22
  return null;
26
23
  }
24
+ const hasContent = getState().doc.textContent.length > 0;
25
+ const getSelectedRichText = () => {
26
+ const { from, to } = getState().selection;
27
+ const fragment = getState().doc.slice(from, to).content;
28
+ const div = document.createElement('div');
29
+ fragment.forEach((node) => {
30
+ div.appendChild(model_1.DOMSerializer.fromSchema(getState().schema).serializeNode(node));
31
+ });
32
+ return div.innerHTML;
33
+ };
34
+ const onInsertAfter = (content) => {
35
+ const schema = getState().schema;
36
+ const doc = getState().doc;
37
+ const tr = getState().tr;
38
+ // Parse the HTML string into a document fragment
39
+ const element = document.createElement('div');
40
+ element.innerHTML = content;
41
+ const newContent = prosemirror_model_1.DOMParser.fromSchema(schema).parse(element);
42
+ // Append the new content to the existing document
43
+ const newTransaction = tr.insert(doc.content.size, newContent.content);
44
+ view.dispatch(newTransaction);
45
+ };
46
+ const onReplace = (content) => {
47
+ const schema = getState().schema;
48
+ const { from, to, empty } = getState().selection;
49
+ const { tr } = getState();
50
+ // Parse the HTML string into a document fragment
51
+ const element = document.createElement('div');
52
+ element.innerHTML = content;
53
+ const newContent = prosemirror_model_1.DOMParser.fromSchema(schema).parse(element);
54
+ if (empty) {
55
+ // If there is no selection, replace the entire document content
56
+ const newTransaction = tr.replaceWith(0, getState().doc.content.size, newContent.content);
57
+ view.dispatch(newTransaction);
58
+ }
59
+ else {
60
+ // Replace the selected content with the new content
61
+ const newTransaction = tr.replaceWith(from, to, newContent.content);
62
+ view.dispatch(newTransaction);
63
+ }
64
+ };
65
+ const dropdownItems = contentTools?.map((item) => {
66
+ return {
67
+ items: [
68
+ {
69
+ action: () => {
70
+ // If we don't have a selection, use all text
71
+ const richContent = isSelectionEmpty() ? getHTML() : getSelectedRichText(); // NOTE: selected rich text may not work as expected
72
+ const dialogProps = {
73
+ dialogContent: {
74
+ renderContent: (dialogContentProps) => {
75
+ return (react_1.default.createElement(dxp_content_tools_modal_1.ModalContent, { aiServiceOverride: aiService, content: richContent, contentTool: item, dialogContentProps: dialogContentProps, onInsertAfter: onInsertAfter, onReplace: onReplace }));
76
+ },
77
+ },
78
+ dialogSize: sds_1.DIALOG_SIZE_XL,
79
+ heading: `Rewrite content to ... ${item.name}`,
80
+ icon: dxp_content_tools_modal_1.ICON_DXP_AI,
81
+ stateHandler: sds_1.useDialogStore,
82
+ };
83
+ updateActiveDialog(dialogProps);
84
+ },
85
+ key: item.id,
86
+ label: react_1.default.createElement("span", null, item.name),
87
+ },
88
+ ],
89
+ key: item.id,
90
+ };
91
+ });
27
92
  return (react_1.default.createElement(react_1.default.Fragment, null,
28
93
  react_1.default.createElement(react_2.VerticalDivider, null),
29
- react_1.default.createElement(sds_1.Dropdown, { title: "Content tools", "aria-label": "Content tools", buttonProps: {
94
+ hasContent && (react_1.default.createElement(sds_1.Dropdown, { title: "Content tools", "aria-label": "Content tools", buttonProps: {
30
95
  format: sds_1.BUTTON_FORMAT_TRANSPARENT,
31
96
  icon: AiIcon_1.ICON_AI,
32
97
  theme: sds_1.BUTTON_THEME_DEFAULT,
33
- }, className: "content-tools-dropdown", dropdownPosition: sds_1.DROPDOWN_POSITION_RIGHT, heading: 'Rewrite to...', sections: dropdownItems ?? [] })));
98
+ }, className: "content-tools-dropdown", dropdownPosition: sds_1.DROPDOWN_POSITION_RIGHT, heading: 'Rewrite to...', sections: dropdownItems ?? [], disabled: !hasContent })),
99
+ !hasContent && react_1.default.createElement("div", { className: "content-tools-dropdown--disabled" }, AiIcon_1.ICON_AI)));
34
100
  };
35
101
  exports.default = ContentToolsDropdown;
package/lib/index.css CHANGED
@@ -2655,7 +2655,8 @@
2655
2655
  .squiz-fte-scope .sds-text-field textarea {
2656
2656
  resize: vertical;
2657
2657
  }
2658
- .squiz-fte-scope .sds-text-field .sds-field-error__icon {
2658
+ .squiz-fte-scope .sds-text-field .sds-field-error__icon,
2659
+ .squiz-fte-scope .sds-text-field .sds-text-field__copy-icon {
2659
2660
  align-items: center;
2660
2661
  display: flex;
2661
2662
  height: 100%;
@@ -2664,6 +2665,9 @@
2664
2665
  right: 0;
2665
2666
  top: 0;
2666
2667
  }
2668
+ .squiz-fte-scope .sds-text-field:has(.sds-text-field__copy-icon) input {
2669
+ padding-right: 2rem;
2670
+ }
2667
2671
  .squiz-fte-scope .sds-text-field.readonly input,
2668
2672
  .squiz-fte-scope .sds-text-field.readonly textarea {
2669
2673
  background: rgba(0, 0, 0, 0.04);
@@ -4179,6 +4183,63 @@
4179
4183
  width: 400px;
4180
4184
  }
4181
4185
 
4186
+ /* ../../node_modules/@squiz/dxp-content-tools-modal/lib/package.css */
4187
+ @keyframes skeleton-pulse {
4188
+ 0% {
4189
+ opacity: 1;
4190
+ }
4191
+ 50% {
4192
+ opacity: 0.45;
4193
+ }
4194
+ 100% {
4195
+ opacity: 1;
4196
+ }
4197
+ }
4198
+ .squiz-fte-scope .dxp-ctm-section__header {
4199
+ align-items: center;
4200
+ display: flex;
4201
+ flex-direction: row;
4202
+ height: 36px;
4203
+ justify-content: space-between;
4204
+ margin-bottom: 0.5rem;
4205
+ }
4206
+ .squiz-fte-scope .dxp-ctm-section__header h3 {
4207
+ font-size: 0.96rem;
4208
+ }
4209
+ .squiz-fte-scope .dxp-ctm-section__spinner {
4210
+ display: flex;
4211
+ justify-content: center;
4212
+ padding-top: 1.5rem;
4213
+ }
4214
+ .squiz-fte-scope .dxp-ctm-content {
4215
+ align-content: stretch;
4216
+ align-items: stretch;
4217
+ display: flex;
4218
+ flex-flow: row nowrap;
4219
+ gap: 0.25rem;
4220
+ }
4221
+ .squiz-fte-scope .dxp-ctm-content > .dxp-ctm-section {
4222
+ display: flex;
4223
+ flex: 1;
4224
+ flex-direction: column;
4225
+ padding: 1rem;
4226
+ }
4227
+ .squiz-fte-scope .dxp-ctm-content > .dxp-ctm-section > .squiz-fte-scope {
4228
+ flex-grow: 1;
4229
+ }
4230
+ .squiz-fte-scope .dxp-ctm-content > .dxp-ctm-section > .squiz-fte-scope:not(.dxp-ctm-section__spinner) {
4231
+ background-color: #fff;
4232
+ }
4233
+ .squiz-fte-scope .dxp-ctm-section {
4234
+ background-color: #ededed;
4235
+ border-radius: 0.5rem;
4236
+ min-height: 400px;
4237
+ padding: 0.5rem;
4238
+ }
4239
+ .squiz-fte-scope .dxp-ctm-section__header h3 {
4240
+ margin: 0;
4241
+ }
4242
+
4182
4243
  /* src/index.scss */
4183
4244
  .squiz-fte-scope *,
4184
4245
  .squiz-fte-scope ::before,
@@ -5376,6 +5437,12 @@
5376
5437
  .squiz-fte-scope .editor-toolbar__tools .squiz-fte-btn ~ .squiz-fte-btn {
5377
5438
  margin-left: 2px;
5378
5439
  }
5440
+ .squiz-fte-scope .editor-toolbar .content-tools-dropdown--disabled,
5441
+ .squiz-fte-scope__floating-popover .content-tools-dropdown--disabled {
5442
+ cursor: not-allowed;
5443
+ filter: grayscale(100%);
5444
+ padding: 6px 8px;
5445
+ }
5379
5446
  .squiz-fte-scope .header-toolbar {
5380
5447
  transition-duration: 0.3s;
5381
5448
  transition-property: max-height, opacity;
@@ -5746,4 +5813,10 @@
5746
5813
  * @license
5747
5814
  * Copyright Squiz Australia Pty Ltd. All Rights Reserved.
5748
5815
  *)
5816
+
5817
+ @squiz/dxp-content-tools-modal/lib/package.css:
5818
+ (*!
5819
+ * @license
5820
+ * Copyright Squiz Australia Pty Ltd. All Rights Reserved.
5821
+ *)
5749
5822
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/formatted-text-editor",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/index.d.ts",
6
6
  "private": false,
@@ -24,12 +24,13 @@
24
24
  "@mui/icons-material": "5.15.18",
25
25
  "@remirror/extension-react-tables": "^2.2.19",
26
26
  "@remirror/react": "2.0.35",
27
+ "@squiz/dam-resource-browser-plugin": "^0.9.0-rc.0",
27
28
  "@squiz/dx-json-schema-lib": "^1.72.0",
28
- "@squiz/dxp-ai-client-react": "^0.1.3-alpha",
29
+ "@squiz/dxp-ai-client-react": "^0.2.0",
30
+ "@squiz/dxp-content-tools-modal": "^0.1.0",
29
31
  "@squiz/matrix-resource-browser-plugin": "^2.0.0",
30
32
  "@squiz/resource-browser": "^2.0.0",
31
- "@squiz/sds": "^1.0.0-alpha.50",
32
- "@squiz/dam-resource-browser-plugin": "^0.9.0-rc.0",
33
+ "@squiz/sds": "^1.0.0-alpha.59",
33
34
  "clsx": "2.1.1",
34
35
  "react-hook-form": "7.51.4",
35
36
  "react-image-size": "2.0.0",
@@ -10,6 +10,44 @@ jest.mock('@squiz/dxp-ai-client-react', () => ({
10
10
  useAiService: jest.fn(),
11
11
  }));
12
12
 
13
+ jest.mock('@remirror/react', () => {
14
+ const actualModule = jest.requireActual('@remirror/react');
15
+
16
+ return {
17
+ ...actualModule,
18
+ useHelpers: () => ({
19
+ isSelectionEmpty: jest.fn().mockReturnValue(false),
20
+ getHTML: jest.fn().mockReturnValue('<strong>test</strong>'),
21
+ }),
22
+ useRemirrorContext: () => ({
23
+ getState: jest.fn().mockReturnValue({
24
+ doc: {
25
+ textContent: 'test',
26
+ slice: jest.fn().mockReturnValue({
27
+ content: { forEach: jest.fn() },
28
+ }),
29
+ },
30
+ selection: {
31
+ from: 1,
32
+ to: 2,
33
+ },
34
+ }),
35
+ view: jest.fn().mockReturnValue({}),
36
+ }),
37
+ };
38
+ });
39
+
40
+ jest.mock('@squiz/sds', () => {
41
+ const actualModule = jest.requireActual('@squiz/sds');
42
+
43
+ return {
44
+ ...actualModule,
45
+ useDialogStore: () => ({
46
+ updateActiveDialog: jest.fn(),
47
+ }),
48
+ };
49
+ });
50
+
13
51
  // Cast useAiService to a Jest mock
14
52
  const mockedUseAiService = useAiService as jest.MockedFunction<typeof useAiService>;
15
53
 
@@ -1,44 +1,143 @@
1
1
  import React from 'react';
2
2
  import { ICON_AI } from '../../../Icons/AiIcon';
3
- import { BUTTON_FORMAT_TRANSPARENT, BUTTON_THEME_DEFAULT, DROPDOWN_POSITION_RIGHT, Dropdown } from '@squiz/sds';
3
+ import {
4
+ BUTTON_FORMAT_TRANSPARENT,
5
+ BUTTON_THEME_DEFAULT,
6
+ DIALOG_SIZE_XL,
7
+ DROPDOWN_POSITION_RIGHT,
8
+ DialogContentProps,
9
+ DialogProps,
10
+ Dropdown,
11
+ DropdownSectionData,
12
+ useDialogStore,
13
+ } from '@squiz/sds';
4
14
  import { useAiService } from '@squiz/dxp-ai-client-react';
5
- import { VerticalDivider } from '@remirror/react';
15
+ import { VerticalDivider, useHelpers, useRemirrorContext } from '@remirror/react';
16
+ import { ICON_DXP_AI, ModalContent } from '@squiz/dxp-content-tools-modal';
17
+ import { DOMSerializer } from '@remirror/pm/model';
18
+ import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
6
19
 
7
20
  const ContentToolsDropdown = () => {
8
- const { contentTools } = useAiService();
9
-
10
- const dropdownItems = contentTools?.map((item) => ({
11
- items: [
12
- {
13
- action: () => alert(JSON.stringify(item, null, 2)),
14
- key: item.id,
15
- label: <span>{item.name}</span>,
16
- },
17
- ],
18
- key: item.id,
19
- }));
21
+ const aiService = useAiService();
22
+ const { contentTools } = aiService;
23
+ const { updateActiveDialog } = useDialogStore();
24
+ const { isSelectionEmpty, getHTML } = useHelpers();
25
+ const { getState, view } = useRemirrorContext();
20
26
 
21
27
  // No content tools to show, don't show dropdown at all
22
28
  if (!contentTools || contentTools?.length === 0) {
23
29
  return null;
24
30
  }
25
31
 
32
+ const hasContent = getState().doc.textContent.length > 0;
33
+
34
+ const getSelectedRichText = () => {
35
+ const { from, to } = getState().selection;
36
+ const fragment = getState().doc.slice(from, to).content;
37
+ const div = document.createElement('div');
38
+
39
+ fragment.forEach((node) => {
40
+ div.appendChild(DOMSerializer.fromSchema(getState().schema).serializeNode(node));
41
+ });
42
+
43
+ return div.innerHTML;
44
+ };
45
+
46
+ const onInsertAfter = (content: string) => {
47
+ const schema = getState().schema;
48
+ const doc = getState().doc;
49
+ const tr = getState().tr;
50
+
51
+ // Parse the HTML string into a document fragment
52
+ const element = document.createElement('div');
53
+ element.innerHTML = content;
54
+ const newContent = ProseMirrorDOMParser.fromSchema(schema).parse(element);
55
+
56
+ // Append the new content to the existing document
57
+ const newTransaction = tr.insert(doc.content.size, newContent.content);
58
+ view.dispatch(newTransaction);
59
+ };
60
+
61
+ const onReplace = (content: string) => {
62
+ const schema = getState().schema;
63
+ const { from, to, empty } = getState().selection;
64
+ const { tr } = getState();
65
+
66
+ // Parse the HTML string into a document fragment
67
+ const element = document.createElement('div');
68
+ element.innerHTML = content;
69
+ const newContent = ProseMirrorDOMParser.fromSchema(schema).parse(element);
70
+
71
+ if (empty) {
72
+ // If there is no selection, replace the entire document content
73
+ const newTransaction = tr.replaceWith(0, getState().doc.content.size, newContent.content);
74
+ view.dispatch(newTransaction);
75
+ } else {
76
+ // Replace the selected content with the new content
77
+ const newTransaction = tr.replaceWith(from, to, newContent.content);
78
+ view.dispatch(newTransaction);
79
+ }
80
+ };
81
+
82
+ const dropdownItems: Array<DropdownSectionData> = contentTools?.map((item) => {
83
+ return {
84
+ items: [
85
+ {
86
+ action: () => {
87
+ // If we don't have a selection, use all text
88
+ const richContent = isSelectionEmpty() ? getHTML() : getSelectedRichText(); // NOTE: selected rich text may not work as expected
89
+
90
+ const dialogProps: DialogProps = {
91
+ dialogContent: {
92
+ renderContent: (dialogContentProps: DialogContentProps) => {
93
+ return (
94
+ <ModalContent
95
+ aiServiceOverride={aiService}
96
+ content={richContent}
97
+ contentTool={item}
98
+ dialogContentProps={dialogContentProps}
99
+ onInsertAfter={onInsertAfter}
100
+ onReplace={onReplace}
101
+ />
102
+ );
103
+ },
104
+ },
105
+ dialogSize: DIALOG_SIZE_XL,
106
+ heading: `Rewrite content to ... ${item.name}`,
107
+ icon: ICON_DXP_AI,
108
+ stateHandler: useDialogStore,
109
+ };
110
+
111
+ updateActiveDialog(dialogProps);
112
+ },
113
+ key: item.id,
114
+ label: <span>{item.name}</span>,
115
+ },
116
+ ],
117
+ key: item.id,
118
+ };
119
+ });
120
+
26
121
  return (
27
122
  <>
28
123
  <VerticalDivider />
29
- <Dropdown
30
- title="Content tools"
31
- aria-label="Content tools"
32
- buttonProps={{
33
- format: BUTTON_FORMAT_TRANSPARENT,
34
- icon: ICON_AI,
35
- theme: BUTTON_THEME_DEFAULT,
36
- }}
37
- className="content-tools-dropdown"
38
- dropdownPosition={DROPDOWN_POSITION_RIGHT}
39
- heading={'Rewrite to...'}
40
- sections={dropdownItems ?? []}
41
- />
124
+ {hasContent && (
125
+ <Dropdown
126
+ title="Content tools"
127
+ aria-label="Content tools"
128
+ buttonProps={{
129
+ format: BUTTON_FORMAT_TRANSPARENT,
130
+ icon: ICON_AI,
131
+ theme: BUTTON_THEME_DEFAULT,
132
+ }}
133
+ className="content-tools-dropdown"
134
+ dropdownPosition={DROPDOWN_POSITION_RIGHT}
135
+ heading={'Rewrite to...'}
136
+ sections={dropdownItems ?? []}
137
+ disabled={!hasContent}
138
+ />
139
+ )}
140
+ {!hasContent && <div className="content-tools-dropdown--disabled">{ICON_AI}</div>}
42
141
  </>
43
142
  );
44
143
  };
@@ -21,6 +21,12 @@
21
21
  }
22
22
  }
23
23
  }
24
+
25
+ .content-tools-dropdown--disabled {
26
+ cursor: not-allowed;
27
+ filter: grayscale(100%);
28
+ padding: 6px 8px;
29
+ }
24
30
  }
25
31
 
26
32
  .header-toolbar {
package/src/index.scss CHANGED
@@ -4,6 +4,7 @@
4
4
  @import 'tailwindcss/utilities';
5
5
 
6
6
  @import '@squiz/sds/lib/package.css';
7
+ @import '@squiz/dxp-content-tools-modal/lib/package.css';
7
8
 
8
9
  /* So we can use icons inside of FTE content */
9
10
  @import 'https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded';