Skip to content

Commit c771392

Browse files
authored
perf: optimize toolbar rendering (#978)
1 parent d30d9b1 commit c771392

19 files changed

Lines changed: 378 additions & 87 deletions

packages/editor/src/bundle/MarkupEditorView.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ export const MarkupEditorView = memo<MarkupEditorViewProps>((props) => {
8080
hiddenActionsConfig={hiddenActionsConfig}
8181
stickyToolbar={stickyToolbar}
8282
toolbarConfig={toolbarConfig}
83-
toolbarFocus={() => editor.focus()}
8483
settingsVisible={settingsVisible}
8584
className={b('toolbar', [toolbarClassName])}
8685
toolbarDisplay={toolbarDisplay}

packages/editor/src/bundle/ToolbarView.tsx

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,36 @@
1-
import {useLayoutEffect, useRef} from 'react';
1+
import {type ComponentProps, useCallback, useLayoutEffect, useMemo, useRef} from 'react';
22

33
import type {QAProps} from '@gravity-ui/uikit';
4-
import {useUpdate} from 'react-use';
54

65
import {LAYOUT} from 'src/common/layout';
6+
import {typedMemo} from 'src/react-utils/memo';
7+
import {EventEmitter} from 'src/utils';
78

89
import type {ClassNameProps} from '../classname';
910
import {i18n} from '../i18n/menubar';
1011
import {useSticky} from '../react-utils/useSticky';
11-
import {FlexToolbar, type ToolbarData, type ToolbarDisplay, type ToolbarItemData} from '../toolbar';
12+
import {
13+
FlexToolbar,
14+
type FlexToolbarProps,
15+
type ToolbarData,
16+
type ToolbarDisplay,
17+
type ToolbarItemData,
18+
ToolbarProvider,
19+
} from '../toolbar';
1220

1321
import type {EditorInt} from './Editor';
1422
import {stickyCn} from './sticky';
1523
import type {MarkdownEditorMode} from './types';
1624

25+
const MemoizedFlexibleToolbar = typedMemo(FlexToolbar);
26+
27+
type ToolbarProviderValue = NonNullable<ComponentProps<typeof ToolbarProvider>['value']>;
28+
1729
export type ToolbarViewProps<T> = ClassNameProps &
1830
QAProps & {
1931
editor: EditorInt;
2032
editorMode: MarkdownEditorMode;
2133
toolbarEditor: T;
22-
toolbarFocus: () => void;
2334
toolbarConfig: ToolbarData<T>;
2435
settingsVisible?: boolean;
2536
hiddenActionsConfig?: ToolbarItemData<T>[];
@@ -32,7 +43,6 @@ export function ToolbarView<T>({
3243
editor,
3344
editorMode,
3445
toolbarEditor,
35-
toolbarFocus,
3646
toolbarConfig,
3747
toolbarDisplay,
3848
hiddenActionsConfig,
@@ -42,19 +52,40 @@ export function ToolbarView<T>({
4252
stickyToolbar,
4353
qa,
4454
}: ToolbarViewProps<T>) {
45-
const rerender = useUpdate();
46-
useLayoutEffect(() => {
47-
editor.on('rerender-toolbar', rerender);
48-
return () => {
49-
editor.off('rerender-toolbar', rerender);
50-
};
51-
}, [editor, rerender]);
52-
5355
const wrapperRef = useRef<HTMLDivElement>(null);
5456
const isStickyActive = useSticky(wrapperRef) && stickyToolbar;
5557

5658
const mobile = editor.mobile;
5759

60+
const clickHandle = useCallback<NonNullable<FlexToolbarProps<T>['onClick']>>(
61+
(id, attrs) => editor.emit('toolbar-action', {id, attrs, editorMode}),
62+
[editor, editorMode],
63+
);
64+
65+
const toolbarProviderValue = useMemo(
66+
() =>
67+
({
68+
editor: toolbarEditor,
69+
eventBus: new EventEmitter<{update: null}>(),
70+
}) satisfies ToolbarProviderValue,
71+
[toolbarEditor],
72+
);
73+
74+
const handleFocus = useCallback(() => {
75+
editor.focus();
76+
}, [editor]);
77+
78+
useLayoutEffect(() => {
79+
const onRerender = () => {
80+
toolbarProviderValue.eventBus.emit('update', null);
81+
};
82+
83+
editor.on('rerender-toolbar', onRerender);
84+
return () => {
85+
editor.off('rerender-toolbar', onRerender);
86+
};
87+
}, [editor, toolbarProviderValue]);
88+
5889
return (
5990
<div
6091
data-qa={qa}
@@ -69,18 +100,20 @@ export function ToolbarView<T>({
69100
)}
70101
data-layout={LAYOUT.STICKY_TOOLBAR}
71102
>
72-
<FlexToolbar
73-
data={toolbarConfig}
74-
hiddenActions={hiddenActionsConfig}
75-
editor={toolbarEditor}
76-
focus={toolbarFocus}
77-
dotsTitle={i18n('more_action')}
78-
onClick={(id, attrs) => editor.emit('toolbar-action', {id, attrs, editorMode})}
79-
display={toolbarDisplay}
80-
disableTooltip={mobile}
81-
disableHotkey={mobile}
82-
disablePreview={mobile}
83-
/>
103+
<ToolbarProvider value={toolbarProviderValue}>
104+
<MemoizedFlexibleToolbar
105+
data={toolbarConfig}
106+
hiddenActions={hiddenActionsConfig}
107+
editor={toolbarEditor}
108+
focus={handleFocus}
109+
dotsTitle={i18n('more_action')}
110+
onClick={clickHandle}
111+
display={toolbarDisplay}
112+
disableTooltip={mobile}
113+
disableHotkey={mobile}
114+
disablePreview={mobile}
115+
/>
116+
</ToolbarProvider>
84117
{children}
85118
</div>
86119
);

packages/editor/src/bundle/WysiwygEditorView.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import {memo} from 'react';
2-
31
import type {QAProps} from '@gravity-ui/uikit';
42

53
import {type ClassNameProps, cn} from '../classname';
@@ -30,7 +28,7 @@ export type WysiwygEditorViewProps = ClassNameProps &
3028
toolbarDisplay?: ToolbarDisplay;
3129
};
3230

33-
export const WysiwygEditorView = memo<WysiwygEditorViewProps>((props) => {
31+
export const WysiwygEditorView: React.FC<WysiwygEditorViewProps> = (props) => {
3432
const {
3533
editor,
3634
autofocus,
@@ -69,7 +67,6 @@ export const WysiwygEditorView = memo<WysiwygEditorViewProps>((props) => {
6967
toolbarEditor={editor}
7068
stickyToolbar={stickyToolbar}
7169
toolbarConfig={toolbarConfig}
72-
toolbarFocus={() => editor.focus()}
7370
hiddenActionsConfig={hiddenActionsConfig}
7471
settingsVisible={settingsVisible}
7572
className={b('toolbar', [toolbarClassName])}
@@ -83,5 +80,5 @@ export const WysiwygEditorView = memo<WysiwygEditorViewProps>((props) => {
8380
</WysiwygEditorComponent>
8481
</div>
8582
);
86-
});
83+
};
8784
WysiwygEditorView.displayName = 'MarkdownWysiwgEditorView';

packages/editor/src/bundle/config/markup.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,7 @@ export const mToolbarConfig: MToolbarData = [
535535
{
536536
id: 'colorify',
537537
type: ToolbarDataType.ReactComponent,
538+
noRerenderOnUpdate: true, // static state in markup mode
538539
component: MToolbarColors,
539540
width: 42,
540541
},

packages/editor/src/bundle/config/wysiwyg.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ export const wToolbarConfig: WToolbarData = [
487487
id: 'colorify',
488488
type: ToolbarDataType.ReactComponent,
489489
component: WToolbarColors,
490+
noRerenderOnUpdate: true, // WToolbarColors uses toolbar context to update its own state
490491
width: 42,
491492
},
492493
wLinkItemData,
@@ -521,6 +522,7 @@ export const wSelectionMenuConfig: SelectionContextConfig = [
521522
id: 'colorify',
522523
type: ToolbarDataType.ReactComponent,
523524
component: WToolbarColors,
525+
noRerenderOnUpdate: true, // WToolbarColors uses toolbar context to update its own state
524526
props: {disablePortal: true} satisfies Partial<WToolbarColorsProps>,
525527
width: 42,
526528
},

packages/editor/src/bundle/toolbar/wysiwyg/WToolbarColors.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import {useEffect, useState} from 'react';
2+
3+
import {useLatest} from 'react-use';
4+
5+
import type {ActionStorage} from '#core';
6+
import {isEqual} from 'src/lodash';
7+
import {useToolbarContext} from 'src/toolbar/context';
8+
19
import {ToolbarColors, type ToolbarColorsProps} from '../custom/ToolbarColors';
210
import type {WToolbarBaseProps} from '../types';
311

@@ -10,13 +18,13 @@ export const WToolbarColors: React.FC<WToolbarColorsProps> = ({
1018
focus,
1119
onClick,
1220
}) => {
21+
const {active, enabled, currentColor} = useColorsState(editor);
1322
const action = editor.actions.colorify;
14-
const currentColor = action.meta();
1523

1624
return (
1725
<ToolbarColors
18-
active={action.isActive()}
19-
enable={action.isEnable()}
26+
active={active}
27+
enable={enabled}
2028
currentColor={currentColor}
2129
exec={(color) => {
2230
action.run({color: color === currentColor ? '' : color});
@@ -29,3 +37,48 @@ export const WToolbarColors: React.FC<WToolbarColorsProps> = ({
2937
/>
3038
);
3139
};
40+
41+
type ColorsState = {
42+
active: boolean;
43+
enabled: boolean;
44+
currentColor: string;
45+
};
46+
47+
function useColorsState(editor: ActionStorage): ColorsState {
48+
const action = editor.actions.colorify;
49+
50+
const context = useToolbarContext();
51+
52+
const [state, setState] = useState<ColorsState>({
53+
active: false,
54+
enabled: true,
55+
currentColor: '',
56+
});
57+
const stateRef = useLatest(state);
58+
59+
useEffect(() => {
60+
if (!context) return undefined;
61+
62+
const onUpdate = () => {
63+
const newState = {
64+
active: action.isActive(),
65+
enabled: action.isEnable(),
66+
currentColor: action.meta(),
67+
};
68+
69+
if (!isEqual(stateRef.current, newState)) {
70+
setState(newState);
71+
}
72+
};
73+
74+
onUpdate();
75+
76+
context.eventBus.on('update', onUpdate);
77+
78+
return () => {
79+
context.eventBus.off('update', onUpdate);
80+
};
81+
}, [action, context, stateRef]);
82+
83+
return state;
84+
}

packages/editor/src/modules/toolbars/items.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,10 +776,12 @@ export const colorifyItemView: ToolbarItemView<ToolbarDataType.ReactComponent> =
776776
};
777777
export const colorifyItemWysiwyg: ToolbarItemWysiwyg<ToolbarDataType.ReactComponent> = {
778778
component: WToolbarColors,
779+
noRerenderOnUpdate: true, // WToolbarColors uses toolbar context to update its own state
779780
width: 42,
780781
};
781782
export const colorifyItemMarkup: ToolbarItemMarkup<ToolbarDataType.ReactComponent> = {
782783
component: MToolbarColors,
784+
noRerenderOnUpdate: true, // static state in markup mode
783785
width: 42,
784786
};
785787

packages/editor/src/modules/toolbars/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type ToolbarItemEditor<T, E> = Partial<EditorActions<E>> & {
6060
: T extends ToolbarDataType.ReactComponent
6161
? {
6262
width: number;
63+
noRerenderOnUpdate?: boolean;
6364
component: React.ComponentType<ToolbarBaseProps<E>>;
6465
}
6566
: {});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// hack to allow using generic components with memo
2+
3+
import {memo} from 'react';
4+
5+
// DO NOT TRY TO PASS GENERIC PARAMETERS TO THIS FUNCTION!
6+
export const typedMemo: <Props extends object, Return extends React.ReactNode>(
7+
Component: (props: Props) => Return,
8+
compare?: (prevProps: Props, newProps: Props) => boolean,
9+
) => (props: Props) => Return = memo;

packages/editor/src/toolbar/Toolbar.tsx

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {Fragment} from 'react';
33
import {cn} from '../classname';
44

55
import {ToolbarButtonGroup} from './ToolbarGroup';
6+
import {ToolbarWrapToContext} from './ToolbarRerender';
67
import type {ToolbarBaseProps, ToolbarData} from './types';
78

89
import './Toolbar.scss';
@@ -15,6 +16,10 @@ export type ToolbarProps<E> = ToolbarBaseProps<E> & {
1516
data: ToolbarData<E>;
1617
};
1718

19+
/**
20+
The component is not memoized. To optimize number of rerenders,
21+
memoize component yourself and wrap it in a toolbar context (use ToolbarProvider component).
22+
*/
1823
export function Toolbar<E>({
1924
editor,
2025
data,
@@ -28,26 +33,28 @@ export function Toolbar<E>({
2833
disableTooltip,
2934
}: ToolbarProps<E>) {
3035
return (
31-
<div className={b({display}, [className])} data-qa={qa}>
32-
{data.map<React.ReactNode>((group, index) => {
33-
const isLastGroup = index === data.length - 1;
34-
35-
return (
36-
<Fragment key={index}>
37-
<ToolbarButtonGroup
38-
data={group}
39-
editor={editor}
40-
focus={focus}
41-
onClick={onClick}
42-
className={b('group')}
43-
disableHotkey={disableHotkey}
44-
disablePreview={disablePreview}
45-
disableTooltip={disableTooltip}
46-
/>
47-
{isLastGroup || <div className={b('group-separator')} />}
48-
</Fragment>
49-
);
50-
})}
51-
</div>
36+
<ToolbarWrapToContext editor={editor}>
37+
<div className={b({display}, [className])} data-qa={qa}>
38+
{data.map<React.ReactNode>((group, index) => {
39+
const isLastGroup = index === data.length - 1;
40+
41+
return (
42+
<Fragment key={index}>
43+
<ToolbarButtonGroup
44+
data={group}
45+
editor={editor}
46+
focus={focus}
47+
onClick={onClick}
48+
className={b('group')}
49+
disableHotkey={disableHotkey}
50+
disablePreview={disablePreview}
51+
disableTooltip={disableTooltip}
52+
/>
53+
{isLastGroup || <div className={b('group-separator')} />}
54+
</Fragment>
55+
);
56+
})}
57+
</div>
58+
</ToolbarWrapToContext>
5259
);
5360
}

0 commit comments

Comments
 (0)