/** * Text editor - all contenteditable text-editing setup * * Reads (via globals): * SFE.Context - .draftEditState * SFE.ElementPrep - .getCleanHTML, .getContent * SFE.OverlayManager * SFE.PositionManager - .positionFloatingElements * SFE.FocusManager - .createFocusManager * SFE.handleInlineSave - set by SaveManager * SFE.closeInPlaceEditor - set by EditorLifecycle * SFE.startMediaEditing - set by frontend-inline-edit.js (via SFE.MediaEditor) * SFE.MWPEditor - rich text editor class * SFE.ManagerData - .handlers * * Exposes: SFE.TextEditor * { startTextEditing, startMultiComponentEditing, refreshEditableComponents } */ (function() { 'use strict'; window.MWP = window.MWP || {}; window.MWP.SFE = window.MWP.SFE || {}; const SFE = window.MWP.SFE; SFE.ManagerData = SFE.ManagerData || {}; const MWPEditor = SFE.MWPEditor; function normalizeEditorFormatsSpec(rawFormats, depth = 0) { if (depth > 3 || !Array.isArray(rawFormats)) { return null; } const normalized = []; rawFormats.forEach((item) => { if (typeof item === 'string') { const token = item.trim(); if (token) { normalized.push(token); } return; } if (Array.isArray(item)) { const group = normalizeEditorFormatsSpec(item, depth + 1); if (Array.isArray(group) && group.length) { normalized.push(group); } } }); return normalized.length ? normalized : null; } function cloneEditorFormatsSpec(formatsSpec) { if (!Array.isArray(formatsSpec)) return null; return formatsSpec.map(item => ( Array.isArray(item) ? cloneEditorFormatsSpec(item) : item )); } function normalizeInlineFormatCapability(rawCapability) { if (!rawCapability || typeof rawCapability !== 'object' || Array.isArray(rawCapability)) { return null; } const tag = typeof rawCapability.tag === 'string' ? rawCapability.tag.trim().toLowerCase() : ''; if (!tag) return null; const normalizeStringArray = (rawValue) => { if (!Array.isArray(rawValue)) return null; const values = rawValue .map((value) => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean); return values.length ? values : null; }; const normalized = { tag }; const attributes = normalizeStringArray(rawCapability.attributes); const requiredAttributes = normalizeStringArray(rawCapability.requiredAttributes); const allowedTargets = normalizeStringArray(rawCapability.allowedTargets); const allowedRelTokens = normalizeStringArray(rawCapability.allowedRelTokens); const allowedProtocols = normalizeStringArray(rawCapability.allowedProtocols); if (attributes) normalized.attributes = attributes; if (requiredAttributes) normalized.requiredAttributes = requiredAttributes; if (allowedTargets) normalized.allowedTargets = allowedTargets; if (allowedRelTokens) normalized.allowedRelTokens = allowedRelTokens; if (allowedProtocols) normalized.allowedProtocols = allowedProtocols; if (typeof rawCapability.allowsRelativeUrls === 'boolean') { normalized.allowsRelativeUrls = rawCapability.allowsRelativeUrls; } if (typeof rawCapability.allowsAnchorLinks === 'boolean') { normalized.allowsAnchorLinks = rawCapability.allowsAnchorLinks; } if (typeof rawCapability.autoProtocol === 'string' && rawCapability.autoProtocol.trim()) { normalized.autoProtocol = rawCapability.autoProtocol.trim().toLowerCase(); } if (typeof rawCapability.preservesUnknownRelTokens === 'boolean') { normalized.preservesUnknownRelTokens = rawCapability.preservesUnknownRelTokens; } return normalized; } function cloneInlineFormatCapabilities(inlineFormatCapabilities) { if (!inlineFormatCapabilities || typeof inlineFormatCapabilities !== 'object' || Array.isArray(inlineFormatCapabilities)) { return null; } const cloned = {}; Object.keys(inlineFormatCapabilities).forEach((key) => { const cleanKey = typeof key === 'string' ? key.trim() : ''; if (!cleanKey) return; const capability = normalizeInlineFormatCapability(inlineFormatCapabilities[key]); if (!capability) return; cloned[cleanKey] = capability; }); return Object.keys(cloned).length ? cloned : null; } function normalizeAttributeCapabilityValues(rawValues) { if (!Array.isArray(rawValues)) return null; const values = rawValues.filter(value => ( typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null )); return values.length ? values : null; } function normalizeAttributeCapability(rawCapability) { if (!rawCapability || typeof rawCapability !== 'object' || Array.isArray(rawCapability)) { return null; } const normalized = {}; const attribute = typeof rawCapability.attribute === 'string' ? rawCapability.attribute.trim() : ''; const attributes = Array.isArray(rawCapability.attributes) ? rawCapability.attributes .map(value => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean) : null; if (attribute) { normalized.attribute = attribute; } else if (attributes && attributes.length) { normalized.attributes = attributes; } else { return null; } const values = normalizeAttributeCapabilityValues(rawCapability.values); if (values) { normalized.values = values; } if ( Object.prototype.hasOwnProperty.call(rawCapability, 'unsetValue') && ( typeof rawCapability.unsetValue === 'string' || typeof rawCapability.unsetValue === 'number' || typeof rawCapability.unsetValue === 'boolean' || rawCapability.unsetValue === null ) ) { normalized.unsetValue = rawCapability.unsetValue; } return normalized; } function cloneAttributeCapabilities(attributeCapabilities) { if (!attributeCapabilities || typeof attributeCapabilities !== 'object' || Array.isArray(attributeCapabilities)) { return null; } const cloned = {}; Object.keys(attributeCapabilities).forEach((key) => { const cleanKey = typeof key === 'string' ? key.trim() : ''; if (!cleanKey) return; const capability = normalizeAttributeCapability(attributeCapabilities[key]); if (!capability) return; cloned[cleanKey] = { ...capability }; if (Array.isArray(capability.attributes)) { cloned[cleanKey].attributes = [ ...capability.attributes ]; } if (Array.isArray(capability.values)) { cloned[cleanKey].values = [ ...capability.values ]; } }); return Object.keys(cloned).length ? cloned : null; } function normalizeEditorOperationValues(rawValues) { if (!Array.isArray(rawValues)) return null; const values = rawValues.filter(value => ( typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null )); return values.length ? values : null; } function normalizeEditorOperationStringArray(rawValues) { if (!Array.isArray(rawValues)) return null; const values = rawValues .map(value => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean); return values.length ? values : null; } function normalizeEditorOperation(rawOperation) { if (!rawOperation || typeof rawOperation !== 'object' || Array.isArray(rawOperation)) { return null; } const id = typeof rawOperation.id === 'string' ? rawOperation.id.trim() : ''; const kind = typeof rawOperation.kind === 'string' ? rawOperation.kind.trim() : ''; const component = typeof rawOperation.component === 'string' ? rawOperation.component.trim() : ''; if (!id || !kind || !component) { return null; } const normalized = { id, kind, component }; const attribute = typeof rawOperation.attribute === 'string' ? rawOperation.attribute.trim() : ''; const format = typeof rawOperation.format === 'string' ? rawOperation.format.trim() : ''; const attributes = normalizeEditorOperationStringArray(rawOperation.attributes); const formats = normalizeEditorOperationStringArray(rawOperation.formats); const targetModes = normalizeEditorOperationStringArray(rawOperation.targetModes); const values = normalizeEditorOperationValues(rawOperation.values); if (attribute) { normalized.attribute = attribute; } if (attributes) { normalized.attributes = attributes; } if (format) { normalized.format = format; } if (formats) { normalized.formats = formats; } if (targetModes) { normalized.targetModes = targetModes; } if (values) { normalized.values = values; } if (Object.prototype.hasOwnProperty.call(rawOperation, 'unsetValue')) { normalized.unsetValue = rawOperation.unsetValue; } if (typeof rawOperation.preserveInlineFormatting === 'boolean') { normalized.preserveInlineFormatting = rawOperation.preserveInlineFormatting; } if (typeof rawOperation.preserveUnchangedText === 'boolean') { normalized.preserveUnchangedText = rawOperation.preserveUnchangedText; } if (typeof rawOperation.preserveUnspecifiedAttributes === 'boolean') { normalized.preserveUnspecifiedAttributes = rawOperation.preserveUnspecifiedAttributes; } if (typeof rawOperation.mergeRelTokens === 'boolean') { normalized.mergeRelTokens = rawOperation.mergeRelTokens; } return normalized; } function cloneEditorOperations(rawOperations) { if (!Array.isArray(rawOperations)) return null; const normalized = rawOperations .map(normalizeEditorOperation) .filter(Boolean); return normalized.length ? normalized : null; } /** * Resolve the concrete toolbar format config for one text component. * * @param {HTMLElement|null} element Active editable element. * @param {Object} runtimeOptions Normalized component editor options. * @returns {Array} Concrete toolbar format definitions. */ function resolveComponentFormats(element, runtimeOptions) { const toolbarManager = SFE.ToolbarManager || null; if (toolbarManager && typeof toolbarManager.resolveFormats === 'function') { return toolbarManager.resolveFormats(runtimeOptions, element); } return []; } /** * Execute one list indentation action for the active rich-text editor host. * * @param {Object|null} editor Active rich-text editor host. * @param {string} action Tab-mode action: `indent` or `outdent`. * @returns {void} */ function executeListTabAction(editor, action) { if (!editor || typeof editor.executeListStructureOperation !== 'function') { return; } const operationKind = action === 'indent' ? 'indent_list_item' : action === 'outdent' ? 'outdent_list_item' : ''; if (!operationKind) { return; } editor.executeListStructureOperation({ kind: operationKind, }); } /** * Place caret from click coordinates when available, otherwise at element end. */ function placeCaretInEditableElement(editableElement, clickEvent) { if (!editableElement) return; editableElement.focus(); setTimeout(() => { // Guard: element may have been detached from the document between the // time this setTimeout was scheduled and when it fires (e.g. during // rapid component switches or editor teardown). Calling addRange() on // a range whose boundary nodes are not in the document throws: // "addRange(): The given range isn't in document." if (!document.body.contains(editableElement)) return; const selection = window.getSelection(); const range = document.createRange(); const placeholderApi = SFE.RichTextPlaceholder || null; const placeholderCaret = ( placeholderApi && typeof placeholderApi.getCaretTarget === 'function' ) ? placeholderApi.getCaretTarget(editableElement) : null; if (placeholderCaret?.node) { // Guard placeholder node as well - it may reference a node inside // a ghost/placeholder element that was removed between ticks. if (!document.body.contains(placeholderCaret.node)) return; range.setStart(placeholderCaret.node, placeholderCaret.offset); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); return; } if (clickEvent && clickEvent.clientX !== undefined && clickEvent.clientY !== undefined) { try { let clickRange = null; if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(clickEvent.clientX, clickEvent.clientY); if (pos) { clickRange = document.createRange(); clickRange.setStart(pos.offsetNode, pos.offset); clickRange.collapse(true); } } else if (document.caretRangeFromPoint) { clickRange = document.caretRangeFromPoint(clickEvent.clientX, clickEvent.clientY); } if (clickRange && editableElement.contains(clickRange.startContainer)) { selection.removeAllRanges(); selection.addRange(clickRange); return; } } catch (e) { /* ignore */ } } range.selectNodeContents(editableElement); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); }, 0); } /** * Find the first selector match that belongs to this block root (not a nested block). */ function findScopedComponentElement(rootElement, selector) { if (!rootElement || !selector) return null; if (rootElement.matches(selector)) { const owner = rootElement.closest('[data-mwp-sfe-uuid]'); if (!owner || owner === rootElement) return rootElement; } const matches = rootElement.querySelectorAll(selector); for (const candidate of matches) { const owner = candidate.closest('[data-mwp-sfe-uuid]'); if (!owner || owner === rootElement) { return candidate; } } return null; } function getEditableComponentFromTarget(components, target) { if (!target) return null; const targetEl = target.nodeType === Node.ELEMENT_NODE ? target : target.parentElement; if (!targetEl) return null; const distanceToAncestor = (descendant, ancestor) => { let steps = 0; let node = descendant; while (node && node !== ancestor) { node = node.parentElement; steps++; } return node === ancestor ? steps : null; }; let bestComponent = null; let bestScore = Number.POSITIVE_INFINITY; components.forEach(component => { if (!component?.element) return; let score = null; // Exact target match has highest priority. if (component.element === targetEl) { score = 0; } else { // Prefer the closest containing component when multiple components overlap. const containsDistance = distanceToAncestor(targetEl, component.element); if (containsDistance !== null) { score = containsDistance; } else if (isFileComponent(component) && targetEl.contains(component.element)) { // Fallback for file components when clicking a wrapper around the media element. const wrapperDistance = distanceToAncestor(component.element, targetEl); if (wrapperDistance !== null) { score = 100 + wrapperDistance; } } } if (score !== null && score < bestScore) { bestScore = score; bestComponent = component; } }); return bestComponent; } function resolveInitialEditableComponent(components, clickEvent, preferredComponentId = '') { const normalizedPreferredComponentId = String(preferredComponentId || '').trim(); if (normalizedPreferredComponentId) { const preferredComponent = components.find(component => component?.id === normalizedPreferredComponentId); if (preferredComponent) { return preferredComponent; } } const fromClick = getEditableComponentFromTarget(components, clickEvent?.target || null); if (fromClick) return fromClick; return components.find(component => component.default) || components[0] || null; } function normalizeComponentEditorOptions(rawEditorOptions) { if (!rawEditorOptions || typeof rawEditorOptions !== 'object') { return {}; } const normalized = {}; if (typeof rawEditorOptions.enterMode === 'string') { const mode = rawEditorOptions.enterMode.trim().toLowerCase(); if (mode === 'auto' || mode === 'always' || mode === 'never' || mode === 'linebreak') { normalized.enterMode = mode; } } if (typeof rawEditorOptions.linkUIMode === 'string') { const mode = rawEditorOptions.linkUIMode.trim().toLowerCase(); if (mode === 'auto' || mode === 'manual') { normalized.linkUIMode = mode; } } if (rawEditorOptions.tabMode && typeof rawEditorOptions.tabMode === 'object') { const tabMode = {}; const tabAction = typeof rawEditorOptions.tabMode.tab === 'string' ? rawEditorOptions.tabMode.tab.trim() : ''; const shiftTabAction = typeof rawEditorOptions.tabMode.shiftTab === 'string' ? rawEditorOptions.tabMode.shiftTab.trim() : ''; const isValidTabAction = (action) => ( action === 'none' || action === 'indent' || action === 'outdent' || action === 'nextComponent' || action === 'previousComponent' ); if (isValidTabAction(tabAction)) { tabMode.tab = tabAction; } if (isValidTabAction(shiftTabAction)) { tabMode.shiftTab = shiftTabAction; } if (Object.keys(tabMode).length) { normalized.tabMode = tabMode; } } if (rawEditorOptions.options && typeof rawEditorOptions.options === 'object') { const options = {}; if (rawEditorOptions.options.preserveNewlines === true) { options.preserveNewlines = true; } if (rawEditorOptions.options.newlinesToBR === true) { options.newlinesToBR = true; } if (Object.keys(options).length) { normalized.options = options; } } if (rawEditorOptions.formatTargets && typeof rawEditorOptions.formatTargets === 'object') { const targets = {}; Object.keys(rawEditorOptions.formatTargets).forEach((key) => { const cleanKey = typeof key === 'string' ? key.trim() : ''; const value = rawEditorOptions.formatTargets[key]; const cleanValue = typeof value === 'string' ? value.trim() : ''; if (!cleanKey || !cleanValue) return; targets[cleanKey] = cleanValue; }); if (Object.keys(targets).length) { normalized.formatTargets = targets; } } const formatsSpec = normalizeEditorFormatsSpec(rawEditorOptions.formats); if (formatsSpec) { normalized.formats = formatsSpec; } const inlineFormatCapabilities = cloneInlineFormatCapabilities(rawEditorOptions.inlineFormatCapabilities); if (inlineFormatCapabilities) { normalized.inlineFormatCapabilities = inlineFormatCapabilities; } const attributeCapabilities = cloneAttributeCapabilities(rawEditorOptions.attributeCapabilities); if (attributeCapabilities) { normalized.attributeCapabilities = attributeCapabilities; } const operations = cloneEditorOperations(rawEditorOptions.operations); if (operations) { normalized.operations = operations; } return normalized; } function normalizeComponentPlaceholder(rawPlaceholder) { if (typeof rawPlaceholder !== 'string') { return ''; } return rawPlaceholder.trim(); } /** * Normalize the schema media descriptor attached to a file component. * * @param {object|null} component Editable component definition. * @param {string} id Normalized component id. * @param {string} selector Normalized component selector. * @param {object|null} target Normalized schema target definition. * @returns {object|null} Normalized schema media descriptor, or null when invalid. */ function normalizeSchemaMediaDescriptor(component, id, selector, target) { const MediaHelper = SFE.MediaHelper || null; const rawDescriptor = component?.mediaDescriptor && typeof component.mediaDescriptor === 'object' ? component.mediaDescriptor : null; const descriptor = rawDescriptor || { componentId: id, scopeSelector: selector, targetSelector: target?.selector || '', attribute: target?.attribute || '', mediaType: target?.mediaType || '', }; if (!MediaHelper?.isSchemaMediaDescriptor?.(descriptor)) { return null; } return { componentId: typeof descriptor.componentId === 'string' && descriptor.componentId.trim() ? descriptor.componentId.trim() : id, scopeSelector: typeof descriptor.scopeSelector === 'string' && descriptor.scopeSelector.trim() ? descriptor.scopeSelector.trim() : selector, targetSelector: descriptor.targetSelector.trim(), attribute: descriptor.attribute.trim().toLowerCase(), mediaType: descriptor.mediaType.trim().toLowerCase(), }; } /** * Collect normalized editable schema components for the active block root. * * @param {HTMLElement} rootElement Active block root element. * @param {object} handler Active schema-backed handler. * @returns {Array} Normalized editable component definitions. */ function collectEditableComponents(rootElement, handler) { const ElementPrep = SFE.ElementPrep; const configured = handler?.client_config?.editableComponents; if (!Array.isArray(configured) || !configured.length) return []; const normalized = []; const seenIds = new Set(); configured.forEach((component, index) => { if (!component || typeof component !== 'object') return; const selector = typeof component.selector === 'string' ? component.selector.trim() : ''; if (!selector) return; const componentType = ( typeof component.type === 'string' && component.type.trim().toLowerCase() === 'file' ) ? 'file' : 'text'; const attribute = typeof component.attribute === 'string' ? component.attribute.trim() : ''; if (componentType !== 'file' && !attribute) return; const providedElement = component.element && component.element.nodeType === Node.ELEMENT_NODE ? component.element : null; const element = ( providedElement && (rootElement === providedElement || rootElement.contains(providedElement)) ) ? providedElement : findScopedComponentElement(rootElement, selector); if (!element) return; const idRaw = typeof component.id === 'string' ? component.id.trim() : ''; const id = idRaw || `component_${index + 1}`; if (seenIds.has(id)) return; seenIds.add(id); const normalizedComponent = { id, label: typeof component.label === 'string' && component.label.trim() ? component.label.trim() : id, selector, type: componentType, default: !!component.default, element, // Keep a lossless snapshot for emergency cancel restore fallback paths. originalRestoreOuterHTML: element.outerHTML, originalHTML: ElementPrep.getCleanHTML(element), originalOuterHTML: ElementPrep.getCleanHTML(element, true), editorOptions: normalizeComponentEditorOptions(component.editor), placeholder: normalizeComponentPlaceholder(component.placeholder), isGhost: !!component.isGhost || element.getAttribute('data-mwp-sfe-ghost') === '1', }; if (component.missingUI && typeof component.missingUI === 'object') { normalizedComponent.missingUI = component.missingUI; } if (componentType === 'file') { const target = component.target && typeof component.target === 'object' ? component.target : null; const targetSelector = typeof target?.selector === 'string' ? target.selector.trim() : ''; const targetAttribute = typeof target?.attribute === 'string' ? target.attribute.trim().toLowerCase() : ''; const mediaType = typeof target?.mediaType === 'string' ? target.mediaType.trim().toLowerCase() : ''; if (!targetSelector || !targetAttribute || !mediaType) return; normalizedComponent.target = { selector: targetSelector, attribute: targetAttribute, mediaType, }; normalizedComponent.mediaDescriptor = normalizeSchemaMediaDescriptor( component, id, selector, normalizedComponent.target ); if (!normalizedComponent.mediaDescriptor) return; normalizedComponent.urlBindingPath = typeof component.urlBindingPath === 'string' ? component.urlBindingPath.trim() : ''; normalizedComponent.idBindingPath = typeof component.idBindingPath === 'string' ? component.idBindingPath.trim() : ''; } else { normalizedComponent.attribute = attribute; } normalized.push(normalizedComponent); }); if (normalized.length && !normalized.some(component => component.default)) { normalized[0].default = true; } return normalized; } function stripEditableComponentState(component, editorState) { if (!component?.element) return; const elementPrep = SFE.ElementPrep || null; component.element.classList.remove( 'mwp-sfe-inline-editor', 'mwp-sfe-component-active', 'mwp-sfe-editable-component', 'mwp-sfe-editor-content' ); if (elementPrep && typeof elementPrep.pruneEmptyClassAttribute === 'function') { elementPrep.pruneEmptyClassAttribute(component.element); } component.element.removeAttribute('data-mwp-sfe-editable-component'); component.element.removeAttribute('data-mwp-sfe-active-component'); if (component.element._mwpEditor === editorState?.mwpEditor) { delete component.element._mwpEditor; } if (editorState?._mwpActiveComponentType !== 'text' || component.id !== editorState?.activeComponentId) { component.element.removeAttribute('contenteditable'); component.element.removeAttribute('spellcheck'); } } function applyEditableComponentState(component) { if (!component?.element) return; component.element.classList.add('mwp-sfe-editable-component'); component.element.dataset.mwpSfeEditableComponent = component.id; } function buildComponentEditorRuntimeOptions(editorState, component) { const runtimeOptions = { enterMode: 'auto', linkUIMode: 'auto', formatTargets: {}, }; if (editorState?.element) { runtimeOptions.blockRootElement = editorState.element; } const editorOptions = component?.editorOptions; if (!editorOptions || typeof editorOptions !== 'object') { return runtimeOptions; } if (typeof editorOptions.enterMode === 'string') { runtimeOptions.enterMode = editorOptions.enterMode; } if (typeof editorOptions.linkUIMode === 'string') { runtimeOptions.linkUIMode = editorOptions.linkUIMode; } if (editorOptions.formatTargets && typeof editorOptions.formatTargets === 'object') { runtimeOptions.formatTargets = { ...editorOptions.formatTargets }; } if (Array.isArray(editorOptions.formats)) { runtimeOptions.formats = cloneEditorFormatsSpec(editorOptions.formats); } if (editorOptions.inlineFormatCapabilities && typeof editorOptions.inlineFormatCapabilities === 'object') { const inlineFormatCapabilities = cloneInlineFormatCapabilities(editorOptions.inlineFormatCapabilities); if (inlineFormatCapabilities) { runtimeOptions.inlineFormatCapabilities = inlineFormatCapabilities; } } if (editorOptions.attributeCapabilities && typeof editorOptions.attributeCapabilities === 'object') { const attributeCapabilities = cloneAttributeCapabilities(editorOptions.attributeCapabilities); if (attributeCapabilities) { runtimeOptions.attributeCapabilities = attributeCapabilities; } } if (Array.isArray(editorOptions.operations)) { const operations = cloneEditorOperations(editorOptions.operations); if (operations) { runtimeOptions.operations = operations; } } if (editorOptions.options && typeof editorOptions.options === 'object') { runtimeOptions.options = { ...editorOptions.options }; } if (typeof component?.placeholder === 'string' && component.placeholder.trim()) { runtimeOptions.placeholder = component.placeholder.trim(); } if (typeof component?.id === 'string' && component.id.trim()) { runtimeOptions.componentId = component.id.trim(); } if (component?.missingUI && typeof component.missingUI === 'object') { runtimeOptions.missingUI = component.missingUI; } return runtimeOptions; } function isFileComponent(component) { return component?.type === 'file'; } function bindInlineEditButtons(editorState) { const actionsContainer = editorState?.actionsContainer; if (!actionsContainer) return; if (actionsContainer._saveBtn) { actionsContainer._saveBtn.addEventListener('click', e => { e.preventDefault(); e.stopImmediatePropagation(); SFE.handleInlineSave(editorState); }); } if (actionsContainer._cancelBtn) { actionsContainer._cancelBtn.addEventListener('click', e => { e.preventDefault(); e.stopImmediatePropagation(); SFE.closeInPlaceEditor(editorState, true); }); } } function restoreInlineEditButtons(editorState) { const ctx = SFE.Context; const actionBar = ctx?.actionBar; const buttonManager = ctx?.buttonManager; const actionsContainer = editorState?.actionsContainer; if (!actionBar || !buttonManager || !actionsContainer) return; actionBar.updateState({ bar: actionsContainer, element: editorState.element, state: 'edit', content: buttonManager.getEditButtons(), }); bindInlineEditButtons(editorState); } function cleanupSchemaMediaSession(editorState, options = {}) { if (!editorState) return; const session = editorState._mwpSchemaMediaSession; if (session && typeof session.cleanup === 'function') { session.cleanup(options); } editorState.activeSchemaHost = null; delete editorState._mwpSchemaMediaSession; } /** * Resolve the current primary rich-text host for one active editor state. * * @param {Object|null} editorState Active editor state. * @returns {Object|null} Current text editor host, or null. */ function resolveCurrentTextEditorHost(editorState) { return SFE.SchemaEditorHost?.resolveTextEditorHost?.(editorState) || null; } function setupSharedEditorLifecycle(editorState) { const { debouncedPosition } = SFE.PositionManager; const actionsContainer = editorState?.actionsContainer || null; const element = editorState?.element || null; if (!editorState || !element || !actionsContainer) { return; } actionsContainer.classList.add('mwp-sfe-inline-editor'); if (!editorState._mwpInlineButtonsBound) { bindInlineEditButtons(editorState); editorState._mwpInlineButtonsBound = true; } if (!editorState.cleanupFocus) { editorState.cleanupFocus = () => {}; } if (!editorState.updatePositions) { const updatePositions = () => debouncedPosition( editorState.element || null, editorState.toolbarContainer || null, editorState.actionsContainer || null ); window.addEventListener('scroll', updatePositions, true); editorState.updatePositions = updatePositions; } if ( !editorState.resizeObserver || editorState._mwpObservedEditorElement !== element ) { if (editorState.resizeObserver) { editorState.resizeObserver.disconnect(); } const resizeObserver = new ResizeObserver(editorState.updatePositions); resizeObserver.observe(element); editorState.resizeObserver = resizeObserver; editorState._mwpObservedEditorElement = element; } if (!editorState.escapeHandler) { const escapeHandler = (e) => { if (e.key === 'Escape') { e.preventDefault(); const textEditorHost = resolveCurrentTextEditorHost(editorState); const mediaSession = editorState._mwpSchemaMediaSession; if ( editorState._mwpActiveComponentType === 'file' && mediaSession && typeof mediaSession.handleEscape === 'function' && mediaSession.handleEscape() ) { return; } if (textEditorHost && textEditorHost._linkUIActive && typeof textEditorHost.closeLinkUI === 'function') { textEditorHost.closeLinkUI(); } else { SFE.closeInPlaceEditor(editorState, true); } } }; document.addEventListener('keydown', escapeHandler); editorState.escapeHandler = escapeHandler; } } function showToolbar(toolbar) { if (!toolbar) return; if (toolbar._mwpSwitchHideTimeout) { clearTimeout(toolbar._mwpSwitchHideTimeout); delete toolbar._mwpSwitchHideTimeout; } toolbar.classList.remove('mwp-sfe-closing'); toolbar.style.display = ''; } function teardownEditableComponentSwitching(editorState) { if (!editorState) return; const switchTarget = editorState._mwpComponentSwitchTarget || editorState.element || null; if (!switchTarget) return; if (editorState.componentSwitchHandler) { switchTarget.removeEventListener('mousedown', editorState.componentSwitchHandler, true); delete editorState.componentSwitchHandler; } if (editorState.componentClickGuard) { switchTarget.removeEventListener('click', editorState.componentClickGuard, true); delete editorState.componentClickGuard; } if (editorState.componentTabHandler) { switchTarget.removeEventListener('keydown', editorState.componentTabHandler, true); delete editorState.componentTabHandler; } delete editorState._mwpComponentSwitchTarget; } function activateEditableComponent(editorState, targetComponent, clickEvent, options = {}) { const overlayManager = SFE.OverlayManager; if (!editorState || !targetComponent || !targetComponent.element) return; if (!editorState.actionsContainer) return; const shouldPlaceCaret = options?.placeCaret !== false; const previousComponent = editorState.activeEditableComponent || null; const targetIsFile = isFileComponent(targetComponent); if (previousComponent && previousComponent.id === targetComponent.id) { if (!targetIsFile && shouldPlaceCaret) { placeCaretInEditableElement(targetComponent.element, clickEvent); } return; } if (previousComponent && previousComponent.element) { const textEditorHost = resolveCurrentTextEditorHost(editorState); previousComponent.element.classList.remove('mwp-sfe-component-active', 'mwp-sfe-inline-editor', 'mwp-sfe-editor-content'); previousComponent.element.removeAttribute('contenteditable'); previousComponent.element.removeAttribute('spellcheck'); previousComponent.element.removeAttribute('data-mwp-sfe-active-component'); if (previousComponent.element._mwpEditor === textEditorHost) { delete previousComponent.element._mwpEditor; } } cleanupSchemaMediaSession(editorState, { preserveChanges: true }); if (targetIsFile) { const textEditorHost = resolveCurrentTextEditorHost(editorState); editorState.activeSchemaHost = null; if (textEditorHost && textEditorHost._linkUIActive && typeof textEditorHost.closeLinkUI === 'function') { textEditorHost.closeLinkUI(); } if (textEditorHost?.toolbarManager && typeof textEditorHost.toolbarManager.destroy === 'function') { textEditorHost.toolbarManager.destroy(); } targetComponent.element.classList.add('mwp-sfe-component-active'); targetComponent.element.dataset.mwpSfeActiveComponent = targetComponent.id; editorState.activeEditableComponent = targetComponent; editorState.activeComponentId = targetComponent.id; editorState._mwpActiveComponentType = 'file'; if (overlayManager) { overlayManager.updateActiveElement(targetComponent.element); } const mediaEditor = SFE.MediaEditor; if (mediaEditor && typeof mediaEditor.startSchemaComponentEditing === 'function') { editorState._mwpSchemaMediaSession = mediaEditor.startSchemaComponentEditing(editorState, targetComponent, { onCancel: () => SFE.closeInPlaceEditor(editorState, true), }); } else { editorState._mwpActiveComponentType = 'text'; if (editorState.toolbarContainer) { showToolbar(editorState.toolbarContainer); } restoreInlineEditButtons(editorState); } if (previousComponent && previousComponent.id !== targetComponent.id && SFE.PublicApiBridge) { SFE.PublicApiBridge.emitEditorEvent('editor:componentChanged', editorState, { source: 'sfe', }); } return; } if (editorState._mwpActiveComponentType === 'file') { restoreInlineEditButtons(editorState); } setupSharedTextEditing(editorState, targetComponent.element, clickEvent, targetComponent, { placeCaret: shouldPlaceCaret, }); const textEditorHost = resolveCurrentTextEditorHost(editorState); if (!textEditorHost) return; targetComponent.element._mwpEditor = textEditorHost; targetComponent.element.classList.add('mwp-sfe-inline-editor', 'mwp-sfe-component-active'); targetComponent.element.dataset.mwpSfeActiveComponent = targetComponent.id; editorState.activeEditableComponent = targetComponent; editorState.activeComponentId = targetComponent.id; editorState._mwpActiveComponentType = 'text'; if (overlayManager) { overlayManager.updateActiveElement(targetComponent.element); } if (previousComponent && previousComponent.id !== targetComponent.id && SFE.PublicApiBridge) { SFE.PublicApiBridge.emitEditorEvent('editor:componentChanged', editorState, { source: 'sfe', }); } } /** * Activate one editable component by its public component identifier. * * @param {Object|null} editorState Active editor state. * @param {string} componentId Target component identifier. * @returns {boolean} Whether activation succeeded. */ function activateEditableComponentById(editorState, componentId) { if (!editorState || !Array.isArray(editorState.editableComponents)) { return false; } const normalizedComponentId = String(componentId || '').trim(); if (!normalizedComponentId) { return false; } const targetComponent = editorState.editableComponents.find(component => component?.id === normalizedComponentId) || null; if (!targetComponent) { return false; } activateEditableComponent(editorState, targetComponent, null); return true; } /** * Clone one plain object used for tracked block attribute changes. * * @param {Object|null} attributeChanges Source attribute map. * @returns {Object} Cloned plain attribute map. */ function cloneAttributeChanges(attributeChanges) { return attributeChanges && typeof attributeChanges === 'object' ? { ...attributeChanges } : {}; } /** * Replace the shared tracked block attribute map in place. * * @param {Object} editorState Active editor state. * @param {Object|null} nextChanges Plain attribute-change map. * @returns {void} */ function replaceAttributeChanges(editorState, nextChanges) { if (!editorState) { return; } const target = ( editorState.attributeChanges && typeof editorState.attributeChanges === 'object' && !Array.isArray(editorState.attributeChanges) ) ? editorState.attributeChanges : (editorState.attributeChanges = {}); Object.keys(target).forEach((key) => { delete target[key]; }); if (nextChanges && typeof nextChanges === 'object' && !Array.isArray(nextChanges)) { Object.keys(nextChanges).forEach((key) => { target[key] = nextChanges[key]; }); } if (editorState.mwpEditor && typeof editorState.mwpEditor === 'object') { editorState.mwpEditor.attributeChanges = target; } } /** * Capture one block-scoped session snapshot from the live editor DOM. * * @param {Object} editorState Active editor state. * @returns {Object|null} Block snapshot or null. */ function captureBlockSessionSnapshot(editorState) { const ElementPrep = SFE.ElementPrep || null; if (!editorState?.element) { return null; } const rootOuterHTML = ( ElementPrep && typeof ElementPrep.getCleanHTML === 'function' ) ? ElementPrep.getCleanHTML(editorState.element, true) : editorState.element.outerHTML; return { rootOuterHTML, activeComponentId: String( editorState.activeComponentId || editorState.activeEditableComponent?.id || '' ).trim(), activeComponentType: String(editorState._mwpActiveComponentType || '').trim() || 'text', attributeChanges: cloneAttributeChanges(editorState.attributeChanges), rootMediaChanges: cloneAttributeChanges(editorState.element?._mwpMediaChanges || null), }; } /** * Capture the current active text selection for one block session snapshot. * * @param {Object} editorState Active editor state. * @returns {*} Saved selection snapshot or null. */ function captureBlockSessionSelection(editorState) { const mwpEditor = editorState?.mwpEditor || null; if ( editorState?._mwpActiveComponentType !== 'text' || !mwpEditor || typeof mwpEditor.isSelectionInEditor !== 'function' || typeof mwpEditor.saveCursorPosition !== 'function' || !mwpEditor.isSelectionInEditor() ) { return null; } const selection = mwpEditor.saveCursorPosition(); if (!selection || typeof selection !== 'object') { return selection || null; } // Block-session history is JSON-cloned, so strip any live DOM references // from the shared selection snapshot here instead of changing MWPEditor's // native cursor snapshot shape used by live list keyboard flows. const sanitizedSelection = { ...selection }; delete sanitizedSelection.listItem; delete sanitizedSelection.container; return sanitizedSelection; } /** * Resolve the session-owned history API for the active text scope. * * @param {Object} editorState Active editor state. * @returns {Object|null} Session history API, or null when unavailable. */ function getTextSessionHistoryApi(editorState) { if (typeof editorState?.getSessionHistoryApi === 'function') { return editorState.getSessionHistoryApi('text'); } const blockEditSession = editorState?.blockEditSession || null; if (!blockEditSession || typeof blockEditSession.getHistoryApi !== 'function') { return null; } return blockEditSession.getHistoryApi('text'); } /** * Seed the text session's initial entry with the current live block-attribute * state once the active component is fully registered. * * @param {Object} editorState Active editor state. * @param {Object} mwpEditor Active rich-text editor instance. * @returns {void} */ function syncInitialTextHistoryBaseline(editorState, mwpEditor) { const historyApi = getTextSessionHistoryApi(editorState); if ( !mwpEditor || !historyApi || typeof historyApi.canReplaceInitialHistoryEntry !== 'function' || !historyApi.canReplaceInitialHistoryEntry() ) { return; } const executor = SFE.SchemaOperationExecutor || null; const operations = Array.isArray(mwpEditor.options?.operations) ? mwpEditor.options.operations : []; if (!executor || !operations.length) { return; } let didChange = false; operations.forEach((operation) => { if (String(operation?.kind || '').trim() !== 'block_attribute_change') { return; } const attributePath = String(operation?.attribute || '').trim(); if (!attributePath) { return; } let trackedValue; if (attributePath === 'level') { const currentLevel = mwpEditor.getHeadingLevelState?.(); trackedValue = Number.isInteger(currentLevel) ? currentLevel : undefined; } else if (attributePath === 'align') { trackedValue = typeof executor.normalizeBlockAttributeTrackedValue === 'function' ? executor.normalizeBlockAttributeTrackedValue(operation, mwpEditor.getBlockAlignState?.()) : mwpEditor.getBlockAlignState?.(); } else if ( typeof executor.getTextAlignmentCapability === 'function' && executor.getTextAlignmentCapability(mwpEditor, operation) ) { const textAlignmentCapability = executor.getTextAlignmentCapability(mwpEditor, operation); trackedValue = typeof executor.normalizeBlockAttributeTrackedValue === 'function' ? executor.normalizeBlockAttributeTrackedValue(textAlignmentCapability, mwpEditor.getTextAlignmentState?.()) : mwpEditor.getTextAlignmentState?.(); } else { return; } if ( Object.prototype.hasOwnProperty.call(mwpEditor.attributeChanges, attributePath) && mwpEditor.attributeChanges[attributePath] === trackedValue ) { return; } mwpEditor.attributeChanges[attributePath] = trackedValue; didChange = true; }); if (!didChange) { return; } historyApi.replaceInitialHistoryEntry?.(); mwpEditor.updateUndoRedoButtons?.(); } /** * Run the shared pre-restore cleanup required before one block snapshot swap. * * @param {Object} editorState Active editor state. * @returns {void} */ function prepareTextBlockSnapshotRestore(editorState) { cleanupSchemaMediaSession(editorState, { preserveChanges: true, preserveToolbarDom: true, }); teardownEditableComponentSwitching(editorState); const textEditorHost = resolveCurrentTextEditorHost(editorState); if ( textEditorHost && typeof textEditorHost.closeLinkUI === 'function' && textEditorHost._linkUIActive ) { textEditorHost.closeLinkUI(); } } /** * Rebuild runtime state immediately after the block root has been swapped. * * @param {Object} editorState Active editor state. * @param {Object} snapshot Restored block snapshot. * @param {Object|null} schemaRuntime Optional schema runtime module. * @returns {void} */ function syncTextBlockSnapshotAfterRootReplace(editorState, snapshot, schemaRuntime = null) { // History snapshots intentionally store clean block markup without managed // missingUI ghost shells. Re-materialize schema placeholders/ghost // components before rebuilding the editable component list so empty // optional components stay addressable after undo/redo restores. if (schemaRuntime && typeof schemaRuntime.refreshEditorState === 'function') { schemaRuntime.refreshEditorState(editorState, { blockState: editorState.blockState || null, attributeChanges: snapshot?.attributeChanges || {}, }); } refreshEditableComponents(editorState); replaceAttributeChanges(editorState, snapshot?.attributeChanges || {}); if (snapshot?.rootMediaChanges && typeof snapshot.rootMediaChanges === 'object') { editorState.element._mwpMediaChanges = { ...snapshot.rootMediaChanges }; } else { delete editorState.element._mwpMediaChanges; } } /** * Resolve which editable component should be reactivated after restore. * * @param {Object} editorState Active editor state. * @param {Object} snapshot Restored block snapshot. * @returns {Object|false} Activation context or false on failure. */ function resolveTextBlockSnapshotActivation(editorState, snapshot) { const targetComponentId = String(snapshot?.activeComponentId || '').trim(); const targetComponent = ( targetComponentId && Array.isArray(editorState.editableComponents) ) ? editorState.editableComponents.find(component => component?.id === targetComponentId) || null : null; const fallbackComponent = targetComponent || editorState.editableComponents?.find(component => component?.default) || editorState.editableComponents?.[0] || null; if (!fallbackComponent || !fallbackComponent.element) { return false; } return { component: fallbackComponent, activeElement: fallbackComponent.element, }; } /** * Reactivate the restored editable component without applying default caret placement. * * @param {Object} editorState Active editor state. * @param {Object} snapshot Restored block snapshot. * @param {Object} activation Resolved activation context. * @returns {Object|false} Activation result or false on failure. */ function activateTextBlockSnapshot(editorState, snapshot, activation) { const fallbackComponent = activation?.component || null; if (!fallbackComponent?.element) { return false; } if (snapshot?.activeComponentType === 'file') { const textEditorHost = resolveCurrentTextEditorHost(editorState); if ( textEditorHost?.toolbarManager && typeof textEditorHost.toolbarManager.destroy === 'function' ) { textEditorHost.toolbarManager.destroy(); } if (snapshot?.rootMediaChanges && typeof snapshot.rootMediaChanges === 'object') { fallbackComponent.element._mwpMediaChanges = { ...snapshot.rootMediaChanges }; } else if (fallbackComponent.element?._mwpMediaChanges) { delete fallbackComponent.element._mwpMediaChanges; } } editorState.activeEditableComponent = null; editorState.activeComponentId = ''; editorState._mwpActiveComponentType = ''; activateEditableComponent(editorState, fallbackComponent, null, { placeCaret: false, }); return { activeElement: fallbackComponent.element, }; } /** * Restore one text selection after the target component has been reactivated. * * @param {Object} editorState Active editor state. * @param {*} selection Saved text selection snapshot. * @returns {void} */ function restoreTextBlockSnapshotSelection(editorState, selection) { const textEditorHost = resolveCurrentTextEditorHost(editorState); if ( selection && editorState._mwpActiveComponentType === 'text' && textEditorHost && typeof textEditorHost.restoreCursorPosition === 'function' ) { textEditorHost.restoreCursorPosition(selection); } } /** * Recreate any post-activation runtime sessions and shared editor wiring. * * @param {Object} editorState Active editor state. * @param {Object} snapshot Restored block snapshot. * @param {Object} activation Resolved activation context. * @param {Object|null} mediaEditor Optional media editor module. * @returns {void} */ function finalizeTextBlockSnapshotActivation(editorState, snapshot, activation, mediaEditor = null) { const fallbackComponent = activation?.component || null; if ( snapshot?.activeComponentType === 'file' && mediaEditor && typeof mediaEditor.startSchemaComponentEditing === 'function' && !editorState._mwpSchemaMediaSession && fallbackComponent ) { editorState._mwpSchemaMediaSession = mediaEditor.startSchemaComponentEditing(editorState, fallbackComponent, { onCancel: () => SFE.closeInPlaceEditor(editorState, true), }); } setupSharedEditorLifecycle(editorState); setupEditableComponentSwitching(editorState); } /** * Run any deferred layout work required after finalizing one restored block. * * @param {Object} editorState Active editor state. * @param {Object} snapshot Restored block snapshot. * @returns {void} */ function finalizeTextBlockSnapshotLayout(editorState, snapshot) { if (snapshot?.activeComponentType === 'file') { requestAnimationFrame(() => { requestAnimationFrame(() => { editorState.updatePositions?.(); }); }); } } /** * Build the text-specific restore adapter consumed by the shared block * session restore pipeline. * * @param {Object} editorState Active editor state. * @param {Object} snapshot Session snapshot to restore. * @param {*} selection Optional text selection snapshot. * @returns {Object} Restore configuration for BlockEditSession. */ function buildTextBlockSessionRestoreConfig(editorState, snapshot, selection = null) { const mediaEditor = SFE.MediaEditor || null; const schemaRuntime = SFE.SchemaRuntime || null; return { editorState, snapshot, selection, beforeRestore: ({ editorState: activeEditorState }) => { prepareTextBlockSnapshotRestore(activeEditorState); }, afterRootReplace: ({ editorState: activeEditorState, snapshot: activeSnapshot }) => { syncTextBlockSnapshotAfterRootReplace(activeEditorState, activeSnapshot, schemaRuntime); }, resolveActivation: ({ editorState: activeEditorState, snapshot: activeSnapshot }) => { return resolveTextBlockSnapshotActivation(activeEditorState, activeSnapshot); }, activate: ({ editorState: activeEditorState, snapshot: activeSnapshot, activation }) => { return activateTextBlockSnapshot(activeEditorState, activeSnapshot, activation); }, restoreSelection: ({ editorState: activeEditorState, selection: selectionToRestore }) => { restoreTextBlockSnapshotSelection(activeEditorState, selectionToRestore); }, afterActivate: ({ editorState: activeEditorState, snapshot: activeSnapshot, activation }) => { finalizeTextBlockSnapshotActivation(activeEditorState, activeSnapshot, activation, mediaEditor); }, afterFinalize: ({ editorState: activeEditorState, snapshot: activeSnapshot }) => { finalizeTextBlockSnapshotLayout(activeEditorState, activeSnapshot); }, }; } /** * Restore one full block-scoped session snapshot into the live editor. * * @param {Object} editorState Active editor state. * @param {Object} snapshot Session snapshot to restore. * @param {*} selection Optional text selection snapshot. * @returns {boolean} Whether restore succeeded. */ function restoreBlockSessionSnapshot(editorState, snapshot, selection = null) { const blockEditSession = editorState?.blockEditSession || null; if ( !editorState?.element || !blockEditSession || typeof blockEditSession.restoreManagedBlockSnapshot !== 'function' ) { return false; } return blockEditSession.restoreManagedBlockSnapshot( buildTextBlockSessionRestoreConfig(editorState, snapshot, selection) ); } /** * Build the text snapshot-host registration descriptor for one active block * edit session. * * @param {Object} editorState Active editor state. * @returns {Object} Snapshot-host registration config. */ function buildTextHistorySnapshotHostConfig(editorState) { return { editorState, scopeId: 'text', attachHistoryApi: true, seedInitialHistory: true, captureSnapshot: ({ editorState: activeEditorState }) => captureBlockSessionSnapshot(activeEditorState), captureSelection: ({ editorState: activeEditorState }) => captureBlockSessionSelection(activeEditorState), restoreSnapshot: ({ editorState: activeEditorState, snapshot, selectionToRestore }) => { restoreBlockSessionSnapshot(activeEditorState, snapshot, selectionToRestore || null); }, }; } /** * Register the active rich-text editor as the block session history host. * * @param {Object} editorState Active editor state. * @param {Object} mwpEditor Active rich-text editor instance. * @returns {void} */ function registerTextHistorySessionHost(editorState, mwpEditor) { const blockEditSession = editorState?.blockEditSession || null; if (!blockEditSession || typeof blockEditSession.registerSnapshotHost !== 'function' || !mwpEditor) { return; } blockEditSession.registerSnapshotHost( mwpEditor, buildTextHistorySnapshotHostConfig(editorState) ); } function cycleEditableComponent(editorState, direction = 1) { if (!editorState || !Array.isArray(editorState.editableComponents)) return false; const components = editorState.editableComponents.filter(component => component?.element); if (components.length < 2) return false; const activeId = editorState.activeComponentId; let currentIndex = components.findIndex(component => component.id === activeId); if (currentIndex < 0) currentIndex = 0; const step = direction < 0 ? -1 : 1; const nextIndex = (currentIndex + step + components.length) % components.length; const nextComponent = components[nextIndex]; if (!nextComponent || nextComponent.id === activeId) return false; activateEditableComponent(editorState, nextComponent, null); editorState._suppressNextComponentClick = true; return true; } function refreshEditableComponents(editorState) { if (!editorState?.element || !editorState?.handler) return false; if (!Array.isArray(editorState.editableComponents)) return false; const previousComponents = editorState.editableComponents; const nextComponents = collectEditableComponents(editorState.element, editorState.handler); previousComponents.forEach(component => { if (!component?.id || !component.element) return; const stillPresent = nextComponents.some(nextComponent => ( nextComponent?.id === component.id && nextComponent.element === component.element )); if (stillPresent) return; stripEditableComponentState(component, editorState); }); nextComponents.forEach(component => applyEditableComponentState(component)); editorState.editableComponents = nextComponents; if (!nextComponents.length) { editorState.activeEditableComponent = null; editorState.activeComponentId = ''; return false; } const activeId = editorState.activeComponentId || editorState.activeEditableComponent?.id || ''; const nextActive = activeId ? nextComponents.find(component => component.id === activeId) || null : null; if (nextActive) { editorState.activeEditableComponent = nextActive; editorState.activeComponentId = nextActive.id; if (editorState._mwpActiveComponentType === 'text') { nextActive.element._mwpEditor = editorState.mwpEditor; nextActive.element.classList.add('mwp-sfe-inline-editor', 'mwp-sfe-component-active'); nextActive.element.dataset.mwpSfeActiveComponent = nextActive.id; nextActive.element.setAttribute('contenteditable', 'true'); nextActive.element.setAttribute('spellcheck', 'true'); } else if (editorState._mwpActiveComponentType === 'file') { nextActive.element.classList.add('mwp-sfe-component-active'); nextActive.element.dataset.mwpSfeActiveComponent = nextActive.id; } return true; } const fallbackComponent = nextComponents.find(component => component.default) || nextComponents[0] || null; if (!fallbackComponent) { editorState.activeEditableComponent = null; editorState.activeComponentId = ''; return false; } activateEditableComponent(editorState, fallbackComponent, null); return true; } function setupEditableComponentSwitching(editorState) { if (!editorState || !Array.isArray(editorState.editableComponents) || !editorState.editableComponents.length) { return; } const switchTarget = editorState.element; if (!switchTarget) return; teardownEditableComponentSwitching(editorState); const getTabModeAction = (component, shiftKey) => { const tabMode = component?.editorOptions?.tabMode; if (!tabMode || typeof tabMode !== 'object') return ''; const action = shiftKey ? tabMode.shiftTab : tabMode.tab; return typeof action === 'string' ? action : ''; }; const switchHandler = (event) => { const targetComponent = getEditableComponentFromTarget(editorState.editableComponents, event.target); if (!targetComponent) { return; } if (targetComponent.id === editorState.activeComponentId) { if (isFileComponent(targetComponent)) { event.preventDefault(); event.stopPropagation(); } return; } event.preventDefault(); event.stopPropagation(); activateEditableComponent(editorState, targetComponent, event); editorState._suppressNextComponentClick = true; }; const clickGuard = (event) => { if (editorState._suppressNextComponentClick) { editorState._suppressNextComponentClick = false; event.preventDefault(); event.stopPropagation(); return; } const targetComponent = getEditableComponentFromTarget(editorState.editableComponents, event.target); if (!targetComponent) { return; } if (isFileComponent(targetComponent) || targetComponent.id !== editorState.activeComponentId) { event.preventDefault(); event.stopPropagation(); } }; const tabHandler = (event) => { if (event.key !== 'Tab') return; if (event.ctrlKey || event.metaKey || event.altKey) return; if (!editorState.actionsContainer || !document.body.contains(editorState.actionsContainer)) { teardownEditableComponentSwitching(editorState); return; } const activeComponent = editorState.activeEditableComponent; if (!activeComponent) return; const tabAction = getTabModeAction(activeComponent, event.shiftKey); if (!tabAction) return; if (tabAction === 'nextComponent' || tabAction === 'previousComponent') { if (cycleEditableComponent(editorState, tabAction === 'previousComponent' ? -1 : 1)) { event.preventDefault(); event.stopPropagation(); } return; } if (tabAction === 'indent' || tabAction === 'outdent') { event.preventDefault(); event.stopPropagation(); const editor = editorState.mwpEditor; const inList = !!(editor && typeof editor.getParentList === 'function' && editor.getParentList()); if (inList) { executeListTabAction(editor, tabAction); } return; } if (tabAction === 'none') { event.preventDefault(); event.stopPropagation(); } }; switchTarget.addEventListener('mousedown', switchHandler, true); switchTarget.addEventListener('click', clickGuard, true); switchTarget.addEventListener('keydown', tabHandler, true); editorState._mwpComponentSwitchTarget = switchTarget; editorState.componentSwitchHandler = switchHandler; editorState.componentClickGuard = clickGuard; editorState.componentTabHandler = tabHandler; } /** * Shared setup for text-based editing */ function setupSharedTextEditing(editorState, editableElement, clickEvent, activeComponent = null, options = {}) { const { positionFloatingElements } = SFE.PositionManager; const { element, handler, actionsContainer } = editorState; if (!actionsContainer) return editorState; const resolvedActiveComponent = activeComponent || editorState.activeEditableComponent || null; const shouldPlaceCaret = options?.placeCaret !== false; // Multi-component sessions need the intended active component registered // before the block session seeds its initial history baseline. Otherwise // undo-to-zero can reactivate the first/default component instead of the // component where editing actually started. if (resolvedActiveComponent?.id) { editorState.activeEditableComponent = resolvedActiveComponent; editorState.activeComponentId = resolvedActiveComponent.id; editorState._mwpActiveComponentType = 'text'; } editableElement.classList.add('mwp-sfe-inline-editor'); let toolbarContainer = editorState.toolbarContainer || null; if (!toolbarContainer) { toolbarContainer = document.createElement('div'); toolbarContainer.className = 'mwp-sfe-inline-toolbar mwp-sfe-inline-editor'; toolbarContainer.setAttribute('data-mwp-sfe-control', 'true'); toolbarContainer.style.pointerEvents = 'auto'; toolbarContainer.style.display = 'none'; document.body.appendChild(toolbarContainer); editorState.toolbarContainer = toolbarContainer; } const componentRuntimeOptions = { ...buildComponentEditorRuntimeOptions( editorState, resolvedActiveComponent ), toolbarContainer, historyApi: getTextSessionHistoryApi(editorState), }; const formats = resolveComponentFormats(editableElement, componentRuntimeOptions); componentRuntimeOptions.formats = formats; let mwpEditor = resolveCurrentTextEditorHost(editorState) || editorState.mwpEditor || null; if (mwpEditor && typeof mwpEditor.reinitialize === 'function') { mwpEditor.reinitialize(editableElement, componentRuntimeOptions); } else { if (mwpEditor && typeof mwpEditor.destroy === 'function') { mwpEditor.destroy(); } mwpEditor = new MWPEditor(editableElement, { formats, ...componentRuntimeOptions, }); } editorState.mwpEditor = mwpEditor; editorState.textEditorHost = mwpEditor; editorState.activeSchemaHost = mwpEditor; editableElement._mwpEditor = mwpEditor; if (editorState.attributeChanges && typeof editorState.attributeChanges === 'object') { mwpEditor.attributeChanges = editorState.attributeChanges; } else { editorState.attributeChanges = mwpEditor.attributeChanges; } registerTextHistorySessionHost(editorState, mwpEditor); toolbarContainer.classList.remove('mwp-sfe-no-position-transition'); actionsContainer.classList.remove('mwp-sfe-no-position-transition'); positionFloatingElements(element, toolbarContainer, actionsContainer, true); showToolbar(toolbarContainer); if (shouldPlaceCaret) { placeCaretInEditableElement(editableElement, clickEvent); } syncInitialTextHistoryBaseline(editorState, mwpEditor); editorState._mwpActiveComponentType = 'text'; setupSharedEditorLifecycle(editorState); return editorState; } /** * Shared plain-text edit setup against the block root element. */ function startRootTextEditing(commonState, clickEvent) { const ElementPrep = SFE.ElementPrep; const { element, handler } = commonState; const contentType = handler.contentType || 'text'; const ctx = SFE.Context; let originalContent; if (ctx.draftEditState && ctx.draftEditState.draftElement === element && ctx.draftEditState.originalElement) { originalContent = ElementPrep.getContent(ctx.draftEditState.originalElement, contentType); } else { originalContent = ElementPrep.getContent(element, contentType); } // Capture a clean DOM snapshot now, before MWPEditor modifies anything. const originalCleanHTML = ElementPrep.getCleanHTML(element); const editorState = { ...commonState, originalContent, originalCleanHTML }; return setupSharedTextEditing(editorState, element, clickEvent); } /** * Content-type-specific: Text Editing * Receives common state, adds text-specific resources, returns complete editor state */ function startTextEditing(commonState, clickEvent) { return startRootTextEditing(commonState, clickEvent); } /** * Multi-component editing (single block root, multiple switchable sub-components). */ function startMultiComponentEditing(commonState, clickEvent) { const overlayManager = SFE.OverlayManager; const { element, handler } = commonState; const editableComponents = collectEditableComponents(element, handler); if (!editableComponents.length) { return startRootTextEditing(commonState, clickEvent); } const initialComponent = resolveInitialEditableComponent( editableComponents, clickEvent, commonState.initialComponentId || '' ); if (!initialComponent || !initialComponent.element) { return startRootTextEditing(commonState, clickEvent); } editableComponents.forEach(component => applyEditableComponentState(component)); const editorState = { ...commonState, editableComponents, activeEditableComponent: null, activeComponentId: '', }; // Narrow the active overlay to the selected component instead of full block root. if (overlayManager && overlayManager.activeTarget === element) { overlayManager.updateActiveElement(initialComponent.element); } setupSharedEditorLifecycle(editorState); setupEditableComponentSwitching(editorState); activateEditableComponent(editorState, initialComponent, clickEvent); return editorState; } SFE.TextEditor = { startTextEditing, startMultiComponentEditing, refreshEditableComponents, activateEditableComponentById, captureBlockSessionSnapshot, captureBlockSessionSelection, restoreBlockSessionSnapshot }; })(); .flexa-devices{background:#fff;border-bottom:1px solid #e0e0e0;display:flex;gap:4px;margin:0 0 4px;padding:10px 16px;position:sticky;top:0;z-index:2}.flexa-devices__btn.components-button{background:#f0f0f1;border-radius:6px;color:#1e1e1e;flex:1;height:34px;justify-content:center;transition:background .12s ease,color .12s ease}.flexa-devices__btn.components-button svg{height:22px;width:22px}.flexa-devices__btn.components-button:hover{background:#e8e8ea}.flexa-devices__btn.components-button.is-active{background:#2563eb;color:#fff}.flexa-tabs .components-tab-panel__tabs{border-bottom:1px solid #e0e0e0;display:flex;gap:2px;padding:8px 16px 0}.flexa-tabs .components-tab-panel__tabs-item{border-radius:6px 6px 0 0;color:#6d7882;flex:1;font-weight:500;height:38px;justify-content:center;transition:color .12s ease,background .12s ease}.flexa-tabs .components-tab-panel__tabs-item:hover{background:#f6f7f7;color:#1e1e1e}.flexa-tabs .components-tab-panel__tabs-item.is-active{box-shadow:inset 0 -2px 0 0 #2563eb;color:#2563eb;font-weight:600}.flexa-tabs .components-tab-panel__tabs-item.is-active:after{display:none}.flexa-tabs .flexa-tab__content{padding-top:4px}.flexa-inspector .components-panel__body-title .components-button{color:#1e1e1e;font-size:13px;font-weight:600;padding-bottom:14px;padding-top:14px}.flexa-inspector .components-panel__body.is-opened{padding-bottom:4px}.flexa-inspector .components-base-control,.flexa-inspector .components-toggle-group-control{margin-bottom:16px}.flexa-inspector .components-base-control__label,.flexa-inspector .components-input-control__label,.flexa-inspector .components-toggle-group-control+.components-base-control__label{color:#6d7882!important;font-size:13px!important;font-weight:500!important;letter-spacing:normal!important;margin-bottom:6px;text-transform:none!important}.flexa-inspector .components-input-control__container,.flexa-inspector .components-select-control__input,.flexa-inspector .components-text-control__input{border-radius:5px}.flexa-inspector .components-input-control__backdrop{border-radius:5px!important}.flexa-inspector .components-toggle-group-control{background:#f6f7f7;border:none!important;border-radius:6px}.flexa-inspector .components-toggle-group-control,.flexa-inspector .components-toggle-group-control *,.flexa-inspector .components-toggle-group-control :active,.flexa-inspector .components-toggle-group-control :focus,.flexa-inspector .components-toggle-group-control :focus-visible,.flexa-inspector .components-toggle-group-control:focus,.flexa-inspector .components-toggle-group-control:focus-visible,.flexa-inspector .components-toggle-group-control:focus-within{box-shadow:none!important;outline:none!important}.flexa-inspector .components-toggle-group-control:before{background:#dcf2ff!important;border:none!important;box-shadow:none!important;outline:none!important}.flexa-inspector .components-toggle-group-control .components-toggle-group-control__backdrop{display:none!important}.flexa-inspector .components-toggle-group-control .components-toggle-group-control-option-base{border:none!important;color:#1e1e1e}.flexa-inspector .components-toggle-group-control .components-toggle-group-control-option-base svg{fill:currentcolor}.flexa-inspector .components-toggle-group-control .components-toggle-group-control-option-base:active,.flexa-inspector .components-toggle-group-control .components-toggle-group-control-option-base:focus,.flexa-inspector .components-toggle-group-control .components-toggle-group-control-option-base:focus-visible{border:none!important;box-shadow:none!important;outline:none!important}.flexa-inspector .components-toggle-group-control .is-selected,.flexa-inspector .components-toggle-group-control [aria-checked=true],.flexa-inspector .components-toggle-group-control [data-active=true]{background:#dcf2ff!important;border:none!important;border-radius:5px;box-shadow:none!important;color:#1d4ed8!important;font-weight:600;outline:none!important}.flexa-inspector .components-toggle-group-control .is-selected svg,.flexa-inspector .components-toggle-group-control [aria-checked=true] svg,.flexa-inspector .components-toggle-group-control [data-active=true] svg{fill:#1d4ed8!important}.flexa-inspector{--wp-admin-theme-color:#2563eb;--wp-admin-theme-color-darker-10:#1d4ed8;--wp-admin-theme-color-darker-20:#1e40af}.flexa-slider-unit{margin-bottom:16px}.flexa-slider-unit__head{align-items:center;display:flex;justify-content:space-between;margin-bottom:6px}.flexa-slider-unit__label{align-items:center;color:#6d7882;display:flex;font-size:13px;font-weight:500;gap:6px}.flexa-slider-unit__tools{align-items:center;display:flex;gap:10px}.flexa-slider-unit__reset,.flexa-slider-unit__reset.components-button{align-items:center;background:none;border:0;color:#2563eb;cursor:pointer;display:inline-flex;justify-content:center;padding:0}.flexa-slider-unit__reset svg,.flexa-slider-unit__reset.components-button svg{fill:none;height:15px;stroke:currentcolor;width:15px}.flexa-slider-unit__reset.components-button:hover,.flexa-slider-unit__reset:hover{color:#1d4ed8}.flexa-slider-unit .components-base-control{margin-bottom:0}.flexa-slider-unit .components-range-control__root{align-items:center}.flexa-unit-chip{appearance:none!important;-webkit-appearance:none!important;background:#fff url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%278%27 height=%278%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27none%27 stroke=%27%236d7882%27 stroke-width=%271.6%27 d=%27m2 4 4 4 4-4%27/%3E%3C/svg%3E") no-repeat left 4px center;background-size:8px;border:1px solid #e0e0e0;border-radius:4px;box-sizing:border-box;color:#2563eb;cursor:pointer;font-size:11px!important;font-weight:600;height:22px!important;letter-spacing:.2px;line-height:20px!important;margin:0!important;max-width:54px;min-height:0!important;padding:0 6px 0 15px!important}.flexa-unit-chip:focus{border-color:#2563eb;box-shadow:0 0 0 1px #2563eb;outline:none}.flexa-box__head{align-items:center;display:flex;justify-content:space-between;margin-bottom:8px}.flexa-box__tools{align-items:center;display:flex;gap:8px}.flexa-box__inputs{align-items:stretch;display:flex;gap:6px}.flexa-box__input{appearance:textfield;-moz-appearance:textfield;border:1px solid #e0e0e0!important;border-radius:5px!important;box-shadow:none!important;box-sizing:border-box;color:#1e1e1e;flex:1 1 0;font-size:12px!important;height:32px!important;margin:0!important;min-height:0!important;min-width:0;padding:0 4px!important;text-align:center}.flexa-box__input::-webkit-inner-spin-button,.flexa-box__input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.flexa-box__input:focus{border-color:#2563eb!important;box-shadow:0 0 0 1px #2563eb!important;outline:none}.flexa-box__link{align-items:center;background:#fff;border:1px solid #e0e0e0;border-radius:5px;color:#6d7882;cursor:pointer;display:inline-flex;flex:0 0 34px;height:32px;justify-content:center;padding:0}.flexa-box__link svg{fill:none;height:16px;stroke:currentcolor;width:16px}.flexa-box__link:hover{color:#1e1e1e}.flexa-box__link.is-linked{background:#dcf2ff;border-color:#dcf2ff;color:#1d4ed8}.flexa-box__labels{display:flex;gap:6px;margin-top:4px}.flexa-box__labels span{color:#6d7882;flex:1 1 0;font-size:9px;font-weight:500;letter-spacing:.3px;text-align:center;text-transform:uppercase}.flexa-box__labels:after{content:"";flex:0 0 34px}.flexa-device-tag{align-items:center;background:none;border:0;color:#6d7882;cursor:pointer;display:inline-flex;justify-content:center;padding:0}.flexa-device-tag svg{fill:none;height:16px;stroke:currentcolor;width:16px}.flexa-device-tag.is-active,.flexa-device-tag:hover{color:#2563eb}.flexa-device-menu{display:flex;flex-direction:row;gap:4px;padding:5px}.flexa-device-menu__item{align-items:center;background:none;border:0;border-radius:5px;color:#6d7882;cursor:pointer;display:inline-flex;height:30px;justify-content:center;padding:0;width:34px}.flexa-device-menu__item svg{fill:none;height:17px;stroke:currentcolor;width:17px}.flexa-device-menu__item:hover{background:#f6f7f7;color:#1e1e1e}.flexa-device-menu__item.is-active{background:#dcf2ff;color:#2563eb}.flexa-inspector .flexa-field{margin-bottom:16px}.flexa-inspector .flexa-field .components-base-control,.flexa-inspector .flexa-field .components-toggle-group-control{margin-bottom:0}.flexa-inspector .flexa-field .flexa-slider-unit{margin-bottom:14px}.flexa-inspector .flexa-field .flexa-slider-unit:last-child{margin-bottom:0}.flexa-inspector .flexa-field__head{align-items:center;display:flex;justify-content:flex-start;margin-bottom:6px;min-height:20px}.flexa-inspector .flexa-field__label{align-items:center;color:#6d7882;display:inline-flex;font-size:13px;font-weight:500;gap:6px}.flexa-inspector .components-input-control__container,.flexa-inspector .components-input-control__suffix,.flexa-inspector .components-unit-control{overflow:visible}.flexa-inspector .components-input-control__suffix,.flexa-inspector .components-unit-control select,.flexa-inspector .components-unit-control__select{align-items:center;display:flex;height:auto;line-height:1.4!important;min-height:28px}.flexa-dualcolor__head{align-items:center;display:flex;justify-content:space-between}.flexa-dualcolor__tools{align-items:center;display:flex;gap:10px}.flexa-dualcolor__reset{align-items:center;background:none;border:0;color:#2563eb;cursor:pointer;display:inline-flex;justify-content:center;padding:0}.flexa-dualcolor__reset svg{fill:none;height:15px;stroke:currentcolor;width:15px}.flexa-dualcolor__reset:hover{color:#1d4ed8}.flexa-gradient .flexa-dualcolor__head{margin-bottom:10px}.flexa-bg-preview{background:#f6f7f7;border:1px solid #e0e0e0;border-radius:6px;cursor:pointer;display:block;height:120px;margin-bottom:10px;overflow:hidden;padding:0;width:100%}.flexa-bg-preview img{display:block;height:100%;object-fit:cover;width:100%}.flexa-bg-preview:hover{border-color:#2563eb}.flexa-swatch__btn{border:1px solid rgba(0,0,0,.12);border-radius:50%;box-shadow:inset 0 0 0 2px #fff,0 1px 2px rgba(0,0,0,.12);cursor:pointer;height:28px;overflow:hidden;padding:0;position:relative;transition:transform .1s ease,box-shadow .1s ease;width:28px}.flexa-swatch__btn:hover{transform:scale(1.08)}.flexa-swatch__btn:focus-visible{outline:2px solid #2563eb;outline-offset:1px}.flexa-swatch__empty{background:linear-gradient(to top right,transparent calc(50% - 1px),#e02929 calc(50% - 1px),#e02929 calc(50% + 1px),transparent calc(50% + 1px));inset:0;position:absolute}.flexa-swatch__content{padding:12px}.flexa-iconpicker__row{align-items:center;display:flex;gap:8px}.flexa-iconpicker__toggle.components-button{align-items:center;display:inline-flex;gap:8px;height:34px}.flexa-iconpicker__preview{align-items:center;color:#1e1e1e;display:inline-flex;font-size:18px;height:18px;justify-content:center;width:18px}.flexa-iconpicker__preview img,.flexa-iconpicker__preview svg{height:18px;width:18px}.flexa-iconpicker__preview.is-empty{border:1px dashed #e0e0e0;border-radius:4px}.flexa-iconpicker__panel{padding:6px 10px 10px;width:288px}.flexa-iconpicker__search{margin-bottom:4px;position:sticky;top:0;z-index:1}.flexa-iconpicker__grid{display:grid;gap:4px;grid-template-columns:repeat(6,1fr);max-height:220px;overflow-y:auto;padding-top:8px}.flexa-iconpicker__empty{color:#6d7882;grid-column:1/-1;margin:8px 0;text-align:center}.flexa-iconpicker__btn{align-items:center;background:#fff;border:1px solid #e0e0e0;border-radius:6px;color:#1e1e1e;cursor:pointer;display:inline-flex;height:34px;justify-content:center;padding:0;transition:border-color .12s ease,box-shadow .12s ease,color .12s ease;width:100%}.flexa-iconpicker__btn svg{height:20px;width:20px}.flexa-iconpicker__btn.is-active,.flexa-iconpicker__btn:hover{border-color:#2563eb;color:#2563eb}.flexa-iconpicker__btn.is-active{box-shadow:0 0 0 1px #2563eb}.flexa-iconpicker__upload{padding:12px 2px 2px}.flexa-iconpicker__hint{color:#6d7882;font-size:11px;margin:8px 0 0}.block-editor-list-view-leaf.is-selected .block-editor-block-icon,.block-editor-list-view-leaf.is-selected .block-editor-block-icon svg{color:#fff!important;fill:currentcolor}.flexa-skel{background:#dfe3e8;border-radius:5px;display:block;overflow:hidden;position:relative}.flexa-skel:after{animation:flexa-skeleton-shimmer 1.5s ease-in-out infinite;background:linear-gradient(-100deg,hsla(0,0%,100%,0) 30%,hsla(0,0%,100%,.55) 50%,hsla(0,0%,100%,0) 70%);background-repeat:no-repeat;background-size:200% 100%;content:"";inset:0;position:absolute}.flexa-skel--circle{border-radius:50%}.flexa-skel--box{border-radius:8px}@keyframes flexa-skeleton-shimmer{0%{background-position:-50% 0}to{background-position:150% 0}}.flexa-item-card{border:1px solid #e0e0e0;border-radius:6px;margin-bottom:8px;padding:8px 10px}.flexa-item-card__head{align-items:center;display:flex;gap:8px;justify-content:space-between}.flexa-item-card__toggle{align-items:center;background:none;border:0;cursor:pointer;display:flex;flex:1 1 auto;gap:8px;min-width:0;padding:2px 0;text-align:right}.flexa-item-card__preview{align-items:center;display:inline-flex;flex:0 0 auto;height:20px;justify-content:center;width:20px}.flexa-item-card__preview img,.flexa-item-card__preview svg{height:100%;width:100%}.flexa-item-card__preview.is-empty{border:1px dashed silver;border-radius:4px}.flexa-item-card__title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.flexa-item-card__tools{display:inline-flex;flex:0 0 auto;gap:2px}.flexa-item-card__body{margin-top:10px}.flexa-item-card__body .components-base-control,.flexa-item-card__body .flexa-field,.flexa-item-card__body .flexa-iconpicker{margin-bottom:12px}.flexa-item-card__add{justify-content:center;width:100%} High-Volatility Slots: Managing Free-Spin Bankroll Swings
Headlines

High-Volatility Slots: Managing Free-Spin Bankroll Swings

High-Volatility Slots Managing Free-Spin Bankroll Swings

High-volatility slots can deliver dramatic wins, but they can also produce sharp bankroll swings. When free spins enter the equation, understanding high-volatility slots becomes even more important because promotional wagering can increase the amount you need to play.

A free-spin promotion may look attractive at first glance. However, the headline number rarely tells the complete story. You need to consider the game’s volatility, the value of the spins, the wagering requirement, and any restrictions attached to the promotion.

Players comparing promotions can also review current industry examples such as Betya casino offers to understand how promotional offers can differ in structure. The important point is to assess the terms rather than simply chasing the biggest advertised reward.

This matters because high volatility does not automatically mean higher returns. Instead, it describes how frequently a game tends to pay and how large those potential payouts may be. Consequently, a suitable bankroll and clear session limits are essential.

What Makes High-Volatility Slots Different?

Volatility describes the distribution of a slot’s potential results over time. A high-volatility game may produce fewer winning spins, but individual wins can potentially be larger.

By contrast, lower-volatility games generally offer more frequent smaller wins. Neither approach guarantees a particular result. Random outcomes still determine every spin.

With high-volatility slots, losing streaks can therefore feel more noticeable. A player might experience several consecutive spins without a meaningful return before hitting a larger combination.

That pattern becomes especially relevant during promotional play. Free spins can extend gameplay, but they do not remove the underlying variance of the slot.

Volatility Is Not the Same as RTP

RTP and volatility measure different characteristics.

RTP represents the theoretical percentage a game is designed to return over a very large number of plays. Volatility describes how those returns can be distributed across individual sessions.

For example, two slots can have similar theoretical RTP figures while producing very different playing experiences. One may generate frequent modest wins, while another may deliver longer dry periods followed by larger payouts.

Therefore, players should avoid using RTP alone when evaluating high-volatility slots. A high theoretical RTP does not guarantee a smooth short-term session.

Why Free Spins Change the Equation

Free spins can reduce the immediate cost of entering a promotion, but they often come with conditions.

For example, a casino might award a fixed number of spins on a qualifying slot. The resulting winnings may then need to satisfy a wagering requirement before becoming fully withdrawable.

This creates an important distinction between free spins and withdrawable cash.

Suppose a promotion awards $10 worth of free spins. If those spins generate $35, the entire $35 may not necessarily be available for immediate withdrawal. The promotion could require the winnings to be wagered multiple times.

Consequently, players should check the complete promotional terms before starting.

Check Free-Spin Wagering Requirements First

The wagering requirement is one of the most important details in any free-spin promotion.

Consider a hypothetical promotion that generates $40 from free spins and applies a 20x wagering requirement. If the entire $40 qualifies, the required turnover would be:

$40 × 20 = $800

That does not mean you will necessarily lose $800. It means the promotion requires $800 in qualifying wagers before the relevant condition is completed.

Now add a high-volatility slot.

A game with larger bankroll swings can make the journey toward that turnover less predictable. A player may experience substantial fluctuations before completing the required amount.

Therefore, always calculate the potential turnover before accepting the promotion.

Look for Different Contribution Rules

Not every game necessarily contributes equally toward wagering requirements.

Some promotions may allow selected slots to contribute fully. Others can impose different contribution percentages or exclude certain titles altogether.

This distinction can significantly affect the practical value of free spins.

If a game contributes 100%, every qualifying dollar wagered may count toward the requirement. If the contribution is lower, you may need substantially more total betting activity to achieve the same qualifying turnover.

Consequently, checking eligible games should happen before you begin playing.

Managing Larger Bankroll Swings

High-volatility slots require a different mindset from games that produce frequent small wins.

The biggest mistake is often increasing the stake after a losing streak. A player may assume a large payout is “due” because several spins have produced little.

However, previous results do not determine the next random outcome.

Instead, establish a maximum promotional bankroll before playing. Treat that amount as money allocated to entertainment rather than funds that must be recovered.

For example, you could divide your available promotional bankroll into several smaller sessions. This approach makes it easier to stop when a predefined limit is reached.

Use Session Limits Before You Start

Session limits work best when you establish them before opening the game.

Set three basic boundaries:

  • Time limit: Decide how long the session will last.
  • Loss limit: Establish the maximum amount you are prepared to lose.
  • Stake limit: Set a maximum amount per spin.

These limits reduce emotional decision-making.

Moreover, they prevent a promotional deadline from becoming a reason to continue playing beyond your planned budget.

A free-spin offer should not change your personal financial limits.

How Stake Size Affects Volatility

The mathematical volatility of a slot does not change because you increase your stake. However, the size of your financial swings does.

Imagine a high-volatility slot with a $0.50 stake. A run of 30 spins without a significant win costs $15.

At a $2 stake, the same sequence costs $60.

The game’s underlying mechanics have not changed. Your exposure has.

Therefore, players pursuing potentially larger payouts should avoid confusing larger stakes with better value. Increasing the stake simply increases the financial impact of both wins and losses.

This becomes particularly important when promotional wagering creates pressure to finish a requirement.

High-Volatility Slots and Promotional Pressure

Promotions can create a psychological sense of urgency.

An expiry timer might encourage you to complete wagering quickly. Likewise, a large advertised prize can make a player feel that stopping means missing an opportunity.

However, neither factor changes the probability of the next spin.

If a promotion requires substantial wagering, calculate the remaining turnover and compare it with your original session plan.

For instance, if you have already reached your predetermined loss limit, continuing simply because a free-spin reward remains available may undermine the purpose of setting that limit.

The promotion should fit your playing plan. Your playing plan should not be rebuilt around the promotion.

Compare Free Spins by Their Real Value

The number of free spins can be misleading.

Ten free spins on a high-volatility slot are not necessarily more valuable than 50 spins on another game. The stake size, qualifying game, maximum win, wagering rules, and volatility all influence the practical value.

A useful comparison should include:

Factor Why It Matters
Number of spins Shows the headline promotional value
Spin value Determines the initial reward size
Volatility Indicates potential bankroll swings
RTP Provides long-term theoretical return information
Wagering requirement Determines required qualifying turnover
Game contribution Shows how much play counts
Expiry period Determines how long you have to complete conditions
Maximum win Can limit promotional returns
Maximum bet May restrict your staking options

Looking at these factors together gives you a much clearer picture.

A Practical Example

Suppose a promotion provides 30 free spins at $0.50 each.

The nominal spin value is:

30 × $0.50 = $15

Now imagine those spins generate $24.

The promotional terms impose a 15x wagering requirement on the resulting winnings.

That creates:

$24 × 15 = $360

in required qualifying turnover.

At this point, volatility becomes important.

A high-volatility slot could produce significant fluctuations while you complete that turnover. You might move above $40 before falling below $20 again, for example.

That is why the original $15 free-spin value should not be treated as guaranteed cash.

Choose Eligible Games Carefully

Promotional restrictions can determine which high-volatility slots are actually suitable.

Some offers limit free spins to specific games. Others allow winnings to be generated across a broader collection of titles.

Before playing, check whether the chosen slot:

  • qualifies for the promotion;
  • contributes toward wagering;
  • has a maximum bet restriction;
  • carries a maximum promotional payout;
  • limits bonus-feature purchases;
  • has special free-spin conditions.

These details can make a major difference.

Furthermore, avoid switching games simply because the current slot has produced several losses. Changing titles does not reset randomness or improve the mathematical outcome of future spins.

Bankroll Planning for High-Volatility Slots

A practical bankroll plan should account for variance rather than assuming consistent results.

One approach is to determine how many losing spins your planned stake can withstand.

For example, if your session budget is $100 and your chosen stake is $1, you have a theoretical 100-spin starting cushion before considering any returns.

At $5 per spin, the same budget covers only 20 spins.

That does not predict how long the session will last. It simply demonstrates how quickly stake size can affect exposure.

Consequently, players interested in high-volatility slots should generally focus on sustainable stakes rather than chasing the game’s largest advertised win.

Know When to Walk Away

Winning can also create risky behaviour.

A large payout may tempt you to increase stakes because the balance appears comfortably above your starting amount. However, a promotional session can reverse quickly.

Set a profit target if that helps you maintain discipline. More importantly, decide beforehand what happens when you reach it.

You might withdraw eligible winnings, end the session, or reduce exposure.

Similarly, respect your loss limit without trying to recover the difference through additional spins.

Responsible gambling is not about predicting when a slot will pay. It is about controlling how much money and time you expose to random outcomes.

The 2026 Approach to Game-Specific Promotions

Casino promotions are increasingly differentiated by game type, mechanics, and player preferences. That makes it more important to read the specific terms attached to individual offers.

Instead of asking, “How big is the bonus?” ask several better questions.

Which games qualify?

How volatile are those games?

How much wagering is required?

How long does the promotion last?

Are there maximum win or withdrawal restrictions?

What stake limits apply?

This approach turns a promotional headline into a measurable decision.

Ultimately, high-volatility slots can provide an exciting experience, especially when free spins introduce additional gameplay. However, the larger potential payouts come with greater short-term uncertainty.

The smartest approach is therefore not to chase volatility. It is to understand it.

Final Takeaway: Control the Swings

High-volatility slots can create some of the biggest bankroll swings in online casino play. Free spins may provide additional opportunities, but promotional conditions can also introduce wagering requirements and time pressure.

Before playing, calculate the potential turnover. Check the eligible games and contribution rules. Then establish your session, stake, and loss limits.

Most importantly, never assume that a losing streak means a win is coming.

A disciplined player treats free spins as a promotion rather than a promise. By understanding volatility, reading the terms, and controlling bankroll exposure, you can make more informed decisions while keeping promotional play within sensible limits.