Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {useEffect, useMemo, useState} from 'react';

import {Popup, type PopupPlacement, type PopupProps, sp} from '@gravity-ui/uikit';

import type {ActionStorage} from '#core';
import type {EditorView} from '#pm/view';
import {isFunction} from 'src/lodash';
import {typedMemo} from 'src/react-utils/memo';
import {Toolbar, type ToolbarData, type ToolbarProps} from 'src/toolbar';
import {ToolbarWrapToContext} from 'src/toolbar/ToolbarRerender';

import type {ContextConfig} from './types';

const ToolbarMemoized = typedMemo(Toolbar);
const KEY_SEP = '|||';

export type TextSelectionTooltipProps = Pick<
ToolbarProps<ActionStorage>,
'onClick' | 'editor' | 'focus'
> & {
config: ContextConfig;
editorView: EditorView;
popupPlacement: PopupPlacement;
popupAnchor: PopupProps['anchorElement'];
popupOnOpenChange: PopupProps['onOpenChange'];
};

export const TextSelectionTooltip: React.FC<TextSelectionTooltipProps> =
function TextSelectionTooltip({
popupAnchor,
popupPlacement,
popupOnOpenChange,

config,
focus,
editor,
onClick,
editorView,
}) {
const [conditionKey, setConditionKey] = useState(() =>
calcConditionKey(config, editor, editorView),
);

useEffect(() => {
const newKey = calcConditionKey(config, editor, editorView);
if (conditionKey !== newKey) setConditionKey(newKey);
});

const toolbarData = useMemo<ToolbarData<ActionStorage>>(() => {
const results = conditionKey.split(KEY_SEP);
let idx = 0;
return config
.map((groupData) => groupData.filter(() => results[idx++] === 'true'))
.filter((groupData) => Boolean(groupData.length));
}, [config, conditionKey]);

return (
<Popup
open
className={sp({py: 1, px: 2})}
placement={popupPlacement}
anchorElement={popupAnchor}
onOpenChange={popupOnOpenChange}
>
<ToolbarWrapToContext editor={editor}>
<ToolbarMemoized
focus={focus}
editor={editor}
onClick={onClick}
data={toolbarData}
qa="g-md-toolbar-selection"
/>
</ToolbarWrapToContext>
</Popup>
);
};

