import type { BlokModules } from '../../../types-internal/blok-modules'; import type { Block } from '../../block'; import { DATA_ATTR, TEST_ID } from '../../constants'; import { Dom as $ } from '../../dom'; import { IconPlus } from '../../icons'; import { SelectionUtils } from '../../selection/index'; import { getUserOS } from '../../utils'; import { PopoverRegistry } from '../../utils/popover/popover-registry'; import { onHover } from '../../utils/tooltip'; import { twJoin } from '../../utils/tw'; import { createTooltipContent } from './tooltip'; import type { ToolbarNodes } from './types'; /** * Stable id applied to the Toolbox popover container element (in Toolbar.make()). * Referenced by the plus button's `aria-controls` so assistive tech knows which * menu the button expands. */ export const TOOLBOX_POPOVER_ID = 'blok-toolbox-popover'; /** * PlusButtonHandler manages the plus button creation and behavior. * Creates the plus button element with tooltip. */ export class PlusButtonHandler { /** * Getter function to access Blok modules dynamically * This ensures the handler always has access to the current state */ private getBlok: () => BlokModules; /** * Callback to get the current toolbox state */ private getToolboxOpened: () => boolean; /** * Callback to open the toolbox in slash-search mode */ private openToolbox: () => void; /** * Callback to open the toolbox in no-slash mode (used when clicking the plus button) */ private openToolboxWithoutSlash: () => void; /** * Callback to close the toolbox */ private closeToolbox: () => void; /** * Callback to move and open the toolbar */ private moveAndOpenToolbar: (block?: Block | null, target?: Element | null) => void; /** * Optional callback invoked at the very start of handleClick(), before any * block manipulation, with the block that currently has focus. * Used by Toolbar to capture the pre-toolbox block for focus restoration on cancel. */ private onFocusBlockCaptured: ((block: Block | null, insertedBlock: Block | null) => void) | undefined; /** * @param getBlok - Function to get Blok modules reference * @param callbacks - Object containing callback functions */ constructor( getBlok: () => BlokModules, callbacks: { getToolboxOpened: () => boolean; openToolbox: () => void; openToolboxWithoutSlash: () => void; closeToolbox: () => void; moveAndOpenToolbar: (block?: Block | null, target?: Element | null) => void; onFocusBlockCaptured?: (block: Block | null, insertedBlock: Block | null) => void; } ) { this.getBlok = getBlok; this.getToolboxOpened = callbacks.getToolboxOpened; this.openToolbox = callbacks.openToolbox; this.openToolboxWithoutSlash = callbacks.openToolboxWithoutSlash; this.closeToolbox = callbacks.closeToolbox; this.moveAndOpenToolbar = callbacks.moveAndOpenToolbar; this.onFocusBlockCaptured = callbacks.onFocusBlockCaptured; } /** * Gets the current hovered block */ get hoveredBlock(): Block | null { return this.hoveredBlockInternal; } /** * Sets the hovered block */ setHoveredBlock(block: Block | null): void { this.hoveredBlockInternal = block; } /** * Internal storage for the hovered block */ private hoveredBlockInternal: Block | null = null; /** * Reference to the plus button element, used to recognize the toolbox this * button anchors when enforcing the same-trigger no-op in handleClick() */ private plusButtonElement: HTMLElement | null = null; /** * Creates the plus button element with tooltip * @param nodes - Toolbar nodes object to populate with the plus button * @returns The created plus button element */ public make(nodes: ToolbarNodes): HTMLElement { const plusButton = $.make('div', [ twJoin( // Base toolbox-button styles 'text-text-secondary cursor-pointer w-6 h-6 rounded-[5px] inline-flex justify-center items-center select-none', 'shrink-0', // SVG sizing '[&_svg]:h-[22px] [&_svg]:w-[22px]', // Hover (can-hover) 'can-hover:hover:bg-bg-light', // Hide when the toolbox popover is open 'group-data-[blok-toolbox-opened=true]:hidden', // Hide when block settings popover is open 'group-data-[blok-block-settings-opened=true]:hidden', // Mobile styles (static positioning with overlay-pane appearance) 'mobile:bg-popover-bg mobile:border mobile:border-mobile-border mobile:shadow-overlay-pane mobile:rounded-[6px] mobile:z-2', 'mobile:w-toolbox-btn-mobile mobile:h-toolbox-btn-mobile', // RTL styles 'group-data-[blok-rtl=true]:right-[calc(-1*(var(--spacing-toolbox-btn)))] group-data-[blok-rtl=true]:left-auto' ), ], { innerHTML: IconPlus, }); plusButton.setAttribute(DATA_ATTR.testid, TEST_ID.plusButton); /** * Accessibility: expose the plus button as the trigger for the Toolbox * listbox (a searchable combobox surface), not a menu. tabindex="-1" keeps * it out of the tab order (keyboard users insert blocks via "/" or * shortcuts) while still exposing role/label to assistive tech. aria-controls * points at the listbox container (id lives on the popover items element). */ plusButton.setAttribute('role', 'button'); plusButton.setAttribute('tabindex', '-1'); plusButton.setAttribute('aria-haspopup', 'listbox'); plusButton.setAttribute('aria-expanded', 'false'); plusButton.setAttribute('aria-controls', TOOLBOX_POPOVER_ID); /** * Keyboard activation: Enter / Space open the toolbox, mirroring the * mouse-driven flow. Space is prevented from scrolling the page. */ plusButton.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key !== 'Enter' && e.key !== ' ') { return; } e.preventDefault(); this.handleClick(); }); // eslint-disable-next-line no-param-reassign -- nodes is mutated by design nodes.plusButton = plusButton; this.plusButtonElement = plusButton; this.refreshI18n(plusButton); return plusButton; } /** * Stamps the translated label and tooltip onto the plus button. * Called on creation and again whenever the locale changes at runtime — * both are eager writes that would otherwise stay in the old language. * @param element - the plus button, defaults to the one already built */ public refreshI18n(element: HTMLElement | undefined): void { if (element === undefined) { return; } const blok = this.getBlok(); element.setAttribute('aria-label', blok.I18n.t('a11y.insertBlock')); const modifierClickText = blok.I18n.t( getUserOS().win ? 'toolbox.ctrlAddAbove' : 'toolbox.optionAddAbove' ); onHover(element, createTooltipContent([ blok.I18n.t('toolbox.addBelow'), modifierClickText, ]), { delay: 500, }); } /** * Handles the plus button click. * Inserts "/" into target block and opens toolbox, or toggles toolbox closed if already open. * @param insertAbove - if true, insert above the current block instead of below */ public handleClick(insertAbove = false): void { /** * Same-trigger law: activating the plus button while the toolbox it * anchors is still open must do nothing — the toolbox stays open with no * side effects (no close, no selection clearing). Escape or an outside * press closes it. */ if ( this.getToolboxOpened() && this.plusButtonElement !== null && PopoverRegistry.instance.isOpenTrigger(this.plusButtonElement) ) { return; } const { BlockManager, BlockSettings, BlockSelection, Caret } = this.getBlok(); // Close other menus and clear selections if (BlockSettings.opened) { BlockSettings.close(); } if (BlockSelection.anyBlockSelected) { BlockSelection.clearSelection(); } SelectionUtils.get()?.removeAllRanges(); // Toggle closed if already open if (this.getToolboxOpened()) { this.closeToolbox(); return; } // Determine target block: reuse any empty block, or create a new one const hoveredBlock = this.hoveredBlockInternal; const isParagraph = hoveredBlock?.name === 'paragraph'; const startsWithSlash = isParagraph && hoveredBlock.pluginsContent.textContent?.startsWith('/'); // Reuse the hovered block if it's empty (any type, not just paragraphs). // If hoveredBlock is not empty (e.g. a table), check if the focused block // is empty and nested inside it (e.g. an empty paragraph in a table cell). const currentBlock = BlockManager.currentBlock ?? null; /** * Capture the block that CURRENTLY HAS DOM FOCUS before any manipulation, * so that focus can be restored to it if the user cancels (Escape) without * selecting a tool. * * We cannot rely on BlockManager.currentBlock here: the mousedown event on * the plus button (which lives inside the hovered block's DOM) triggers the * redactorTouchHandler in capture phase, which calls setCurrentBlockByChildNode * and overwrites currentBlock to the hovered block BEFORE our preventDefault * or handleClick() runs. Instead we look at the actual DOM-focused element * and find which block owns it. */ const activeEl = document.activeElement; const focusedBlockBeforeOpen = activeEl !== null && activeEl !== document.body ? (BlockManager.getBlockByChildNode(activeEl) ?? null) : null; const hoveredIsEmpty = hoveredBlock !== null && hoveredBlock.isEmpty; const nestedCurrentBlockIsEmpty = !hoveredIsEmpty && currentBlock !== null && currentBlock !== hoveredBlock && currentBlock.isEmpty && hoveredBlock !== null && hoveredBlock.holder.contains(currentBlock.holder); const emptyBlockToReuse: Block | null = (hoveredIsEmpty && hoveredBlock) || (nestedCurrentBlockIsEmpty && currentBlock) || null; // Calculate insert index based on direction const hoveredBlockIndex = hoveredBlock !== null ? BlockManager.getBlockIndex(hoveredBlock) : BlockManager.currentBlockIndex; const baseInsertIndex = insertAbove ? hoveredBlockIndex : hoveredBlockIndex + 1; // When inserting below, skip past the hovered block's OWN nested children // (e.g. paragraph blocks inside its table cells, which trail the table in // the flat array) so "below" means below the hovered block's whole subtree. // // Only the hovered block's descendants may be skipped. Treating EVERY // nested block as skippable (the previous behaviour) walked past the // hovered block's follower siblings inside a column — and past the next // columns' children too — landing the new block's FLAT index at the end of // the layout while a raw DOM hoist made it LOOK right under the hovered // block. The Saver derives each parent's content[] from the flat array, so // the block then SAVED at the bottom of the (wrong) column even though the // editor displayed it under the title. const blocksAfterInsert = BlockManager.blocks.slice(baseInsertIndex); const isNested = (block: Block): boolean => block.holder.parentElement?.closest('[data-blok-testid="block-wrapper"]') !== null; const isInHoveredSubtree = (block: Block): boolean => hoveredBlock !== null && hoveredBlock.holder.contains(block.holder); const firstNonSubtreeOffset = !insertAbove && hoveredBlock && blocksAfterInsert.length > 0 ? blocksAfterInsert.findIndex((block) => !isInHoveredSubtree(block)) : 0; const insertIndex = baseInsertIndex + (firstNonSubtreeOffset === -1 ? blocksAfterInsert.length : firstNonSubtreeOffset); // When the hovered block is top-level, the new block must be inserted as a // top-level sibling from the start (forceTopLevel). Inserting by flat index // alone can transiently mount the holder inside a nested container (e.g. the // last table cell, when the flat predecessor is a cell child) — nesting // tools claim such blocks into their model synchronously on block-added, and // a raw DOM hoist afterwards leaves that model entry stale. The stale entry // then mis-claims the block created by a later replace (toolbox selection) // into the cell. const hoveredIsTopLevel = hoveredBlock === null || !isNested(hoveredBlock); // startsWithSlash is only true when isParagraph is true, which requires // hoveredBlock to be non-null. TypeScript narrows this correctly. const targetBlock: Block = startsWithSlash ? hoveredBlock : (emptyBlockToReuse ?? BlockManager.insertDefaultBlockAtIndex(insertIndex, true, false, hoveredIsTopLevel)); // For a NESTED hovered block (inside a column, toggle or callout), the new // block belongs in the hovered block's container: reparent it through the // model so parentId, the parent's contentIds AND the holder's DOM position // stay consistent with the flat index. A raw `holder.after(...)` DOM hoist // here (the previous behaviour) moved only the DOM — the model kept the // stale flat position/parent, so the block visually sat under the hovered // block but SAVED at the bottom of the layout. if ( targetBlock !== hoveredBlock && emptyBlockToReuse === null && hoveredBlock !== null && hoveredBlock.parentId !== null && targetBlock.parentId !== hoveredBlock.parentId ) { BlockManager.setBlockParent(targetBlock, hoveredBlock.parentId); } /** * Notify Toolbar of the pre-open focus context. * insertedBlock is non-null only when we created a brand-new empty block * (not when we're reusing an existing empty block or operating in slash mode). * On cancel (Escape), Toolbar will remove the inserted block and restore focus * to focusedBlockBeforeOpen. */ const insertedBlock = (!startsWithSlash && emptyBlockToReuse === null) ? targetBlock : null; this.onFocusBlockCaptured?.(focusedBlockBeforeOpen, insertedBlock); // Position caret and open toolbox if (startsWithSlash) { // Block already has "/" - keep slash-search mode, position after the slash Caret.setToBlock(targetBlock, Caret.positions.DEFAULT, 1); this.moveAndOpenToolbar(targetBlock); this.openToolbox(); } else { // New empty block - open toolbox directly without inserting "/" Caret.setToBlock(targetBlock, Caret.positions.START); this.moveAndOpenToolbar(targetBlock); this.openToolboxWithoutSlash(); } } }