Skip to content

Commit 7821e35

Browse files
feat(block tunes): Conversion Menu in Block Tunes (#2692)
* Support delimiter * Rename types, move types to popover-item folder * Fix ts errors * Add tests * Review fixes * Review fixes 2 * Fix delimiter while search * Fix flipper issue * Fix block tunes types * Fix types * tmp * Fixes * Make search input emit event * Fix types * Rename delimiter to separator * Update chengelog * Add convert to to block tunes * i18n * Lint * Fix tests * Fix tests 2 * Tests * Add caching * Rename * Fix for miltiple toolbox entries * Update changelog * Update changelog * Fix popover test * Fix flipper tests * Fix popover tests * Remove type: 'default' * Create isSameBlockData util * Add testcase
1 parent 4118dc3 commit 7821e35

File tree

9 files changed

+407
-58
lines changed

9 files changed

+407
-58
lines changed

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
`New` – Block Tunes now supports nesting items
66
`New` – Block Tunes now supports separator items
7+
`New` – "Convert to" control is now also available in Block Tunes
78

89
### 2.30.0
910

src/components/block/index.ts

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ import BlockTune from '../tools/tune';
2121
import { BlockTuneData } from '../../../types/block-tunes/block-tune-data';
2222
import ToolsCollection from '../tools/collection';
2323
import EventsDispatcher from '../utils/events';
24-
import { TunesMenuConfigItem } from '../../../types/tools';
24+
import { TunesMenuConfig, TunesMenuConfigItem } from '../../../types/tools';
2525
import { isMutationBelongsToElement } from '../utils/mutations';
2626
import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events';
2727
import { RedactorDomChangedPayload } from '../events/RedactorDomChanged';
28-
import { convertBlockDataToString } from '../utils/blocks';
28+
import { convertBlockDataToString, isSameBlockData } from '../utils/blocks';
2929

3030
/**
3131
* Interface describes Block class constructor argument
@@ -229,7 +229,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
229229
tunesData,
230230
}: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) {
231231
super();
232-
233232
this.name = tool.name;
234233
this.id = id;
235234
this.settings = tool.settings;
@@ -612,34 +611,60 @@ export default class Block extends EventsDispatcher<BlockEvents> {
612611

613612
/**
614613
* Returns data to render in tunes menu.
615-
* Splits block tunes settings into 2 groups: popover items and custom html.
616-
*/
617-
public getTunes(): [PopoverItemParams[], HTMLElement] {
614+
* Splits block tunes into 3 groups: block specific tunes, common tunes
615+
* and custom html that is produced by combining tunes html from both previous groups
616+
*/
617+
public getTunes(): {
618+
toolTunes: PopoverItemParams[];
619+
commonTunes: PopoverItemParams[];
620+
customHtmlTunes: HTMLElement
621+
} {
618622
const customHtmlTunesContainer = document.createElement('div');
619-
const tunesItems: TunesMenuConfigItem[] = [];
623+
const commonTunesPopoverParams: TunesMenuConfigItem[] = [];
620624

621625
/** Tool's tunes: may be defined as return value of optional renderSettings method */
622626
const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : [];
623627

628+
/** Separate custom html from Popover items params for tool's tunes */
629+
const {
630+
items: toolTunesPopoverParams,
631+
htmlElement: toolTunesHtmlElement,
632+
} = this.getTunesDataSegregated(tunesDefinedInTool);
633+
634+
if (toolTunesHtmlElement !== undefined) {
635+
customHtmlTunesContainer.appendChild(toolTunesHtmlElement);
636+
}
637+
624638
/** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */
625639
const commonTunes = [
626640
...this.tunesInstances.values(),
627641
...this.defaultTunesInstances.values(),
628642
].map(tuneInstance => tuneInstance.render());
629643

630-
[tunesDefinedInTool, commonTunes].flat().forEach(rendered => {
631-
if ($.isElement(rendered)) {
632-
customHtmlTunesContainer.appendChild(rendered);
633-
} else if (Array.isArray(rendered)) {
634-
tunesItems.push(...rendered);
635-
} else {
636-
tunesItems.push(rendered);
644+
/** Separate custom html from Popover items params for common tunes */
645+
commonTunes.forEach(tuneConfig => {
646+
const {
647+
items,
648+
htmlElement,
649+
} = this.getTunesDataSegregated(tuneConfig);
650+
651+
if (htmlElement !== undefined) {
652+
customHtmlTunesContainer.appendChild(htmlElement);
653+
}
654+
655+
if (items !== undefined) {
656+
commonTunesPopoverParams.push(...items);
637657
}
638658
});
639659

640-
return [tunesItems, customHtmlTunesContainer];
660+
return {
661+
toolTunes: toolTunesPopoverParams,
662+
commonTunes: commonTunesPopoverParams,
663+
customHtmlTunes: customHtmlTunesContainer,
664+
};
641665
}
642666

667+
643668
/**
644669
* Update current input index with selection anchor node
645670
*/
@@ -711,11 +736,8 @@ export default class Block extends EventsDispatcher<BlockEvents> {
711736
const blockData = await this.data;
712737
const toolboxItems = toolboxSettings;
713738

714-
return toolboxItems.find((item) => {
715-
return Object.entries(item.data)
716-
.some(([propName, propValue]) => {
717-
return blockData[propName] && _.equals(blockData[propName], propValue);
718-
});
739+
return toolboxItems?.find((item) => {
740+
return isSameBlockData(item.data, blockData);
719741
});
720742
}
721743

@@ -728,6 +750,25 @@ export default class Block extends EventsDispatcher<BlockEvents> {
728750
return convertBlockDataToString(blockData, this.tool.conversionConfig);
729751
}
730752

753+
/**
754+
* Determines if tool's tunes settings are custom html or popover params and separates one from another by putting to different object fields
755+
*
756+
* @param tunes - tool's tunes config
757+
*/
758+
private getTunesDataSegregated(tunes: HTMLElement | TunesMenuConfig): { htmlElement?: HTMLElement; items: PopoverItemParams[] } {
759+
const result = { } as { htmlElement?: HTMLElement; items: PopoverItemParams[] };
760+
761+
if ($.isElement(tunes)) {
762+
result.htmlElement = tunes as HTMLElement;
763+
} else if (Array.isArray(tunes)) {
764+
result.items = tunes as PopoverItemParams[];
765+
} else {
766+
result.items = [ tunes ];
767+
}
768+
769+
return result;
770+
}
771+
731772
/**
732773
* Make default Block wrappers and put Tool`s content there
733774
*

src/components/i18n/locales/en/messages.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
},
1919
"popover": {
2020
"Filter": "",
21-
"Nothing found": ""
21+
"Nothing found": "",
22+
"Convert to": ""
2223
}
2324
},
2425
"toolNames": {

src/components/modules/toolbar/blockSettings.ts

Lines changed: 125 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import { I18nInternalNS } from '../../i18n/namespace-internal';
77
import Flipper from '../../flipper';
88
import { TunesMenuConfigItem } from '../../../../types/tools';
99
import { resolveAliases } from '../../utils/resolve-aliases';
10-
import { type Popover, PopoverDesktop, PopoverMobile } from '../../utils/popover';
10+
import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemDefaultParams } from '../../utils/popover';
1111
import { PopoverEvent } from '../../utils/popover/popover.types';
1212
import { isMobileScreen } from '../../utils';
1313
import { EditorMobileLayoutToggled } from '../../events';
14+
import * as _ from '../../utils';
15+
import { IconReplace } from '@codexteam/icons';
16+
import { isSameBlockData } from '../../utils/blocks';
1417

1518
/**
1619
* HTML Elements that used for BlockSettings
@@ -105,7 +108,7 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
105108
*
106109
* @param targetBlock - near which Block we should open BlockSettings
107110
*/
108-
public open(targetBlock: Block = this.Editor.BlockManager.currentBlock): void {
111+
public async open(targetBlock: Block = this.Editor.BlockManager.currentBlock): Promise<void> {
109112
this.opened = true;
110113

111114
/**
@@ -120,10 +123,8 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
120123
this.Editor.BlockSelection.selectBlock(targetBlock);
121124
this.Editor.BlockSelection.clearCache();
122125

123-
/**
124-
* Fill Tool's settings
125-
*/
126-
const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes();
126+
/** Get tool's settings data */
127+
const { toolTunes, commonTunes, customHtmlTunes } = targetBlock.getTunes();
127128

128129
/** Tell to subscribers that block settings is opened */
129130
this.eventsDispatcher.emit(this.events.opened);
@@ -132,9 +133,9 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
132133

133134
this.popover = new PopoverClass({
134135
searchable: true,
135-
items: tunesItems.map(tune => this.resolveTuneAliases(tune)),
136-
customContent: customHtmlTunesContainer,
137-
customContentFlippableItems: this.getControls(customHtmlTunesContainer),
136+
items: await this.getTunesItems(targetBlock, commonTunes, toolTunes),
137+
customContent: customHtmlTunes,
138+
customContentFlippableItems: this.getControls(customHtmlTunes),
138139
scopeElement: this.Editor.API.methods.ui.nodes.redactor,
139140
messages: {
140141
nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'),
@@ -197,6 +198,117 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
197198
}
198199
};
199200

201+
/**
202+
* Returns list of items to be displayed in block tunes menu.
203+
* Merges tool specific tunes, conversion menu and common tunes in one list in predefined order
204+
*
205+
* @param currentBlock – block we are about to open block tunes for
206+
* @param commonTunes – common tunes
207+
* @param toolTunes - tool specific tunes
208+
*/
209+
private async getTunesItems(currentBlock: Block, commonTunes: TunesMenuConfigItem[], toolTunes?: TunesMenuConfigItem[]): Promise<PopoverItemParams[]> {
210+
const items = [] as TunesMenuConfigItem[];
211+
212+
if (toolTunes !== undefined && toolTunes.length > 0) {
213+
items.push(...toolTunes);
214+
items.push({
215+
type: 'separator',
216+
});
217+
}
218+
219+
const convertToItems = await this.getConvertToItems(currentBlock);
220+
221+
if (convertToItems.length > 0) {
222+
items.push({
223+
icon: IconReplace,
224+
title: I18n.ui(I18nInternalNS.ui.popover, 'Convert to'),
225+
children: {
226+
items: convertToItems,
227+
},
228+
});
229+
items.push({
230+
type: 'separator',
231+
});
232+
}
233+
234+
items.push(...commonTunes);
235+
236+
return items.map(tune => this.resolveTuneAliases(tune));
237+
}
238+
239+
/**
240+
* Returns list of all available conversion menu items
241+
*
242+
* @param currentBlock - block we are about to open block tunes for
243+
*/
244+
private async getConvertToItems(currentBlock: Block): Promise<PopoverItemDefaultParams[]> {
245+
const conversionEntries = Array.from(this.Editor.Tools.blockTools.entries());
246+
247+
const resultItems: PopoverItemDefaultParams[] = [];
248+
249+
const blockData = await currentBlock.data;
250+
251+
conversionEntries.forEach(([toolName, tool]) => {
252+
const conversionConfig = tool.conversionConfig;
253+
254+
/**
255+
* Skip tools without «import» rule specified
256+
*/
257+
if (!conversionConfig || !conversionConfig.import) {
258+
return;
259+
}
260+
261+
tool.toolbox?.forEach((toolboxItem) => {
262+
/**
263+
* Skip tools that don't pass 'toolbox' property
264+
*/
265+
if (_.isEmpty(toolboxItem) || !toolboxItem.icon) {
266+
return;
267+
}
268+
269+
let shouldSkip = false;
270+
271+
if (toolboxItem.data !== undefined) {
272+
/**
273+
* When a tool has several toolbox entries, we need to make sure we do not add
274+
* toolbox item with the same data to the resulting array. This helps exclude duplicates
275+
*/
276+
const hasSameData = isSameBlockData(toolboxItem.data, blockData);
277+
278+
shouldSkip = hasSameData;
279+
} else {
280+
shouldSkip = toolName === currentBlock.name;
281+
}
282+
283+
284+
if (shouldSkip) {
285+
return;
286+
}
287+
288+
resultItems.push({
289+
icon: toolboxItem.icon,
290+
title: toolboxItem.title,
291+
name: toolName,
292+
onActivate: () => {
293+
const { BlockManager, BlockSelection, Caret } = this.Editor;
294+
295+
BlockManager.convert(this.Editor.BlockManager.currentBlock, toolName, toolboxItem.data);
296+
297+
BlockSelection.clearSelection();
298+
299+
this.close();
300+
301+
window.requestAnimationFrame(() => {
302+
Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END);
303+
});
304+
},
305+
});
306+
});
307+
});
308+
309+
return resultItems;
310+
}
311+
200312
/**
201313
* Handles popover close event
202314
*/
@@ -224,7 +336,10 @@ export default class BlockSettings extends Module<BlockSettingsNodes> {
224336
*
225337
* @param item - item with resolved aliases
226338
*/
227-
private resolveTuneAliases(item: TunesMenuConfigItem): TunesMenuConfigItem {
339+
private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams {
340+
if (item.type === 'separator') {
341+
return item;
342+
}
228343
const result = resolveAliases(item, { label: 'title' });
229344

230345
if (item.confirmation) {

src/components/utils/blocks.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { ConversionConfig } from '../../../types/configs/conversion-config';
22
import type { BlockToolData } from '../../../types/tools/block-tool-data';
33
import type Block from '../block';
4-
import { isFunction, isString, log } from '../utils';
4+
import { isFunction, isString, log, equals } from '../utils';
5+
56

67
/**
78
* Check if block has valid conversion config for export or import.
@@ -19,6 +20,18 @@ export function isBlockConvertable(block: Block, direction: 'export' | 'import')
1920
return isFunction(conversionProp) || isString(conversionProp);
2021
}
2122

23+
/**
24+
* Checks that all the properties of the first block data exist in second block data with the same values.
25+
*
26+
* @param data1 – first block data
27+
* @param data2 – second block data
28+
*/
29+
export function isSameBlockData(data1: BlockToolData, data2: BlockToolData): boolean {
30+
return Object.entries(data1).some((([propName, propValue]) => {
31+
return data2[propName] && equals(data2[propName], propValue);
32+
}));
33+
}
34+
2235
/**
2336
* Check if two blocks could be merged.
2437
*

src/components/utils/popover/components/popover-item/popover-item.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ interface PopoverItemDefaultBaseParams {
1717
/**
1818
* Item type
1919
*/
20-
type: 'default';
20+
type?: 'default';
2121

2222
/**
2323
* Displayed text

0 commit comments

Comments
 (0)