/** * Editor lifecycle - open/close/cleanup/restore for all editor types * * Reads (via globals): * SFE.Context - .activeEditor (r/w), .activeMode (r/w), * .draftEditState (r/w), .actionBar, * .buttonManager, .hoverTracker * SFE.ElementState - .ElementState * SFE.FocusManager - .createFocusManager * SFE.PositionManager - .positionFloatingElements * SFE.OverlayManager * SFE.TIMING * SFE.LifecycleHelpers - .setupDraftPreviewLifecycle * SFE.attachActionBarToElement - set by frontend-inline-edit.js (via HoverManager) * SFE.closeDraftPreview - set by frontend-inline-edit.js (via DraftManager) * * Exposes: * SFE.EditorLifecycle * { closeInPlaceEditor, cleanupEditorResources, restoreElementState, * restoreElementContent, resetActionBar, rebindChildren } * SFE.closeInPlaceEditor - direct alias for cross-module access * SFE.restoreElementContent - direct alias for cross-module access * SFE.rebindChildren - direct alias for cross-module access */ (function() { 'use strict'; window.MWP = window.MWP || {}; window.MWP.SFE = window.MWP.SFE || {}; const SFE = window.MWP.SFE; SFE.ManagerData = SFE.ManagerData || {}; /** * Recursively bind all nested editable elements found within a container. */ function rebindChildren(container) { const children = container.querySelectorAll('[data-mwp-sfe-uuid]'); children.forEach(child => { if (child !== container) { SFE.attachActionBarToElement(child); } }); } /** * Restore element content on cancel. * * Text container (Details): restores only the heading child, not the whole block. * The container root stays in place, preserving visible inner blocks. * * All other blocks: replaces the entire element in DOM. */ function restoreElementContent(element, editorState) { const restoreSnapshot = ( typeof editorState?.originalRestoreOuterHTML === 'string' && editorState.originalRestoreOuterHTML.trim() ) ? editorState.originalRestoreOuterHTML : editorState.originalOuterHTML; if (Array.isArray(editorState.editableComponents) && editorState.editableComponents.length) { // For schema multi-component blocks (tables, pullquotes, etc.), restore the // entire root element atomically. Restoring individual components can leave // malformed DOM behind when browser contenteditable normalization detaches or // re-parents table cells during editing. if (element.parentNode) { const temp = document.createElement('div'); temp.innerHTML = restoreSnapshot; const restoredElement = temp.firstElementChild; if (!restoredElement) { console.error('FrontEdit: Failed to restore multi-component root'); return element; } element.parentNode.replaceChild(restoredElement, element); editorState.editableComponents.forEach(component => { if (!component || typeof component.selector !== 'string') return; const selector = component.selector.trim(); if (!selector) return; try { if (restoredElement.matches(selector)) { component.element = restoredElement; return; } const restoredComponent = restoredElement.querySelector(selector); if (restoredComponent) { component.element = restoredComponent; } } catch (error) { // Ignore invalid selectors and keep current references. } }); return restoredElement; } editorState.editableComponents.forEach(component => { if (!component || !component.element) return; if (!component.element.parentNode) return; const componentRestoreSnapshot = ( typeof component.originalRestoreOuterHTML === 'string' && component.originalRestoreOuterHTML.trim() ) ? component.originalRestoreOuterHTML : component.originalOuterHTML; if (!componentRestoreSnapshot) return; const temp = document.createElement('div'); temp.innerHTML = componentRestoreSnapshot; const restoredComponent = temp.firstElementChild; if (!restoredComponent) return; component.element.parentNode.replaceChild(restoredComponent, component.element); component.element = restoredComponent; }); if (editorState.activeEditableComponent) { const activeId = editorState.activeEditableComponent.id; const restoredActive = editorState.editableComponents.find(component => component.id === activeId); if (restoredActive) { editorState.activeEditableComponent = restoredActive; } } return element; // block root is unchanged } if (editorState.headingElement && editorState.originalHeadingOuterHTML) { const temp = document.createElement('div'); temp.innerHTML = editorState.originalHeadingOuterHTML; const restoredHeading = temp.firstElementChild; if (restoredHeading && editorState.headingElement.parentNode) { editorState.headingElement.parentNode.replaceChild(restoredHeading, editorState.headingElement); editorState.headingElement = restoredHeading; } return element; // container root is unchanged } const temp = document.createElement('div'); temp.innerHTML = restoreSnapshot; const restoredElement = temp.firstElementChild; if (!restoredElement) { console.error('FrontEdit: Failed to restore element from originalOuterHTML'); return element; } if (!element || !element.parentNode) { console.warn('FrontEdit: Skipped restore because the editor element is no longer attached.'); return element; } element.parentNode.replaceChild(restoredElement, element); return restoredElement; } /** * Remove transient editor-only attributes and references from one element. * * @param {HTMLElement|null} element Element to normalize. * @param {Object|null} editorInstance Active editor instance reference. * @returns {void} */ function clearTransientEditingState(element, editorInstance = null) { if (!element || element.nodeType !== Node.ELEMENT_NODE) { return; } element.removeAttribute('contenteditable'); element.removeAttribute('spellcheck'); if (editorInstance && element._mwpEditor === editorInstance) { delete element._mwpEditor; } } /** * Cleanup all editor resources (observers, listeners, managers). * * @param {Object} editorState Active editor state being torn down. * @param {Object} options Cleanup options. * @returns {void} */ function cleanupEditorResources(editorState, options = {}) { const overlayManager = SFE.OverlayManager; const schemaRuntime = SFE.SchemaRuntime || null; const schemaEditorHost = SFE.SchemaEditorHost || null; const elementPrep = SFE.ElementPrep || null; const TIMING = SFE.TIMING; const { ElementState } = SFE.ElementState; const textEditorHost = schemaEditorHost?.resolveTextEditorHost?.(editorState) || null; // Clear global reference FIRST if (SFE.activeEditorInstance === editorState) { SFE.activeEditorInstance = null; } // Cleanup managers if (editorState.cleanupFocus) editorState.cleanupFocus(); // Cleanup observers and listeners if (editorState.resizeObserver) editorState.resizeObserver.disconnect(); if (editorState.escapeHandler) document.removeEventListener('keydown', editorState.escapeHandler); if (editorState.updatePositions) { window.removeEventListener('scroll', editorState.updatePositions, true); window.removeEventListener('resize', editorState.updatePositions); } // Remove editing class from element and action bar if (editorState.element) { editorState.element.classList.remove('mwp-sfe-inline-editor'); clearTransientEditingState(editorState.element, textEditorHost); } const switchTarget = editorState._mwpComponentSwitchTarget || editorState.element || null; if (editorState.componentSwitchHandler && switchTarget) { switchTarget.removeEventListener('mousedown', editorState.componentSwitchHandler, true); delete editorState.componentSwitchHandler; } if (editorState.componentClickGuard && switchTarget) { switchTarget.removeEventListener('click', editorState.componentClickGuard, true); delete editorState.componentClickGuard; } if (editorState.componentTabHandler && switchTarget) { switchTarget.removeEventListener('keydown', editorState.componentTabHandler, true); delete editorState.componentTabHandler; } delete editorState._mwpComponentSwitchTarget; if (editorState._mwpSchemaMediaSession && typeof editorState._mwpSchemaMediaSession.cleanup === 'function') { editorState._mwpSchemaMediaSession.cleanup({ preserveChanges: true, preserveToolbarDom: true, }); delete editorState._mwpSchemaMediaSession; } if (Array.isArray(editorState.editableComponents)) { editorState.editableComponents.forEach(component => { if (!component || !component.element) return; 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'); clearTransientEditingState(component.element, textEditorHost); if ( schemaRuntime && typeof schemaRuntime.syncManagedMissingComponentStateForElement === 'function' && component.type === 'text' ) { // Final teardown pass: prune empty optional missingUI shells now that // edit-mode classes/attributes have been removed from all components. schemaRuntime.syncManagedMissingComponentStateForElement(component.element, { id: typeof component.id === 'string' ? component.id.trim() : '', type: 'text', missingUI: component?.missingUI && typeof component.missingUI === 'object' ? component.missingUI : null, }, { removeInactiveEmpty: true, forceRemoveInactive: true, } ); } }); } if (editorState.headingElement) { editorState.headingElement.classList.remove('mwp-sfe-inline-editor'); clearTransientEditingState(editorState.headingElement, textEditorHost); } if (editorState.actionsContainer) { editorState.actionsContainer.classList.remove('mwp-sfe-inline-editor'); } // Remove toolbar with animation if (editorState.toolbarContainer) { const toolbar = editorState.toolbarContainer; const onCloseDone = () => { toolbar.remove(); }; toolbar.addEventListener('transitionend', onCloseDone, { once: true }); toolbar.classList.add('mwp-sfe-closing'); // Failsafe: force removal if transitionend doesn't fire // (e.g. element hidden, tab inactive) after slightly longer than CSS duration setTimeout(() => { if (toolbar && toolbar.nodeType === Node.ELEMENT_NODE && document.body.contains(toolbar)) { onCloseDone(); } }, TIMING.SAFETY_DURATION); } // Remove preview overlay if present if (editorState.previewOverlay) editorState.previewOverlay.remove(); // Destroy MWPEditor to release internal references (event listeners, history, etc.). // Keep the toolbar DOM mounted until the toolbarContainer close animation finishes; // ToolbarManager listeners are still torn down immediately. if (textEditorHost && typeof textEditorHost.destroy === 'function') { textEditorHost.destroy({ removeToolbar: false }); } delete editorState.activeSchemaHost; delete editorState.textEditorHost; if (!options.skipEnableAll) { ElementState.enableAllElements(); } } /** * Resolves the DOM element for the live editor state. */ function resolveLiveEditorElement(editorState) { if (!editorState) return null; if (editorState.element && editorState.element.parentNode) { return editorState.element; } const uuid = String(editorState.uuid || '').trim(); if (!uuid) { return editorState.element || null; } try { return document.querySelector(`[data-mwp-sfe-uuid="${CSS.escape(uuid)}"]`) || editorState.element || null; } catch (error) { return document.querySelector(`[data-mwp-sfe-uuid="${uuid.replace(/"/g, '\\"')}"]`) || editorState.element || null; } } /** * Restore element to its original or current state. * Returns the final element (might be a new one if the tag changed). */ function restoreElementState(editorState, restoreOriginal) { const { ElementState } = SFE.ElementState; const elementPrep = SFE.ElementPrep || null; let el = resolveLiveEditorElement(editorState); if (el && el !== editorState.element) { editorState.element = el; } if (restoreOriginal) { el = restoreElementContent(el, editorState); } else { // Not restoring content (user kept their changes). Remove only // editor-specific utility classes, but preserve content classes // that the editor may have added - most importantly text alignment // classes like `has-text-align-center` set by MWPEditor.changeTextAlignment(). // Resetting to originalClasses here would visually revert the // text alignment change even though it IS being saved to the block state. const editorOnlyClasses = [ 'mwp-sfe-inline-editor', 'mwp-sfe-editor-content', 'mwp-sfe-editing-active' ]; editorOnlyClasses.forEach(cls => el.classList.remove(cls)); if (elementPrep && typeof elementPrep.pruneEmptyClassAttribute === 'function') { elementPrep.pruneEmptyClassAttribute(el); } if (editorState.originalStyles) { el.setAttribute('style', editorState.originalStyles); } else { el.removeAttribute('style'); } } if (el && el.classList) { // ABE's staged pulse is only for the initial handoff preview. Never // carry it forward into cancel/close restore flows. el.classList.remove('mwp-abe-staged-block-pulse'); } ElementState.markInactive(el); return el; } /** * Reset action bar to hover state and reattach to element. * * attachActionBarToElement is called synchronously before actionBar.reset() * so that data-mwp-sfe-bound is stamped by the time _resetActionBar runs * its elementsFromPoint check. */ function resetActionBar(editorState, restoredElement) { const overlayManager = SFE.OverlayManager; const actionBar = SFE.Context.actionBar; if (!editorState.actionsContainer) return; const bar = editorState.actionsContainer; delete bar._saveBtn; delete bar._cancelBtn; actionBar.activeElement = restoredElement; bar._targetElement = restoredElement; // Stamp data-mwp-sfe-bound and reattach event listeners synchronously so // _resetActionBar's elementsFromPoint check finds the element as editable. // // Always rebind nested children too. A history undo/redo may have replaced // the live block subtree earlier in the session without a subsequent close- // time root replacement, which leaves innerBlocks on fresh DOM nodes with // no hover/action-bar listeners until they are explicitly rebound here. try { SFE.attachActionBarToElement(restoredElement); if (restoredElement) { rebindChildren(restoredElement); } } catch (err) { console.error('FrontEdit: failed to reattach action bar after close', err); } actionBar.reset(bar, restoredElement, editorState.uuid, () => {}); if (overlayManager) { requestAnimationFrame(() => overlayManager.updateAllOverlays()); } } /** * Close the in-place editor and restore element state. */ function closeInPlaceEditor(editorState, restoreOriginal, options = {}) { const ctx = SFE.Context; const overlayManager = SFE.OverlayManager; const { setupDraftPreviewLifecycle } = SFE.LifecycleHelpers; const schemaEditorHost = SFE.SchemaEditorHost || null; const actionBar = ctx.actionBar; const buttonManager = ctx.buttonManager; const closeSource = String(options.source || 'sfe').trim() || 'sfe'; const closeReason = String(options.closeReason || options.reason || 'close').trim() || 'close'; const shouldPersistBlockSession = !restoreOriginal && (closeReason === 'switch' || closeReason === 'outside-click'); const shouldRevertPersistedBlockSession = restoreOriginal; if (!editorState) return; const batchManager = SFE.BatchEditManager || null; if ( batchManager && typeof batchManager.canCloseEditor === 'function' && batchManager.canCloseEditor(editorState, restoreOriginal, closeReason) === false ) { return false; } if (SFE.PublicApiBridge) { SFE.PublicApiBridge.emitEditorEvent('editor:beforeClose', editorState, { source: closeSource, reason: closeReason, }); } if (batchManager && typeof batchManager.onEditorClose === 'function') { try { batchManager.onEditorClose(editorState, restoreOriginal); } catch (error) { console.warn('FrontEdit: batch close hook failed', error); } } if (shouldPersistBlockSession) { SFE.detachPersistedBlockEditSessionEditor?.(editorState); } else if (shouldRevertPersistedBlockSession) { const didRevertToSessionOrigin = SFE.revertPersistedBlockEditSessionToOpenPass?.(editorState.uuid) !== false; if (didRevertToSessionOrigin) { SFE.destroyPersistedBlockEditSession?.(editorState.uuid); } else { SFE.detachPersistedBlockEditSessionEditor?.(editorState); } } else { SFE.destroyPersistedBlockEditSession?.(editorState.uuid); } // Clean up stored attributes to prevent memory leaks delete editorState.originalAttributes; delete editorState.blockName; delete editorState.originalHTML; // Clear any pending success message timeouts if (editorState._successTimeoutId) { clearTimeout(editorState._successTimeoutId); delete editorState._successTimeoutId; } // ── Draft-cancel path ──────────────────────────────────────────────── // Canceling an edit that was opened from a draft preview: skip full // re-enable of other elements and return to the draft preview state instead. if (restoreOriginal && ctx.draftEditState && ctx.draftEditState.draftElement === editorState.element) { const { bar, handlers, draftElement } = ctx.draftEditState; const uuid = editorState.uuid; const textEditorHost = schemaEditorHost?.resolveTextEditorHost?.(editorState) || null; // Minimal resource cleanup only - do NOT call ElementState.enableAllElements if (editorState.cleanupFocus) editorState.cleanupFocus(); if (editorState.resizeObserver) editorState.resizeObserver.disconnect(); if (editorState.escapeHandler) document.removeEventListener('keydown', editorState.escapeHandler); if (editorState.updatePositions) { window.removeEventListener('scroll', editorState.updatePositions, true); window.removeEventListener('resize', editorState.updatePositions); } const switchTarget = editorState._mwpComponentSwitchTarget || editorState.element || null; if (editorState.componentSwitchHandler && switchTarget) { switchTarget.removeEventListener('mousedown', editorState.componentSwitchHandler, true); delete editorState.componentSwitchHandler; } if (editorState.componentClickGuard && switchTarget) { switchTarget.removeEventListener('click', editorState.componentClickGuard, true); delete editorState.componentClickGuard; } if (editorState.componentTabHandler && switchTarget) { switchTarget.removeEventListener('keydown', editorState.componentTabHandler, true); delete editorState.componentTabHandler; } delete editorState._mwpComponentSwitchTarget; if (editorState.toolbarContainer) editorState.toolbarContainer.remove(); if (SFE.activeEditorInstance === editorState) SFE.activeEditorInstance = null; // Remove only editor-specific classes, leave draft classes alone draftElement.classList.remove( 'mwp-sfe-inline-editor', 'mwp-sfe-editing-active', 'mwp-sfe-editor-content' ); clearTransientEditingState(draftElement, textEditorHost); // Restore draft preview visual state draftElement.classList.add('mwp-sfe-draft-active'); if (overlayManager) overlayManager.showActive(draftElement, 'draft-preview'); // Clean up action bar save/cancel buttons bar.classList.remove('mwp-sfe-state-hover', 'mwp-sfe-inline-editor'); delete bar._saveBtn; delete bar._cancelBtn; // We are no longer in the live draft editor session. Returning to draft // preview should restore preview-mode lifecycle ownership and release the // edit-session flag used by click/close guards. ctx.draftEditState = null; ctx.activeEditor = null; ctx.activeMode = 'draft'; // Restore preview buttons actionBar.updateState({ bar, element: draftElement, state: 'preview-loaded', content: buttonManager.getDraftPreviewButtons( bar, draftElement, handlers, uuid ) }); // Re-attach full draft preview lifecycle (focus, escape, scroll, resize) setupDraftPreviewLifecycle(bar, draftElement, handlers, uuid); if (SFE.PublicApiBridge) { SFE.PublicApiBridge.emitEditorEvent('editor:closed', editorState, { source: closeSource, reason: closeReason, }); } return; } // ── Normal exit - full cleanup ─────────────────────────────────────── // Clear draft state if exiting normally if (ctx.draftEditState && ctx.draftEditState.draftElement === editorState.element) { ctx.draftEditState = null; } cleanupEditorResources(editorState, options); const restoredElement = restoreElementState(editorState, restoreOriginal); ctx.activeEditor = null; resetActionBar(editorState, restoredElement); // Sever all DOM node references to allow garbage collection Object.keys(editorState).forEach(key => { if (editorState[key] instanceof HTMLElement || editorState[key] instanceof Node) { editorState[key] = null; } }); // Ensure hoverTracker is not holding onto detached elements if (ctx.hoverTracker && ctx.hoverTracker.lastHoveredElements) { ctx.hoverTracker.lastHoveredElements = ctx.hoverTracker.lastHoveredElements.filter(el => document.body.contains(el)); } if (SFE.PublicApiBridge) { SFE.PublicApiBridge.emitEditorEvent('editor:closed', editorState, { source: closeSource, reason: closeReason, }); } return true; } // Place functions on window for cross-module access SFE.rebindChildren = rebindChildren; SFE.restoreElementContent = restoreElementContent; SFE.closeInPlaceEditor = closeInPlaceEditor; SFE.EditorLifecycle = { closeInPlaceEditor, cleanupEditorResources, restoreElementState, restoreElementContent, resetActionBar, rebindChildren }; })();