Skip to content

Commit 6372f3e

Browse files
authored
perf(SelectionContext): reduce number of selection context tooltip re-renders (#1037)
1 parent fe485fb commit 6372f3e

4 files changed

Lines changed: 162 additions & 100 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {useEffect, useMemo, useState} from 'react';
2+
3+
import {Popup, type PopupPlacement, type PopupProps, sp} from '@gravity-ui/uikit';
4+
5+
import type {ActionStorage} from '#core';
6+
import type {EditorView} from '#pm/view';
7+
import {isFunction} from 'src/lodash';
8+
import {typedMemo} from 'src/react-utils/memo';
9+
import {Toolbar, type ToolbarData, type ToolbarProps} from 'src/toolbar';
10+
import {ToolbarWrapToContext} from 'src/toolbar/ToolbarRerender';
11+
12+
import type {ContextConfig} from './types';
13+
14+
const ToolbarMemoized = typedMemo(Toolbar);
15+
const KEY_SEP = '|||';
16+
17+
export type TextSelectionTooltipProps = Pick<
18+
ToolbarProps<ActionStorage>,
19+
'onClick' | 'editor' | 'focus'
20+
> & {
21+
config: ContextConfig;
22+
editorView: EditorView;
23+
popupPlacement: PopupPlacement;
24+
popupAnchor: PopupProps['anchorElement'];
25+
popupOnOpenChange: PopupProps['onOpenChange'];
26+
};
27+
28+
export const TextSelectionTooltip: React.FC<TextSelectionTooltipProps> =
29+
function TextSelectionTooltip({
30+
popupAnchor,
31+
popupPlacement,
32+
popupOnOpenChange,
33+
34+
config,
35+
focus,
36+
editor,
37+
onClick,
38+
editorView,
39+
}) {
40+
const [conditionKey, setConditionKey] = useState(() =>
41+
calcConditionKey(config, editor, editorView),
42+
);
43+
44+
useEffect(() => {
45+
const newKey = calcConditionKey(config, editor, editorView);
46+
if (conditionKey !== newKey) setConditionKey(newKey);
47+
});
48+
49+
const toolbarData = useMemo<ToolbarData<ActionStorage>>(() => {
50+
const results = conditionKey.split(KEY_SEP);
51+
let idx = 0;
52+
return config
53+
.map((groupData) => groupData.filter(() => results[idx++] === 'true'))
54+
.filter((groupData) => Boolean(groupData.length));
55+
}, [config, conditionKey]);
56+
57+
return (
58+
<Popup
59+
open
60+
className={sp({py: 1, px: 2})}
61+
placement={popupPlacement}
62+
anchorElement={popupAnchor}
63+
onOpenChange={popupOnOpenChange}
64+
>
65+
<ToolbarWrapToContext editor={editor}>
66+
<ToolbarMemoized
67+
focus={focus}
68+
editor={editor}
69+
onClick={onClick}
70+
data={toolbarData}
71+
qa="g-md-toolbar-selection"
72+
/>
73+
</ToolbarWrapToContext>
74+
</Popup>
75+
);
76+
};
77+
78+
function calcConditionKey(
79+
config: ContextConfig,
80+
editor: ActionStorage,
81+
editorView: EditorView,
82+
): string {
83+
return config
84+
.flatMap((groupData) =>
85+
groupData.map((item) => {
86+
const {condition} = item;
87+
if (condition === 'enabled') return item.isEnable(editor);
88+
if (isFunction(condition)) return condition(editorView.state);
89+
return true;
90+
}),
91+
)
92+
.join(KEY_SEP);
93+
}

packages/editor/src/extensions/behavior/SelectionContext/index.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class SelectionTooltip implements PluginSpec<PluginState> {
6666
private destroyed = false;
6767

6868
private tooltip: TooltipView;
69+
private editorView: EditorView | null = null;
6970
private hideTimeoutRef: ReturnType<typeof setTimeout> | null = null;
7071

7172
private _isMousePressed = false;
@@ -76,7 +77,13 @@ class SelectionTooltip implements PluginSpec<PluginState> {
7677
logger: Logger2.ILogger,
7778
options: SelectionContextOptions,
7879
) {
79-
this.tooltip = new TooltipView(actions, menuConfig, logger, options);
80+
this.tooltip = new TooltipView(actions, menuConfig, logger, {
81+
...options,
82+
onPopupOpenChange: (_open, _event, reason) => {
83+
if (reason !== 'escape-key' && this.editorView)
84+
this.scheduleTooltipHiding(this.editorView);
85+
},
86+
});
8087
}
8188

8289
get key(): PluginKey<PluginState> {
@@ -140,6 +147,8 @@ class SelectionTooltip implements PluginSpec<PluginState> {
140147
}
141148

142149
private update(view: EditorView, prevState?: TinyState) {
150+
this.editorView = view;
151+
143152
if (this._isMousePressed) return;
144153

145154
this.cancelTooltipHiding();
@@ -185,11 +194,7 @@ class SelectionTooltip implements PluginSpec<PluginState> {
185194
return;
186195
}
187196

188-
this.tooltip.show(view, {
189-
onOpenChange: (_open, _event, reason) => {
190-
if (reason !== 'escape-key') this.scheduleTooltipHiding(view);
191-
},
192-
});
197+
this.tooltip.show(view);
193198
}
194199

195200
private scheduleTooltipHiding(view: EditorView) {
Lines changed: 40 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,23 @@
11
import type {VirtualElement} from '@floating-ui/react';
2-
import {Popup, type PopupPlacement, type PopupProps} from '@gravity-ui/uikit';
3-
import type {EditorState} from 'prosemirror-state';
2+
import type {PopupPlacement, PopupProps} from '@gravity-ui/uikit';
43
import type {EditorView} from 'prosemirror-view';
54

65
import type {ActionStorage} from '../../../core';
7-
import {isFunction} from '../../../lodash';
86
import {type Logger2, globalLogger} from '../../../logger';
97
import {ErrorLoggerBoundary} from '../../../react-utils/ErrorBoundary';
10-
import {Toolbar} from '../../../toolbar';
11-
import type {
12-
ToolbarButtonPopupData,
13-
ToolbarGroupItemData,
14-
ToolbarProps,
15-
ToolbarSingleItemData,
16-
} from '../../../toolbar';
178
import {type RendererItem, getReactRendererFromState} from '../ReactRenderer';
189

19-
type SelectionTooltipBaseProps = {
20-
show?: boolean;
21-
poppupProps: PopupProps;
22-
};
23-
type SelectionTooltipProps = SelectionTooltipBaseProps & ToolbarProps<ActionStorage>;
24-
25-
const SelectionTooltip: React.FC<SelectionTooltipProps> = ({
26-
show,
27-
poppupProps,
28-
...toolbarProps
29-
}) => {
30-
if (!show) return null;
31-
return (
32-
<Popup open {...poppupProps} style={{padding: '4px 8px'}}>
33-
<Toolbar {...toolbarProps} />
34-
</Popup>
35-
);
36-
};
10+
import {TextSelectionTooltip} from './TextSelectionTooltip';
11+
import type {ContextConfig} from './types';
3712

38-
export type ContextGroupItemData =
39-
| (ToolbarGroupItemData<ActionStorage> & {
40-
condition?: (state: EditorState) => void;
41-
})
42-
| ((ToolbarSingleItemData<ActionStorage> | ToolbarButtonPopupData<ActionStorage>) & {
43-
condition?: 'enabled';
44-
});
45-
46-
export type ContextGroupData = ContextGroupItemData[];
47-
export type ContextConfig = ContextGroupData[];
13+
export type {ContextGroupItemData, ContextGroupData, ContextConfig} from './types';
4814

4915
export type TooltipViewParams = {
5016
/** @default 'bottom' */
5117
placement?: 'top' | 'bottom';
5218
/** @default false */
5319
flip?: boolean;
20+
onPopupOpenChange: PopupProps['onOpenChange'];
5421
};
5522

5623
export class TooltipView {
@@ -60,9 +27,11 @@ export class TooltipView {
6027
private readonly actions: ActionStorage;
6128
private readonly menuConfig: ContextConfig;
6229
private readonly placement: PopupPlacement;
30+
private readonly onPopupOpenChange: PopupProps['onOpenChange'];
6331

6432
private view!: EditorView;
65-
private baseProps: SelectionTooltipBaseProps = {show: false, poppupProps: {}};
33+
private visible = false;
34+
private anchor: PopupProps['anchorElement'] = undefined;
6635
private _tooltipRenderItem: RendererItem | null = null;
6736

6837
constructor(
@@ -75,35 +44,32 @@ export class TooltipView {
7544
this.actions = actions;
7645
this.menuConfig = menuConfig;
7746

78-
const {flip, placement = 'bottom'} = params;
47+
const {flip, placement = 'bottom', onPopupOpenChange} = params;
7948
this.placement = flip ? placement : [placement];
49+
this.onPopupOpenChange = onPopupOpenChange;
8050
}
8151

8252
get isTooltipOpen(): boolean {
8353
return this.#isTooltipOpen;
8454
}
8555

86-
show(view: EditorView, popupProps?: PopupProps) {
56+
show(view: EditorView) {
8757
this.view = view;
8858
this.#isTooltipOpen = true;
89-
this.baseProps = {
90-
show: true,
91-
poppupProps: {
92-
...popupProps,
93-
...this.calcPosition(view),
94-
},
95-
};
59+
this.visible = true;
60+
this.anchor ??= this.createVirtualElement(view);
9661
this.renderPopup();
9762
}
9863

9964
hide(view: EditorView) {
10065
this.view = view;
10166

10267
// do not rerender popup if it is already hidden
103-
if (!this.#isTooltipOpen && !this.baseProps.show) return;
68+
if (!this.#isTooltipOpen && !this.visible) return;
10469

10570
this.#isTooltipOpen = false;
106-
this.baseProps = {show: false, poppupProps: {}};
71+
this.visible = false;
72+
this.anchor = undefined;
10773
this.renderPopup();
10874
}
10975

@@ -112,38 +78,11 @@ export class TooltipView {
11278
this._tooltipRenderItem = null;
11379
}
11480

115-
private getSelectionTooltipProps(): SelectionTooltipProps {
116-
return {
117-
...this.baseProps,
118-
qa: 'g-md-toolbar-selection',
119-
focus: () => this.view.focus(),
120-
data: this.getFilteredConfig(),
121-
editor: this.actions,
122-
onClick: (id) => {
123-
globalLogger.action({mode: 'wysiwyg', source: 'context-menu', action: id});
124-
this.logger.action({source: 'context-menu', action: id});
125-
},
126-
};
127-
}
128-
129-
private getFilteredConfig(): ContextConfig {
130-
return this.baseProps.show
131-
? this.menuConfig
132-
.map((groupData) =>
133-
groupData.filter((item) => {
134-
const {condition} = item;
135-
if (condition === 'enabled') {
136-
return item.isEnable(this.actions);
137-
}
138-
if (isFunction(condition)) {
139-
return condition(this.view.state);
140-
}
141-
return true;
142-
}),
143-
)
144-
.filter((groupData) => Boolean(groupData.length))
145-
: [];
146-
}
81+
private readonly handleFocus = () => this.view.focus();
82+
private readonly handleClick = (id: string) => {
83+
globalLogger.action({mode: 'wysiwyg', source: 'context-menu', action: id});
84+
this.logger.action({source: 'context-menu', action: id});
85+
};
14786

14887
private renderPopup() {
14988
this.tooltipRenderItem.rerender();
@@ -152,17 +91,29 @@ export class TooltipView {
15291
private get tooltipRenderItem() {
15392
if (!this._tooltipRenderItem) {
15493
const reactRenderer = getReactRendererFromState(this.view.state);
155-
this._tooltipRenderItem = reactRenderer.createItem('selection_context', () => (
156-
<ErrorLoggerBoundary>
157-
<SelectionTooltip {...this.getSelectionTooltipProps()} />
158-
</ErrorLoggerBoundary>
159-
));
94+
this._tooltipRenderItem = reactRenderer.createItem('selection_context', () => {
95+
if (!this.visible) return null;
96+
return (
97+
<ErrorLoggerBoundary>
98+
<TextSelectionTooltip
99+
config={this.menuConfig}
100+
editor={this.actions}
101+
editorView={this.view}
102+
focus={this.handleFocus}
103+
onClick={this.handleClick}
104+
popupPlacement={this.placement}
105+
popupAnchor={this.anchor}
106+
popupOnOpenChange={this.onPopupOpenChange}
107+
/>
108+
</ErrorLoggerBoundary>
109+
);
110+
});
160111
}
161112
return this._tooltipRenderItem;
162113
}
163114

164-
private calcPosition(view: EditorView): PopupProps {
165-
const virtualElem: VirtualElement = {
115+
private createVirtualElement(view: EditorView): VirtualElement {
116+
return {
166117
getBoundingClientRect() {
167118
// These are in screen coordinates
168119
const start = view.coordsAtPos(view.state.selection.from);
@@ -188,10 +139,5 @@ export class TooltipView {
188139
};
189140
},
190141
};
191-
192-
return {
193-
placement: this.placement,
194-
anchorElement: virtualElem,
195-
};
196142
}
197143
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type {ActionStorage} from '#core';
2+
import type {EditorState} from '#pm/state';
3+
import type {
4+
ToolbarButtonPopupData,
5+
ToolbarGroupItemData,
6+
ToolbarSingleItemData,
7+
} from 'src/toolbar';
8+
9+
export type ContextGroupItemData =
10+
| (ToolbarGroupItemData<ActionStorage> & {
11+
condition?: (state: EditorState) => void;
12+
})
13+
| ((ToolbarSingleItemData<ActionStorage> | ToolbarButtonPopupData<ActionStorage>) & {
14+
condition?: 'enabled';
15+
});
16+
17+
export type ContextGroupData = ContextGroupItemData[];
18+
export type ContextConfig = ContextGroupData[];

0 commit comments

Comments
 (0)