function calcConditionKey(
config: ContextConfig,
editor: ActionStorage,
editorView: EditorView,
): string {
return config
.flatMap((groupData) =>
groupData.map((item) => {
const {condition} = item;
if (condition === 'enabled') return item.isEnable(editor);
if (isFunction(condition)) return condition(editorView.state);
return true;
}),
)
.join(KEY_SEP);
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class SelectionTooltip implements PluginSpec<PluginState> {
private destroyed = false;

private tooltip: TooltipView;
private editorView: EditorView | null = null;
private hideTimeoutRef: ReturnType<typeof setTimeout> | null = null;

private _isMousePressed = false;
Expand All @@ -76,7 +77,13 @@ class SelectionTooltip implements PluginSpec<PluginState> {
logger: Logger2.ILogger,
options: SelectionContextOptions,
) {
this.tooltip = new TooltipView(actions, menuConfig, logger, options);
this.tooltip = new TooltipView(actions, menuConfig, logger, {
...options,
onPopupOpenChange: (_open, _event, reason) => {
if (reason !== 'escape-key' && this.editorView)
this.scheduleTooltipHiding(this.editorView);
},
});
}

get key(): PluginKey<PluginState> {
Expand Down Expand Up @@ -140,6 +147,8 @@ class SelectionTooltip implements PluginSpec<PluginState> {
}

private update(view: EditorView, prevState?: TinyState) {
this.editorView = view;

if (this._isMousePressed) return;

this.cancelTooltipHiding();
Expand Down Expand Up @@ -185,11 +194,7 @@ class SelectionTooltip implements PluginSpec<PluginState> {
return;
}

this.tooltip.show(view, {
onOpenChange: (_open, _event, reason) => {
if (reason !== 'escape-key') this.scheduleTooltipHiding(view);
},
});
this.tooltip.show(view);
}

private scheduleTooltipHiding(view: EditorView) {
Expand Down
134 changes: 40 additions & 94 deletions packages/editor/src/extensions/behavior/SelectionContext/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,23 @@
import type {VirtualElement} from '@floating-ui/react';
import {Popup, type PopupPlacement, type PopupProps} from '@gravity-ui/uikit';
import type {EditorState} from 'prosemirror-state';
import type {PopupPlacement, PopupProps} from '@gravity-ui/uikit';
import type {EditorView} from 'prosemirror-view';

import type {ActionStorage} from '../../../core';
import {isFunction} from '../../../lodash';
import {type Logger2, globalLogger} from '../../../logger';
import {ErrorLoggerBoundary} from '../../../react-utils/ErrorBoundary';
import {Toolbar} from '../../../toolbar';
import type {
ToolbarButtonPopupData,
ToolbarGroupItemData,
ToolbarProps,
ToolbarSingleItemData,
} from '../../../toolbar';
import {type RendererItem, getReactRendererFromState} from '../ReactRenderer';

type SelectionTooltipBaseProps = {
show?: boolean;
poppupProps: PopupProps;
};
type SelectionTooltipProps = SelectionTooltipBaseProps & ToolbarProps<ActionStorage>;

const SelectionTooltip: React.FC<SelectionTooltipProps> = ({
show,
poppupProps,
...toolbarProps
}) => {
if (!show) return null;
return (
<Popup open {...poppupProps} style={{padding: '4px 8px'}}>
<Toolbar {...toolbarProps} />
</Popup>
);
};
import {TextSelectionTooltip} from './TextSelectionTooltip';
import type {ContextConfig} from './types';

export type ContextGroupItemData =
| (ToolbarGroupItemData<ActionStorage> & {
condition?: (state: EditorState) => void;
})
| ((ToolbarSingleItemData<ActionStorage> | ToolbarButtonPopupData<ActionStorage>) & {
condition?: 'enabled';
});

export type ContextGroupData = ContextGroupItemData[];
export type ContextConfig = ContextGroupData[];
export type {ContextGroupItemData, ContextGroupData, ContextConfig} from './types';

export type TooltipViewParams = {
/** @default 'bottom' */
placement?: 'top' | 'bottom';
/** @default false */
flip?: boolean;
onPopupOpenChange: PopupProps['onOpenChange'];
};

export class TooltipView {
Expand All @@ -60,9 +27,11 @@ export class TooltipView {
private readonly actions: ActionStorage;
private readonly menuConfig: ContextConfig;
private readonly placement: PopupPlacement;
private readonly onPopupOpenChange: PopupProps['onOpenChange'];

private view!: EditorView;
private baseProps: SelectionTooltipBaseProps = {show: false, poppupProps: {}};
private visible = false;
private anchor: PopupProps['anchorElement'] = undefined;
private _tooltipRenderItem: RendererItem | null = null;

constructor(
Expand All @@ -75,35 +44,32 @@ export class TooltipView {
this.actions = actions;
this.menuConfig = menuConfig;

const {flip, placement = 'bottom'} = params;
const {flip, placement = 'bottom', onPopupOpenChange} = params;
this.placement = flip ? placement : [placement];
this.onPopupOpenChange = onPopupOpenChange;
}

get isTooltipOpen(): boolean {
return this.#isTooltipOpen;
}

show(view: EditorView, popupProps?: PopupProps) {
show(view: EditorView) {
this.view = view;
this.#isTooltipOpen = true;
this.baseProps = {
show: true,
poppupProps: {
...popupProps,
...this.calcPosition(view),
},
};
this.visible = true;
this.anchor ??= this.createVirtualElement(view);
this.renderPopup();
}

hide(view: EditorView) {
this.view = view;

// do not rerender popup if it is already hidden
if (!this.#isTooltipOpen && !this.baseProps.show) return;
if (!this.#isTooltipOpen && !this.visible) return;

this.#isTooltipOpen = false;
this.baseProps = {show: false, poppupProps: {}};
this.visible = false;
this.anchor = undefined;
this.renderPopup();
}

Expand All @@ -112,38 +78,11 @@ export class TooltipView {
this._tooltipRenderItem = null;
}

private getSelectionTooltipProps(): SelectionTooltipProps {
return {
...this.baseProps,
qa: 'g-md-toolbar-selection',
focus: () => this.view.focus(),
data: this.getFilteredConfig(),
editor: this.actions,
onClick: (id) => {
globalLogger.action({mode: 'wysiwyg', source: 'context-menu', action: id});
this.logger.action({source: 'context-menu', action: id});
},
};
}

private getFilteredConfig(): ContextConfig {
return this.baseProps.show
? this.menuConfig
.map((groupData) =>
groupData.filter((item) => {
const {condition} = item;
if (condition === 'enabled') {
return item.isEnable(this.actions);
}
if (isFunction(condition)) {
return condition(this.view.state);
}
return true;
}),
)
.filter((groupData) => Boolean(groupData.length))
: [];
}
private readonly handleFocus = () => this.view.focus();
private readonly handleClick = (id: string) => {
globalLogger.action({mode: 'wysiwyg', source: 'context-menu', action: id});
this.logger.action({source: 'context-menu', action: id});
};

private renderPopup() {
this.tooltipRenderItem.rerender();
Expand All @@ -152,17 +91,29 @@ export class TooltipView {
private get tooltipRenderItem() {
if (!this._tooltipRenderItem) {
const reactRenderer = getReactRendererFromState(this.view.state);
this._tooltipRenderItem = reactRenderer.createItem('selection_context', () => (
<ErrorLoggerBoundary>
<SelectionTooltip {...this.getSelectionTooltipProps()} />
</ErrorLoggerBoundary>
));
this._tooltipRenderItem = reactRenderer.createItem('selection_context', () => {
if (!this.visible) return null;
return (
<ErrorLoggerBoundary>
<TextSelectionTooltip
config={this.menuConfig}
editor={this.actions}
editorView={this.view}
focus={this.handleFocus}
onClick={this.handleClick}
popupPlacement={this.placement}
popupAnchor={this.anchor}
popupOnOpenChange={this.onPopupOpenChange}
/>
</ErrorLoggerBoundary>
);
});
}
return this._tooltipRenderItem;
}

private calcPosition(view: EditorView): PopupProps {
const virtualElem: VirtualElement = {
private createVirtualElement(view: EditorView): VirtualElement {
return {
getBoundingClientRect() {
// These are in screen coordinates
const start = view.coordsAtPos(view.state.selection.from);
Expand All @@ -188,10 +139,5 @@ export class TooltipView {
};
},
};

return {
placement: this.placement,
anchorElement: virtualElem,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type {ActionStorage} from '#core';
import type {EditorState} from '#pm/state';
import type {
ToolbarButtonPopupData,
ToolbarGroupItemData,
ToolbarSingleItemData,
} from 'src/toolbar';

export type ContextGroupItemData =
| (ToolbarGroupItemData<ActionStorage> & {
condition?: (state: EditorState) => void;
})
| ((ToolbarSingleItemData<ActionStorage> | ToolbarButtonPopupData<ActionStorage>) & {
condition?: 'enabled';
});

export type ContextGroupData = ContextGroupItemData[];
export type ContextConfig = ContextGroupData[];
Loading