@squiz/formatted-text-editor 1.40.1-alpha.23 → 1.40.1-alpha.25
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/demo/AppContext.tsx +14 -3
- package/demo/resources.json +135 -3
- package/lib/Editor/Editor.js +1 -1
- package/lib/hooks/useFocus.js +24 -5
- package/package.json +4 -4
- package/src/Editor/Editor.spec.tsx +91 -16
- package/src/Editor/Editor.tsx +1 -1
- package/src/hooks/useFocus.ts +30 -7
package/demo/AppContext.tsx
CHANGED
@@ -10,9 +10,20 @@ export const AppContext = ({ children }: AppContextProps) => (
|
|
10
10
|
<ResourceBrowserContext.Provider
|
11
11
|
value={{
|
12
12
|
onRequestSources: (): Promise<Source> => Promise.resolve(sources),
|
13
|
-
onRequestChildren: (): Promise<Resource[]> =>
|
14
|
-
|
15
|
-
|
13
|
+
onRequestChildren: (source: Source, resource: Resource | null): Promise<Resource[]> =>
|
14
|
+
Promise.resolve(resource._children || resources),
|
15
|
+
onRequestResource: (reference: ResourceReference): Promise<Resource | null> => {
|
16
|
+
const flattenResources = (resources: unknown[]) => {
|
17
|
+
return [
|
18
|
+
...resources,
|
19
|
+
...resources.flatMap((resource) => ('_children' in resource ? flattenResources(resource._children) : [])),
|
20
|
+
];
|
21
|
+
};
|
22
|
+
|
23
|
+
return Promise.resolve(
|
24
|
+
flattenResources(resources).find((resource) => resource.id === reference.resource) || null,
|
25
|
+
);
|
26
|
+
},
|
16
27
|
}}
|
17
28
|
>
|
18
29
|
<EditorContext.Provider
|
package/demo/resources.json
CHANGED
@@ -9,11 +9,26 @@
|
|
9
9
|
"code": "live",
|
10
10
|
"name": "Live"
|
11
11
|
},
|
12
|
-
"name": "Example image",
|
13
|
-
"childCount": 0
|
12
|
+
"name": "Example image one",
|
13
|
+
"childCount": 0,
|
14
|
+
"url": "https://picsum.photos/200/300"
|
14
15
|
},
|
15
16
|
{
|
16
17
|
"id": "2",
|
18
|
+
"type": {
|
19
|
+
"code": "image",
|
20
|
+
"name": "Image"
|
21
|
+
},
|
22
|
+
"status": {
|
23
|
+
"code": "live",
|
24
|
+
"name": "Live"
|
25
|
+
},
|
26
|
+
"name": "Example image two",
|
27
|
+
"childCount": 0,
|
28
|
+
"url": "https://picsum.photos/200/300"
|
29
|
+
},
|
30
|
+
{
|
31
|
+
"id": "3",
|
17
32
|
"type": {
|
18
33
|
"code": "page_standard",
|
19
34
|
"name": "Standard Page"
|
@@ -23,6 +38,123 @@
|
|
23
38
|
"name": "Live"
|
24
39
|
},
|
25
40
|
"name": "Example page",
|
26
|
-
"childCount": 0
|
41
|
+
"childCount": 0,
|
42
|
+
"url": "https://picsum.photos/200/300"
|
43
|
+
},
|
44
|
+
{
|
45
|
+
"id": "4",
|
46
|
+
"type": {
|
47
|
+
"code": "folder",
|
48
|
+
"name": "Folder"
|
49
|
+
},
|
50
|
+
"status": {
|
51
|
+
"code": "live",
|
52
|
+
"name": "Live"
|
53
|
+
},
|
54
|
+
"name": "Example folder",
|
55
|
+
"childCount": 10,
|
56
|
+
"url": "",
|
57
|
+
"_children": [
|
58
|
+
{
|
59
|
+
"id": "5",
|
60
|
+
"type": {
|
61
|
+
"code": "image",
|
62
|
+
"name": "Image"
|
63
|
+
},
|
64
|
+
"status": {
|
65
|
+
"code": "live",
|
66
|
+
"name": "Live"
|
67
|
+
},
|
68
|
+
"name": "Example image one #2",
|
69
|
+
"childCount": 0,
|
70
|
+
"url": "https://picsum.photos/200/300"
|
71
|
+
},
|
72
|
+
{
|
73
|
+
"id": "6",
|
74
|
+
"type": {
|
75
|
+
"code": "image",
|
76
|
+
"name": "Image"
|
77
|
+
},
|
78
|
+
"status": {
|
79
|
+
"code": "live",
|
80
|
+
"name": "Live"
|
81
|
+
},
|
82
|
+
"name": "Example image two #2",
|
83
|
+
"childCount": 0,
|
84
|
+
"url": "https://picsum.photos/200/300"
|
85
|
+
},
|
86
|
+
{
|
87
|
+
"id": "7",
|
88
|
+
"type": {
|
89
|
+
"code": "page_standard",
|
90
|
+
"name": "Standard Page"
|
91
|
+
},
|
92
|
+
"status": {
|
93
|
+
"code": "live",
|
94
|
+
"name": "Live"
|
95
|
+
},
|
96
|
+
"name": "Example page #2",
|
97
|
+
"childCount": 0,
|
98
|
+
"url": "https://picsum.photos/200/300"
|
99
|
+
},
|
100
|
+
{
|
101
|
+
"id": "8",
|
102
|
+
"type": {
|
103
|
+
"code": "folder",
|
104
|
+
"name": "Folder"
|
105
|
+
},
|
106
|
+
"status": {
|
107
|
+
"code": "live",
|
108
|
+
"name": "Live"
|
109
|
+
},
|
110
|
+
"name": "Example folder #2",
|
111
|
+
"childCount": 10,
|
112
|
+
"url": "",
|
113
|
+
"_children": [
|
114
|
+
{
|
115
|
+
"id": "9",
|
116
|
+
"type": {
|
117
|
+
"code": "image",
|
118
|
+
"name": "Image"
|
119
|
+
},
|
120
|
+
"status": {
|
121
|
+
"code": "live",
|
122
|
+
"name": "Live"
|
123
|
+
},
|
124
|
+
"name": "Example image one #3",
|
125
|
+
"childCount": 0,
|
126
|
+
"url": "https://picsum.photos/200/300"
|
127
|
+
},
|
128
|
+
{
|
129
|
+
"id": "10",
|
130
|
+
"type": {
|
131
|
+
"code": "image",
|
132
|
+
"name": "Image"
|
133
|
+
},
|
134
|
+
"status": {
|
135
|
+
"code": "live",
|
136
|
+
"name": "Live"
|
137
|
+
},
|
138
|
+
"name": "Example image two #3",
|
139
|
+
"childCount": 0,
|
140
|
+
"url": "https://picsum.photos/200/300"
|
141
|
+
},
|
142
|
+
{
|
143
|
+
"id": "11",
|
144
|
+
"type": {
|
145
|
+
"code": "page_standard",
|
146
|
+
"name": "Standard Page"
|
147
|
+
},
|
148
|
+
"status": {
|
149
|
+
"code": "live",
|
150
|
+
"name": "Live"
|
151
|
+
},
|
152
|
+
"name": "Example page #3",
|
153
|
+
"childCount": 0,
|
154
|
+
"url": "https://picsum.photos/200/300"
|
155
|
+
}
|
156
|
+
]
|
157
|
+
}
|
158
|
+
]
|
27
159
|
}
|
28
160
|
]
|
package/lib/Editor/Editor.js
CHANGED
@@ -65,7 +65,7 @@ const Editor = ({ content, editable = true, onChange, children, isFocused }) =>
|
|
65
65
|
}
|
66
66
|
}, []);
|
67
67
|
return (react_1.default.createElement("div", { className: "squiz-fte-scope" },
|
68
|
-
react_1.default.createElement("div", { ref: wrapperRef, onBlur: handleBlur, onFocusCapture: handleFocus, className: (0, clsx_1.default)('
|
68
|
+
react_1.default.createElement("div", { ref: wrapperRef, onBlur: handleBlur, onFocusCapture: handleFocus, className: (0, clsx_1.default)('formatted-text-editor', !editable && 'formatted-text-editor--is-disabled') },
|
69
69
|
react_1.default.createElement(react_2.Remirror, { manager: manager, state: state, editable: editable, onChange: handleChange, placeholder: "Write something", label: "Text editor" },
|
70
70
|
editable && react_1.default.createElement(EditorToolbar_1.Toolbar, { isVisible: isVisible }),
|
71
71
|
children,
|
package/lib/hooks/useFocus.js
CHANGED
@@ -6,13 +6,32 @@ const useFocus = (initialState) => {
|
|
6
6
|
const [isVisible, setIsVisible] = (0, react_1.useState)(initialState);
|
7
7
|
const handleFocus = (0, react_1.useCallback)(() => {
|
8
8
|
setIsVisible(true);
|
9
|
-
}, []);
|
10
|
-
const handleBlur = (event) => {
|
11
|
-
|
12
|
-
|
9
|
+
}, [wrapperRef]);
|
10
|
+
const handleBlur = (0, react_1.useCallback)((event) => {
|
11
|
+
// React event bubbling is interesting, it bubbles up the React tree rather than the DOM tree.
|
12
|
+
// The tree deviates when rendering portals (eg. for modals).
|
13
|
+
//
|
14
|
+
// Only hide the toolbar if:
|
15
|
+
// 1. We are blurring a node in the editor **DOM** tree.
|
16
|
+
// 2. We are focusing on something that is not in the editor DOM tree
|
17
|
+
// (elements in the portal won't be in the tree but don't influence the focus state per #1).
|
18
|
+
//
|
19
|
+
// This avoids the scenario where an element in a portal is blurred and another one in the portal focused.
|
20
|
+
// Without this logic the blur and focus handlers are called (in that order). The impact of these handlers being
|
21
|
+
// called is that the "isFocused" state changes inconsistently. This state changing then causes subtle issues.
|
22
|
+
// eg. unable to drill down in resource browser, toolbar appearing/disappearing.
|
23
|
+
//
|
24
|
+
// Ideally we would instead solely seeing if the "relatedTarget" is in the React tree. This isn't easily
|
25
|
+
// identifiable however without reaching into React internals.
|
26
|
+
//
|
27
|
+
// An assumption here is that anything in a portal will only blur to another element that is also in the portal
|
28
|
+
// (and therefore still in our React tree resulting in the element still effectively being focused).
|
29
|
+
const isBlurringEditor = wrapperRef.current?.contains(event.target);
|
30
|
+
const isFocusedInEditor = wrapperRef.current?.contains(event.relatedTarget);
|
31
|
+
if (isBlurringEditor && !isFocusedInEditor) {
|
13
32
|
setIsVisible(false);
|
14
33
|
}
|
15
|
-
};
|
34
|
+
}, [wrapperRef]);
|
16
35
|
return { handleFocus, handleBlur, isVisible, wrapperRef };
|
17
36
|
};
|
18
37
|
exports.default = useFocus;
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@squiz/formatted-text-editor",
|
3
|
-
"version": "1.40.1-alpha.
|
3
|
+
"version": "1.40.1-alpha.25",
|
4
4
|
"main": "lib/index.js",
|
5
5
|
"types": "lib/index.d.ts",
|
6
6
|
"scripts": {
|
@@ -20,8 +20,8 @@
|
|
20
20
|
"@headlessui/react": "1.7.11",
|
21
21
|
"@mui/icons-material": "5.11.16",
|
22
22
|
"@remirror/react": "2.0.25",
|
23
|
-
"@squiz/dx-json-schema-lib": "1.40.1-alpha.
|
24
|
-
"@squiz/resource-browser": "1.40.1-alpha.
|
23
|
+
"@squiz/dx-json-schema-lib": "1.40.1-alpha.25",
|
24
|
+
"@squiz/resource-browser": "1.40.1-alpha.25",
|
25
25
|
"clsx": "1.2.1",
|
26
26
|
"react-hook-form": "7.43.2",
|
27
27
|
"react-image-size": "2.0.0",
|
@@ -75,5 +75,5 @@
|
|
75
75
|
"volta": {
|
76
76
|
"node": "18.15.0"
|
77
77
|
},
|
78
|
-
"gitHead": "
|
78
|
+
"gitHead": "1691925a1778532593a87010af01419c49ff02a4"
|
79
79
|
}
|
@@ -1,27 +1,16 @@
|
|
1
|
+
import '@testing-library/jest-dom';
|
1
2
|
import React from 'react';
|
2
|
-
import { act, fireEvent, render, screen } from '@testing-library/react';
|
3
|
+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
4
|
+
import { ResourceBrowserContext } from '@squiz/resource-browser';
|
3
5
|
import Editor from './Editor';
|
4
|
-
import '@testing-library/jest-dom';
|
5
6
|
import { renderWithEditor } from '../../tests';
|
6
7
|
import ImageButton from '../EditorToolbar/Tools/Image/ImageButton';
|
8
|
+
import * as useFocus from '../hooks/useFocus';
|
7
9
|
|
8
|
-
const isVisible = jest.fn().mockReturnValue(false);
|
9
10
|
const handleFocusMock = jest.fn();
|
10
11
|
const handleBlurMock = jest.fn();
|
11
|
-
jest.mock('../hooks/useFocus', () => ({
|
12
|
-
__esModule: true,
|
13
|
-
default: () => ({
|
14
|
-
isVisible: isVisible(),
|
15
|
-
handleFocus: handleFocusMock,
|
16
|
-
handleBlur: handleBlurMock,
|
17
|
-
}),
|
18
|
-
}));
|
19
12
|
|
20
13
|
describe('Formatted text editor', () => {
|
21
|
-
afterEach(() => {
|
22
|
-
jest.clearAllMocks();
|
23
|
-
});
|
24
|
-
|
25
14
|
it('Renders the text editor', () => {
|
26
15
|
render(<Editor />);
|
27
16
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
@@ -329,6 +318,13 @@ describe('Formatted text editor', () => {
|
|
329
318
|
});
|
330
319
|
|
331
320
|
it('triggers handleFocus when editor is focused', async () => {
|
321
|
+
jest.spyOn(useFocus, 'default').mockReturnValue({
|
322
|
+
isVisible: false,
|
323
|
+
handleFocus: handleFocusMock,
|
324
|
+
handleBlur: handleBlurMock,
|
325
|
+
wrapperRef: { current: null },
|
326
|
+
});
|
327
|
+
|
332
328
|
const { getByLabelText } = render(<Editor />);
|
333
329
|
const editorInput = getByLabelText('Text editor');
|
334
330
|
|
@@ -339,6 +335,13 @@ describe('Formatted text editor', () => {
|
|
339
335
|
});
|
340
336
|
|
341
337
|
it('triggers handleBlur when editor is blurred', () => {
|
338
|
+
jest.spyOn(useFocus, 'default').mockReturnValue({
|
339
|
+
isVisible: false,
|
340
|
+
handleFocus: handleFocusMock,
|
341
|
+
handleBlur: handleBlurMock,
|
342
|
+
wrapperRef: { current: null },
|
343
|
+
});
|
344
|
+
|
342
345
|
const { getByLabelText } = render(<Editor />);
|
343
346
|
const editorInput = getByLabelText('Text editor');
|
344
347
|
|
@@ -349,9 +352,81 @@ describe('Formatted text editor', () => {
|
|
349
352
|
});
|
350
353
|
|
351
354
|
it('should apply hide class when focus hook returns false', () => {
|
352
|
-
|
355
|
+
jest.spyOn(useFocus, 'default').mockReturnValue({
|
356
|
+
isVisible: true,
|
357
|
+
handleFocus: handleFocusMock,
|
358
|
+
handleBlur: handleBlurMock,
|
359
|
+
wrapperRef: { current: null },
|
360
|
+
});
|
361
|
+
|
353
362
|
const { container } = render(<Editor />);
|
354
363
|
|
355
364
|
expect(container.querySelector('.show-toolbar')).toBeInTheDocument();
|
356
365
|
});
|
366
|
+
|
367
|
+
it('Interactivity in modals functions correctly, toolbar remains visible throughout', async () => {
|
368
|
+
const onRequestSources = jest.fn().mockResolvedValue([
|
369
|
+
{
|
370
|
+
id: 'my-matrix-instance',
|
371
|
+
name: 'My Matrix instance',
|
372
|
+
nodes: [],
|
373
|
+
},
|
374
|
+
]);
|
375
|
+
const onRequestChildren = jest
|
376
|
+
.fn()
|
377
|
+
.mockResolvedValueOnce([
|
378
|
+
{
|
379
|
+
id: '1',
|
380
|
+
name: 'Folder 1',
|
381
|
+
type: { code: 'folder', name: 'Folder' },
|
382
|
+
status: { code: 'live', name: 'Live' },
|
383
|
+
childCount: 1,
|
384
|
+
},
|
385
|
+
])
|
386
|
+
.mockResolvedValueOnce([
|
387
|
+
{
|
388
|
+
id: '2',
|
389
|
+
name: 'Folder 2',
|
390
|
+
type: { code: 'folder', name: 'Folder' },
|
391
|
+
status: { code: 'live', name: 'Live' },
|
392
|
+
childCount: 1,
|
393
|
+
},
|
394
|
+
])
|
395
|
+
.mockResolvedValueOnce([
|
396
|
+
{
|
397
|
+
id: '3',
|
398
|
+
name: 'My image',
|
399
|
+
type: { code: 'image', name: 'Image' },
|
400
|
+
status: { code: 'live', name: 'Live' },
|
401
|
+
childCount: 0,
|
402
|
+
},
|
403
|
+
]);
|
404
|
+
const onRequestResource = jest.fn().mockResolvedValue({
|
405
|
+
id: '3',
|
406
|
+
name: 'My image',
|
407
|
+
type: { code: 'image', name: 'Image' },
|
408
|
+
status: { code: 'live', name: 'Live' },
|
409
|
+
childCount: '1',
|
410
|
+
});
|
411
|
+
|
412
|
+
render(
|
413
|
+
<ResourceBrowserContext.Provider value={{ onRequestSources, onRequestChildren, onRequestResource }}>
|
414
|
+
<Editor />
|
415
|
+
</ResourceBrowserContext.Provider>,
|
416
|
+
);
|
417
|
+
|
418
|
+
await act(() => fireEvent.click(screen.getByRole('button', { name: 'Link (cmd+K)' })));
|
419
|
+
await act(() => fireEvent.click(screen.getByText('Choose asset')));
|
420
|
+
await act(() => fireEvent.click(screen.getByRole('button', { name: 'Drill down to My Matrix instance children' })));
|
421
|
+
await act(() => fireEvent.click(screen.getByRole('button', { name: 'Drill down to Folder 1 children' })));
|
422
|
+
await act(() => fireEvent.click(screen.getByRole('button', { name: 'Drill down to Folder 2 children' })));
|
423
|
+
await act(() => fireEvent.blur(screen.getByRole('button', { name: /My image/ })));
|
424
|
+
|
425
|
+
// Contents of "Folder 2" should be mounted.
|
426
|
+
// Toolbar (Link button) should be mounted.
|
427
|
+
// Toolbar should be shown (.show-toolbar class).
|
428
|
+
expect(screen.getByRole('button', { name: /My image/ })).toBeInTheDocument();
|
429
|
+
expect(within(document.body).getByRole('button', { name: 'Link (cmd+K)', hidden: true })).toBeInTheDocument();
|
430
|
+
expect(document.querySelector('.show-toolbar')).toBeInTheDocument();
|
431
|
+
});
|
357
432
|
});
|
package/src/Editor/Editor.tsx
CHANGED
@@ -60,7 +60,7 @@ const Editor = ({ content, editable = true, onChange, children, isFocused }: Edi
|
|
60
60
|
ref={wrapperRef}
|
61
61
|
onBlur={handleBlur}
|
62
62
|
onFocusCapture={handleFocus}
|
63
|
-
className={clsx('
|
63
|
+
className={clsx('formatted-text-editor', !editable && 'formatted-text-editor--is-disabled')}
|
64
64
|
>
|
65
65
|
<Remirror
|
66
66
|
manager={manager}
|
package/src/hooks/useFocus.ts
CHANGED
@@ -13,14 +13,37 @@ const useFocus = (
|
|
13
13
|
|
14
14
|
const handleFocus = useCallback(() => {
|
15
15
|
setIsVisible(true);
|
16
|
-
}, []);
|
16
|
+
}, [wrapperRef]);
|
17
17
|
|
18
|
-
const handleBlur
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
18
|
+
const handleBlur = useCallback(
|
19
|
+
(event: FocusEvent<HTMLDivElement>) => {
|
20
|
+
// React event bubbling is interesting, it bubbles up the React tree rather than the DOM tree.
|
21
|
+
// The tree deviates when rendering portals (eg. for modals).
|
22
|
+
//
|
23
|
+
// Only hide the toolbar if:
|
24
|
+
// 1. We are blurring a node in the editor **DOM** tree.
|
25
|
+
// 2. We are focusing on something that is not in the editor DOM tree
|
26
|
+
// (elements in the portal won't be in the tree but don't influence the focus state per #1).
|
27
|
+
//
|
28
|
+
// This avoids the scenario where an element in a portal is blurred and another one in the portal focused.
|
29
|
+
// Without this logic the blur and focus handlers are called (in that order). The impact of these handlers being
|
30
|
+
// called is that the "isFocused" state changes inconsistently. This state changing then causes subtle issues.
|
31
|
+
// eg. unable to drill down in resource browser, toolbar appearing/disappearing.
|
32
|
+
//
|
33
|
+
// Ideally we would instead solely seeing if the "relatedTarget" is in the React tree. This isn't easily
|
34
|
+
// identifiable however without reaching into React internals.
|
35
|
+
//
|
36
|
+
// An assumption here is that anything in a portal will only blur to another element that is also in the portal
|
37
|
+
// (and therefore still in our React tree resulting in the element still effectively being focused).
|
38
|
+
const isBlurringEditor = wrapperRef.current?.contains(event.target);
|
39
|
+
const isFocusedInEditor = wrapperRef.current?.contains(event.relatedTarget);
|
40
|
+
|
41
|
+
if (isBlurringEditor && !isFocusedInEditor) {
|
42
|
+
setIsVisible(false);
|
43
|
+
}
|
44
|
+
},
|
45
|
+
[wrapperRef],
|
46
|
+
);
|
24
47
|
|
25
48
|
return { handleFocus, handleBlur, isVisible, wrapperRef };
|
26
49
|
};
|