@squiz/formatted-text-editor 1.21.1-alpha.19 → 1.21.1-alpha.20

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 (86) hide show
  1. package/demo/App.tsx +31 -10
  2. package/demo/index.scss +2 -7
  3. package/lib/Editor/Editor.js +26 -2
  4. package/lib/Editor/EditorContext.d.ts +10 -0
  5. package/lib/Editor/EditorContext.js +15 -0
  6. package/lib/EditorToolbar/FloatingToolbar.js +15 -16
  7. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.d.ts +14 -5
  8. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +66 -14
  9. package/lib/EditorToolbar/Tools/Link/LinkButton.js +11 -15
  10. package/lib/EditorToolbar/Tools/Link/LinkModal.js +12 -5
  11. package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +1 -8
  12. package/lib/Extensions/CommandsExtension/CommandsExtension.d.ts +20 -0
  13. package/lib/Extensions/CommandsExtension/CommandsExtension.js +52 -0
  14. package/lib/Extensions/Extensions.d.ts +6 -1
  15. package/lib/Extensions/Extensions.js +31 -20
  16. package/lib/Extensions/LinkExtension/AssetLinkExtension.d.ts +26 -0
  17. package/lib/Extensions/LinkExtension/AssetLinkExtension.js +102 -0
  18. package/lib/Extensions/LinkExtension/LinkExtension.d.ts +21 -12
  19. package/lib/Extensions/LinkExtension/LinkExtension.js +63 -65
  20. package/lib/Extensions/LinkExtension/common.d.ts +7 -0
  21. package/lib/Extensions/LinkExtension/common.js +14 -0
  22. package/lib/hooks/index.d.ts +1 -0
  23. package/lib/hooks/index.js +1 -0
  24. package/lib/hooks/useExpandedSelection.d.ts +23 -0
  25. package/lib/hooks/useExpandedSelection.js +37 -0
  26. package/lib/index.css +15 -3
  27. package/lib/index.d.ts +3 -2
  28. package/lib/index.js +5 -3
  29. package/lib/types.d.ts +3 -0
  30. package/lib/types.js +2 -0
  31. package/lib/ui/Button/Button.js +2 -3
  32. package/lib/ui/Fields/Input/Input.d.ts +1 -0
  33. package/lib/ui/Fields/Input/Input.js +8 -3
  34. package/lib/ui/Modal/Modal.js +2 -1
  35. package/lib/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.d.ts +1 -2
  36. package/lib/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.js +110 -105
  37. package/lib/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.js +93 -69
  38. package/lib/utils/undefinedIfEmpty.d.ts +1 -0
  39. package/lib/utils/undefinedIfEmpty.js +7 -0
  40. package/package.json +3 -2
  41. package/src/Editor/Editor.spec.tsx +0 -26
  42. package/src/Editor/Editor.tsx +4 -3
  43. package/src/Editor/EditorContext.spec.tsx +26 -0
  44. package/src/Editor/EditorContext.ts +19 -0
  45. package/src/EditorToolbar/FloatingToolbar.tsx +19 -18
  46. package/src/EditorToolbar/Tools/Link/Form/LinkForm.spec.tsx +37 -9
  47. package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +96 -26
  48. package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +103 -25
  49. package/src/EditorToolbar/Tools/Link/LinkButton.tsx +16 -19
  50. package/src/EditorToolbar/Tools/Link/LinkModal.tsx +13 -6
  51. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +26 -26
  52. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +2 -10
  53. package/src/EditorToolbar/Tools/Undo/UndoButton.spec.tsx +22 -1
  54. package/src/Extensions/CommandsExtension/CommandsExtension.ts +54 -0
  55. package/src/Extensions/Extensions.ts +31 -19
  56. package/src/Extensions/LinkExtension/AssetLinkExtension.spec.ts +104 -0
  57. package/src/Extensions/LinkExtension/AssetLinkExtension.ts +128 -0
  58. package/src/Extensions/LinkExtension/LinkExtension.spec.ts +68 -0
  59. package/src/Extensions/LinkExtension/LinkExtension.ts +88 -82
  60. package/src/Extensions/LinkExtension/common.ts +10 -0
  61. package/src/hooks/index.ts +1 -0
  62. package/src/hooks/useExpandedSelection.ts +44 -0
  63. package/src/index.ts +3 -2
  64. package/src/types.ts +5 -0
  65. package/src/ui/Button/Button.tsx +2 -4
  66. package/src/ui/Fields/Input/Input.tsx +18 -4
  67. package/src/ui/Modal/Modal.tsx +2 -1
  68. package/src/ui/_forms.scss +14 -0
  69. package/src/utils/converters/mocks/squizNodeJson.mock.ts +177 -0
  70. package/src/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.spec.ts +41 -6
  71. package/src/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.ts +124 -113
  72. package/src/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.spec.ts +56 -34
  73. package/src/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.ts +107 -79
  74. package/src/utils/undefinedIfEmpty.spec.ts +12 -0
  75. package/src/utils/undefinedIfEmpty.ts +3 -0
  76. package/tailwind.config.cjs +3 -0
  77. package/tests/renderWithEditor.tsx +21 -12
  78. package/lib/FormattedTextEditor.d.ts +0 -2
  79. package/lib/FormattedTextEditor.js +0 -7
  80. package/lib/utils/converters/validNodeTypes.d.ts +0 -2
  81. package/lib/utils/converters/validNodeTypes.js +0 -21
  82. package/src/Editor/Editor.mock.tsx +0 -43
  83. package/src/FormattedTextEditor.spec.tsx +0 -10
  84. package/src/FormattedTextEditor.tsx +0 -3
  85. package/src/utils/converters/validNodeTypes.spec.ts +0 -33
  86. package/src/utils/converters/validNodeTypes.ts +0 -21
