import type { Block } from '../../block';
import { POSITION_TOLERANCE } from './constants';
import type { PositioningOptions, ToolbarNodes } from './types';
/**
* ToolbarPositioner handles toolbar positioning calculations and movements.
* Calculates Y position, moves toolbar, applies content offset, and repositions on block changes.
*/
export class ToolbarPositioner {
/**
* Last calculated toolbar Y position
* Used to avoid unnecessary repositioning when the position hasn't changed
*/
private lastToolbarY: number | null = null;
/**
* The actual element being hovered (could be a nested element like a list item)
*/
private hoveredTarget: Element | null = null;
/**
* Observer that fires when the hovered block's holder changes size so the
* toolbar can recompute Y + content offset mid-drag (e.g. image resize).
*/
private targetResizeObserver: ResizeObserver | null = null;
/**
* Gets the current last toolbar Y position
*/
public get lastY(): number | null {
return this.lastToolbarY;
}
/**
* Gets the current hovered target element
*/
public get target(): Element | null {
return this.hoveredTarget;
}
/**
* Sets the hovered target element
*/
public setHoveredTarget(target: Element | null): void {
this.hoveredTarget = target;
}
/**
* Resets the cached toolbar Y position
*/
public resetCachedPosition(): void {
this.lastToolbarY = null;
}
/**
* Observe an element (e.g. the hovered block holder) and fire `onResize`
* when its box changes. Replaces any previous observer.
*/
public watchTargetResize(target: Element, onResize: () => void): void {
this.stopWatchingTargetResize();
if (typeof ResizeObserver === 'undefined') return;
this.targetResizeObserver = new ResizeObserver(() => onResize());
this.targetResizeObserver.observe(target);
}
/**
* Stops the current resize observer, if any.
*/
public stopWatchingTargetResize(): void {
this.targetResizeObserver?.disconnect();
this.targetResizeObserver = null;
}
/**
* Calculates the Y position for the toolbar, centered on the first line of the block
* @param options - Positioning options containing target block, hovered target, and mobile state
* @param plusButton - The plus button element (used to get toolbar height)
* @returns the Y position in pixels, or null if calculation is not possible
*/
public calculateToolbarY(
options: PositioningOptions,
plusButton: HTMLElement
): number | null {
const { targetBlock, hoveredTarget, isMobile } = options;
if (!targetBlock || !plusButton) {
return null;
}
const targetBlockHolder = targetBlock.holder;
const holderRect = targetBlockHolder.getBoundingClientRect();
/**
* Use the hovered target element (e.g., a nested list item) if available,
* otherwise fall back to the block's pluginsContent
*/
const listItemElement = hoveredTarget?.closest('[role="listitem"]');
/**
* For list items, find the actual text content element ([contenteditable]) and use its position
* to properly center the toolbar on the text, not on the marker which may have different font-size
*/
const textElement = listItemElement?.querySelector('[contenteditable]:not([data-blok-mutation-free])');
const pluginsContent = targetBlock.pluginsContent;
/**
* If the tool provides a toolbar anchor element, use it instead of searching
* for a contenteditable descendant. This allows tools with deeply nested
* editable areas (e.g., code block with a header above the code) to control
* where the toolbar centers vertically.
*/
const toolbarAnchor = !textElement && !listItemElement
? targetBlock.getToolbarAnchorElement()
: undefined;
/**
* If pluginsContent is a non-editable container (e.g. a toggle heading wrapper
),
* use its first contenteditable descendant for accurate line-height centering.
*/
const editableDescendant =
!toolbarAnchor && !pluginsContent.matches('[contenteditable]')
? (pluginsContent.querySelector('[contenteditable]:not([data-blok-mutation-free])') ?? null)
: null;
const contentElement = textElement ?? listItemElement ?? toolbarAnchor ?? editableDescendant ?? pluginsContent;
const contentRect = contentElement.getBoundingClientRect();
const contentOffset = contentRect.top - holderRect.top;
const contentStyle = window.getComputedStyle(contentElement);
const contentPaddingTop = parseInt(contentStyle.paddingTop, 10) || 0;
const lineHeight = parseFloat(contentStyle.lineHeight) || 24;
const toolbarHeight = parseInt(window.getComputedStyle(plusButton).height, 10);
if (isMobile) {
return contentOffset - toolbarHeight;
}
const firstLineTop = contentOffset + contentPaddingTop;
const firstLineCenterY = firstLineTop + (lineHeight / 2);
return firstLineCenterY - (toolbarHeight / 2);
}
/**
* Moves the toolbar to the specified Y position
* @param nodes - Toolbar nodes containing wrapper element
* @param toolbarY - The Y position to move to
* @returns the new Y position, or null if movement failed
*/
public moveToY(nodes: ToolbarNodes, toolbarY: number): number | null {
const { wrapper } = nodes;
if (!wrapper) {
return null;
}
const newToolbarY = Math.floor(toolbarY);
this.lastToolbarY = newToolbarY;
wrapper.style.top = `${newToolbarY}px`;
return newToolbarY;
}
/**
* Repositions the toolbar to stay centered on the first line of the current block
* without closing/opening toolbox or block settings
* @param nodes - Toolbar nodes
* @param options - Positioning options
* @param plusButton - The plus button element
* @returns true if position changed, false otherwise
*/
public repositionToolbar(
nodes: ToolbarNodes,
options: PositioningOptions,
plusButton: HTMLElement
): boolean {
const { wrapper } = nodes;
if (!wrapper || !plusButton) {
return false;
}
const newToolbarY = this.calculateToolbarY(options, plusButton);
if (newToolbarY === null) {
return false;
}
/**
* Only update the toolbar position if it has actually changed significantly.
* This prevents unnecessary repositioning when block changes don't affect
* the toolbar's position (e.g., toggling checkbox styles in a checklist).
*
* We use a tolerance to account for:
* - Floating-point precision issues in getBoundingClientRect()
* - Minor layout changes that don't warrant toolbar repositioning
* - Browser rendering differences during DOM mutations
*/
const positionChanged = this.lastToolbarY === null ||
Math.abs(newToolbarY - this.lastToolbarY) > POSITION_TOLERANCE;
if (positionChanged) {
this.lastToolbarY = newToolbarY;
wrapper.style.top = `${newToolbarY}px`;
}
this.applyContentOffset(nodes, options.targetBlock, options.dockedToEnd === true);
return positionChanged;
}
/**
* Applies the content offset transform to the actions element based on the hovered target.
* This positions the toolbar closer to nested content like list items.
* @param nodes - Toolbar nodes
* @param targetBlock - The block to get the content offset from
* @param dockedToEnd - true while the actions bar is docked past the content
* column's far edge (`toolbarPosition: 'right'`). Nested content is indented
* from the START edge only — a list item's right edge is the block's right
* edge — so the nudge that closes the gap on the left would open one there.
*/
public applyContentOffset(nodes: ToolbarNodes, targetBlock: Block, dockedToEnd = false): void {
const { actions } = nodes;
if (!actions) {
return;
}
if (!this.hoveredTarget || dockedToEnd) {
actions.style.transform = '';
return;
}
const contentOffset = targetBlock.getContentOffset(this.hoveredTarget);
const hasValidOffset = contentOffset && contentOffset.left > 0;
actions.style.transform = hasValidOffset ? `translateX(${contentOffset.left}px)` : '';
}
}