/** * Module UI * @type {UI} */ import type { EditorWidth } from '../../../types/api/width'; import styles from '../../styles/main.css?inline'; import { Module } from '../__module'; import { DATA_ATTR, BLOK_INTERFACE_VALUE, } from '../constants'; import { Dom as $, toggleEmptyMark } from '../dom'; import { BlokMobileLayoutToggled } from '../events'; import { Flipper } from '../flipper'; import { SelectionUtils as Selection } from '../selection/index'; import { debounce, getBlokVersion, getValidUrl, isEmpty, openSameWindow, openTab, mobileScreenBreakpoint } from '../utils'; import { destroyAnnouncer, registerAnnouncer } from '../utils/announcer'; import { buildFontSizeVarLines } from '../utils/font-size-tokens'; import { LinkHoverCard } from '../utils/link-hover-card'; import { log } from '../utils/logger'; import { decodeHashFragment, resolveHashTarget } from '../utils/hash-target'; import { hasUnsafeScheme } from '../utils/sanitize-url'; import { isSamePageLink } from '../../tools/link/registry'; // Controllers and handlers import { BlockHoverController } from './uiControllers/controllers/blockHover'; import { KeyboardController } from './uiControllers/controllers/keyboard'; import { SelectionController } from './uiControllers/controllers/selection'; import { createDocumentClickedHandler } from './uiControllers/handlers/click'; import { createRedactorTouchHandler } from './uiControllers/handlers/touch'; import { ToggleShortcuts } from '../../tools/toggle/toggle-shortcuts'; /** * Classes that hide an empty, focused block's placeholder while the toolbox is open. * * Each rule is gated on the wrapper carrying `data-blok-toolbox-opened=true`, plus a * focused editable block, and targets the placeholder attribute the tool ACTUALLY renders: * - paragraph → `data-blok-placeholder-active` * - header → `data-placeholder` * * Both attributes must be covered; the historical `[data-blok-placeholder]` selector * matched neither and was inert, leaving the placeholder visible behind the + menu. */ export const PLACEHOLDER_HIDE_ON_TOOLBOX_CLASSES: string[] = [ '[&[data-blok-toolbox-opened=true]_[contentEditable=true][data-blok-placeholder-active]:focus]:before:opacity-0!', '[&[data-blok-toolbox-opened=true]_[contentEditable=true][data-placeholder]:focus]:before:opacity-0!', ]; /** * HTML Elements used for UI */ interface UINodes extends Record { holder: HTMLElement; wrapper: HTMLElement; redactor: HTMLElement; bottomZone: HTMLElement; } /** * @class * @classdesc Makes Blok UI: * * * * * * @typedef {UI} UI * @property {BlokConfig} config - blok configuration {@link Blok#configuration} * @property {object} Blok - available blok modules {@link Blok#moduleInstances} * @property {object} nodes - * @property {Element} nodes.holder - element where we need to append redactor * @property {Element} nodes.wrapper - * @property {Element} nodes.redactor - */ /** * Monotonically increasing suffix for theme-token style tag ids. Two * instances can land on the same holderId (e.g. both holders are id-less, * so the id falls back to the constant BLOK_INTERFACE_VALUE), so the * holderId alone is not enough to keep instances' tags — and destroy() * cleanup — independent of each other. */ const themeTokenStyleTagCounter = { value: 0 }; /** * Monotonically increasing per-editor id, stamped on the wrapper as * `data-blok-instance`. Injected stylesheets are page-level (they must also * reach body-mounted popovers), so this is the only handle that distinguishes * one editor's rules from another's. */ const instanceCounter = { value: 0 }; export class UI extends Module { /** * Controllers for UI state management */ private keyboardController: KeyboardController | null = null; private selectionController: SelectionController | null = null; private blockHoverController: BlockHoverController | null = null; private toggleShortcuts: ToggleShortcuts | null = null; /** * Hoverable card shown when the pointer rests on a link while editing — * surfaces the destination URL with copy/edit actions, mirroring the * read-only "clickable link" affordance. Created lazily once modules (I18n) * are ready. Only active in edit mode (its listeners live on * {@link readOnlyMutableListeners}). */ private linkHoverCard: LinkHoverCard | null = null; /** * Handlers for simple event behaviors */ private documentClickedHandler: ((event: MouseEvent) => void) | null = null; private redactorTouchHandler: ((event: Event) => void) | null = null; /** This editor's `data-blok-instance` value — see {@link instanceCounter}. */ private readonly instanceId = String(instanceCounter.value++); /** Unique style tag ID for this instance's font override, derived from the holder element */ private fontStyleTagId: string | null = null; /** Unique style tag ID for this instance's theme token overrides, derived from the holder element */ private themeTokenStyleTagId: string | null = null; /** Theme tokens currently applied, post-validation. Mirrors the live style tag. */ private appliedThemeTokens: Record = {}; /** Rendered `--name: value;` lines for {@link appliedThemeTokens}. */ private themeTokenVarLines: string[] = []; /** * Whether setThemeTokens() was called. Such a call can precede prepare(), and * must then win over `config.style.tokens` when prepare() finally injects. */ private themeTokensSetAtRuntime = false; /** * Reset the block hover state (used after drag cancellation to allow toolbar to show again) */ public resetBlockHoverState(): void { this.blockHoverController?.resetHoverState(); } /** * Temporarily disable hover detection for a cooldown period. * Used after cross-block selection to prevent spurious hover events. */ public disableHoverForCooldown(): void { this.blockHoverController?.disableHoverForCooldown(); } /** * Return Width of center column of Blok * @returns {DOMRect} */ public get contentRect(): DOMRect { if (this.contentRectCache !== null) { return this.contentRectCache; } const someBlock = this.nodes.wrapper.querySelector('[data-blok-testid="block-content"]'); /** * When Blok is not ready, there is no Blocks, so return the default value */ if (!someBlock) { return { width: 650, left: 0, right: 0, } as DOMRect; } this.contentRectCache = someBlock.getBoundingClientRect(); return this.contentRectCache; } /** * Flag that became true on mobile viewport * @type {boolean} */ public isMobile = false; /** * Cache for center column rectangle info * Invalidates on window resize * @type {DOMRect} */ private contentRectCache: DOMRect | null = null; /** * Handle window resize only when it finished * @type {() => void} */ private resizeDebouncer: () => void = debounce(() => { this.windowResize(); }, 200); /** * Making main interface */ public async prepare(): Promise { /** * Detect mobile version */ this.setIsMobile(); /** * Make main UI elements */ this.make(); /** * Load and append CSS */ this.loadStyles(); this.loadFontStyles(); this.loadThemeTokenStyles(); /** * Register this Blok instance with the accessibility announcer * for proper multi-instance cleanup */ registerAnnouncer(); /** * Initialize controllers after Blok modules are ready */ this.initializeControllers(); /** * Enable selection controller after initialization. * This is needed because bindReadOnlyInsensitiveListeners() is called in make() * before initializeControllers(), so the selectionController doesn't exist yet. * Must happen before toggleShortcuts.register() so that a shortcut registration * error cannot prevent the selectionchange listener from being set up. */ this.selectionController?.enable(); /** * Register toggle shortcuts (CMD+ALT+T) for collapsing/expanding all toggle blocks. * Wrapped in try-catch because the Shortcuts singleton may throw if shortcuts are * already registered (e.g. race condition with multiple editor instances in CI). * This is non-critical — the editor works fine without toggle shortcuts. */ this.toggleShortcuts = new ToggleShortcuts( this.Blok.API.methods, this.nodes.wrapper ); try { this.toggleShortcuts.register(); } catch (error) { console.warn('Blok: Failed to register toggle shortcuts:', error); } } /** * Initialize controllers with their dependencies */ private initializeControllers(): void { /** * Keyboard controller needs someToolbarOpened callback to avoid circular dependencies */ this.keyboardController = new KeyboardController({ config: this.config, eventsDispatcher: this.eventsDispatcher, someToolbarOpened: () => this.someToolbarOpened, }); this.keyboardController.state = this.Blok; this.keyboardController.setRedactorElement(this.nodes.redactor); this.keyboardController.setWrapperElement(this.nodes.wrapper); /** * Selection controller needs wrapper element for click detection */ this.selectionController = new SelectionController({ config: this.config, eventsDispatcher: this.eventsDispatcher, }); this.selectionController.state = this.Blok; this.selectionController.setWrapperElement(this.nodes.wrapper); /** * Block hover controller detects hover over blocks and finds nearest block */ this.blockHoverController = new BlockHoverController({ config: this.config, eventsDispatcher: this.eventsDispatcher, }); this.blockHoverController.state = this.Blok; this.blockHoverController.setWrapperElement(this.nodes.wrapper); /** * Create handlers for click and touch events */ this.documentClickedHandler = createDocumentClickedHandler({ Blok: this.Blok, nodes: { holder: this.nodes.holder, redactor: this.nodes.redactor, }, }); this.redactorTouchHandler = createRedactorTouchHandler({ Blok: this.Blok, redactorElement: this.nodes.redactor, }); } /** * Toggle read-only state * * If readOnly is true: * - removes all listeners from main UI module elements * - sets contenteditable="false" on all block content elements * * if readOnly is false: * - enables all listeners to UI module elements * - sets contenteditable="true" on all block content elements * @param {boolean} readOnlyEnabled - "read only" state */ public toggleReadOnly(readOnlyEnabled: boolean): void { /** * Expose the read-only state in the DOM as a public styling hook. */ this.nodes.wrapper?.toggleAttribute(DATA_ATTR.readonly, readOnlyEnabled); /** * Chromeless read-only (readOnly: { hideControls: true }) is the state * that collapses the gutter — plain read-only keeps it, both for the * block-hover copy-link control that lives there and so in-place * readOnly.set() mode flips never shift the document sideways. * Re-stamped on every toggle because set(state, { hideControls }) * rewrites config.readOnly before delegating here. */ this.nodes.wrapper?.toggleAttribute( DATA_ATTR.controlsHidden, readOnlyEnabled && this.Blok.ReadOnly.isControlsHidden ); /** * Collapse the bottom zone in read-only mode. Its only purpose is to act as * a clickable area below the last block for adding/focusing a block, which is * disabled in read-only mode. Restore the configured min-height when editing. */ if (this.nodes.bottomZone) { this.nodes.bottomZone.style.minHeight = readOnlyEnabled ? '0px' : `${this.config.minHeight}px`; } /** * Prepare components based on read-only state */ if (readOnlyEnabled) { /** * Unbind editing-only events but keep block hover active so the toolbar * can still appear on hover (used by the read-only "copy link to block" * popover; see Toolbar.toggleReadOnly). */ this.unbindReadOnlySensitiveListeners({ keepBlockHover: true }); /** * Ensure block hover detection is active even when the editor starts in * read-only mode — bindReadOnlySensitiveListeners() was never called in * that path. */ this.blockHoverController?.enable(); /** * Set contenteditable="false" on all block content elements */ this.updateBlocksContentEditable(false); return; } const bindListeners = (): void => { /** * Bind events for the UI elements */ this.bindReadOnlySensitiveListeners(); }; /** * Ensure listeners are attached immediately for interactive use. */ bindListeners(); /** * Set contenteditable="true" on all block content elements */ this.updateBlocksContentEditable(true); const idleCallback = window.requestIdleCallback; if (typeof idleCallback !== 'function') { return; } /** * Re-bind on idle to preserve historical behavior when additional nodes appear later. */ idleCallback(bindListeners, { timeout: 2000, }); } /** * Update contenteditable attribute on all block content elements * @param editable - whether blocks should be editable */ private updateBlocksContentEditable(editable: boolean): void { const { BlockManager } = this.Blok; for (const block of BlockManager.blocks) { // Exclude mutation-free decorations (e.g. a list item's bullet/number // marker, which is deliberately contenteditable="false"). Without this, // querySelector('[contenteditable]') would grab the marker — the first // `[contenteditable]` in the holder — and flip it editable, later letting // a block split overwrite the bullet glyph with the item's own text. const contentEditable = block.holder.querySelector( '[contenteditable]:not([data-blok-mutation-free])' ); if (contentEditable) { contentEditable.contentEditable = editable ? 'true' : 'false'; } } } /** * Check if Blok is empty and set data attribute on wrapper */ public checkEmptiness(): void { const { BlockManager } = this.Blok; this.nodes.wrapper.setAttribute(DATA_ATTR.empty, BlockManager.isBlokEmpty ? 'true' : 'false'); } /** * Current editor content width mode. Defaults to 'narrow'. */ private widthMode: EditorWidth = 'narrow'; /** * Returns the current editor content width mode. */ public getWidthMode(): EditorWidth { return this.widthMode; } /** * Sets the editor content width mode by writing the width data attribute on * the editor wrapper. 'narrow' (the default) leaves the attribute absent so * the content keeps its `--max-width-content` constraint; 'full' adds it so * the dedicated CSS rule removes the constraint. * @param mode - the width mode to apply */ public setWidthMode(mode: EditorWidth): void { this.widthMode = mode; if (mode === 'full') { this.nodes.wrapper.setAttribute(DATA_ATTR.width, 'full'); } else { this.nodes.wrapper.removeAttribute(DATA_ATTR.width); } } /** * Re-stamps the wrapper's text direction. * * Direction was applied only while the wrapper was being built, so an * already-mounted editor could not follow a locale change from an LTR to an * RTL language. Both hooks are toggled here: the `[direction:rtl]` utility * that drives layout and the `data-blok-rtl` attribute that scoped CSS and * the keyboard-navigation composer read. * @param direction - the direction to apply */ public setDirection(direction: 'ltr' | 'rtl'): void { const isRtl = direction === 'rtl'; this.nodes.wrapper.classList.toggle('[direction:rtl]', isRtl); if (isRtl) { this.nodes.wrapper.setAttribute(DATA_ATTR.rtl, 'true'); } else { this.nodes.wrapper.removeAttribute(DATA_ATTR.rtl); } } /** * Check if one of Toolbar is opened * Used to prevent global keydowns (for example, Enter) conflicts with Enter-on-toolbar * @returns {boolean} */ public get someToolbarOpened(): boolean { const { Toolbar, BlockSettings, InlineToolbar } = this.Blok; return Boolean(BlockSettings.opened || InlineToolbar.opened || Toolbar.toolbox.opened); } /** * Check for some Flipper-buttons is under focus */ public get someFlipperButtonFocused(): boolean { /** * Toolbar has internal module (Toolbox) that has own Flipper, * so we check it manually */ if (this.Blok.Toolbar.toolbox.hasFocus()) { return true; } /** * Type guard to check if a module has a flipper property */ const hasFlipper = (module: unknown): module is { flipper: Flipper } => { return typeof module === 'object' && module !== null && 'flipper' in module && module.flipper instanceof Flipper; }; return Object.values(this.Blok).some((moduleClass) => { return hasFlipper(moduleClass) && moduleClass.flipper.hasFocus(); }); } /** * Clean blok`s UI */ public destroy(): void { this.toggleShortcuts?.unregister(); this.nodes.holder.innerHTML = ''; this.unbindReadOnlyInsensitiveListeners(); this.unbindReadOnlySensitiveListeners(); this.linkHoverCard?.destroy(); this.linkHoverCard = null; // Remove the per-instance font style tag to prevent leaks in SPAs if (this.fontStyleTagId !== null) { const fontStyleTag = $.get(this.fontStyleTagId); if (fontStyleTag) { fontStyleTag.remove(); } } // Remove the per-instance theme token style tag to prevent leaks in SPAs this.removeThemeTokenStyleTag(); // Clean up accessibility announcer destroyAnnouncer(); } /** * Close all Blok's toolbars */ public closeAllToolbars(): void { const { Toolbar, BlockSettings, InlineToolbar } = this.Blok; BlockSettings.close(); InlineToolbar.close(); Toolbar.toolbox.close(); } /** * Event listener for 'mousedown' and 'touchstart' events * @param event - TouchEvent or MouseEvent */ private documentTouchedListener = (event: Event): void => { if (this.redactorTouchHandler) { this.redactorTouchHandler(event); } }; /** * Link hover card show/hide handlers. Kept as stable references (not inline * wrappers) so they can be bound to the read-only-insensitive listener set: * the card must appear on hover in both edit and read-only modes. */ private anchorMouseMoveListener = (event: Event): void => { if (event instanceof MouseEvent) { this.handleAnchorMouseMove(event); } }; private anchorMouseOutListener = (event: Event): void => { if (event instanceof MouseEvent) { this.handleAnchorMouseOut(event); } }; /** * Anchor navigation. Read-only-insensitive like the hover card above: * following a link is reading, not editing, and a same-page link that falls * through to the browser in read-only mode moves the URL without moving the * page — block ids live in `data-blok-id`, not `id`. */ private redactorClickListener = (event: Event): void => { if (event instanceof MouseEvent) { this.redactorClicked(event); } }; /** * Right-click inside block content opens the block context menu (Block * Settings) anchored at the cursor, mirroring a desktop application. This is * a hover-independent path to the block menu that avoids the "wrong block" * race in the hover-driven settings toggler. * * The native context menu is left intact on interactive and media elements * (links, form fields, images, media) where it carries real value — only * plain block content is hijacked. * @param event - contextmenu event */ private redactorContextMenu = (event: Event): void => { if (!(event instanceof MouseEvent)) { return; } const target = event.target; if (!(target instanceof HTMLElement)) { return; } if (target.closest('a, input, textarea, select, img, video, audio')) { return; } const block = this.Blok.BlockManager.setCurrentBlockByChildNode(target); if (block === undefined) { return; } event.preventDefault(); const { BlockSettings, Toolbar } = this.Blok; /** * Anchor the toolbar to the right-clicked block (moveAndOpen also closes any * already-open settings menu, so a second right-click repositions cleanly), * then open Block Settings at the cursor via a zero-size virtual rect. */ Toolbar.moveAndOpen(block); void BlockSettings.open(block, new DOMRect(event.clientX, event.clientY, 0, 0)); }; /** * Check for mobile mode and save the result */ private setIsMobile(): void { const isMobile = window.innerWidth < mobileScreenBreakpoint; if (isMobile !== this.isMobile) { /** * Dispatch global event */ this.eventsDispatcher.emit(BlokMobileLayoutToggled, { isEnabled: this.isMobile, }); } this.isMobile = isMobile; } /** * Makes Blok interface */ private make(): void { /** * Element where we need to append Blok * @type {Element} */ const holder = this.config.holder; if (!holder) { throw new Error('Blok holder is not specified in the configuration.'); } this.nodes.holder = $.getHolder(holder); /** * Create and save main UI elements */ this.nodes.wrapper = $.make('div', [ 'group', 'relative', 'box-border', 'z-1', 'data-[blok-dragging=true]:cursor-grabbing', // SVG defaults. // // NOTE: no ambient `stroke: currentColor` here. Broadcasting it over the // whole subtree also hits HOST-authored glyphs rendered inside blocks — // author CSS beats an SVG presentation attribute, so a solid icon's own // `stroke="none"` lost and the glyph came out rimmed and fattened. Every // Blok icon declares its own stroke instead (guarded by // test/unit/architecture/icon-self-stroke-law.test.ts). '[&_svg]:max-h-full', // Native selection color (omitted when the host opts out via style.nativeSelection) ...(this.config.style?.nativeSelection === true ? [] : [ '[&_::selection]:bg-selection-inline' ]), // Hide placeholder when toolbox is opened ...PLACEHOLDER_HIDE_ON_TOOLBOX_CLASSES, ...(this.isRtl ? [ '[direction:rtl]' ] : []), ]); this.nodes.wrapper.setAttribute(DATA_ATTR.interface, BLOK_INTERFACE_VALUE); this.nodes.wrapper.setAttribute(DATA_ATTR.editor, ''); this.nodes.wrapper.setAttribute(DATA_ATTR.instance, this.instanceId); this.nodes.wrapper.setAttribute(DATA_ATTR.version, getBlokVersion()); this.nodes.wrapper.setAttribute('data-blok-testid', 'blok-editor'); this.nodes.wrapper.setAttribute(DATA_ATTR.contentAlign, this.config.style?.contentAlign ?? 'left'); this.nodes.wrapper.setAttribute(DATA_ATTR.toolbarPosition, this.config.toolbarPosition ?? 'left'); if (this.isRtl) { this.nodes.wrapper.setAttribute(DATA_ATTR.rtl, 'true'); } if (this.config.hideToolbar === true) { /** * Public styling hook: collapses the editor gutter (see main.css) — * with the hover toolbar disabled there are no +/⠿ controls to house. */ this.nodes.wrapper.setAttribute(DATA_ATTR.toolbarHidden, ''); } if (this.config.style?.nativeSelection === true) { /** * Public styling hook: opts out of Blok's ::selection repaint. Gates the * preflight.css selection rule and re-points --blok-selection-inline at * the UA Highlight color (colors.css) so the fake-background highlight * stays consistent with the native selection colour. */ this.nodes.wrapper.setAttribute(DATA_ATTR.nativeSelection, ''); } this.nodes.redactor = $.make('div', [ // Firefox empty contenteditable fix '[&_[contenteditable]:empty]:after:content-["\\feff_"]', ]); this.nodes.redactor.setAttribute(DATA_ATTR.redactor, ''); this.nodes.redactor.setAttribute('data-blok-testid', 'redactor'); /** * Create dedicated bottom zone element */ this.nodes.bottomZone = $.make('div', ['cursor-text']); this.nodes.bottomZone.setAttribute('data-blok-bottom-zone', ''); this.nodes.bottomZone.setAttribute('data-blok-testid', 'bottom-zone'); this.nodes.bottomZone.style.minHeight = this.config.minHeight + 'px'; this.nodes.wrapper.appendChild(this.nodes.redactor); this.nodes.wrapper.appendChild(this.nodes.bottomZone); this.nodes.holder.appendChild(this.nodes.wrapper); this.bindReadOnlyInsensitiveListeners(); } /** * Appends CSS */ private loadStyles(): void { /** * Load CSS */ const styleTagId = 'blok-styles'; /** * Do not append styles again if they are already on the page */ if ($.get(styleTagId)) { return; } /** * Declare the canonical Tailwind cascade-layer order up front. * * This stylesheet imports Tailwind utilities into `@layer utilities` (see * src/styles/main.css) and is PREPENDED as the first