import type { BlockToolData } from '@/types'; import type { MenuConfigItem } from '../../../types/tools'; import { PopoverItemType } from '../../../types/utils/popover/popover-item-type'; import type { BlockToolAdapter } from '../tools/block'; import { CURRENT_CONVERT_VARIANT } from './blocks'; import { translateToolTitle, type I18nInstance } from './tools'; /** * i18n surface the convert-menu builder needs: the {@link I18nInstance} used by * translateToolTitle, plus getEnglishTranslation for building search titles. */ export interface ConvertMenuI18n extends I18nInstance { getEnglishTranslation(key: string): string; } /** * A single "Turn into" menu entry, shared by the inline toolbar and block * settings. Both menus map this to their own item type and attach their own * onActivate handler. */ export interface ConvertMenuEntry { /** Toolbox icon (always present — upstream filtering drops icon-less entries) */ icon: string; /** Localized, human-readable title (resolved from title ?? titleKey) */ title: string; /** English title for multilingual search */ englishTitle?: string; /** Item name = toolboxItem.name ?? tool.name (used for data-blok-item-name) */ name: string; /** Search aliases carried through from the toolbox entry */ searchTerms?: string[]; /** Tool name passed to blocks.convert() */ toolName: string; /** Convert payload from the toolbox entry */ data?: BlockToolData; group?: 'heading' | 'toggle-heading'; isCurrent?: boolean; } /** * Turn the already-filtered convertible tools into flat menu entries. * * The tools are pre-filtered by getConvertibleToolsForBlock / * getConvertibleToolsForBlocks (export/import gating + current-variant dedup), * so this function only flattens toolboxes and resolves display fields. * * The title is resolved via translateToolTitle, which handles titleKey — a * toolbox entry with only titleKey and no raw title still produces a title. * This is the fix for the inline dropdown, which previously dropped such * entries entirely. * @param convertibleTools - tools the current block(s) can convert to * @param i18n - i18n instance for title resolution */ export const buildConvertMenuEntries = ( convertibleTools: BlockToolAdapter[], i18n: ConvertMenuI18n, ): ConvertMenuEntry[] => { const entries: ConvertMenuEntry[] = []; convertibleTools.forEach((tool) => { tool.toolbox?.forEach((toolboxItem) => { if (toolboxItem.icon === undefined) { return; } const titleKey = toolboxItem.titleKey; const resolvedTitleKey = titleKey?.includes('.') ? titleKey : `toolNames.${titleKey}`; const englishTitleKey = titleKey ? resolvedTitleKey : undefined; const englishTitle = englishTitleKey ? i18n.getEnglishTranslation(englishTitleKey) : toolboxItem.title; const headingKind = titleKey?.match(/^tools\.header\.(heading|toggleHeading)[1-6]$/)?.[1]; const regularHeadingGroup = headingKind === 'heading' ? 'heading' : undefined; const group = headingKind === 'toggleHeading' ? 'toggle-heading' : regularHeadingGroup; const isCurrent = CURRENT_CONVERT_VARIANT in toolboxItem && toolboxItem[CURRENT_CONVERT_VARIANT] === true; if (isCurrent && group === undefined) { return; } entries.push({ icon: toolboxItem.icon, title: translateToolTitle(i18n, toolboxItem, tool.name), englishTitle, name: toolboxItem.name ?? tool.name, searchTerms: toolboxItem.searchTerms, toolName: tool.name, data: toolboxItem.data, group, ...(isCurrent ? { isCurrent: true } : {}), }); }); }); // Keep the picker above ordinary actions without changing toolbox order. return [ ...entries.filter((entry) => entry.group !== undefined), ...entries.filter((entry) => entry.group === undefined), ]; }; const buildConvertMenuTitle = (entry: ConvertMenuEntry, i18n: ConvertMenuI18n): HTMLElement | undefined => { const level = entry.data?.level; if (entry.group !== 'toggle-heading' || typeof level !== 'number' || !i18n.has(`tools.header.heading${level}`)) { return undefined; } const titleEl = document.createElement('span'); const preview = document.createElement('span'); const fullTitle = document.createElement('span'); titleEl.setAttribute('aria-label', entry.title); preview.setAttribute('data-blok-convert-preview', ''); preview.setAttribute('aria-hidden', 'true'); preview.textContent = i18n.t(`tools.header.heading${level}`); fullTitle.setAttribute('data-blok-convert-full-title', ''); fullTitle.textContent = entry.title; titleEl.append(preview, fullTitle); return titleEl; }; /** * Keep native items so search, keyboard navigation, and activation share the menu pipeline. */ export const buildConvertMenuItems = ( entries: ConvertMenuEntry[], i18n: ConvertMenuI18n, onActivate: (entry: ConvertMenuEntry) => void | Promise, ): MenuConfigItem[] => { const items: MenuConfigItem[] = []; const groups = (['heading', 'toggle-heading'] as const) .filter(group => entries.some(entry => entry.group === group)); if (groups.length > 0) { const tabs = document.createElement('div'); const selectedGroup = entries.find(entry => entry.isCurrent)?.group ?? groups[0]; tabs.setAttribute('role', 'tablist'); tabs.setAttribute('aria-label', i18n.t('toolNames.heading')); tabs.setAttribute('data-blok-popover-tabs', ''); const buttons = groups.map(group => { const button = document.createElement('button'); button.type = 'button'; button.setAttribute('role', 'tab'); button.setAttribute('data-blok-popover-tab', group); button.setAttribute('aria-selected', String(group === selectedGroup)); button.tabIndex = group === selectedGroup ? 0 : -1; button.textContent = i18n.t(group === 'heading' ? 'toolNames.heading' : 'tools.header.toggleHeading'); tabs.appendChild(button); return button; }); const selectTab = (selected: HTMLButtonElement): void => { buttons.forEach(button => { button.setAttribute('aria-selected', String(button === selected)); button.setAttribute('tabindex', button === selected ? '0' : '-1'); }); tabs.dispatchEvent(new Event('blok-popover-tabs-change', { bubbles: true })); }; buttons.forEach((button, index) => { button.addEventListener('click', () => selectTab(button)); button.addEventListener('keydown', event => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { return; } event.preventDefault(); event.stopPropagation(); const direction = event.key === 'ArrowRight' ? 1 : -1; const adjacentIndex = (index + direction + buttons.length) % buttons.length; const endOrAdjacentIndex = event.key === 'End' ? buttons.length - 1 : adjacentIndex; const nextIndex = event.key === 'Home' ? 0 : endOrAdjacentIndex; const next = buttons[nextIndex]; if (next !== undefined) { selectTab(next); next.focus(); } }); }); items.push({ type: PopoverItemType.Html, name: 'convert-heading-tabs', element: tabs }); } entries.forEach((entry, index) => { if (index > 0 && entry.group === undefined && entries[index - 1]?.group !== undefined) { items.push({ type: PopoverItemType.Separator }); } const level = entry.data?.level; const titleEl = buildConvertMenuTitle(entry, i18n); items.push({ icon: entry.icon, title: entry.title, ...(titleEl === undefined ? {} : { titleEl }), name: entry.name, englishTitle: entry.englishTitle, searchTerms: entry.searchTerms, ...(entry.isCurrent ? { isActive: true } : {}), dataset: { 'blok-convert-item': 'true', ...(entry.group === undefined ? {} : { 'blok-convert-group': entry.group, 'blok-popover-tab': entry.group, 'blok-convert-level': typeof level === 'number' ? String(level) : '', }), }, closeOnActivate: true, onActivate: () => onActivate(entry), }); }); return items; };