@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.
@@ -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[]> => Promise.resolve(resources),
14
- onRequestResource: (reference: ResourceReference): Promise<Resource | null> =>
15
- Promise.resolve(resources.find((resource) => resource.id === reference.resource) || null),
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
@@ -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
  ]
@@ -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)('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled') },
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,
@@ -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
- const isOutside = wrapperRef.current !== null && !wrapperRef.current.contains(event.relatedTarget);
12
- if (isOutside) {
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.23",
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.23",
24
- "@squiz/resource-browser": "1.40.1-alpha.23",
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": "32156ccf2be6ae91cfcaa42df6cee5c449f1fa26"
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
- isVisible.mockReturnValue(true);
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
  });
@@ -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('remirror-theme formatted-text-editor', !editable && 'formatted-text-editor--is-disabled')}
63
+ className={clsx('formatted-text-editor', !editable && 'formatted-text-editor--is-disabled')}
64
64
  >
65
65
  <Remirror
66
66
  manager={manager}
@@ -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: FocusEventHandler<HTMLDivElement> = (event: FocusEvent<HTMLDivElement>) => {
19
- const isOutside = wrapperRef.current !== null && !wrapperRef.current.contains(event.relatedTarget as Node);
20
- if (isOutside) {
21
- setIsVisible(false);
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
  };