import type { BlockAPI, ToolConfig } from '../../../types'; import type { ConversionConfig } from '../../../types/configs/conversion-config'; import type { SavedData } from '../../../types/data-formats'; import type { BlockToolData } from '../../../types/tools/block-tool-data'; import type { Block } from '../block'; import type { BlockToolAdapter } from '../tools/block'; import { isFunction, isString, log, equals, isEmpty } from '../utils'; import { isToolConvertable } from './tools'; export const CURRENT_CONVERT_VARIANT = Symbol('current-convert-variant'); /** * Check if block has valid conversion config for export or import. * @param block - block to check * @param direction - export for block to merge from, import for block to merge to */ export const isBlockConvertable = (block: Block, direction: 'export' | 'import'): boolean => { return isToolConvertable(block.tool, direction); }; /** * Checks that all the properties of the first block data exist in second block data with the same values. * * Example: * * data1 = { level: 1 } * * data2 = { * text: "Heading text", * level: 1 * } * * isSameBlockData(data1, data2) => true * @param data1 – first block data * @param data2 – second block data */ export const isSameBlockData = (data1: BlockToolData, data2: BlockToolData): boolean => { return Object.entries(data1).some((([propName, propValue]) => { return data2[propName] && equals(data2[propName], propValue); })); }; /** * Returns list of tools you can convert specified block to * @param block - block to get conversion items for * @param allBlockTools - all block tools available in the blok * @param options - retain the current data variant for selected-state menus */ export const getConvertibleToolsForBlock = async ( block: BlockAPI, allBlockTools: BlockToolAdapter[], options: { keepCurrentVariant?: boolean } = {}, ): Promise => { const savedData = await block.save() as SavedData; const blockData = savedData.data; /** * Checking that the block's tool has an «export» rule */ const blockTool = allBlockTools.find((tool) => tool.name === block.name); if (blockTool !== undefined && !isToolConvertable(blockTool, 'export')) { return []; } return allBlockTools.reduce((result, tool) => { /** * Skip tools without «import» rule specified */ if (!isToolConvertable(tool, 'import')) { return result; } /** * Skip tools that does not specify toolbox */ if (tool.toolbox === undefined) { return result; } /** * Collect all data property names that appear in any toolbox entry for this tool. * This lets us compare toolbox items against the current block using ALL distinguishing * properties, not just the ones in a single entry. * * Example: header tool has entries with { level } and { level, isToggleable }. * The union of keys is { level, isToggleable }, so regular headings and toggle * headings are correctly treated as different block variants. */ const allToolboxDataKeys = new Set( tool.toolbox .map(item => item.data) .filter((data): data is BlockToolData => data !== undefined) .flatMap(data => Object.keys(data)) ); /** Filter out invalid toolbox entries */ const actualToolboxItems = tool.toolbox.flatMap((toolboxItem) => { /** * Skip items that don't pass 'toolbox' property or do not have an icon */ if (isEmpty(toolboxItem) || toolboxItem.icon === undefined) { return []; } const hasToolboxData = toolboxItem.data !== undefined; /** * When a tool has several toolbox entries, we need to make sure we do not add * toolbox item with the same data to the resulting array. This helps exclude duplicates. * * We compare ALL distinguishing data properties (union of keys across all toolbox entries) * to correctly differentiate variants like regular heading vs toggle heading. */ if (hasToolboxData && toolboxItem.data !== undefined) { const wouldProduceSameBlock = [...allToolboxDataKeys].every(key => { const toolboxValue = toolboxItem.data !== undefined ? toolboxItem.data[key] : undefined; const blockValue = blockData[key]; return equals(toolboxValue, blockValue); }); if (wouldProduceSameBlock) { // Toolbox entries are shared with other menus. return options.keepCurrentVariant && tool.name === block.name ? [{ ...toolboxItem, [CURRENT_CONVERT_VARIANT]: true }] : []; } } if (!hasToolboxData && tool.name === block.name) { return []; } return [toolboxItem]; }); if (actualToolboxItems.length > 0) { result.push({ ...tool, toolbox: actualToolboxItems, } as BlockToolAdapter); } return result; }, [] as BlockToolAdapter[]); }; /** * Check if two blocks could be merged. * * We can merge two blocks if: * - they have the same type * - they have a merge function (.mergeable = true) * - If they have valid conversions config * @param targetBlock - block to merge to * @param blockToMerge - block to merge from */ export const areBlocksMergeable = (targetBlock: Block, blockToMerge: Block): boolean => { /** * If target block has not 'merge' method, we can't merge blocks. * * Technically we can (through the conversion) but it will lead a target block delete and recreation, which is unexpected behavior. */ if (!targetBlock.mergeable) { return false; } /** * Tool knows how to merge own data format */ if (targetBlock.name === blockToMerge.name) { return true; } /** * We can merge blocks if they have valid conversion config */ return isBlockConvertable(blockToMerge, 'export') && isBlockConvertable(targetBlock, 'import'); }; /** * Returns list of tools that all specified blocks can be converted to. * Only returns tools that are valid conversion targets for ALL blocks. * @param blocks - array of blocks to get common conversion items for * @param allBlockTools - all block tools available in the blok */ export const getConvertibleToolsForBlocks = async (blocks: BlockAPI[], allBlockTools: BlockToolAdapter[]): Promise => { if (blocks.length === 0) { return []; } /** * If only one block, use the single block function */ if (blocks.length === 1) { return getConvertibleToolsForBlock(blocks[0], allBlockTools); } /** * Only the "roots" of the selection constrain the conversion targets: * * - A block nested under another selected block rides with its container * (e.g. the contents of a selected column_list) and must NOT be converted * on its own — so it's ignored here. * - A block whose tool has no «export» rule (e.g. container blocks like * column_list/column) cannot convert and is left untouched — ignored too, * NOT used to suppress every target for the whole selection. */ const selectedIds = new Set(blocks.map((block) => block.id)); const convertibleBlocks = blocks.filter((block) => { if (block.parentId !== null && selectedIds.has(block.parentId)) { return false; } const blockTool = allBlockTools.find((tool) => tool.name === block.name); return blockTool === undefined || isToolConvertable(blockTool, 'export'); }); if (convertibleBlocks.length === 0) { return []; } /** * Get the set of tool names that the convertible blocks currently use */ const blockToolNames = new Set(convertibleBlocks.map((block) => block.name)); /** * Filter tools that have import conversion config and valid toolbox */ return allBlockTools.reduce((result, tool) => { /** * Skip tools without «import» rule specified */ if (!isToolConvertable(tool, 'import')) { return result; } /** * Skip tools that does not specify toolbox */ if (tool.toolbox === undefined) { return result; } /** Filter out invalid toolbox entries */ const actualToolboxItems = tool.toolbox.filter((toolboxItem) => { /** * Skip items that don't pass 'toolbox' property or do not have an icon */ if (isEmpty(toolboxItem) || toolboxItem.icon === undefined) { return false; } /** * For multiple blocks, we skip the tool if ALL selected blocks are already of this type * (with no toolbox data override) */ const hasToolboxData = toolboxItem.data !== undefined; if (!hasToolboxData && blockToolNames.size === 1 && blockToolNames.has(tool.name)) { return false; } return true; }); if (actualToolboxItems.length > 0) { result.push({ ...tool, toolbox: actualToolboxItems, } as BlockToolAdapter); } return result; }, [] as BlockToolAdapter[]); }; /** * Using conversionConfig, convert block data to string. * @param blockData - block data to convert * @param conversionConfig - tool's conversion config */ export const convertBlockDataToString = (blockData: BlockToolData, conversionConfig?: ConversionConfig ): string => { const exportProp = conversionConfig?.export; if (isFunction(exportProp)) { return exportProp(blockData) as string; } if (isString(exportProp)) { return blockData[exportProp] as string; } /** * Tool developer provides 'export' property, but it is not correct. Warn him. */ if (exportProp !== undefined) { log('Conversion «export» property must be a string or function. ' + 'String means key of saved data object to export. Function should export processed string to export.'); } return ''; }; /** * Using conversionConfig, convert string to block data. * @param stringToImport - string to convert * @param conversionConfig - tool's conversion config * @param targetToolConfig - target tool config, used in conversionConfig.import method */ export const convertStringToBlockData = (stringToImport: string, conversionConfig?: ConversionConfig, targetToolConfig?: ToolConfig): BlockToolData => { const importProp = conversionConfig?.import; if (isFunction(importProp)) { return importProp(stringToImport, targetToolConfig) as BlockToolData; } if (isString(importProp)) { return { [importProp]: stringToImport, }; } /** * Tool developer provides 'import' property, but it is not correct. Warn him. */ if (importProp !== undefined) { log('Conversion «import» property must be a string or function. ' + 'String means key of tool data to import. Function accepts a imported string and return composed tool data.'); } return {}; };