@squiz/formatted-text-editor 1.21.1-alpha.6 → 1.21.1-alpha.9

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.
@@ -0,0 +1,307 @@
1
+ import { FORMATTED_TEXT_MODELS as FormattedTextModels } from '@squiz/dx-json-schema-lib';
2
+ import { RemirrorJSON } from '@remirror/core';
3
+ import { squizNodeToRemirrorNode } from './squizNodeToRemirrorNode';
4
+ import { mockSquizNodeJson, mockSquizNodeTextJson } from '../mocks/squizNodeJson.mock';
5
+
6
+ type FormattedText = FormattedTextModels.v1.FormattedText;
7
+
8
+ describe('squizNodeToRemirrorNode', () => {
9
+ it('should convert complex Squiz component JSON to Remirror JSON', () => {
10
+ expect(squizNodeToRemirrorNode(mockSquizNodeJson)).toEqual({
11
+ type: 'doc',
12
+ content: [
13
+ {
14
+ type: 'paragraph',
15
+ attrs: {
16
+ nodeIndent: null,
17
+ nodeTextAlignment: null,
18
+ nodeLineHeight: null,
19
+ style: '',
20
+ },
21
+ content: [
22
+ {
23
+ type: 'text',
24
+ text: 'Hello ',
25
+ },
26
+ {
27
+ type: 'text',
28
+ marks: [
29
+ {
30
+ type: 'link',
31
+ attrs: {
32
+ href: 'https://www.google.com',
33
+ target: null,
34
+ auto: false,
35
+ title: null,
36
+ },
37
+ },
38
+ {
39
+ type: 'bold',
40
+ },
41
+ ],
42
+ text: 'Mr Bean',
43
+ },
44
+ {
45
+ type: 'text',
46
+ text: ', nice to ',
47
+ },
48
+ {
49
+ type: 'text',
50
+ marks: [
51
+ {
52
+ type: 'link',
53
+ attrs: {
54
+ href: 'https://www.google.com',
55
+ target: null,
56
+ auto: false,
57
+ title: null,
58
+ },
59
+ },
60
+ ],
61
+ text: 'meet you',
62
+ },
63
+ {
64
+ type: 'text',
65
+ text: '.',
66
+ },
67
+ {
68
+ type: 'image',
69
+ attrs: {
70
+ alt: 'Test',
71
+ height: '150',
72
+ width: '200',
73
+ src: 'https://media2.giphy.com/media/3o6ozsIxg5legYvggo/giphy.gif',
74
+ title: '',
75
+ },
76
+ },
77
+ ],
78
+ },
79
+ ],
80
+ });
81
+ });
82
+
83
+ it('should convert top level text component JSON to Remirror JSON', () => {
84
+ expect(squizNodeToRemirrorNode(mockSquizNodeTextJson)).toEqual({
85
+ content: [
86
+ {
87
+ attrs: { nodeIndent: null, nodeLineHeight: null, nodeTextAlignment: null, style: '' },
88
+ type: 'paragraph',
89
+ content: [
90
+ {
91
+ type: 'text',
92
+ text: 'Hello world!',
93
+ },
94
+ ],
95
+ },
96
+ {
97
+ attrs: { nodeIndent: null, nodeLineHeight: null, nodeTextAlignment: null, style: '' },
98
+ type: 'paragraph',
99
+ content: [
100
+ {
101
+ type: 'text',
102
+ text: 'Another one...',
103
+ },
104
+ ],
105
+ },
106
+ ],
107
+ type: 'doc',
108
+ });
109
+ });
110
+
111
+ it('should handle empty Squiz component JSON', () => {
112
+ expect(squizNodeToRemirrorNode([])).toEqual({ content: [], type: 'doc' });
113
+ expect(
114
+ squizNodeToRemirrorNode([
115
+ {
116
+ children: [],
117
+ type: 'tag',
118
+ tag: 'p',
119
+ },
120
+ ]),
121
+ ).toEqual({
122
+ content: [
123
+ {
124
+ attrs: { nodeIndent: null, nodeLineHeight: null, nodeTextAlignment: null, style: '' },
125
+ type: 'paragraph',
126
+ },
127
+ ],
128
+ type: 'doc',
129
+ });
130
+ });
131
+
132
+ it('should throw an error for non supported node types', () => {
133
+ const squizComponentJSON: FormattedText = [
134
+ {
135
+ children: [
136
+ {
137
+ type: 'text',
138
+ value: 'Hello world!',
139
+ },
140
+ ],
141
+ type: 'tag',
142
+ tag: 'p',
143
+ },
144
+ // This should be filtered out, as we don't currently support <code> tags
145
+ {
146
+ children: [
147
+ {
148
+ type: 'text',
149
+ value: 'Should be filtered out...',
150
+ },
151
+ ],
152
+ type: 'tag',
153
+ tag: 'code',
154
+ },
155
+ ];
156
+
157
+ expect(() => squizNodeToRemirrorNode(squizComponentJSON)).toThrow(`Unsupported node type provided: code`);
158
+ });
159
+
160
+ it('should handle pre formatted text', () => {
161
+ const squizComponentJSON: FormattedText = [
162
+ {
163
+ children: [
164
+ {
165
+ type: 'text',
166
+ value: 'Hello world!',
167
+ },
168
+ ],
169
+ type: 'tag',
170
+ tag: 'pre',
171
+ },
172
+ ];
173
+
174
+ const expected: RemirrorJSON = {
175
+ content: [
176
+ {
177
+ attrs: { nodeIndent: null, nodeLineHeight: null, nodeTextAlignment: null, style: '' },
178
+ content: [{ text: 'Hello world!', type: 'text' }],
179
+ type: 'preformatted',
180
+ },
181
+ ],
182
+ type: 'doc',
183
+ };
184
+
185
+ const result = squizNodeToRemirrorNode(squizComponentJSON);
186
+ expect(result).toEqual(expected);
187
+ });
188
+
189
+ it('should handle images', () => {
190
+ const squizComponentJSON: FormattedText = [
191
+ {
192
+ children: [
193
+ {
194
+ children: [],
195
+ attributes: {
196
+ alt: 'This is a test alt',
197
+ height: '360',
198
+ width: '480',
199
+ src: 'https://media2.giphy.com/media/3o6ozsIxg5legYvggo/giphy.gif',
200
+ title: '',
201
+ },
202
+ type: 'tag',
203
+ tag: 'img',
204
+ },
205
+ ],
206
+ type: 'tag',
207
+ tag: 'p',
208
+ },
209
+ ];
210
+
211
+ const expected: RemirrorJSON = {
212
+ content: [
213
+ {
214
+ attrs: { nodeIndent: null, nodeLineHeight: null, nodeTextAlignment: null, style: '' },
215
+ content: [
216
+ {
217
+ type: 'image',
218
+ attrs: {
219
+ alt: 'This is a test alt',
220
+ height: '360',
221
+ width: '480',
222
+ src: 'https://media2.giphy.com/media/3o6ozsIxg5legYvggo/giphy.gif',
223
+ title: '',
224
+ },
225
+ },
226
+ ],
227
+ type: 'paragraph',
228
+ },
229
+ ],
230
+ type: 'doc',
231
+ };
232
+
233
+ const result = squizNodeToRemirrorNode(squizComponentJSON);
234
+ expect(result).toEqual(expected);
235
+ });
236
+
237
+ it.each([
238
+ ['italics', 'italic'],
239
+ ['bold', 'bold'],
240
+ ['underline', 'underline'],
241
+ ])('should handle %s formatting', (a, b) => {
242
+ const squizComponentJSON: FormattedText = [
243
+ {
244
+ children: [
245
+ {
246
+ children: [
247
+ {
248
+ type: 'text',
249
+ value: 'Hello world!',
250
+ },
251
+ ],
252
+ font: {
253
+ [a]: true,
254
+ },
255
+ tag: 'span',
256
+ type: 'tag',
257
+ },
258
+ ],
259
+ type: 'tag',
260
+ tag: 'p',
261
+ },
262
+ ];
263
+
264
+ const expected: RemirrorJSON = {
265
+ content: [
266
+ {
267
+ attrs: { nodeIndent: null, nodeLineHeight: null, nodeTextAlignment: null, style: '' },
268
+ content: [{ text: 'Hello world!', marks: [{ type: b }], type: 'text' }],
269
+ type: 'paragraph',
270
+ },
271
+ ],
272
+ type: 'doc',
273
+ };
274
+
275
+ const result = squizNodeToRemirrorNode(squizComponentJSON);
276
+ expect(result).toEqual(expected);
277
+ });
278
+
279
+ it.each([1, 2, 3, 4, 5, 6])('should handle heading %s and set it to level %s', (level) => {
280
+ const squizComponentJSON: FormattedText = [
281
+ {
282
+ children: [
283
+ {
284
+ type: 'text',
285
+ value: 'Hello world!',
286
+ },
287
+ ],
288
+ type: 'tag',
289
+ tag: `h${level}`,
290
+ },
291
+ ];
292
+
293
+ const expected: RemirrorJSON = {
294
+ content: [
295
+ {
296
+ attrs: { nodeIndent: null, nodeLineHeight: null, nodeTextAlignment: null, style: '', level },
297
+ content: [{ text: 'Hello world!', type: 'text' }],
298
+ type: 'heading',
299
+ },
300
+ ],
301
+ type: 'doc',
302
+ };
303
+
304
+ const result = squizNodeToRemirrorNode(squizComponentJSON);
305
+ expect(result).toEqual(expected);
306
+ });
307
+ });
@@ -0,0 +1,123 @@
1
+ import { RemirrorJSON, Literal, ObjectMark } from '@remirror/core';
2
+ import { FORMATTED_TEXT_MODELS as FormattedTextModels } from '@squiz/dx-json-schema-lib';
3
+
4
+ type FormattedText = FormattedTextModels.v1.FormattedText;
5
+ type FormattedTextTag = FormattedTextModels.v1.FormattedTextTag;
6
+ type FormattedNodes = FormattedTextModels.v1.FormattedNodes;
7
+
8
+ const getNodeType = (node: FormattedTextTag): string => {
9
+ const nodeTypeMap: Record<string, string> = {
10
+ h1: 'heading',
11
+ h2: 'heading',
12
+ h3: 'heading',
13
+ h4: 'heading',
14
+ h5: 'heading',
15
+ h6: 'heading',
16
+ img: 'image',
17
+ pre: 'preformatted',
18
+ p: 'paragraph',
19
+ text: 'paragraph',
20
+ };
21
+
22
+ const nodeType = nodeTypeMap[node.tag || node.type];
23
+
24
+ // Unsupported node type
25
+ if (!nodeType) throw new Error(`Unsupported node type provided: ${node.tag}`);
26
+
27
+ return nodeType;
28
+ };
29
+
30
+ const getNodeAttributes = (node: FormattedTextTag): Record<string, Literal> => {
31
+ const { alignment } = node.formattingOptions || {};
32
+ return {
33
+ nodeIndent: null,
34
+ nodeTextAlignment: alignment ?? null,
35
+ nodeLineHeight: null,
36
+ style: '',
37
+ level: node.tag?.startsWith('h') ? parseInt(node.tag.substring(1)) : undefined,
38
+ };
39
+ };
40
+
41
+ const resolveChild = (child: FormattedNodes): RemirrorJSON => {
42
+ if (child.type === 'text') {
43
+ return { type: 'text', text: child.value };
44
+ }
45
+
46
+ let text = '';
47
+ const marks: ObjectMark[] = [];
48
+
49
+ if (child.type === 'tag') {
50
+ // Handle link type
51
+ if (child.tag === 'a') {
52
+ marks.push({
53
+ type: 'link',
54
+ attrs: {
55
+ href: child.attributes?.href,
56
+ target: child.attributes?.target ?? null,
57
+ auto: false,
58
+ title: child.attributes?.title ?? null,
59
+ },
60
+ });
61
+ }
62
+
63
+ // Handle image type
64
+ if (child.tag === 'img') {
65
+ return {
66
+ type: 'image',
67
+ attrs: {
68
+ alt: child.attributes?.alt,
69
+ height: child.attributes?.height,
70
+ width: child.attributes?.width,
71
+ src: child.attributes?.src,
72
+ title: child.attributes?.title,
73
+ },
74
+ };
75
+ }
76
+
77
+ // Handle font formatting
78
+ child.font?.bold && marks.push({ type: 'bold' });
79
+ child.font?.italics && marks.push({ type: 'italic' });
80
+ child.font?.underline && marks.push({ type: 'underline' });
81
+
82
+ // For now all children types should be "text"
83
+ text = child.children[0].type === 'text' ? child.children[0].value : '';
84
+ }
85
+
86
+ return { type: 'text', marks, text };
87
+ };
88
+
89
+ const formatNode = (node: FormattedNodes): RemirrorJSON => {
90
+ let content: RemirrorJSON[] | undefined;
91
+
92
+ if (node.type === 'tag') {
93
+ content = node.children.length ? node.children.map((child: FormattedNodes) => resolveChild(child)) : undefined;
94
+ }
95
+
96
+ if (node.type === 'text') {
97
+ content = [
98
+ {
99
+ type: 'text',
100
+ text: node.value,
101
+ },
102
+ ];
103
+ }
104
+
105
+ return {
106
+ type: getNodeType(node as FormattedTextTag),
107
+ attrs: getNodeAttributes(node as FormattedTextTag),
108
+ content,
109
+ };
110
+ };
111
+
112
+ /**
113
+ * Converts Squiz component JSON structure to Remirror node JSON structure.
114
+ * @param {FormattedText} nodes Squiz nodes to convert to Remirror.
115
+ * @export
116
+ * @returns {RemirrorJSON} The converted Remirror JSON.
117
+ */
118
+ export const squizNodeToRemirrorNode = (nodes: FormattedText): RemirrorJSON => {
119
+ return {
120
+ type: 'doc',
121
+ content: nodes.filter((node: FormattedNodes) => getNodeType(node as FormattedTextTag)).map(formatNode),
122
+ };
123
+ };
@@ -0,0 +1,33 @@
1
+ import { validRemirrorNode } from './validNodeTypes';
2
+
3
+ describe('validRemirrorNode', () => {
4
+ it('returns false for null input', () => {
5
+ expect(validRemirrorNode(null as any)).toBe(false);
6
+ });
7
+
8
+ it('returns false for unsupported node type', () => {
9
+ const node = { type: { name: 'unsupported' }, marks: [] };
10
+ expect(validRemirrorNode(node as any)).toBe(false);
11
+ });
12
+
13
+ it('returns false for unsupported mark type', () => {
14
+ const node = {
15
+ type: { name: 'doc' },
16
+ marks: [{ type: { name: 'unsupported' } }],
17
+ };
18
+ expect(validRemirrorNode(node as any)).toBe(false);
19
+ });
20
+
21
+ it('returns true for supported node type with no marks', () => {
22
+ const node = { type: { name: 'doc' }, marks: [] };
23
+ expect(validRemirrorNode(node as any)).toBe(true);
24
+ });
25
+
26
+ it('returns true for supported node type with supported mark type', () => {
27
+ const node = {
28
+ type: { name: 'doc' },
29
+ marks: [{ type: { name: 'bold' } }],
30
+ };
31
+ expect(validRemirrorNode(node as any)).toBe(true);
32
+ });
33
+ });
@@ -0,0 +1,21 @@
1
+ import { ProsemirrorNode } from 'remirror';
2
+ import { Extensions } from '../../Extensions/Extensions';
3
+
4
+ export const validRemirrorNode = (node: ProsemirrorNode): boolean => {
5
+ if (!node) return false;
6
+
7
+ const nodeType = node.type.name;
8
+ const nodeMarks = node.marks;
9
+
10
+ // This is pulling in the currently supported extensions, this works for now...
11
+ // Could also just hard code these in as we go, but this should make it easier as we add more extensions
12
+ const supportedNodes: Array<string> = [...Extensions().map((extension: any) => extension.name), 'doc', 'text'];
13
+
14
+ if (!supportedNodes.includes(nodeType)) return false;
15
+
16
+ for (let i = 0; i < nodeMarks.length; i++) {
17
+ if (!supportedNodes.includes(nodeMarks[i].type.name)) return false;
18
+ }
19
+
20
+ return true;
21
+ };
@@ -13,7 +13,7 @@ export type EditorRenderOptions = RenderOptions & {
13
13
  };
14
14
 
15
15
  type TestEditorProps = EditorRenderOptions & {
16
- children: ReactElement;
16
+ children: ReactElement | null;
17
17
  onReady: (manager: RemirrorManager<Extension>) => void;
18
18
  };
19
19
 
@@ -76,7 +76,7 @@ const TestEditor = ({ children, extensions, content, onReady }: TestEditorProps)
76
76
  * @return {Promise<EditorRenderResult>}
77
77
  */
78
78
  export const renderWithEditor = async (
79
- ui: ReactElement,
79
+ ui: ReactElement | null,
80
80
  options?: EditorRenderOptions,
81
81
  ): Promise<EditorRenderResult> => {
82
82
  const result: Partial<EditorRenderResult> = {