import type { BlockControlsPosition } from '../../../../types/configs/blok-config'; import type { ToolbarCloseOptions } from '../../../../types/api/toolbar'; import type { ModuleConfig } from '../../../types-internal/module-config'; import { Module } from '../../__module'; import { Block } from '../../block'; import { DATA_ATTR } from '../../constants'; import { Dom as $ } from '../../dom'; import type { BlockChangedPayload } from '../../events/BlockChanged'; import { BlockChanged } from '../../events/BlockChanged'; import { BlockHovered } from '../../events/BlockHovered'; import { BlockSettingsClosed } from '../../events/BlockSettingsClosed'; import { BlockSettingsOpened } from '../../events/BlockSettingsOpened'; import { Toolbox, ToolboxEvent } from '../../ui/toolbox'; import { getUserOS, isMobileScreen, log } from '../../utils'; import { RovingTabindexController } from '../../utils/roving-tabindex'; import { hide } from '../../utils/tooltip'; /** * Refactored Toolbar module components */ import { ClickDragHandler } from './click-handler'; import { computeVisualContentOffset, resolveVisualContentWidth } from './content-alignment'; import { SETTINGS_POPOVER_ID } from './blockSettings'; import { PlusButtonHandler, TOOLBOX_POPOVER_ID } from './plus-button'; import { ToolbarPositioner } from './positioning'; import { SettingsTogglerHandler } from './settings-toggler'; import { getToolbarStyles } from './styles'; import type { ToolbarNodes } from './types'; /** * *«Toolbar» is the node that moves up/down over current block * *______________________________________ Toolbar ____________________________________________ *| | *| ..................... Content ......................................................... | *| . ........ Block Actions ........... | *| . . [Open Settings] . | *| . [Plus Button] [Toolbox: {Tool1}, {Tool2}] . . | *| . . [Settings Panel] . | *| . .................................. | *| ....................................................................................... | *| | *|___________________________________________________________________________________________| * * *Toolbox — its an Element contains tools buttons. Can be shown by Plus Button. * *_______________ Toolbox _______________ *| | *| [Header] [Image] [List] [Quote] ... | *|_______________________________________| * * *Settings Panel — is an Element with block settings: * *____ Settings Panel ____ *| ...................... | *| . Tool Settings . | *| ...................... | *| . Default Settings . | *| ...................... | *|________________________| * @class * @classdesc Toolbar module */ /** * @property {object} nodes - Toolbar nodes * @property {Element} nodes.wrapper - Toolbar main element * @property {Element} nodes.content - Zone with Plus button and toolbox. * @property {Element} nodes.actions - Zone with Block Settings and Remove Button * @property {Element} nodes.blockActionsButtons - Zone with Block Buttons: [Settings] * @property {Element} nodes.plusButton - Button that opens or closes Toolbox * @property {Element} nodes.toolbox - Container for tools * @property {Element} nodes.settingsToggler - open/close Settings Panel button * @property {Element} nodes.settings - Settings Panel * @property {Element} nodes.pluginSettings - Plugin Settings section of Settings Panel * @property {Element} nodes.defaultSettings - Default Settings section of Settings Panel */ export class Toolbar extends Module { /** * Block near which we display the Toolbox */ private hoveredBlock: Block | null = null; /** * Flag to track if toolbar was explicitly closed (e.g., after block deletion). * This prevents the toolbar from reopening on subsequent block-hovered events. */ private explicitlyClosed: boolean = false; /** * Flag to track if the current hovered block was resolved from a table cell block. * When true, the toolbar suppresses plus button, settings toggler, and * prevents overriding the current block when the toolbox opens. */ private hoveredBlockIsFromTableCell: boolean = false; /** * Toolbox class instance * It will be created in requestIdleCallback so it can be null in some period of time */ private toolboxInstance: Toolbox | null = null; /** * Toolbar positioner instance */ private positioner: ToolbarPositioner; /** * Click-vs-drag handler instance */ private clickDragHandler: ClickDragHandler; /** * Plus button handler instance */ private plusButtonHandler: PlusButtonHandler; /** * Settings toggler handler instance */ private settingsTogglerHandler: SettingsTogglerHandler; /** * Roving tabindex controller for the block toolbar's action buttons * (plus button + settings toggler). Focus is moved into the group via the * Alt+F10 shortcut (the buttons are deliberately kept out of the tab order), * then Arrow keys navigate between them. */ private rovingController: RovingTabindexController | null = null; /** * The block that held focus when the user pressed Alt+F10 to focus the * toolbar. Escape restores focus (caret) to it. */ private blockBeforeToolbarFocus: Block | null = null; /** * The block that had focus immediately before the plus button opened the toolbox. * Captured via the onFocusBlockCaptured callback in PlusButtonHandler.handleClick(), * before any block manipulation occurs. * Used to restore focus if the user dismisses the toolbox without selecting a tool. * Cleared when a tool is selected (ToolboxEvent.BlockAdded) or when focus is restored. */ private preToolboxBlock: Block | null = null; /** * A newly-inserted empty block created by the plus button click (not a reused block). * If the user dismisses the toolbox without selecting a tool, this block is removed. * Cleared when a tool is selected or when the block is removed on cancel. */ private plusInsertedBlock: Block | null = null; /** * Drops the pre-plus focus-restoration context. * * Called when the user commits to editing the plus-opened block — e.g. types * "/" to activate slash search. After that point a Closed event is not a * dismissal (Escape / click outside) but a transition: the toolbox is torn * down and reopened, and restoring caret to the originally-focused block * would strand the caret away from the block the user is actually typing in. */ public discardPlusContext(): void { this.preToolboxBlock = null; this.plusInsertedBlock = null; } /** * @class * @param moduleConfiguration - Module Configuration * @param moduleConfiguration.config - Blok's config * @param moduleConfiguration.eventsDispatcher - Blok's event dispatcher */ constructor({ config, eventsDispatcher }: ModuleConfig) { super({ config, eventsDispatcher, }); this.positioner = new ToolbarPositioner(); this.clickDragHandler = new ClickDragHandler(); /** * Initialize handlers with callbacks to toolbar methods */ this.plusButtonHandler = new PlusButtonHandler( () => this.Blok, { getToolboxOpened: () => this.toolbox.opened ?? false, openToolbox: () => this.toolbox.open(), openToolboxWithoutSlash: () => this.toolbox.openWithoutSlash(), closeToolbox: () => this.toolbox.close(), moveAndOpenToolbar: (block, target) => this.moveAndOpen(block, target), onFocusBlockCaptured: (block, insertedBlock) => { this.preToolboxBlock = block; this.plusInsertedBlock = insertedBlock; }, } ); this.settingsTogglerHandler = new SettingsTogglerHandler( () => this.Blok, this.clickDragHandler, { setHoveredBlock: (block) => { this.hoveredBlock = block; }, getToolboxOpened: () => this.toolbox.opened ?? false, closeToolbox: () => this.toolbox.close(), } ); } /** * CSS styles * @returns {object} * @deprecated Use data attributes via constants instead */ public get CSS(): { [name: string]: string } { // eslint-disable-next-line @typescript-eslint/no-deprecated -- CSS getter is deprecated but still used internally return getToolbarStyles(); } /** * Returns the Toolbar opening state * @returns {boolean} */ public get opened(): boolean { // eslint-disable-next-line @typescript-eslint/no-deprecated return this.nodes.wrapper?.classList.contains(this.CSS.toolbarOpened) ?? false; } /** * Check if the element is contained in the Toolbar or its components (Toolbox, BlockSettings) * @param element - element to check */ public contains(element: HTMLElement): boolean { if (this.nodes.wrapper?.contains(element)) { return true; } if (this.toolboxInstance?.contains(element)) { return true; } if (this.Blok.BlockSettings.contains(element)) { return true; } return false; } /** * Public interface for accessing the Toolbox */ /** * Rebuilds the Toolbox item list. Called by the Tools module when a tool's * `toolbox` setting changes at runtime (`tools.update(name, { toolbox })`) — * the Toolbox caches its items, so visibility changes need an explicit rebuild. */ public refreshToolboxItems(): void { this.toolboxInstance?.refreshItems(); } /** * Re-stamps every translated string the toolbar wrote eagerly, so a runtime * locale change (`blok.i18n.update()`) lands on the already-built chrome. * * Only the eager writes need this: the block-settings menu, the convert * menu and the toolbox items are all resolved when they open, so they pick * up the new locale on their own. */ public refreshI18n(): void { this.nodes.wrapper?.setAttribute('aria-label', this.Blok.I18n.t('a11y.blockToolbar')); this.plusButtonHandler.refreshI18n(this.nodes.plusButton); this.settingsTogglerHandler.refreshAriaLabel(); this.settingsTogglerHandler.refreshTooltip(); /* * The toolbox holds a snapshot of its labels and caches the built popover, * so it needs both the new strings and an explicit rebuild. */ this.toolboxInstance?.setI18nLabels(this.toolboxI18nLabels()); this.toolboxInstance?.refreshItems(); } /** * Resolves the toolbox's text labels against the active locale. */ private toolboxI18nLabels(): { filter: string; nothingFound: string; slashSearchPlaceholder: string } { return { filter: this.Blok.I18n.t('popover.search'), nothingFound: this.Blok.I18n.t('popover.nothingFound'), slashSearchPlaceholder: this.Blok.I18n.t('toolbox.typeToSearch'), }; } public get toolbox(): { opened: boolean | undefined; // undefined is for the case when Toolbox is not initialized yet close: () => void; open: () => void; openWithoutSlash: () => void; toggle: () => void; hasFocus: () => boolean | undefined; } { return { opened: this.toolboxInstance?.opened, close: () => { this.toolboxInstance?.close(); }, openWithoutSlash: () => { if (this.toolboxInstance === null) { log('toolbox.openWithoutSlash() called before initialization is finished', 'warn'); return; } if (this.hoveredBlock && !this.hoveredBlockIsFromTableCell) { const currentBlock = this.Blok.BlockManager.currentBlock; const isCurrentBlockInsideTableCell = currentBlock !== undefined && currentBlock.holder.closest('[data-blok-table-cell-blocks]') !== null; if (!isCurrentBlockInsideTableCell) { this.Blok.BlockManager.currentBlock = this.hoveredBlock; } } this.toolboxInstance.open(false); }, open: () => { /** * If Toolbox is not initialized yet, do nothing */ if (this.toolboxInstance === null) { log('toolbox.open() called before initialization is finished', 'warn'); return; } /** * Set current block to cover the case when the Toolbar showed near hovered Block but caret is set to another Block. * Skip this when: * - the hovered block was resolved from a table cell * - the current block's holder is nested inside a table cell container * (e.g. "/" was typed in a cell while the toolbar was already open from hover, * so hoveredBlockIsFromTableCell may be stale/false from the hover resolution) * * In both cases, overriding currentBlock with the resolved table block * would lose the cell-paragraph context the toolbox needs to hide restricted tools. */ if (this.hoveredBlock && !this.hoveredBlockIsFromTableCell) { const currentBlock = this.Blok.BlockManager.currentBlock; const isCurrentBlockInsideTableCell = currentBlock !== undefined && currentBlock.holder.closest('[data-blok-table-cell-blocks]') !== null; if (!isCurrentBlockInsideTableCell) { this.Blok.BlockManager.currentBlock = this.hoveredBlock; } } this.toolboxInstance.open(); }, toggle: () => { /** * If Toolbox is not initialized yet, do nothing */ if (this.toolboxInstance === null) { log('toolbox.toggle() called before initialization is finished', 'warn'); return; } this.toolboxInstance.toggle(); }, hasFocus: () => this.toolboxInstance?.hasFocus(), }; } /** * Block actions appearance manipulations */ private get blockActions(): { hide: () => void; show: () => void } { return { hide: (): void => { // eslint-disable-next-line @typescript-eslint/no-deprecated this.nodes.actions?.classList.remove(this.CSS.actionsOpened); this.nodes.actions?.removeAttribute('data-blok-opened'); if (this.nodes.actions) { this.nodes.actions.style.pointerEvents = 'none'; } }, show: (): void => { // eslint-disable-next-line @typescript-eslint/no-deprecated this.nodes.actions?.classList.add(this.CSS.actionsOpened); this.nodes.actions?.setAttribute('data-blok-opened', 'true'); if (this.nodes.actions) { this.nodes.actions.style.pointerEvents = 'auto'; /** * Reset inline pointer-events overrides that a previous * left-edge block may have set on actions descendants so * non-left-edge blocks regain click-ability everywhere. */ for (const descendant of Array.from(this.nodes.actions.querySelectorAll('*'))) { descendant.style.pointerEvents = ''; } } }, }; } /** * Methods for working with Block Tunes toggler */ private get blockTunesToggler(): { hide: () => void; show: () => void } { return { // eslint-disable-next-line @typescript-eslint/no-deprecated hide: (): void => this.nodes.settingsToggler?.classList.add(this.CSS.settingsTogglerHidden), // eslint-disable-next-line @typescript-eslint/no-deprecated show: (): void => this.nodes.settingsToggler?.classList.remove(this.CSS.settingsTogglerHidden), }; } /** * Toggles read-only mode. * * Read-only (Notion-style): the toolbar and block settings stay alive so the * user can still hover a block and copy a link to it. The plus button is * hidden and the drag gesture is suppressed (see moveAndOpen + plus-button * handler). * @param {boolean} readOnlyEnabled - read-only mode */ public toggleReadOnly(readOnlyEnabled: boolean): void { /** * Draw the toolbar the first time we need it. Previously the toolbar DOM * was only built on the readOnly=false path; now read-only also hosts a * (restricted) block settings popover, so ensure the DOM exists in both * modes. Subsequent toggles just flip plus-button visibility. */ if (this.nodes.wrapper === undefined) { window.requestIdleCallback(async () => { await this.drawUI(); this.enableModuleBindings(); this.applyReadOnlyToPlusButton(readOnlyEnabled); }, { timeout: 2000 }); return; } this.applyReadOnlyToPlusButton(readOnlyEnabled); this.settingsTogglerHandler.refreshTooltip(); this.settingsTogglerHandler.refreshCursor(); this.settingsTogglerHandler.refreshAriaLabel(); /** * readOnly: { hideControls: true } — a toolbar left open by a runtime * toggle must disappear along with the rest of the controls */ if (readOnlyEnabled && this.Blok.ReadOnly.isControlsHidden) { this.close(); } } /** * Hides the plus button in read-only mode, restores its visibility otherwise. * Uses inline display:none because the button element already uses inline * style toggling elsewhere (moveAndOpen, close, moveAndOpenForMultipleBlocks). */ private applyReadOnlyToPlusButton(readOnlyEnabled: boolean): void { const { plusButton } = this.nodes; if (plusButton === undefined) { return; } plusButton.style.display = readOnlyEnabled ? 'none' : ''; } /** * Runtime setter for `config.hideToolbar` (reactive contract). * * The open guards (moveAndOpen / moveAndOpenForMultipleBlocks) read the * config live, but the editor wrapper's `DATA_ATTR.toolbarHidden` attribute * — the CSS hook that collapses the gutter — is snapshotted once at * construction (ui.ts). A complete runtime toggle must write both. * @param hidden - true to hide the hover toolbar and collapse the gutter */ public setHidden(hidden: boolean): void { this.config.hideToolbar = hidden; const editorWrapper = this.Blok.UI.nodes.wrapper; if (editorWrapper instanceof HTMLElement) { editorWrapper.toggleAttribute(DATA_ATTR.toolbarHidden, hidden); } if (hidden && this.opened) { this.close(); } } /** * True while the floating block controls live in the editor's inline-END * gutter (`config.toolbarPosition === 'right'`). * * Read by the layout paths that CSS cannot mirror on its own: the content * clamp in `syncContentToBlock`, the nested-content offset, the left-edge * overlap suppression, and the block-settings popover's aside placement. */ public get isPositionedRight(): boolean { return this.config.toolbarPosition === 'right'; } /** * Runtime setter for `config.toolbarPosition` (reactive contract). * * Mirrors `setHidden`: the wrapper's `DATA_ATTR.toolbarPosition` attribute is * snapshotted once at construction (ui.ts) and drives every CSS hook (gutter * side, actions-bar side), while the JS layout paths read the config live — * so a complete runtime move must write both. An open toolbar is re-laid out * in place rather than closed: the side is a layout property, not a * visibility one, and closing would drop the hover the user is still holding. * @param position - 'left' for the inline-start gutter, 'right' for inline-end */ public setPosition(position: BlockControlsPosition): void { if (this.config.toolbarPosition === position) { return; } this.config.toolbarPosition = position; const editorWrapper = this.Blok.UI.nodes.wrapper; if (editorWrapper instanceof HTMLElement) { editorWrapper.setAttribute(DATA_ATTR.toolbarPosition, position); } if (this.opened && this.hoveredBlock) { this.moveAndOpen(this.hoveredBlock, this.positioner.target); } } /** * Move Toolbar to the passed (or current) Block * @param block - block to move Toolbar near it * @param target - optional target element that was hovered (for content offset calculation) */ public moveAndOpen(block?: Block | null, target?: Element | null): void { /** * Some UI elements creates inside requestIdleCallback, so the can be not ready yet */ if (this.toolboxInstance === null) { log('Can\'t open Toolbar since Blok initialization is not finished yet', 'warn'); return; } /** * readOnly: { hideControls: true } — pure document view, no hover toolbar */ if (this.Blok.ReadOnly.isControlsHidden) { return; } /** * config.hideToolbar — the hover toolbar (plus / drag controls) never opens */ if (this.config.hideToolbar === true) { return; } /** * Reset explicitlyClosed flag when toolbar is opened */ this.explicitlyClosed = false; /** * Close Toolbox when we move toolbar */ if (this.toolboxInstance.opened) { this.toolboxInstance.close(); } if (this.Blok.BlockSettings.opened) { this.Blok.BlockSettings.close(); } /** * If no one Block selected as a Current */ const unresolvedBlock = block ?? this.Blok.BlockManager.currentBlock; if (!unresolvedBlock) { return; } /** * Track whether the hover originated from inside a table cell. * * Two scenarios: * 1. Called with an explicit `target` (via BlockHovered): blockHover.ts resolves * cell paragraphs up to the TABLE block before emitting the event, so * `unresolvedBlock` is always the TABLE block — its holder is at the top level. * Use the raw `target` element to detect if the pointer is inside a cell. * 2. Called without args (from activateToolbox / slash menu): `unresolvedBlock` * falls back to `BlockManager.currentBlock`, which IS the cell-paragraph. * Check the block's holder directly. * * When this flag is true, the toolbox.open() getter preserves the cell-paragraph * as currentBlock so that restricted tools (table, header) can be hidden. * * NOTE: This flag is NOT used for plus button / settings toggler visibility. * Those are handled separately by the focusin listener (tableCellFocusHandler) * which detects when the user actually clicks/focuses inside a cell. */ const targetIsInsideCell = target instanceof Element && target.closest('[data-blok-table-cell-blocks]') !== null; const blockIsInsideCell = unresolvedBlock.holder.closest('[data-blok-table-cell-blocks]') !== null; this.hoveredBlockIsFromTableCell = targetIsInsideCell || blockIsInsideCell; const targetBlock = this.resolveTableCellBlock(unresolvedBlock); /** * Read-only keeps the settings toggler so a reader can hover a block and copy a * link to it. But a block that paints nothing has nothing to point at: the * handle ends up floating in blank space, reading as a rendering glitch. Leave * the toolbar shut for those. */ if (this.Blok.ReadOnly.isEnabled && this.paintsNothing(targetBlock)) { this.close({ setExplicitlyClosed: false }); return; } /** Clean up draggable on previous block if any */ if (this.hoveredBlock && this.hoveredBlock !== targetBlock) { this.hoveredBlock.cleanupDraggable(); } this.hoveredBlock = targetBlock; this.plusButtonHandler.setHoveredBlock(targetBlock); this.settingsTogglerHandler.setHoveredBlock(targetBlock); this.positioner.setHoveredTarget(target ?? null); this.positioner.resetCachedPosition(); // Reset cached position when moving to a new block const { wrapper, plusButton, settingsToggler } = this.nodes; if (!wrapper || !plusButton) { return; } /** * Adjust toolbar button visibility based on context: * - Callout first child: both plus button and settings toggler hidden * to prevent overlap with the callout's emoji icon * * The callout block itself still shows BOTH buttons — the actions container * sits outside the block (positioned via right:100% on the left gutter) and * does not overlap the emoji which is inside the block at pl-8. * * Note: when the toolbar resolves to a parent table block from a focused * cell, the settings toggler must STAY visible — it is wired via * setupDraggable() to drag the parent table, so hiding it would leave the * whole table undraggable while the user edits cell text. */ const isCalloutFirstChild = this.overlapsCalloutEmoji(targetBlock); const hidePlusButton = isCalloutFirstChild || this.Blok.ReadOnly.isEnabled; plusButton.style.display = hidePlusButton ? 'none' : ''; if (settingsToggler) { settingsToggler.style.display = isCalloutFirstChild ? 'none' : ''; } /** * Adapt toolbar button background for blocks inside a callout with custom colors. * Use color-mix() to create a subtly lighter variant of the callout background * so buttons are distinguishable from the callout surface. * Icon color stays default (text-text-secondary) regardless of callout colors. * * Skip when the target is the callout itself or its first child — their toolbar * buttons render outside the callout's visual background area. */ const isCalloutBlock = targetBlock.name === 'callout'; const calloutBg = isCalloutFirstChild || isCalloutBlock ? null : this.getCalloutBackgroundColor(targetBlock); if (calloutBg !== null) { wrapper.style.setProperty('--blok-bg-light', `light-dark(color-mix(in srgb, ${calloutBg} 70%, white), color-mix(in srgb, ${calloutBg} 85%, white))`); } else { wrapper.style.removeProperty('--blok-bg-light'); } const targetBlockHolder = targetBlock.holder; const { isMobile } = this.Blok.UI; const toolbarY = this.positioner.calculateToolbarY( { targetBlock, hoveredTarget: target ?? null, isMobile }, plusButton ); if (toolbarY === null) { return; } /** * Move Toolbar to the Top coordinate of Block */ this.positioner.moveToY(this.nodes, toolbarY); targetBlockHolder.appendChild(wrapper); /** Set up draggable on the target block using the settings toggler as drag handle */ if (settingsToggler && !this.Blok.ReadOnly.isEnabled) { targetBlock.setupDraggable(settingsToggler, this.Blok.DragManager); } /** * Update toolbox left alignment to the block's content element so the popover * aligns with the actual visible content, not the toolbar's internal wrapper. */ const blockContentElement = targetBlockHolder.querySelector(`[${DATA_ATTR.elementContent}]`); if (blockContentElement) { this.toolboxInstance.updateLeftAlignElement(blockContentElement); } /** * Apply content offset for nested elements (e.g., nested list items) */ this.positioner.applyContentOffset(this.nodes, targetBlock, this.isPositionedRight); /** * Keep the toolbar aligned with the block's current bounds while its size * changes without dispatching a `BlockChanged` event (e.g. image resize * handle drag). ResizeObserver fires on every frame of the user's drag and * re-runs Y + content-offset calculations so + / ⋮⋮ follow the image edge. */ this.positioner.watchTargetResize(targetBlockHolder, () => this.repositionToolbar()); /** * Do not show Block Tunes Toggler near single and empty block */ const tunes = targetBlock.getTunes(); const hasAnyTunes = tunes.toolTunes.length > 0 || tunes.commonTunes.length > 0; if (this.Blok.BlockManager.blocks.length === 1 && targetBlock.isEmpty && !hasAnyTunes) { this.blockTunesToggler.hide(); } else { this.blockTunesToggler.show(); } this.open(); /** * For blocks with interactive elements at the left edge (toggle arrows, * callout emoji buttons), disable pointer-events on the actions * container so clicks pass through to the block content. * Must run after open() which sets pointer-events: auto on actions. */ const isToggleHeader = targetBlock.name === 'header' && targetBlock.holder.querySelector('[data-blok-toggle-arrow]') !== null; const hasLeftEdgeInteraction = !this.isPositionedRight && (targetBlock.name === 'callout' || targetBlock.name === 'toggle' || isToggleHeader); if (hasLeftEdgeInteraction && this.nodes.actions) { this.shieldLeftEdgeControl(targetBlock); } if (blockContentElement) { this.syncContentToBlock(targetBlockHolder, blockContentElement); } } /** * Align the toolbar's inner content wrapper with the block's visible content column. * See `content-alignment.ts` for the two-case reasoning (non-stretched vs stretched). * * The offset is then clamped so the actions bar cannot leave the viewport on * whichever side it docks to — see the two clamp helpers below. * @param targetBlockHolder - Block holder element * @param blockContentElement - `[data-blok-element-content]` element inside the holder */ private syncContentToBlock(targetBlockHolder: HTMLElement, blockContentElement: HTMLElement): void { if (this.nodes.content === undefined) { return; } const wrapperRect = this.nodes.wrapper?.getBoundingClientRect(); const contentRect = blockContentElement.getBoundingClientRect(); const visualOffset = computeVisualContentOffset(targetBlockHolder, contentRect, wrapperRect); const actionsWidth = this.nodes.actions?.offsetWidth ?? 0; const contentWidth = resolveVisualContentWidth(targetBlockHolder, contentRect, wrapperRect); const effectiveOffset = this.isPositionedRight ? this.clampOffsetForEndDock(visualOffset, contentWidth, actionsWidth, wrapperRect) : this.clampOffsetForStartDock(visualOffset, actionsWidth, wrapperRect); this.nodes.content.style.marginLeft = `${effectiveOffset}px`; this.nodes.content.style.maxWidth = `${contentWidth}px`; } /** * Floor for the toolbar content's left margin while the actions bar is docked * at `right:100%` — it grows leftwards, so the margin must leave at least its * own width of room or the drag handle lands off-screen and stops receiving * pointer events. Space to the left of the editor wrapper counts as slack, so * a nested (already indented) block is not pushed into its own text. * @param visualOffset - the alignment offset the block's content column asks for * @param actionsWidth - measured width of the actions bar * @param wrapperRect - bounding rect of the toolbar wrapper (co-located with the holder) */ private clampOffsetForStartDock( visualOffset: number, actionsWidth: number, wrapperRect: DOMRect | undefined ): number { const slackLeft = wrapperRect ? Math.max(0, wrapperRect.left) : 0; return Math.max(visualOffset, Math.max(0, actionsWidth - slackLeft)); } /** * Mirror of {@link clampOffsetForStartDock} for `toolbarPosition: 'right'`, * where the bar is docked at `left:100%` and grows rightwards: the ceiling is * on the offset, not the floor, and the slack is whatever lies between the * editor wrapper's right edge and the viewport's. * @param visualOffset - the alignment offset the block's content column asks for * @param contentWidth - resolved width of the visible content column * @param actionsWidth - measured width of the actions bar * @param wrapperRect - bounding rect of the toolbar wrapper (co-located with the holder) */ private clampOffsetForEndDock( visualOffset: number, contentWidth: number, actionsWidth: number, wrapperRect: DOMRect | undefined ): number { if (wrapperRect === undefined) { return visualOffset; } const viewportWidth = window.innerWidth; const slackRight = Math.max(0, viewportWidth - wrapperRect.right); const overhang = Math.max(0, actionsWidth - slackRight); const maxOffset = wrapperRect.width - contentWidth - overhang; return Math.max(0, Math.min(visualOffset, maxOffset)); } /** * Move Toolbar to the specified block (or first selected block) and open it for multi-block selection. * Keeps the add button visible so users can still insert blocks while multiple are selected. * @param block - optional block to position the toolbar at (defaults to first selected block) */ public moveAndOpenForMultipleBlocks(block?: Block): void { /** * readOnly: { hideControls: true } — pure document view, no hover toolbar */ if (this.Blok.ReadOnly.isControlsHidden) { return; } /** * config.hideToolbar — the hover toolbar (plus / drag controls) never opens */ if (this.config.hideToolbar === true) { return; } /** * Do not move toolbar if Block Settings is opened or opening. * The settings menu should remain anchored to where the user opened it. */ if (this.Blok.BlockSettings.opened || this.Blok.BlockSettings.isOpening) { return; } const selectedBlocks = this.Blok.BlockSelection.selectedBlocks; if (selectedBlocks.length < 2) { return; } /** * Some UI elements creates inside requestIdleCallback, so they can be not ready yet */ if (this.toolboxInstance === null) { log('Can\'t open Toolbar since Blok initialization is not finished yet', 'warn'); return; } /** * Close Toolbox when we move toolbar */ if (this.toolboxInstance.opened) { this.toolboxInstance.close(); } /** * Reset explicitlyClosed flag to allow toolbar to reopen/move on hover */ this.explicitlyClosed = false; /** * Don't close BlockSettings here - it should remain open if the user explicitly opened it via the settings toggler. * The hover behavior that calls this method shouldn't interfere with the user's intent to open the menu. */ /** * Use the provided block or fall back to the first selected block as the anchor for the toolbar */ const targetBlock = block ?? selectedBlocks[0]; /** Clean up draggable on previous block if any */ if (this.hoveredBlock && this.hoveredBlock !== targetBlock) { this.hoveredBlock.cleanupDraggable(); } this.hoveredBlock = targetBlock; this.plusButtonHandler.setHoveredBlock(targetBlock); this.settingsTogglerHandler.setHoveredBlock(targetBlock); this.positioner.setHoveredTarget(null); // No target for multi-block selection this.positioner.resetCachedPosition(); // Reset cached position when moving to a new block const { wrapper, plusButton, settingsToggler } = this.nodes; if (!wrapper || !plusButton) { return; } /** * Restore plus button and settings toggler visibility for multi-block selection, * in case they were hidden for table cell blocks. * In read-only mode, keep the plus button hidden. */ plusButton.style.display = this.Blok.ReadOnly.isEnabled ? 'none' : ''; plusButton.style.color = ''; if (settingsToggler) { settingsToggler.style.display = ''; settingsToggler.style.color = ''; } const targetBlockHolder = targetBlock.holder; const toolbarY = this.positioner.calculateToolbarY( { targetBlock, hoveredTarget: null, isMobile: false }, plusButton ); if (toolbarY === null) { return; } this.positioner.moveToY(this.nodes, toolbarY); targetBlockHolder.appendChild(wrapper); if (settingsToggler && !this.Blok.ReadOnly.isEnabled) { targetBlock.setupDraggable(settingsToggler, this.Blok.DragManager); } const blockContentElement = targetBlockHolder.querySelector(`[${DATA_ATTR.elementContent}]`); if (blockContentElement) { this.toolboxInstance.updateLeftAlignElement(blockContentElement); } /** * Reset content offset for multi-block selection */ this.positioner.applyContentOffset(this.nodes, targetBlock, this.isPositionedRight); /** * Always show the settings toggler for multi-block selection */ this.blockTunesToggler.show(); this.open(); if (blockContentElement) { this.syncContentToBlock(targetBlockHolder, blockContentElement); } } /** * Close the Toolbar * @param options - Optional configuration */ public close(options?: ToolbarCloseOptions): void { // eslint-disable-next-line @typescript-eslint/no-deprecated this.nodes.wrapper?.classList.remove(this.CSS.toolbarOpened); // eslint-disable-next-line @typescript-eslint/no-deprecated this.nodes.wrapper?.classList.add(this.CSS.toolbarClosed); this.nodes.wrapper?.removeAttribute('data-blok-opened'); /** Close components */ this.blockActions.hide(); this.toolboxInstance?.close(); this.Blok.BlockSettings.close(); /** * Clear hovered block state and optionally mark as explicitly closed * to prevent toolbar from reopening on subsequent block-hovered events */ this.hoveredBlock = null; this.hoveredBlockIsFromTableCell = false; // Only set explicitlyClosed if not explicitly disabled (e.g., when called from toolbox after block insertion) if (options?.setExplicitlyClosed !== false) { this.explicitlyClosed = true; /** * Reset the BlockHoverController's lastHoveredBlockId so that the next * mousemove over the same block re-emits BlockHovered. * Without this, deduplication in BlockHoverController suppresses the event * and the toolbar can never reopen on the same block after being closed by * a mousedown (e.g. from RectangleSelection.startSelection). */ this.Blok.UI.resetBlockHoverState(); } /** * Restore plus button and settings toggler visibility * in case they were hidden for table cell blocks. * In read-only mode, keep the plus button hidden. */ if (this.nodes.plusButton) { this.nodes.plusButton.style.display = this.Blok.ReadOnly.isEnabled ? 'none' : ''; this.nodes.plusButton.style.color = ''; } if (this.nodes.settingsToggler) { this.nodes.settingsToggler.style.display = ''; this.nodes.settingsToggler.style.color = ''; } /** * Reset the content offset transform and margin sync */ if (this.nodes.actions) { this.nodes.actions.style.transform = ''; } if (this.nodes.content) { this.nodes.content.style.marginLeft = ''; this.nodes.content.style.maxWidth = ''; } this.positioner.setHoveredTarget(null); this.positioner.stopWatchingTargetResize(); this.reset(); } /** * Prevents the settings menu from opening on the next mouseup event * Used after block drop to avoid accidental menu opening */ public skipNextSettingsToggle(): void { this.settingsTogglerHandler.skipNextToggle(); } /** * Hides the block actions (plus button and settings toggler) without * closing the entire toolbar or setting explicitlyClosed. * Used when the toolbar should remain positioned but its action buttons * should temporarily step aside (e.g., during typing or inline toolbar use). */ public hideBlockActions(): void { this.blockActions.hide(); } /** * Resets the explicitlyClosed flag to allow the toolbar to reopen on hover. * Called when drag is cancelled to re-enable hover-based toolbar opening. */ public resetExplicitlyClosed(): void { this.explicitlyClosed = false; } /** * Checks whether the currently focused element (document.activeElement) is * inside a table cell container. * * Used to decide whether the plus button and settings toggler should be hidden. * Focus-based check distinguishes click (buttons hidden) from hover (buttons visible). */ /** * Whether the block shows the reader nothing at all — no text and no decoration * of its own to hang a handle beside. * * Only tools that draw purely their text qualify when that text is gone. A quote * still rules its left border, a list still sets its marker, a callout still * fills its background, an empty toggle heading still points its arrow — all of * those remain visible blocks with a visible edge, so they keep their handle. A * spacer is the one tool that renders nothing by design: read-only strips its * grips and outline, leaving pure whitespace. * * @param block - the block the toolbar is about to open beside */ private paintsNothing(block: Block): boolean { if (block.name === 'spacer') { return true; } if (block.name !== 'paragraph' && block.name !== 'header') { return false; } // A toggle heading keeps its arrow with or without text. if (block.holder.querySelector('[data-blok-toggle-arrow]') !== null) { return false; } return block.isEmpty; } /** * Checks whether the given block is the first child of a callout block. * Used to hide the plus button and prevent it from overlapping the callout emoji icon. */ /** * Whether the controls would collide with the callout's emoji button. * * The emoji sits at the callout's LEFT edge, which is exactly where the * actions bar lands for a callout's first child — hence the buttons are * hidden there. Docked to the opposite gutter (`toolbarPosition: 'right'`) * they have nothing to collide with, so they stay visible (and, sitting over * the callout's own surface now, take its background adaptation instead). * @param block - the block the toolbar is being positioned at */ private overlapsCalloutEmoji(block: Block | null): boolean { if (block === null || this.isPositionedRight) { return false; } return this.isFirstChildOfCallout(block); } private isFirstChildOfCallout(block: Block): boolean { if (!block.parentId) { return false; } const parentBlock = this.Blok.BlockManager.getBlockById(block.parentId); if (!parentBlock || parentBlock.name !== 'callout') { return false; } return parentBlock.contentIds[0] === block.id; } /** * Returns the background color of the callout containing the given block, * or null if the block is not inside a colored callout (or the callout has * no background color set). */ private getCalloutBackgroundColor(block: Block): string | null { const calloutBlock = this.resolveCalloutBlock(block); if (!calloutBlock) { return null; } try { const bg = calloutBlock.pluginsContent.style.backgroundColor; return bg || null; } catch { return null; } } /** * Returns the callout block if the given block is a callout or is a child of one. */ private resolveCalloutBlock(block: Block): Block | null { if (block.name === 'callout') { return block; } if (!block.parentId) { return null; } const parent = this.Blok.BlockManager.getBlockById(block.parentId); if (!parent || parent.name !== 'callout') { return null; } return parent; } /** * Updates toolbar button visibility based on the current hovered block. * Called from the focusin listener so callout first-child state is * refreshed immediately when focus moves, without waiting for the next * hover/moveAndOpen cycle. * * INVARIANT: the ONLY reason this method may hide the settings toggler * (drag handle) is `isCalloutFirstChild` — a structural property of the * block, NOT where `document.activeElement` currently is. Hiding the drag * handle based on focus position inside nested content (table cells, * code editors, database titles, etc.) will break dragging of the * containing block while the user edits its content. Do NOT add focus- * based or DOM-attribute-based hide branches here. See the arch guard * test in test/unit/components/modules/toolbar/index.test.ts. */ private updateToolbarButtonsForCalloutFirstChild(): void { const { plusButton, settingsToggler } = this.nodes; if (!plusButton) { return; } const isCalloutFirstChild = this.overlapsCalloutEmoji(this.hoveredBlock); const hidePlusButton = isCalloutFirstChild || this.Blok.ReadOnly.isEnabled; plusButton.style.display = hidePlusButton ? 'none' : ''; if (settingsToggler) { settingsToggler.style.display = isCalloutFirstChild ? 'none' : ''; } } /** * Re-enables pointer-events on the settings toggler after the actions * container has been set to pointer-events: none for left-edge blocks. * * Left-edge blocks (callout, toggle, header-with-arrow) disable * pointer-events on the actions container so clicks pass through to the * block's own left-edge interactive element (emoji / toggle arrow). The * settings toggler must remain clickable on top so it can still function * as the drag handle and open the block tunes menu. */ private restoreSettingsTogglerForLeftEdgeBlock(_targetBlock: Block): void { if (this.nodes.settingsToggler) { this.nodes.settingsToggler.style.pointerEvents = 'auto'; } } /** * Stop the actions bar from swallowing a block's own left-edge control. * * The bar is margin-aligned, so on a callout / toggle / toggle-heading it can * be clamped over the block's emoji button or toggle arrow. `pointer-events` * does not inherit, so every descendant keeps its own `auto` and intercepts * whatever lands on its painted area — hence the per-descendant walk. * * Only the parts that ACTUALLY cover the control stand down. Blanking the * whole bar (the original fix) left the plus button painted, tooltipped and * permanently unclickable on those three tools — that is how "+ does nothing * on a callout" shipped, and the protection it bought was already void: the * settings toggler sits nearer the content and is restored to `auto` below, * so it, not the plus button, is what a clamped bar puts over the control. * * With no measurable control the bar is disabled wholesale, as before: a * swallowed emoji click has no other route, an unreachable "+" does. * @param targetBlock - block the toolbar is being opened beside */ private shieldLeftEdgeControl(targetBlock: Block): void { const { actions } = this.nodes; if (!actions) { return; } const control = targetBlock.holder.querySelector( '[data-blok-testid="callout-emoji-btn"], [data-blok-toggle-arrow]' ); if (control === null) { actions.style.pointerEvents = 'none'; for (const descendant of Array.from(actions.querySelectorAll('*'))) { descendant.style.pointerEvents = 'none'; } this.restoreSettingsTogglerForLeftEdgeBlock(targetBlock); return; } const controlRect = control.getBoundingClientRect(); const covers = (element: HTMLElement): boolean => { const rect = element.getBoundingClientRect(); return rect.left < controlRect.right && controlRect.left < rect.right && rect.top < controlRect.bottom && controlRect.top < rect.bottom; }; actions.style.pointerEvents = covers(actions) ? 'none' : 'auto'; for (const descendant of Array.from(actions.querySelectorAll('*'))) { descendant.style.pointerEvents = covers(descendant) ? 'none' : ''; } this.restoreSettingsTogglerForLeftEdgeBlock(targetBlock); } /** * If the block is inside a table cell, resolve to the parent table block. * This ensures the toolbar shows for the table when clicking/focusing inside cells. * Uses the DOM attribute directly to avoid cross-module dependency on the table tool. * * @param block - the block to resolve * @returns the parent table block if inside a cell, the original block otherwise */ private resolveTableCellBlock(block: Block): Block { const cellBlocksContainer = block.holder.closest('[data-blok-table-cell-blocks]'); if (!cellBlocksContainer) { return block; } const tableBlockHolder = cellBlocksContainer.closest('[data-blok-testid="block-wrapper"]'); if (!tableBlockHolder) { return block; } return this.Blok.BlockManager.getBlockByChildNode(tableBlockHolder) ?? block; } /** * Reset the Toolbar position to prevent DOM height growth, for example after blocks deletion */ private reset(): void { this.positioner.resetCachedPosition(); // Reset cached position when toolbar is reset if (this.nodes.wrapper) { this.nodes.wrapper.style.top = 'unset'; /** * Move Toolbar back to the Blok wrapper to save it from deletion */ this.Blok.UI.nodes.wrapper.appendChild(this.nodes.wrapper); } } /** * Open Toolbar with Plus Button and Actions * @param {boolean} withBlockActions - by default, Toolbar opens with Block Actions. * This flag allows to open Toolbar without Actions. */ private open(withBlockActions = true): void { // eslint-disable-next-line @typescript-eslint/no-deprecated this.nodes.wrapper?.classList.remove(this.CSS.toolbarClosed); // eslint-disable-next-line @typescript-eslint/no-deprecated this.nodes.wrapper?.classList.add(this.CSS.toolbarOpened); this.nodes.wrapper?.setAttribute('data-blok-opened', 'true'); if (withBlockActions) { this.blockActions.show(); } else { this.blockActions.hide(); } } /** * Draws Toolbar elements */ private async make(): Promise { const wrapper = $.make('div', [ // eslint-disable-next-line @typescript-eslint/no-deprecated -- CSS getter now returns Tailwind classes this.CSS.toolbar, // eslint-disable-next-line @typescript-eslint/no-deprecated -- CSS getter now returns Tailwind classes this.CSS.toolbarClosed, 'group-data-[blok-dragging=true]:pointer-events-none', ]); this.nodes.wrapper = wrapper; wrapper.setAttribute(DATA_ATTR.toolbar, ''); wrapper.setAttribute('data-blok-testid', 'toolbar'); /** * Accessibility: expose the block toolbar (plus button + settings toggler) * as an ARIA toolbar with a descriptive label. */ wrapper.setAttribute('role', 'toolbar'); wrapper.setAttribute('aria-label', this.Blok.I18n.t('a11y.blockToolbar')); /** * The action buttons are laid out horizontally; mirror the inline * toolbar's convention so the roving group's arrow navigation matches the * declared orientation. */ wrapper.setAttribute('aria-orientation', 'horizontal'); /** * Make Content Zone and Actions Zone */ // eslint-disable-next-line @typescript-eslint/no-deprecated -- CSS getter now returns Tailwind classes const content = $.make('div', this.CSS.content); const actions = $.make('div', [ // eslint-disable-next-line @typescript-eslint/no-deprecated -- CSS getter now returns Tailwind classes this.CSS.actions, ]); /** * Start with pointer-events disabled so invisible (opacity-0) actions * don't intercept clicks on elements underneath (e.g. toggle arrows). * blockActions.show()/hide() toggles this inline style. */ actions.style.pointerEvents = 'none'; this.nodes.content = content; this.nodes.actions = actions; actions.setAttribute(DATA_ATTR.toolbarActions, ''); actions.setAttribute('data-blok-testid', 'toolbar-actions'); /** * Actions will be included to the toolbar content so we can align in to the right of the content */ $.append(wrapper, content); $.append(content, actions); /** * Fill Content Zone: * - Plus Button (created by handler) * - Toolbox */ const plusButton = this.plusButtonHandler.make(this.nodes); $.append(actions, plusButton); /** * Fill Actions Zone: * - Settings Toggler (created by handler) * - Remove Block Button * - Settings Panel */ const settingsToggler = this.settingsTogglerHandler.make(this.nodes); /** * Accessibility: expose the settings toggler as a menu button. * The toggler element itself is built by SettingsTogglerHandler (role/label/ * tabindex live there); here we add the menu-button contract wiring — * aria-haspopup + the collapsed initial state + a link to the settings * popover that BlockSettings creates on open. */ settingsToggler.setAttribute('aria-haspopup', 'menu'); settingsToggler.setAttribute('aria-expanded', 'false'); settingsToggler.setAttribute('aria-controls', SETTINGS_POPOVER_ID); $.append(actions, settingsToggler); /** * Wire the plus button + settings toggler into a roving tabindex group. * `tabbable: false` keeps both buttons out of the tab order (the deliberate * `tabindex="-1"` decision lives in their handlers) — focus is moved in via * the Alt+F10 shortcut, after which Arrow keys roll focus between them. */ this.rovingController = new RovingTabindexController([plusButton, settingsToggler], { orientation: 'horizontal', tabbable: false, }); /** * Appending Toolbar components to itself */ const toolboxElement = this.makeToolbox(); /** * The stable `TOOLBOX_POPOVER_ID` is applied to the popover's listbox * (items) container by the Toolbox (via `listboxId`), so the plus button's * `aria-controls` resolves to the actual `role="listbox"` element it opens. */ $.append(actions, toolboxElement); const blockSettingsElement = this.Blok.BlockSettings.getElement(); if (!blockSettingsElement) { throw new Error('Block Settings element was not created'); } $.append(actions, blockSettingsElement); /** * Append toolbar to the Blok */ $.append(this.Blok.UI.nodes.wrapper, wrapper); } /** * Creates the Toolbox instance and return it's rendered element */ private makeToolbox(): Element { /** * Make the Toolbox */ this.toolboxInstance = new Toolbox({ api: this.Blok.API.methods, tools: this.Blok.Tools.blockTools, i18nLabels: this.toolboxI18nLabels(), i18n: this.Blok.I18n, triggerElement: this.nodes.plusButton, listboxId: TOOLBOX_POPOVER_ID, }); this.toolboxInstance.on(ToolboxEvent.Opened, () => { // eslint-disable-next-line @typescript-eslint/no-deprecated this.Blok.UI.nodes.wrapper.classList.add(this.CSS.openedToolboxHolderModifier); this.Blok.UI.nodes.wrapper.setAttribute(DATA_ATTR.toolboxOpened, 'true'); /** * Reflect the expanded state on the plus button (menu-button contract). */ this.nodes.plusButton?.setAttribute('aria-expanded', 'true'); /** * Adapt search input colors when toolbox opens inside a colored callout. */ const calloutBg = this.hoveredBlock !== null ? this.getCalloutBackgroundColor(this.hoveredBlock) : null; this.toolboxInstance?.setCalloutBackground(calloutBg); /** * Opening the toolbox in slash mode stamps the slash-search pill styling * (margin/padding) onto the block's contenteditable, shifting the block's * inner geometry without necessarily resizing its holder — so the resize * observer cannot be relied on to follow it. Recompute the toolbar * position explicitly so the plus button / settings toggler stay centered * on the pill while the popover is open. */ this.repositionToolbar(); }); this.toolboxInstance.on(ToolboxEvent.Closed, () => { // eslint-disable-next-line @typescript-eslint/no-deprecated this.Blok.UI.nodes.wrapper.classList.remove(this.CSS.openedToolboxHolderModifier); this.Blok.UI.nodes.wrapper.removeAttribute(DATA_ATTR.toolboxOpened); /** * Reflect the collapsed state on the plus button (menu-button contract). */ this.nodes.plusButton?.setAttribute('aria-expanded', 'false'); /** * Closing the toolbox removes the slash-search pill styling (see the * Opened handler above), restoring the block's pre-slash geometry. The * holder's outer size usually does not change, so the resize observer * never fires — without an explicit reposition the toolbar stays stuck * at the pill-era offset (controls misaligned after open/close). * The pill attribute is removed synchronously before this event is * emitted, so the recomputation reads the restored geometry. */ this.repositionToolbar(); /** * If the toolbox was opened via the plus button and the user dismissed * it without selecting a tool (Escape / click outside), restore focus to * the block that was focused BEFORE the plus button was clicked. The * newly-inserted empty line is intentionally kept in place. * * When a tool IS selected, ToolboxEvent.BlockAdded fires first and clears * preToolboxBlock, so this branch is skipped for that case. */ if (this.preToolboxBlock !== null) { const blockToRestore = this.preToolboxBlock; this.preToolboxBlock = null; this.plusInsertedBlock = null; if (blockToRestore.inputs.length > 0) { this.Blok.Caret.setToBlock(blockToRestore, this.Blok.Caret.positions.END); } return; } /** * Restore focus to the current block when the toolbox closes via any * non-plus-button path (e.g. slash-search dismissed via Escape). * Without this, focus falls to document.body after non-keyboard close * paths, causing subsequent keystrokes to be lost. */ const currentBlock = this.Blok.BlockManager.currentBlock; if (currentBlock && currentBlock.inputs.length > 0) { // If a freshly inserted tool already placed the caret inside its own // subtree (e.g. a column_list focusing its FIRST column), keep it. // Restore focus only when it has actually fallen outside the block — // to document.body on non-keyboard close paths — which is what this // branch defends against. Restoring to END would otherwise jump a // multi-input container like columns to its LAST input. const active = document.activeElement; const focusAlreadyInBlock = active instanceof Node && currentBlock.holder.contains(active); if (!focusAlreadyInBlock) { this.Blok.Caret.setToBlock(currentBlock, this.Blok.Caret.positions.END); } } }); this.toolboxInstance.on(ToolboxEvent.BlockAdded, ({ block }) => { /** * A tool was selected and a block was added — clear the cancel context so * ToolboxEvent.Closed (which fires after this) does not try to undo the * insertion and restore focus to the pre-plus block. */ this.preToolboxBlock = null; this.plusInsertedBlock = null; const { BlockManager, Caret } = this.Blok; const newBlock = BlockManager.getBlockById(block.id); if (!newBlock) { return; } if (newBlock.inputs.length !== 0) { return; } /** * If the new block doesn't contain inputs, insert the new paragraph below */ if (newBlock === BlockManager.lastBlock) { BlockManager.insertAtEnd(); Caret.setToBlock(BlockManager.lastBlock); return; } const nextBlock = BlockManager.nextBlock; if (nextBlock) { Caret.setToBlock(nextBlock); } }); const element = this.toolboxInstance.getElement(); if (element === null) { throw new Error('Toolbox element was not created'); } return element; } /** * Enable bindings */ private enableModuleBindings(): void { /** * Plus button mousedown handler * Uses click-vs-drag detection to distinguish clicks from drags. */ const plusButton = this.nodes.plusButton; if (plusButton) { this.readOnlyMutableListeners.on(plusButton, 'mousedown', (e) => { /** * Plus button is inert in read-only mode. It is also visually hidden * (see toggleReadOnly), but guard here too in case of programmatic clicks. */ if (this.Blok.ReadOnly.isEnabled) { return; } /** * Prevent focus from moving away from the currently-active contenteditable block. * Without this, clicking the plus button steals DOM focus, causing subsequent * keystrokes to land in the wrong block (text-jumping bug). */ (e as MouseEvent).preventDefault(); hide(); this.clickDragHandler.setup( e as MouseEvent, (mouseUpEvent) => { /** * Check for modifier key to determine insert direction: * - Option/Alt on Mac, Ctrl on Windows → insert above * - No modifier → insert below (default) */ const userOS = getUserOS(); const insertAbove = userOS.win ? mouseUpEvent.ctrlKey : mouseUpEvent.altKey; this.plusButtonHandler.handleClick(insertAbove); } ); }, true); } /** * Settings toggler mousedown handler * Uses click-vs-drag detection to distinguish clicks from drags. */ const settingsToggler = this.nodes.settingsToggler; if (settingsToggler) { this.readOnlyMutableListeners.on(settingsToggler, 'mousedown', this.settingsTogglerHandler.createMousedownHandler(), true); } /** * Focus-the-toolbar shortcut (WAI-ARIA APG style). Alt+F10 pressed while * editing a block moves focus into the toolbar's roving group. The buttons * are intentionally not Tab-reachable, so this is the keyboard entry point. */ this.readOnlyMutableListeners.on(this.Blok.UI.nodes.wrapper, 'keydown', (e) => { const event = e as KeyboardEvent; if (event.key === 'F10' && event.altKey) { this.focusToolbar(event); } }); /** * Escape while focus is inside the toolbar returns focus (caret) to the * block the user came from. */ if (this.nodes.wrapper) { this.readOnlyMutableListeners.on(this.nodes.wrapper, 'keydown', (e) => { const event = e as KeyboardEvent; if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.returnFocusToBlock(); } }); } /** * Listen for focus changes inside the editor so callout first-child * button state refreshes immediately on click/tab — no 300ms delay. * Drag handle visibility must NOT depend on focus position inside * nested content (see updateToolbarButtonsForCalloutFirstChild doc). */ this.readOnlyMutableListeners.on(this.Blok.UI.nodes.wrapper, 'focusin', () => { this.updateToolbarButtonsForCalloutFirstChild(); }); /** * Subscribe to the 'block-hovered' event if current view is not mobile * @see https://github.com/codex-team/editor.js/issues/1972 */ if (!isMobileScreen()) { /** * Subscribe to the 'block-hovered' event */ this.eventsDispatcher.on(BlockHovered, (data) => { /** * Do not move toolbar during drag, rectangle selection, or when the user * started a mouse-drag from within the editor's content area (even if the * drag originated on a contentEditable element, i.e. rubber-band is not * activated but the toolbar was closed and should stay closed). */ if (this.Blok.DragManager.isDragging || this.Blok.RectangleSelection.isRectActivated() || this.Blok.RectangleSelection.isMouseDownWithinBounds) { return; } const hoveredBlock = (data as { block?: Block; target?: Element }).block; const hoveredTarget = (data as { block?: Block; target?: Element }).target; if (!(hoveredBlock instanceof Block)) { return; } /** * Do not move toolbar if Block Settings or Toolbox opened */ if (this.Blok.BlockSettings.opened || this.Blok.BlockSettings.isOpening || this.toolboxInstance?.opened) { return; } /** * Do not move toolbar if it was explicitly closed and the user is still * hovering the same block. When the user hovers a DIFFERENT block * (or hoveredBlock is null after close()), reset the flag and allow * the toolbar to reopen — this is an intentional user action. */ if (this.explicitlyClosed) { if (this.hoveredBlock !== null && this.hoveredBlock === hoveredBlock) { return; } this.explicitlyClosed = false; } /** * Check if multiple blocks are selected */ const selectedBlocks = this.Blok.BlockSelection.selectedBlocks; const isMultiBlockSelection = selectedBlocks.length > 1; const isHoveredBlockSelected = isMultiBlockSelection && selectedBlocks.some(block => block === hoveredBlock); /** * For multi-block selection, only move toolbar if the hovered block is one of the selected blocks */ if (isMultiBlockSelection && isHoveredBlockSelected) { this.moveAndOpenForMultipleBlocks(hoveredBlock); return; } /** * For multi-block selection where hovered block is not selected, do nothing */ if (isMultiBlockSelection) { return; } this.moveAndOpen(hoveredBlock, hoveredTarget); }); } /** * Subscribe to the Block Settings events to toggle 'opened' state of the Settings Toggler */ this.eventsDispatcher.on(BlockSettingsOpened, this.onBlockSettingsOpen); this.eventsDispatcher.on(BlockSettingsClosed, this.onBlockSettingsClose); /** * Subscribe to block changes to reposition toolbar when block content changes */ this.eventsDispatcher.on(BlockChanged, this.onBlockChanged); } /** * Disable bindings */ private disableModuleBindings(): void { this.readOnlyMutableListeners.clearAll(); this.eventsDispatcher.off(BlockSettingsOpened, this.onBlockSettingsOpen); this.eventsDispatcher.off(BlockSettingsClosed, this.onBlockSettingsClose); this.eventsDispatcher.off(BlockChanged, this.onBlockChanged); } /** * Handler for BlockSettingsOpened event */ private onBlockSettingsOpen = (): void => { this.Blok.UI.nodes.wrapper.setAttribute(DATA_ATTR.blockSettingsOpened, 'true'); this.nodes.settingsToggler?.setAttribute('aria-expanded', 'true'); }; /** * Handler for BlockSettingsClosed event */ private onBlockSettingsClose = (): void => { this.Blok.UI.nodes.wrapper.removeAttribute(DATA_ATTR.blockSettingsOpened); this.nodes.settingsToggler?.setAttribute('aria-expanded', 'false'); }; /** * Handler for BlockChanged event - repositions toolbar when block content changes */ private onBlockChanged = (payload: BlockChangedPayload): void => { /** * Only reposition if toolbar is opened and we have a hovered block */ if (!this.opened || !this.hoveredBlock) { return; } /** * Don't reposition if Block Settings or Toolbox is opened or opening */ if (this.Blok.BlockSettings.opened || this.Blok.BlockSettings.isOpening || this.toolboxInstance?.opened) { return; } /** * Only reposition if the changed block is the hovered block. * This prevents unnecessary repositioning when other blocks change, * and avoids toolbar jumping when interacting with checklist items. */ const changedBlockId = payload.event.detail.target.id; if (changedBlockId !== this.hoveredBlock.id) { return; } this.repositionToolbar(); }; /** * Moves keyboard focus into the toolbar's roving group, opening/positioning * the toolbar on the current block first if needed. Called from the Alt+F10 * shortcut. Remembers the origin block so Escape can restore focus to it. * @param event - the Alt+F10 keyboard event */ private focusToolbar(event: KeyboardEvent): void { const targetBlock = this.hoveredBlock ?? this.Blok.BlockManager.currentBlock ?? null; if (!targetBlock) { return; } event.preventDefault(); this.blockBeforeToolbarFocus = targetBlock; /** * Ensure the toolbar is positioned and open on the target block before * moving focus into it. */ if (!this.opened || this.hoveredBlock !== targetBlock) { this.moveAndOpen(targetBlock); } this.rovingController?.focusFirst(); } /** * Returns focus (caret) to the block the user came from before entering the * toolbar via Alt+F10. Called on Escape from inside the toolbar. */ private returnFocusToBlock(): void { const block = this.blockBeforeToolbarFocus; this.blockBeforeToolbarFocus = null; if (block && block.inputs.length > 0) { this.Blok.Caret.setToBlock(block, this.Blok.Caret.positions.END); } } /** * Repositions the toolbar to stay centered on the first line of the current block * without closing/opening toolbox or block settings */ private repositionToolbar(): void { if (!this.hoveredBlock || !this.nodes.plusButton) { return; } this.positioner.repositionToolbar( this.nodes, { targetBlock: this.hoveredBlock, hoveredTarget: this.positioner.target, isMobile: this.Blok.UI.isMobile, dockedToEnd: this.isPositionedRight, }, this.nodes.plusButton ); } /** * Draws Toolbar UI * * Toolbar contains BlockSettings and Toolbox. * That's why at first we draw its components and then Toolbar itself * * Steps: * - Make Toolbar dependent components like BlockSettings, Toolbox and so on * - Make itself and append dependent nodes to itself * */ private async drawUI(): Promise { /** * Make BlockSettings Panel */ this.Blok.BlockSettings.make(); /** * Make Toolbar */ await this.make(); } /** * Removes all created and saved HTMLElements * It is used in Read-Only mode */ private destroy(): void { this.removeAllNodes(); if (this.toolboxInstance) { this.toolboxInstance.destroy(); } /** * Clean up any pending click-drag handlers */ this.clickDragHandler.destroy(); /** * Detach the roving tabindex listeners from the action buttons. */ this.rovingController?.destroy(); this.rovingController = null; } }