package/src/types.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type DeepPartial<T> = T extends Record<string, unknown>
2
+ ? {
3
+ [P in keyof T]?: DeepPartial<T[P]>;
4
+ }
5
+ : T;
@@ -20,10 +20,8 @@ const Button = ({ handleOnClick, isDisabled, isActive, label, text, icon }: Butt
20
20
  disabled={isDisabled}
21
21
  className={clsx('squiz-fte-btn', isActive && 'squiz-fte-btn--is-active', icon && ' squiz-fte-btn--is-icon')}
22
22
  >
23
- <>
24
- {text && <span>{text}</span>}
25
- {icon && icon}
26
- </>
23
+ {text && <span>{text}</span>}
24
+ {icon && icon}
27
25
  </button>
28
26
  );
29
27
  };
@@ -1,19 +1,33 @@
1
1
  import React, { ForwardedRef, forwardRef, InputHTMLAttributes } from 'react';
2
+ import clsx from 'clsx';
2
3
 
3
4
  type InputProps = InputHTMLAttributes<HTMLInputElement> & {
4
5
  label?: string;
6
+ error?: string;
5
7
  };
6
8
 
7
- const InputInternal = ({ name, label, type = 'text', ...rest }: InputProps, ref: ForwardedRef<HTMLInputElement>) => {
9
+ const InputInternal = (
10
+ { name, label, type = 'text', error, ...rest }: InputProps,
11
+ ref: ForwardedRef<HTMLInputElement>,
12
+ ) => {
8
13
  return (
9
- <>
14
+ <div className={clsx(error && 'squiz-fte-invalid-form-field')}>
10
15
  {label && (
11
16
  <label htmlFor={name} className="squiz-fte-form-label">
12
17
  {label}
13
18
  </label>
14
19
  )}
15
- <input ref={ref} id={name} name={name} type={type} className="squiz-fte-form-control" {...rest} />
16
- </>
20
+ <input
21
+ ref={ref}
22
+ id={name}
23
+ name={name}
24
+ type={type}
25
+ aria-invalid={!!error}
26
+ className="squiz-fte-form-control"
27
+ {...rest}
28
+ />
29
+ {error && <div className="squiz-fte-form-error">{error}</div>}
30
+ </div>
17
31
  );
18
32
  };
19
33
 
@@ -2,6 +2,7 @@ import React, { ForwardedRef, forwardRef, ReactElement, useEffect, useMemo } fro
2
2
  import { createPortal } from 'react-dom';
3
3
  import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
4
4
  import { FocusTrap } from '@mui/base';
5
+ import clsx from 'clsx';
5
6
 
6
7
  export type ModalProps = {
7
8
  title: string;
@@ -50,7 +51,7 @@ const Modal = (
50
51
  return createPortal(
51
52
  <>
52
53
  <FocusTrap open>
53
- <div ref={ref} className={`squiz-fte-modal-wrapper ${className ? className : ''}`} tabIndex={-1}>
54
+ <div ref={ref} className={clsx('squiz-fte-modal-wrapper', className)} tabIndex={-1}>
54
55
  <div className="w-modal-sm my-6 mx-auto">
55
56
  <div className="squiz-fte-modal">
56
57
  <div className="squiz-fte-modal-header p-6 pb-2">
@@ -13,4 +13,18 @@
13
13
  box-shadow: none;
14
14
  }
15
15
  }
16
+ &-invalid-form-field {
17
+ .squiz-fte-form-control {
18
+ @apply border-red-300 bg-no-repeat;
19
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHdpZHRoPSIyNCI+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0ibm9uZSIvPjxnIGNsYXNzPSJjdXJyZW50TGF5ZXIiPjxwYXRoIGQ9Ik00LjQ3IDIxaDE1LjA2YzEuNTQgMCAyLjUtMS42NyAxLjczLTNMMTMuNzMgNC45OWMtLjc3LTEuMzMtMi42OS0xLjMzLTMuNDYgMEwyLjc0IDE4Yy0uNzcgMS4zMy4xOSAzIDEuNzMgM3pNMTIgMTRjLS41NSAwLTEtLjQ1LTEtMXYtMmMwLS41NS40NS0xIDEtMXMxIC40NSAxIDF2MmMwIC41NS0uNDUgMS0xIDF6bTEgNGgtMnYtMmgydjJ6IiBjbGFzcz0ic2VsZWN0ZWQiIGZpbGw9IiNkNzIzMjEiLz48L2c+PC9zdmc+');
20
+ background-position: top 0.25rem right 0.25rem;
21
+ background-size: 1.5rem;
22
+ }
23
+ }
24
+ &-form-error {
25
+ @apply text-red-300;
26
+ font-size: 13px;
27
+ line-height: 1.23;
28
+ padding-top: 0.25rem;
29
+ }
16
30
  }
@@ -1,4 +1,5 @@
1
1
  import { FORMATTED_TEXT_MODELS as FormattedTextModels } from '@squiz/dx-json-schema-lib';
2
+ import { RemirrorJSON } from '@remirror/core';
2
3
 
3
4
  export const mockSquizNodeJson: FormattedTextModels.v1.FormattedText = [
4
5
  {
@@ -73,3 +74,179 @@ export const mockSquizNodeTextJson: FormattedTextModels.v1.FormattedText = [
73
74
  type: 'text',
74
75
  },
75
76
  ];
77
+
78
+ type NodeExample = {
79
+ description: string;
80
+ remirrorNode: RemirrorJSON;
81
+ squizNode: FormattedTextModels.v1.FormattedText;
82
+ };
83
+
84
+ export const sharedNodeExamples: NodeExample[] = [
85
+ {
86
+ description: 'Asset link',
87
+ remirrorNode: {
88
+ type: 'text',
89
+ text: 'Hello',
90
+ marks: [
91
+ {
92
+ type: 'assetLink',
93
+ attrs: {
94
+ target: '_blank',
95
+ matrixAssetId: '123',
96
+ matrixDomain: 'https://my-matrix.squiz.net',
97
+ matrixIdentifier: 'matrix-api-identifier',
98
+ },
99
+ },
100
+ ],
101
+ },
102
+ squizNode: [
103
+ {
104
+ type: 'link-to-matrix-asset',
105
+ target: '_blank',
106
+ matrixAssetId: '123',
107
+ matrixDomain: 'https://my-matrix.squiz.net',
108
+ matrixIdentifier: 'matrix-api-identifier',
109
+ children: [{ type: 'text', value: 'Hello' }],
110
+ },
111
+ ],
112
+ },
113
+ {
114
+ description: 'Asset link with formatting applied inside of the link',
115
+ remirrorNode: {
116
+ type: 'text',
117
+ text: 'Hello',
118
+ marks: [
119
+ { type: 'bold' },
120
+ {
121
+ type: 'assetLink',
122
+ attrs: {
123
+ target: '_blank',
124
+ matrixAssetId: '123',
125
+ matrixDomain: 'https://my-matrix.squiz.net',
126
+ matrixIdentifier: 'matrix-api-identifier',
127
+ },
128
+ },
129
+ ],
130
+ },
131
+ squizNode: [
132
+ {
133
+ type: 'link-to-matrix-asset',
134
+ target: '_blank',
135
+ matrixAssetId: '123',
136
+ matrixDomain: 'https://my-matrix.squiz.net',
137
+ matrixIdentifier: 'matrix-api-identifier',
138
+ children: [
139
+ {
140
+ type: 'tag',
141
+ tag: 'span',
142
+ font: { bold: true },
143
+ children: [{ type: 'text', value: 'Hello' }],
144
+ },
145
+ ],
146
+ },
147
+ ],
148
+ },
149
+ ];
150
+
151
+ export const squizOnlyNodeExamples: NodeExample[] = [
152
+ {
153
+ description: 'Asset link with formatting applied inside, outside and with multiple levels of nesting',
154
+ squizNode: [
155
+ {
156
+ type: 'tag',
157
+ tag: 'span',
158
+ font: { bold: true },
159
+ children: [
160
+ {
161
+ type: 'link-to-matrix-asset',
162
+ target: '_blank',
163
+ matrixAssetId: '123',
164
+ matrixDomain: 'https://my-matrix.squiz.net',
165
+ matrixIdentifier: 'matrix-api-identifier',
166
+ children: [
167
+ {
168
+ type: 'tag',
169
+ tag: 'span',
170
+ font: { italics: true },
171
+ children: [
172
+ {
173
+ type: 'tag',
174
+ tag: 'span',
175
+ font: { underline: true },
176
+ children: [{ type: 'text', value: 'Hello' }],
177
+ },
178
+ ],
179
+ },
180
+ ],
181
+ },
182
+ ],
183
+ },
184
+ ],
185
+ remirrorNode: {
186
+ // reverse operation covered by "Asset link with formatting applied inside of the link".
187
+ type: 'text',
188
+ text: 'Hello',
189
+ marks: [
190
+ { type: 'underline' },
191
+ { type: 'italic' },
192
+ {
193
+ type: 'assetLink',
194
+ attrs: {
195
+ target: '_blank',
196
+ matrixAssetId: '123',
197
+ matrixDomain: 'https://my-matrix.squiz.net',
198
+ matrixIdentifier: 'matrix-api-identifier',
199
+ },
200
+ },
201
+ { type: 'bold' },
202
+ ],
203
+ },
204
+ },
205
+ {
206
+ description: 'Asset link with multiple levels of un-necessary nesting',
207
+ squizNode: [
208
+ {
209
+ type: 'tag',
210
+ tag: 'span',
211
+ children: [
212
+ {
213
+ type: 'link-to-matrix-asset',
214
+ target: '_blank',
215
+ matrixAssetId: '123',
216
+ matrixDomain: 'https://my-matrix.squiz.net',
217
+ matrixIdentifier: 'matrix-api-identifier',
218
+ children: [
219
+ {
220
+ type: 'tag',
221
+ tag: 'span',
222
+ children: [
223
+ {
224
+ type: 'tag',
225
+ tag: 'span',
226
+ children: [{ type: 'text', value: 'Hello' }],
227
+ },
228
+ ],
229
+ },
230
+ ],
231
+ },
232
+ ],
233
+ },
234
+ ],
235
+ remirrorNode: {
236
+ // reverse operation covered by "Asset link".
237
+ type: 'text',
238
+ text: 'Hello',
239
+ marks: [
240
+ {
241
+ type: 'assetLink',
242
+ attrs: {
243
+ target: '_blank',
244
+ matrixAssetId: '123',
245
+ matrixDomain: 'https://my-matrix.squiz.net',
246
+ matrixIdentifier: 'matrix-api-identifier',
247
+ },
248
+ },
249
+ ],
250
+ },
251
+ },
252
+ ];
@@ -2,6 +2,8 @@ import { FORMATTED_TEXT_MODELS as FormattedTextModels } from '@squiz/dx-json-sch
2
2
  import { remirrorNodeToSquizNode, resolveNodeTag } from './remirrorNodeToSquizNode';
3
3
  import { renderWithEditor } from '../../../../tests';
4
4
  import { RemirrorJSON } from '@remirror/core';
5
+ import { sharedNodeExamples } from '../mocks/squizNodeJson.mock';
6
+ import { ParagraphExtension, SupExtension } from 'remirror/extensions';
5
7
 
6
8
  type FormattedText = FormattedTextModels.v1.FormattedText;
7
9
 
@@ -375,8 +377,9 @@ describe('remirrorNodeToSquizNode', () => {
375
377
  });
376
378
 
377
379
  it('should handle invalid Remirror node provided', () => {
378
- const result = remirrorNodeToSquizNode(false as any);
379
- expect(result).toEqual([]);
380
+ expect(() => remirrorNodeToSquizNode(false as any)).toThrow(
381
+ 'Unable to convert from Remirror to Node data structure, unexpected node provided.',
382
+ );
380
383
  });
381
384
 
382
385
  it('should handle no content provided by Remirror', async () => {
@@ -389,6 +392,38 @@ describe('remirrorNodeToSquizNode', () => {
389
392
  const result = remirrorNodeToSquizNode(editor.doc);
390
393
  expect(result).toEqual([]);
391
394
  });
395
+
396
+ it.each(sharedNodeExamples)(
397
+ 'should convert a Remirror node to the expected Squiz representation - $description',
398
+ async ({ remirrorNode, squizNode }: any) => {
399
+ const { editor } = await renderWithEditor(null, {
400
+ content: {
401
+ type: 'doc',
402
+ content: [{ type: 'paragraph', content: [remirrorNode] }],
403
+ },
404
+ });
405
+
406
+ expect(remirrorNodeToSquizNode(editor.doc)).toEqual([
407
+ {
408
+ type: 'tag',
409
+ tag: 'p',
410
+ children: squizNode,
411
+ },
412
+ ]);
413
+ },
414
+ );
415
+
416
+ it('should throw if the remirror node has an unsupported mark applied', async () => {
417
+ const { editor } = await renderWithEditor(null, {
418
+ extensions: [new ParagraphExtension(), new SupExtension()],
419
+ content: {
420
+ type: 'doc',
421
+ content: [{ type: 'paragraph', marks: [{ type: 'sup' }], content: [] }],
422
+ },
423
+ });
424
+
425
+ expect(() => remirrorNodeToSquizNode(editor.doc)).toThrow('Unsupported mark "sup" was applied to node.');
426
+ });
392
427
  });
393
428
 
394
429
  describe('resolveNodeTag', () => {
@@ -425,7 +460,7 @@ describe('resolveNodeTag', () => {
425
460
  expect(resolveNodeTag(node)).toBe('ul');
426
461
  });
427
462
 
428
- it('should return null for a node with a toDOM method that returns undefined', () => {
463
+ it('should throw for a node with a toDOM method that returns undefined', () => {
429
464
  const node: any = {
430
465
  type: {
431
466
  spec: {
@@ -433,13 +468,13 @@ describe('resolveNodeTag', () => {
433
468
  },
434
469
  },
435
470
  };
436
- expect(resolveNodeTag(node)).toBeNull();
471
+ expect(() => resolveNodeTag(node)).toThrow('Unexpected Remirror node encountered, cannot resolve tag.');
437
472
  });
438
473
 
439
- it('should return null for a node without a toDOM method', () => {
474
+ it('should throw for a node without a toDOM method', () => {
440
475
  const node: any = {
441
476
  type: {},
442
477
  };
443
- expect(resolveNodeTag(node)).toBeNull();
478
+ expect(() => resolveNodeTag(node)).toThrow('Unexpected Remirror node encountered, cannot resolve tag.');
444
479
  });
445
480
  });
@@ -1,6 +1,7 @@
1
- import { ProsemirrorNode, Fragment as ProsemirrorFragment } from 'remirror';
1
+ import { ProsemirrorNode, Fragment as ProsemirrorFragment, Mark } from 'remirror';
2
+ import { Attrs } from 'prosemirror-model';
2
3
  import { FORMATTED_TEXT_MODELS as FormattedTextModels } from '@squiz/dx-json-schema-lib';
3
- import { validRemirrorNode } from '../validNodeTypes';
4
+ import { undefinedIfEmpty } from '../../undefinedIfEmpty';
4
5
 
5
6
  type Fragment = ProsemirrorFragment & {
6
7
  content?: Fragment[];
@@ -9,16 +10,23 @@ type Fragment = ProsemirrorFragment & {
9
10
  type FormattingOptions = FormattedTextModels.v1.FormattingOptions;
10
11
  type FontOptions = FormattedTextModels.v1.FormattedNodeFontProperties;
11
12
  type FormattedText = FormattedTextModels.v1.FormattedText;
13
+ type FormattedNode = FormattedTextModels.v1.FormattedNodes;
14
+ type FormattedNodeFontProperties = FormattedTextModels.v1.FormattedNodeFontProperties;
15
+ type FormattedNodeWithChildren = Extract<FormattedNode, { children: FormattedNode[] }>;
16
+
17
+ export const resolveNodeTag = (node: ProsemirrorNode): string => {
18
+ if (node.type.name === 'text') {
19
+ return 'span';
20
+ }
12
21
 
13
- export const resolveNodeTag = (node: ProsemirrorNode): string | null => {
14
22
  if (node.type.spec?.toDOM) {
15
- const domNode = node.type.spec.toDOM(node) as any;
23
+ const domNode = node.type.spec.toDOM(node);
16
24
 
17
25
  if (domNode instanceof window.Node) {
18
26
  return domNode.nodeName.toLowerCase();
19
27
  }
20
28
 
21
- if (domNode?.dom instanceof window.Node) {
29
+ if (typeof domNode === 'object' && 'dom' in domNode && domNode.dom instanceof window.Node) {
22
30
  return domNode.dom.nodeName.toLowerCase();
23
31
  }
24
32
 
@@ -28,7 +36,7 @@ export const resolveNodeTag = (node: ProsemirrorNode): string | null => {
28
36
  }
29
37
  }
30
38
 
31
- return null;
39
+ throw new Error('Unexpected Remirror node encountered, cannot resolve tag.');
32
40
  };
33
41
 
34
42
  const resolveFormattingOptions = (node: ProsemirrorNode): FormattingOptions => {
@@ -41,7 +49,7 @@ const resolveFormattingOptions = (node: ProsemirrorNode): FormattingOptions => {
41
49
  return formattingOptions;
42
50
  };
43
51
 
44
- const resolveFontOptions = (node: ProsemirrorNode): FontOptions => {
52
+ const resolveFontOptions = (node: ProsemirrorNode): FormattedNodeFontProperties => {
45
53
  const fontOptions: FontOptions = {};
46
54
 
47
55
  node.marks.forEach((mark) => {
@@ -55,137 +63,140 @@ const resolveFontOptions = (node: ProsemirrorNode): FontOptions => {
55
63
  case 'underline':
56
64
  fontOptions.underline = true;
57
65
  break;
58
- default:
59
- // Currently unsupported mark type
60
- break;
61
66
  }
62
67
  });
63
68
 
64
69
  return fontOptions;
65
70
  };
66
71
 
67
- const resolveAttributeOptions = (node: ProsemirrorNode, nodeType: string | null): Record<string, string> => {
68
- let attributeOptions: any = {};
69
-
70
- if (nodeType === 'image') {
71
- attributeOptions = { ...node.attrs };
72
- } else {
73
- node.marks.forEach((mark) => {
74
- switch (mark.type.name) {
75
- case 'link':
76
- attributeOptions = { ...mark.attrs };
77
- break;
78
- default:
79
- // Currently unsupported mark type
80
- break;
81
- }
82
- });
83
- }
72
+ const transformAttributes = (attributes: Attrs): Record<string, string> => {
73
+ const transformed: Record<string, string> = {};
84
74
 
85
- // Remove any non string elements from attributes, squiz component only accepts strings.
86
- Object.keys(attributeOptions).forEach((key) => {
87
- if (typeof attributeOptions[key] !== 'string' && typeof attributeOptions[key] !== 'number') {
88
- delete attributeOptions[key];
89
- // If it's a number we make it a string so its accepted by component service
90
- } else {
91
- attributeOptions[key] = String(attributeOptions[key]);
75
+ Object.keys(attributes).forEach((key) => {
76
+ // Component service requires attributes to be a string, cast as needed.
77
+ if (typeof attributes[key] === 'string' || typeof attributes[key] === 'number') {
78
+ transformed[key] = String(attributes[key]);
92
79
  }
93
80
  });
94
81
 
95
- return attributeOptions;
82
+ return transformed;
96
83
  };
97
84
 
98
- /**
99
- * Converts Remirror node JSON structure to Squiz component JSON structure.
100
- * @param {ProsemirrorNode} node Remirror node to convert to component.
101
- * @export
102
- * @returns {FormattedText} The converted Squiz component JSON.
103
- */
104
- export const remirrorNodeToSquizNode = (node: ProsemirrorNode): FormattedText => {
105
- if (!validRemirrorNode(node)) return [];
106
-
107
- const nodeType = node.type.name;
108
- let nodeTag = resolveNodeTag(node);
109
-
110
- // Filter out any children nodes that aren't currently supported.
111
- const children = ((node.content as Fragment).content || []).map((child: any) => remirrorNodeToSquizNode(child));
112
-
113
- let transformed: any = {
114
- children,
115
- formattingOptions: resolveFormattingOptions(node),
116
- attributes: resolveAttributeOptions(node, nodeType),
117
- font: resolveFontOptions(node),
118
- };
85
+ const transformFragment = (fragment: Fragment): FormattedText => {
86
+ const transformed: FormattedText = [];
119
87
 
120
- if (nodeType === 'doc') {
121
- return transformed.children;
122
- }
88
+ fragment.forEach((child) => transformed.push(transformNode(child)));
123
89
 
124
- // If we don't have a node tag yet, check if there is one needed
125
- if (!nodeTag) {
126
- node.marks.forEach((mark) => {
127
- switch (mark.type.name) {
128
- case 'link':
129
- nodeTag = 'a';
130
- break;
131
- default:
132
- // Currently unsupported mark type
133
- break;
134
- }
135
- });
136
- }
90
+ return transformed;
91
+ };
137
92
 
138
- if (nodeTag) {
139
- transformed = {
140
- ...transformed,
93
+ const transformNode = (node: ProsemirrorNode): FormattedNode => {
94
+ const attributes = node.type.name === 'image' ? transformAttributes(node.attrs) : undefined;
95
+ const formattingOptions = undefinedIfEmpty(resolveFormattingOptions(node));
96
+ const font = undefinedIfEmpty(resolveFontOptions(node));
97
+ let transformedNode: FormattedNode = { type: 'text', value: node.text || '' };
98
+
99
+ // Squiz "text" nodes can't have formatting/font options but Remirror "text" nodes can.
100
+ // If the node has formatting options wrap in a tag.
101
+ // If the node isn't a text type assume it is a tag type and wrap in a tag.
102
+ // If we pick the wrong tag here it will be corrected later as part of looping through the
103
+ // non-font marks.
104
+ if (node.type.name !== 'text' || attributes || formattingOptions || font) {
105
+ transformedNode = {
141
106
  type: 'tag',
142
- tag: nodeTag,
107
+ tag: resolveNodeTag(node),
108
+ children: node.type.name === 'text' ? [transformedNode] : transformFragment(node.content),
109
+ attributes,
110
+ formattingOptions,
111
+ font,
143
112
  };
144
113
  }
145
114
 
146
- if (
147
- (Object.keys(transformed.font).length > 0 || Object.keys(transformed.attributes).length > 0) &&
148
- !transformed.type
149
- ) {
150
- // Wrap in span so we can apply formatting to it
151
- transformed = { ...transformed, tag: 'span', type: 'tag' };
152
- }
153
-
154
- if (nodeType === 'text') {
155
- if (transformed.type) {
156
- // If we have a tag already nest the text beneath it so we can preserve formatting options, etc.
157
- transformed = {
158
- ...transformed,
159
- children: [
160
- {
161
- type: 'text',
162
- value: node.text,
163
- },
164
- ],
165
- };
166
- } else {
167
- // If we don't have a tag just rewrite the transformed value to be the text.
168
- transformed = {
169
- type: 'text',
170
- value: node.text,
171
- };
115
+ node.marks.forEach((mark) => {
116
+ switch (mark.type.name) {
117
+ case 'bold':
118
+ case 'italic':
119
+ case 'underline':
120
+ break;
121
+ default:
122
+ transformedNode = transformMark(mark, transformedNode);
172
123
  }
173
- }
124
+ });
125
+
126
+ return transformedNode;
127
+ };
174
128
 
175
- // Remove empty formatting options from transformed object.
176
- if (transformed.formattingOptions && Object.keys(transformed.formattingOptions).length === 0) {
177
- delete transformed.formattingOptions;
129
+ /**
130
+ * Merges 2 nodes together if they are compatible without losing any important details.
131
+ * Otherwise will wrap the node.
132
+ *
133
+ * @param {FormattedNode} node
134
+ * @param {FormattedNodeWithChildren} wrappingNode
135
+ *
136
+ * @return {FormattedNode}
137
+ */
138
+ const wrapNodeIfNeeded = (node: FormattedNode, wrappingNode: FormattedNodeWithChildren): FormattedNode => {
139
+ if (node.type === 'tag' && wrappingNode.type === 'tag' && (node.tag === 'span' || node.tag === wrappingNode.tag)) {
140
+ // if the node we are wrapping with is a DOM node, and the node being wrapped is
141
+ // a plain looking DOM node merge the 2 nodes.
142
+ return {
143
+ ...node,
144
+ ...wrappingNode,
145
+ formattingOptions: undefinedIfEmpty({
146
+ ...node.formattingOptions,
147
+ ...wrappingNode.formattingOptions,
148
+ }),
149
+ attributes: undefinedIfEmpty({
150
+ ...node.attributes,
151
+ ...wrappingNode.attributes,
152
+ }),
153
+ font: undefinedIfEmpty({
154
+ ...node.font,
155
+ ...wrappingNode.font,
156
+ }),
157
+ children: [...node.children, ...wrappingNode.children],
158
+ };
178
159
  }
179
160
 
180
- // Remove empty font options from transformed object.
181
- if (transformed.font && Object.keys(transformed.font).length === 0) {
182
- delete transformed.font;
161
+ // if the node we are wrapping or the wrapping nodes are not compatible merge them.
162
+ return {
163
+ ...wrappingNode,
164
+ children: [node, ...wrappingNode.children],
165
+ };
166
+ };
167
+
168
+ const transformMark = (mark: Mark, node: FormattedNode): FormattedNode => {
169
+ switch (mark.type.name) {
170
+ case 'link':
171
+ return wrapNodeIfNeeded(node, {
172
+ type: 'tag',
173
+ tag: 'a',
174
+ attributes: transformAttributes(mark.attrs),
175
+ children: [],
176
+ });
177
+ case 'assetLink':
178
+ return wrapNodeIfNeeded(node, {
179
+ type: 'link-to-matrix-asset',
180
+ target: mark.attrs.target,
181
+ matrixIdentifier: mark.attrs.matrixIdentifier,
182
+ matrixDomain: mark.attrs.matrixDomain,
183
+ matrixAssetId: mark.attrs.matrixAssetId,
184
+ children: [],
185
+ });
183
186
  }
184
187
 
185
- // Remove empty attributes options from transformed object.
186
- if (transformed.attributes && Object.keys(transformed.attributes).length === 0) {
187
- delete transformed.attributes;
188
+ throw new Error(`Unsupported mark "${mark.type.name}" was applied to node.`);
189
+ };
190
+
191
+ /**
192
+ * Converts Remirror node JSON structure to Squiz component JSON structure.
193
+ * @param {ProsemirrorNode} node Remirror node to convert to component.
194
+ * @returns {FormattedText} The converted Squiz component JSON.
195
+ */
196
+ export const remirrorNodeToSquizNode = (node: ProsemirrorNode): FormattedText => {
197
+ if (node?.type?.name !== 'doc') {
198
+ throw new Error('Unable to convert from Remirror to Node data structure, unexpected node provided.');
188
199
  }
189
200
 
190
- return transformed;
201
+ return transformFragment(node.content);
191
202
  };