import type { BlockAPI as BlockAPIInterface, BlockOrigin, BlockTool as IBlockTool, BlockToolData, SanitizerConfig, ToolConfig, ToolboxConfigEntry, PopoverItemParams } from '../../../types'; import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import type { BlockTuneRenderContext } from '../../../types/block-tunes/block-tune'; import type { SavedData } from '../../../types/data-formats'; import { Dom as $, toggleEmptyMark } from '../dom'; import type { BlokEventMap } from '../events'; import type { API as ApiModules } from '../modules/api'; import type { DragController } from '../modules/drag/DragController'; import type { BlockToolAdapter } from '../tools/block'; import type { ToolsCollection } from '../tools/collection'; import type { BlockTuneAdapter } from '../tools/tune'; import { generateBlockId, isFunction, log } from '../utils'; import { isSameBlockData } from '../utils/blocks'; import { EventsDispatcher } from '../utils/events'; import { BlockAPI } from './api'; import { DataPersistenceManager } from './data-persistence-manager'; import { InputManager } from './input-manager'; import { MutationHandler } from './mutation-handler'; import { SelectionManager } from './selection-manager'; import { StyleManager } from './style-manager'; import { ToolRenderer } from './tool-renderer'; import { TunesManager } from './tunes-manager'; /** * Interface describes Block class constructor argument */ type BlockSaveResult = SavedData & { tunes: { [name: string]: BlockTuneData } }; interface BlockConstructorOptions { /** * Block's id. Should be passed for existed block, and omitted for a new one. */ id?: string; /** * Initial Block data */ data: BlockToolData; /** * Tool object */ tool: BlockToolAdapter; /** * Blok's API methods */ api: ApiModules; /** * This flag indicates that the Block should be constructed in the read-only mode. */ readOnly: boolean; /** * Tunes data for current Block */ tunesData: { [name: string]: BlockTuneData }; /** * Parent block id for hierarchical structure (Notion-like flat-with-references model). * When present, this block is a child of the block with the specified id. */ parentId?: string; /** * Array of child block ids (Notion-like flat-with-references model). * References blocks that are children of this block. */ contentIds?: string[]; /** * When true, bind internal mutation watchers immediately instead of deferring via requestIdleCallback. * Use for user-created blocks where mutations need to be tracked right away. * Note: This controls Block-internal events (MutationObserver, input focus). * Module-level events (keyboard handlers) are controlled separately by BlockManager. */ bindMutationWatchersImmediately?: boolean; /** Timestamp of the last edit to this block (milliseconds since epoch) */ lastEditedAt?: number; /** ID of the user who last edited this block */ lastEditedBy?: string | null; /** * Why this Block is being constructed — a creation the author just made, or a * re-materialisation of a block the document already describes. Handed * straight to the Tool constructor so container Tools can tell a genuine * creation (seed default children) from a load / undo-redo replay / paste / * off-tree probe (never seed). Defaults to `'api'`, never `'user'`. */ origin?: BlockOrigin; } /** * @class Block * @classdesc This class describes blok`s block, including block`s HTMLElement, data and tool * @property {BlockToolAdapter} tool — current block tool (Paragraph, for example) * @property {object} CSS — block`s css classes */ /** * Available Block Tool API methods */ export enum BlockToolAPI { RENDERED = 'rendered', MOVED = 'moved', UPDATED = 'updated', REMOVED = 'removed', ON_PASTE = 'onPaste', } /** * Names of events used in Block */ interface BlockEvents { 'didMutated': Block, } /** * @classdesc Abstract Block class that contains Block information, Tool name and Tool class instance * @property {BlockToolAdapter} tool - Tool instance * @property {HTMLElement} holder - Div element that wraps block content with Tool's content. * @property {HTMLElement} pluginsContent - HTML content that returns by Tool's render function */ export class Block extends EventsDispatcher { /** * Block unique identifier */ public id: string; /** * Parent block id for hierarchical structure (Notion-like flat-with-references model). * Null if this is a root-level block. */ public parentId: string | null; /** * Array of child block ids (Notion-like flat-with-references model). * Empty array if block has no children. */ public contentIds: string[]; /** * Timestamp of the last edit (ms since epoch). Updated by BlockManager on * mutation. Stays `undefined` for a block that was only loaded or created but * never edited — a load is not an edit, and minting a stamp here made every * save() emit a key the loaded document never had. */ public lastEditedAt: number | undefined; /** * Timestamp of this Block instance's construction (ms since epoch). Never * persisted — it exists so the block-settings "Last edited" footer still has * a date for a block that has not been edited yet. */ public readonly createdAt: number = Date.now(); /** ID of the user who last edited. Updated by BlockManager on mutation. */ public lastEditedBy: string | null; /** * Block Tool`s name */ public readonly name: string; /** * Instance of the Tool Block represents */ public readonly tool: BlockToolAdapter; /** * User Tool configuration */ public readonly settings: ToolConfig; /** * Wrapper for Block`s content */ public readonly holder: HTMLDivElement; /** * Tunes used by Tool */ public readonly tunes: ToolsCollection; /** * Tool's user configuration */ public readonly config: ToolConfig; /** * Tool class instance */ private readonly toolInstance: IBlockTool; /** * Manages block tunes */ private readonly tunesManager: TunesManager; /** * Promise that resolves when the block is ready (rendered) */ public ready: Promise; /** * Common blok event bus */ private readonly blokEventBus: EventsDispatcher | null = null; /** * Manages input elements within the block */ private readonly inputManager: InputManager; /** * Handles mutation observation and filtering */ private readonly mutationHandler: MutationHandler; /** * Current block API interface */ private readonly blockAPI: BlockAPIInterface; /** * Current read-only state of the block */ private readOnly: boolean; /** * Cleanup function for draggable behavior */ private draggableCleanup: (() => void) | null = null; /** * Callbacks to invoke at the top of destroy(). External subscribers * (e.g. an in-flight drag) register here so they can cancel themselves * when the Block instance they hold becomes invalid — preventing stale * Block references from reaching drop handlers mid-drag. */ private destroyCallbacks: Set<() => void> = new Set(); /** * Manages tool element composition and rendering */ private readonly toolRenderer: ToolRenderer; /** * Manages block visual state including stretched mode */ private readonly styleManager: StyleManager; /** * Manages block selection logic including fake cursor */ private readonly selectionManager: SelectionManager; /** * Manages data extraction, caching, and in-place updates */ private readonly dataPersistenceManager: DataPersistenceManager; /** * @param options - block constructor options * @param [options.id] - block's id. Will be generated if omitted. * @param options.data - Tool's initial data * @param options.tool — block's tool * @param options.api - Blok API module for pass it to the Block Tunes * @param options.readOnly - Read-Only flag * @param [options.parentId] - parent block id for hierarchical structure * @param [options.contentIds] - array of child block ids * @param [options.origin] - why the Block is being constructed (creation vs restore); defaults to 'api' * @param [eventBus] - Blok common event bus. Allows to subscribe on some Blok events. Could be omitted when "virtual" Block is created. See BlocksAPI@composeBlockData. */ constructor({ id = generateBlockId(), data, tool, api, readOnly, tunesData, parentId, contentIds, bindMutationWatchersImmediately = false, lastEditedAt, lastEditedBy, origin = 'api', }: BlockConstructorOptions, eventBus?: EventsDispatcher) { super(); // Use the provided id as-is; generate a new one only when the id is empty // or a non-string (wire DTOs may carry `id: null` — the parameter default // above covers `undefined` only) const validatedId = typeof id === 'string' && id !== '' ? id : generateBlockId(); // Set basic properties this.name = tool.name; this.id = validatedId; this.parentId = parentId ?? null; // Copy: contentIds is mutated in place (push/splice) by hierarchy ops and // must never alias a caller-owned (possibly frozen) array. Array-checked // rather than undefined-checked: wire DTOs serialize an absent `content` as // `null`, and spreading that throws. this.contentIds = Array.isArray(contentIds) ? [ ...contentIds ] : []; this.lastEditedAt = lastEditedAt; this.lastEditedBy = lastEditedBy ?? null; this.settings = tool.settings; this.config = this.settings; this.blokEventBus = eventBus || null; this.blockAPI = new BlockAPI(this, api); this.readOnly = readOnly; this.tool = tool; this.toolInstance = tool.create(data, this.blockAPI, readOnly, origin); this.tunes = tool.tunes; // Initialize tunes manager (needed by ToolRenderer) this.tunesManager = new TunesManager(this.tunes, tunesData, this.blockAPI); // Initialize ToolRenderer to create the DOM structure this.toolRenderer = new ToolRenderer( this.toolInstance, this.name, this.id, this.tunesManager, this.config ); // Compose the block and get the holder this.holder = this.toolRenderer.compose(); this.ready = this.toolRenderer.ready; // Initialize StyleManager with holder and content element this.styleManager = new StyleManager( this.holder, this.toolRenderer.contentElement ); // Initialize SelectionManager with dependencies this.selectionManager = new SelectionManager( this.holder, () => this.toolRenderer.contentElement, () => this.styleManager.stretched, this.blokEventBus, this.styleManager ); // Initialize input manager (needed by DataPersistenceManager) this.inputManager = new InputManager( this.holder, () => this.didMutated() ); // Initialize mutation handler this.mutationHandler = new MutationHandler( () => this.toolRenderer.toolRenderedElement, this.blokEventBus, (mutations) => this.didMutated(mutations) ); // Initialize DataPersistenceManager this.dataPersistenceManager = new DataPersistenceManager( this.toolInstance, () => this.toolRenderer.toolRenderedElement, this.tunesManager, this.name, () => this.isEmpty, this.inputManager, () => this.call(BlockToolAPI.UPDATED), () => this.toggleInputsEmptyMark(), data, tunesData ); // Bind block mutation watchers and input events // - Skip entirely when block is in read-only mode (no mutations to track, no input events) // - Immediately if bindMutationWatchersImmediately is true (for user-created blocks) // - Deferred via requestIdleCallback otherwise (for initial load optimization) if (!readOnly) { const bindEvents = (): void => { this.mutationHandler.watch(); this.inputManager.addInputEvents(); this.toggleInputsEmptyMark(); }; if (bindMutationWatchersImmediately) { bindEvents(); } else { window.requestIdleCallback(bindEvents); } } void this.ready.then(() => this.hydrateInlineTools()); } /** * Rebuild the derived DOM of every inline tool that declares a `hydrate` * hook, over this block's rendered tool element. * * A mark whose display is GENERATED from a source it persists (the equation's * KaTeX, rendered from `data-latex`) only ever shows that source as text * unless something regenerates it after a render — the sanitizer deliberately * drops the generated markup, since it is derived. This is that step, and it * runs on every path that builds a block, so a document reads the same whether * it was just typed, loaded, pasted or restored by undo. * * Hooks own their own mutation contract (`data-blok-mutation-free`), so * regenerating is not reported as an edit. A throwing or rejecting hook is * logged and ignored: a mark that cannot render must not break the block. */ private hydrateInlineTools(): void { const root = this.toolRenderer.toolRenderedElement; if (root === null) { return; } for (const inlineTool of this.tool.inlineTools.values()) { const hydrate = inlineTool.hydrate; if (hydrate === undefined) { continue; } try { Promise.resolve(hydrate(root)).catch((error: unknown) => { log(`${inlineTool.name}: hydrate() failed: %o`, 'warn', error); }); } catch (error) { log(`${inlineTool.name}: hydrate() threw: %o`, 'warn', error); } } } /** * Makes this block draggable using the provided drag handle element * Called by the toolbar when it moves to this block * @param dragHandle - The element to use as the drag handle * @param dragManager - DragManager instance to handle drag operations */ public setupDraggable(dragHandle: HTMLElement, dragManager: DragController): void { /** Clean up any existing draggable */ this.cleanupDraggable(); /** Set up drag handling via DragManager (pointer-based, not native HTML5 drag) */ this.draggableCleanup = dragManager.setupDragHandle(dragHandle, this); } /** * Cleans up the draggable behavior * Called when the toolbar moves away from this block */ public cleanupDraggable(): void { if (this.draggableCleanup) { this.draggableCleanup(); this.draggableCleanup = null; } } /** * Calls Tool's method * * Method checks tool property {MethodName}. Fires method with passes params If it is instance of Function * @param {string} methodName - method to call * @param {object} params - method argument */ public call(methodName: string, params?: Record): void { /** * call Tool's method with the instance context */ const method = (this.toolInstance as unknown as Record)[methodName]; if (!isFunction(method)) { return; } try { method.call(this.toolInstance, params); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); log(`Error during '${methodName}' call: ${errorMessage}`, 'error'); } } /** * Call plugins merge method * @param {BlockToolData} data - data to merge */ public async mergeWith(data: BlockToolData): Promise { if (!isFunction(this.toolInstance.merge)) { throw new Error(`Block tool "${this.name}" does not support merging`); } await this.toolInstance.merge(data); } /** * Returns the horizontal offset of the content at the hovered element. * Delegates to the tool's getContentOffset method if implemented. * * @param hoveredElement - The element that is currently being hovered * @returns Object with left offset in pixels, or undefined if no offset should be applied */ public getContentOffset(hoveredElement: Element): { left: number } | undefined { if (typeof this.toolInstance.getContentOffset === 'function') { return this.toolInstance.getContentOffset(hoveredElement); } return undefined; } /** * Returns the element that the toolbar should vertically center on. * Delegates to the tool's getToolbarAnchorElement method if implemented. * * @returns The anchor element, or undefined if the tool does not provide one */ public getToolbarAnchorElement(): HTMLElement | undefined { if (typeof this.toolInstance.getToolbarAnchorElement === 'function') { return this.toolInstance.getToolbarAnchorElement(); } return undefined; } /** * Extracts data from Block * Groups Tool's save processing time * @returns {object} */ public async save(): Promise { const result = await this.dataPersistenceManager.save(); if (result === undefined) { return undefined; } // Override id with the actual block id result.id = this.id; return result; } /** * Uses Tool's validation method to check the correctness of output data * Tool's validation method is optional * @description Method returns true|false whether data passed the validation or not * @param {BlockToolData} data - data to validate * @returns {Promise} valid */ public async validate(data: BlockToolData): Promise { return this.dataPersistenceManager.validate(data); } /** * Returns data to render in Block Tunes menu. * Splits block tunes into 2 groups: block specific tunes and common tunes */ public getTunes(renderContext?: BlockTuneRenderContext): { toolTunes: PopoverItemParams[]; commonTunes: PopoverItemParams[]; } { /** Tool's tunes: may be defined as return value of optional renderSettings method */ const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : []; return this.tunesManager.getMenuConfig(tunesDefinedInTool, renderContext); } /** * Update current input index with selection anchor node */ public updateCurrentInput(): void { this.inputManager.updateCurrentInput(); } /** * Allows to say Blok that Block was changed. Used to manually trigger Blok's 'onChange' callback * Can be useful for block changes invisible for blok core. */ public dispatchChange(): void { this.didMutated(); } /** * Updates the block's data in-place without destroying the DOM element. * This preserves focus and caret position during updates like undo/redo. * * @param newData - the new data to apply to the block * @returns true if the update was performed in-place, false if a full re-render is needed */ public async setData(newData: BlockToolData): Promise { const applied = await this.dataPersistenceManager.setData(newData); if (applied) { // The Tool just rewrote its own DOM from the new data, so anything a mark // derives from that data (an equation's rendering) is gone with the old // markup. Same reason the constructor hydrates — this is the in-place // update path (undo/redo, a controlled host, api.blocks.update), which // deliberately does NOT recompose the Block. this.hydrateInlineTools(); } return applied; } /** * Register a callback to be invoked when this Block is destroyed. * Returns an unregister function. * * Used by DragController to cancel an in-flight drag when its source * block is replaced (Yjs remote update, blockManager.update, tool * conversion, etc). Without this hook, the state machine would hold * a stale Block reference and drop the wrong block on mouseup. * @param callback - Function to call during destroy() * @returns Unregister function */ public addDestroyCallback(callback: () => void): () => void { this.destroyCallbacks.add(callback); return (): void => { this.destroyCallbacks.delete(callback); }; } /** * Call Tool instance destroy method */ public destroy(): void { // Notify external subscribers FIRST — before any state is torn down — // so listeners (e.g. DragController) can cancel cleanly while the // Block is still consistent. Copy to a local list so a callback that // unregisters itself doesn't mutate the set during iteration. const callbacks = Array.from(this.destroyCallbacks); this.destroyCallbacks.clear(); callbacks.forEach((cb) => cb()); this.mutationHandler.destroy(); this.inputManager.destroy(); /** Clean up drag and drop */ if (this.draggableCleanup) { this.draggableCleanup(); this.draggableCleanup = null; } super.destroy(); if (isFunction(this.toolInstance.destroy)) { this.toolInstance.destroy(); } } /** * Toggle read-only mode in place without destroying the block. * Updates internal managers and notifies the tool if it supports in-place toggle. * @param state - new read-only state */ public setReadOnly(state: boolean): void { if (this.readOnly === state) { return; } this.readOnly = state; if (state) { this.inputManager.removeInputEvents(); this.mutationHandler.unwatch(); } else { this.inputManager.addInputEvents(); this.mutationHandler.watch(); } if (typeof (this.toolInstance as unknown as { setReadOnly?: unknown }).setReadOnly === 'function') { (this.toolInstance as unknown as { setReadOnly: (s: boolean) => void }).setReadOnly(state); } } /** * Update the block's placeholder in place (reactive editor.placeholder API). * Delegates to the tool instance if it implements `setPlaceholder`. No-op otherwise. * @param value - new placeholder text, or false to clear it */ public setPlaceholder(value: string | false): void { const tool = this.toolInstance as unknown as { setPlaceholder?: (v: string | false) => void }; if (typeof tool.setPlaceholder === 'function') { tool.setPlaceholder(value); } } /** * Tool could specify several entries to be displayed at the Toolbox (for example, "Heading 1", "Heading 2", "Heading 3") * This method returns the entry that is related to the Block (depended on the Block data) */ public async getActiveToolboxEntry(): Promise { const toolboxSettings = this.tool.toolbox; if (!toolboxSettings) { return undefined; } /** * If Tool specifies just the single entry, treat it like an active */ if (toolboxSettings.length === 1) { return Promise.resolve(toolboxSettings[0]); } /** * If we have several entries with their own data overrides, * find those who matches some current data property * * Example: * Tools' toolbox: [ * {title: "Heading 1", data: {level: 1} }, * {title: "Heading 2", data: {level: 2} } * ] * * the Block data: { * text: "Heading text", * level: 2 * } * * that means that for the current block, the second toolbox item (matched by "{level: 2}") is active */ const blockData = await this.data; return toolboxSettings.find((item) => { return item.data !== undefined && isSameBlockData(item.data, blockData); }); } /** * Exports Block data as string using conversion config */ public async exportDataAsString(): Promise { return this.dataPersistenceManager.exportDataAsString(this.tool.conversionConfig ?? {}); } /** * Find and return all editable elements (contenteditable and native inputs) in the Tool HTML */ public get inputs(): HTMLElement[] { return this.inputManager.inputs; } /** * Return current Tool's input * If Block doesn't contain inputs, return undefined */ public get currentInput(): HTMLElement | undefined { return this.inputManager.currentInput; } /** * Returns the current input index (for caret restoration) */ public get currentInputIndex(): number { return this.inputManager.currentInputIndex; } /** * Set input index to the passed element * @param element - HTML Element to set as current input */ public set currentInput(element: HTMLElement | undefined) { this.inputManager.currentInput = element; } /** * Return first Tool's input * If Block doesn't contain inputs, return undefined */ public get firstInput(): HTMLElement | undefined { return this.inputManager.firstInput; } /** * Return last Tool's input * If Block doesn't contain inputs, return undefined */ public get lastInput(): HTMLElement | undefined { return this.inputManager.lastInput; } /** * Return next Tool's input or undefined if it doesn't exist * If Block doesn't contain inputs, return undefined */ public get nextInput(): HTMLElement | undefined { return this.inputManager.nextInput; } /** * Return previous Tool's input or undefined if it doesn't exist * If Block doesn't contain inputs, return undefined */ public get previousInput(): HTMLElement | undefined { return this.inputManager.previousInput; } /** * Get Block's JSON data * @returns {object} */ public get data(): Promise { return this.dataPersistenceManager.data; } /** * Returns last successfully extracted block data */ public get preservedData(): BlockToolData { return this.dataPersistenceManager.preservedData; } /** * Returns last successfully extracted tune data */ public get preservedTunes(): { [name: string]: BlockTuneData } { return this.dataPersistenceManager.preservedTunes; } /** * Returns tool's sanitizer config * @returns {object} */ public get sanitize(): SanitizerConfig { return this.tool.sanitizeConfig; } /** * is block mergeable * We plugin have merge function then we call it mergeable * @returns {boolean} */ public get mergeable(): boolean { return isFunction(this.toolInstance.merge); } /** * If Block contains inputs, it is focusable */ public get focusable(): boolean { return this.inputs.length !== 0; } /** * Check block for emptiness * @returns {boolean} */ public get isEmpty(): boolean { /** * No `ignoreChars`: `$.isNodeEmpty` strips EVERY occurrence of the ignored * substring before measuring, so tolerating '/' made any slash-only block * ("/", "//") report empty — which the Saver's single-empty-default-block * short-circuit then discarded as `{ blocks: [] }`. The slash-command flow * that motivated the tolerance (cd29c52e, from upstream Editor.js) no * longer needs it: Toolbox decides in-place replacement from * `blockHasOnlySlashQuery`/`slashQuerySpan`, not from `isEmpty`. */ const emptyText = $.isEmpty(this.pluginsContent); const emptyMedia = !this.hasMedia; return emptyText && emptyMedia; } /** * Check if block has a media content such as images, iframe and other * @returns {boolean} */ public get hasMedia(): boolean { /** * This tags represents media-content * @type {string[]} */ const mediaTags = [ 'img', 'iframe', 'video', 'audio', 'source', 'input', 'textarea', 'twitterwidget', ]; return !!this.holder.querySelector(mediaTags.join(',')); } /** * Set selected state * We don't need to mark Block as Selected when it is empty * @param {boolean} state - 'true' to select, 'false' to remove selection */ public set selected(state: boolean) { this.selectionManager.selected = state; } /** * Returns True if it is Selected * @returns {boolean} */ public get selected(): boolean { return this.selectionManager.selected; } /** * Set stretched state * @param {boolean} state - 'true' to enable, 'false' to disable stretched state */ public setStretchState(state: boolean): void { this.styleManager.setStretchState(state, this.selected); } /** * Backward-compatible setter for stretched state * @param state - true to enable, false to disable stretched state */ public set stretched(state: boolean) { this.setStretchState(state); } /** * Return Block's stretched state * @returns {boolean} */ public get stretched(): boolean { return this.styleManager.stretched; } /** * Returns Plugins content * @returns {HTMLElement} */ public get pluginsContent(): HTMLElement { return this.toolRenderer.pluginsContent; } /** * Is fired when DOM mutation has been happened * @param mutationsOrInputEvent - actual changes * - MutationRecord[] - any DOM change * - InputEvent — change (from InputManager callback) * - undefined — manual triggering of block.dispatchChange() */ private readonly didMutated = (mutationsOrInputEvent: MutationRecord[] | InputEvent | undefined = undefined): void => { const result = this.mutationHandler.handleMutation(mutationsOrInputEvent); if (result.newToolRoot) { // Update the tool renderer's reference to the new root element this.toolRenderer.toolRenderedElement = result.newToolRoot; } if (!result.shouldFireUpdate) { return; } this.inputManager.dropCache(); this.inputManager.updateCurrentInput(); this.toggleInputsEmptyMark(); this.call(BlockToolAPI.UPDATED); this.emit('didMutated', this); }; /** * Remove redactor dom change event listener. * Can be called to stop watching mutations before destroying the block. */ public unwatchBlockMutations(): void { this.mutationHandler.unwatch(); } /** * Re-arm block mutation watching after a temporary unwatchBlockMutations() * (e.g. the Toolbox suppresses mutation events while its popover is open and * must restore them on close — otherwise the block never emits didMutated * again and consumers like toolbar positioning go permanently stale). * No-op in read-only mode (setReadOnly manages watching there) and when * already watching (the handler is idempotent). */ public watchBlockMutations(): void { if (this.readOnly) { return; } this.mutationHandler.watch(); } /** * Refreshes the reference to the tool's root element by inspecting the block content. * Call this after operations (like onPaste) that might cause the tool to replace its element, * especially when mutation observers haven't been set up yet. */ public refreshToolRootElement(): void { this.toolRenderer.refreshToolRootElement(this.holder); this.inputManager.dropCache(); // The tool just replaced or rewrote its element (this is the post-onPaste // hook), so marks with derived DOM have to be rebuilt over what it wrote. this.hydrateInlineTools(); } /** * Mark inputs with 'data-blok-empty' attribute with the empty state */ private toggleInputsEmptyMark(): void { this.inputs.forEach(toggleEmptyMark); } }