/** * WordPress dependencies */ import type { select as Select, dispatch as Dispatch, subscribe as Subscribe, } from '@safe-wordpress/data'; /* eslint-disable */ const parent = window.parent as any; const select = parent.wp.data.select as typeof Select; const dispatch = parent.wp.data.dispatch as typeof Dispatch; const subscribe = parent.wp.data.subscribe as typeof Subscribe; /* eslint-enable */ /** * External dependencies */ import debounce from 'lodash/debounce'; import { CssRuleAST, CssStylesheetAST, CssTypes, parse, } from '@adobe/css-tools'; import type { Alternative, AlternativeId, CssActiveElement, CssEditorState, CssSelectorFinderState, CssWorkingContentValue, Maybe, } from '@nab/types'; // NOTE. No @nab packages in front. import type { store as dataStore } from '@nab/data'; const NAB_DATA = 'nab/data' as unknown as typeof dataStore; // NOTE. No @nab packages in front. import type { store as editorStore } from '@nab/editor'; const NAB_EDITOR = 'nab/editor' as unknown as typeof editorStore; /** * Internal dependencies */ import { appendFallbackRule, containsRule, stringifyAst, } from '../../utils/ast'; import { getProps } from './get-props'; import { isTag } from './is-tag'; import { isContentEditableSelector } from '../../../../../../../assets/src/admin/scripts/css-selector-finder/editable-content'; import type { AlternativeAttributes } from '../../../../../../../packages/experiment-library/css/types'; export function syncStatesEffect(): void { onEditorChanged( [ 'mode' ], updateVisualEditorDetails ); onEditorChanged( [ 'mode', 'activeSelector' ], updateContentValues ); onEditorChanged( [ 'mode', 'activeSelector' ], updateCssSelectorFinder ); onEditorChanged( [ 'contentValues' ], updateAlternativeContentValues ); onCssSelectorFinderChanged( [ 'value' ], updateEditorOnCssSelectorChanged ); } // ======= // HELPERS // ======= function updateCssSelectorFinder( editor: CssEditorState ): void { const finder = getCssSelectorFinderState(); if ( ! finder ) { return; } const expectedInteractionMode = ( (): CssSelectorFinderState[ 'interactionMode' ] => { if ( 'navigation' === finder.interactionMode ) { return 'navigation'; } if ( 'content-editor' === editor.mode ) { return 'editable-content-inspector'; } return finder.interactionMode === 'editable-content-inspector' ? 'single-inspector' : finder.interactionMode; } )(); if ( finder.value === editor.activeSelector && finder.interactionMode === expectedInteractionMode ) { return; } void dispatch( NAB_DATA ).setPageAttribute( 'css-selector/cssSelectorFinderState', { ...finder, value: editor.activeSelector, interactionMode: expectedInteractionMode, } ); } function updateEditorOnCssSelectorChanged( finder: CssSelectorFinderState ): void { const editor = getEditorState(); if ( ! editor ) { return; } if ( finder.value === editor.activeSelector && editor.activeSelector === editor.activeElement?.selector ) { return; } if ( editor.mode === 'css-editor' && editor.isEditorActive ) { return; } const originalAst = getOriginalAst( editor.alternativeId ); if ( ! originalAst ) { return; } const matches = querySelectorAll( finder.value ); const match = matches[ 0 ]; const activeSelector = 'editable-content-inspector' === finder.interactionMode ? finder.value : findExistingSelector( editor, originalAst, finder.value ) ?? finder.value; const activeElement: Maybe< CssActiveElement > = activeSelector && matches.length ? { selector: activeSelector, index: matches.findIndex( ( e ) => e.classList.contains( 'nab-selected' ) ), matches: matches.length, html: match?.innerHTML ?? '', } : undefined; const contentValues = maybeAddActiveElement( editor.contentValues, activeElement ); const shouldAppendFallbackRule = !! activeElement && ( 'visual-editor' === editor.mode || ! containsRule( originalAst, activeElement.selector ) ); const ast = shouldAppendFallbackRule ? appendFallbackRule( originalAst, activeElement.selector ) : originalAst; if ( ast !== originalAst ) { setAttributes( editor.alternativeId, { css: stringifyAst( ast ), } ); } switch ( editor.mode ) { case 'css-editor': return setEditorState( { ...editor, contentValues, activeSelector, activeElement, } ); case 'visual-editor': const { props, propsRequiringImportant } = getProps( ast, activeElement ); return setEditorState( { ...editor, activeSelector, activeElement, contentValues, details: { ast, props, propsRequiringImportant, }, } ); case 'content-editor': return setEditorState( { ...editor, contentValues, activeSelector, activeElement, } ); } } function updateVisualEditorDetails( editor: CssEditorState ): void { const ast = getOriginalAst( editor.alternativeId ); if ( ! ast ) { return; } if ( editor.mode === 'visual-editor' && editor.activeElement ) { const { props, propsRequiringImportant } = getProps( ast, editor.activeElement ); void setEditorState( { ...editor, details: { ast, props, propsRequiringImportant, }, } ); } } function updateContentValues( editor: CssEditorState ) { const activeContentValue = editor.contentValues.find( ( cv ) => cv.selector === editor.activeSelector ); const areOthersValid = editor.contentValues.every( ( cv ) => cv === activeContentValue || isContentValueValid( cv ) ); const doesActiveContentValueNeedCleaning = 'element' === activeContentValue?.type && ! isContentValueValid( activeContentValue ) && activeContentValue.html !== '' && 'content-editor' !== editor.mode; if ( activeContentValue && areOthersValid && ! doesActiveContentValueNeedCleaning ) { return; } void setEditorState( { ...editor, contentValues: [ ...editor.contentValues.filter( ( cv ) => isContentValueValid( cv ) || cv.selector === editor.activeSelector ), ! activeContentValue && makeContentValue( editor.activeSelector ), ] .filter( ( x ) => x !== false ) .map( ( cv ) => cv === activeContentValue && doesActiveContentValueNeedCleaning ? { ...cv, html: '' } : cv ), } ); } function updateAlternativeContentValues( editor: CssEditorState ): void { const { alternativeId } = editor; const previousValue = select( NAB_EDITOR ).getAlternative< AlternativeAttributes >( alternativeId ); if ( ! previousValue ) { return; } type ContentValue = AlternativeAttributes[ 'content' ][ number ]; const transform = ( cv: CssWorkingContentValue ): ContentValue => { switch ( cv.type ) { case 'element': return { type: 'element', selector: cv.selector, html: cv.html, }; case 'image': return { type: 'image', selector: cv.selector, src: cv.src, alt: cv.alt, }; } }; const newValue: Alternative< AlternativeAttributes > = { ...previousValue, attributes: { ...previousValue.attributes, content: editor.contentValues.map( transform ), }, }; void dispatch( NAB_EDITOR ).setAlternative( alternativeId, newValue ); } function findExistingSelector( editor: CssEditorState, ast: CssStylesheetAST, selector: string ): Maybe< string > { const expectedElements = querySelectorAll( selector ); const contentValues = getAttributes( editor.alternativeId )?.content ?? []; const candidates = 'content-editor' === editor.mode ? contentValues.map( ( cv ) => cv.selector ) : ast.stylesheet.rules .filter( ( r ): r is CssRuleAST => CssTypes.rule === r.type && r.selectors.length === 1 ) .map( ( r ) => r.selectors.join( ', ' ) ); return candidates.find( ( otherSelector ) => { const otherElements = querySelectorAll( otherSelector ); return ( expectedElements.length === otherElements.length && expectedElements.every( ( e ) => otherElements.includes( e ) ) ); } ); } function querySelectorAll( selector: string ): ReadonlyArray< HTMLElement > { try { return Array.from( document.querySelectorAll( selector ) ); } catch ( _ ) { return []; } } const setEditorState = ( state: CssEditorState ) => void dispatch( NAB_DATA ).setPageAttribute( 'css-editor/cssEditorState', state ); function getOriginalAst( alternativeId: AlternativeId ) { const attrs = getAttributes( alternativeId ); const ast = parse( attrs?.css ?? '', { silent: true } ); const hasErrors = !! ast.stylesheet.parsingErrors?.length; return ! hasErrors ? ast : undefined; } function getAttributes( alternativeId: AlternativeId ): Maybe< AlternativeAttributes > { return select( NAB_EDITOR ).getAlternative< AlternativeAttributes >( alternativeId )?.attributes; } function setAttributes( alternativeId: AlternativeId, attrs: Partial< AlternativeAttributes > ) { const alternative = select( NAB_EDITOR ).getAlternative< AlternativeAttributes >( alternativeId ); if ( ! alternative ) { return; } void dispatch( NAB_EDITOR ).setAlternative( alternative.id, { ...alternative, attributes: { ...alternative.attributes, ...attrs, }, } ); } const getCssSelectorFinderState = () => select( NAB_DATA ).getPageAttribute( 'css-selector/cssSelectorFinderState' ); const getEditorState = () => select( NAB_DATA ).getPageAttribute( 'css-editor/cssEditorState' ); function onEditorChanged( attributes: ReadonlyArray< keyof CssEditorState >, callback: ( editor: CssEditorState ) => void ) { let prevState: Maybe< CssEditorState >; subscribe( debounce( () => { const state = getEditorState(); if ( ! state || state === prevState ) { return; } if ( attributes.every( ( a ) => state[ a ] === prevState?.[ a ] ) ) { prevState = state; return; } prevState = state; callback( state ); }, 10 ), NAB_DATA ); } function onCssSelectorFinderChanged( attributes: ReadonlyArray< keyof CssSelectorFinderState >, callback: ( finder: CssSelectorFinderState ) => void ) { let prevState: Maybe< CssSelectorFinderState >; subscribe( debounce( () => { const state = getCssSelectorFinderState(); if ( ! state || state === prevState ) { return; } if ( attributes.every( ( a ) => state[ a ] === prevState?.[ a ] ) ) { prevState = state; return; } prevState = state; callback( state ); }, 10 ), NAB_DATA ); } function maybeAddActiveElement( contentValues: ReadonlyArray< CssWorkingContentValue >, activeElement: Maybe< CssActiveElement > ): ReadonlyArray< CssWorkingContentValue > { contentValues = contentValues.filter( ( cv ) => cv.selector === activeElement?.selector || isDirtyContentChange( cv ) || isDirtyImageChange( cv ) ); if ( ! activeElement ) { return contentValues; } if ( contentValues.some( ( cv ) => cv.selector === activeElement.selector ) ) { return contentValues; } const newContentValue = makeContentValue( activeElement.selector ); if ( ! newContentValue ) { return contentValues; } return [ ...contentValues, newContentValue ]; } function makeContentValue( selector: string ): CssWorkingContentValue | false { if ( ! isContentEditableSelector( selector ) ) { return false; } const els = querySelectorAll( selector ); if ( ! hasOneElement( els ) ) { return false; } const el = els[ 0 ]; if ( isImage( el ) ) { return { type: 'image', selector, src: '', alt: '', originalSrc: el.src, originalAlt: el.alt, status: 'active', }; } return { type: 'element', selector, html: '', originalHtml: el.innerHTML, status: 'active', formats: { bold: isTag( el, [ 'B', 'STRONG' ] ), link: isTag( el, [ 'A' ] ), italic: isTag( el, [ 'I', 'EM' ] ), underline: isTag( el, [ 'U' ] ), }, wasJustCreated: true, }; } const isDirtyContentChange = ( cv: CssWorkingContentValue ) => 'element' === cv.type && !! cv.html.trim(); const isDirtyImageChange = ( cv: CssWorkingContentValue ) => 'image' === cv.type && ( !! cv.src.trim() || !! cv.alt.trim() ); const isImage = ( el: Element ): el is HTMLImageElement => isTag( el, [ 'IMG' ] ); const hasOneElement = < T >( a: ReadonlyArray< T > ): a is Readonly< [ T ] > => a.length === 1; const isContentValueValid = ( cv: CssWorkingContentValue ) => ( 'element' === cv.type && !! html2text( cv.html ).trim() && ! cv.wasJustCreated ) || ( 'image' === cv.type && !! cv.src.trim() ) || ( 'image' === cv.type && !! cv.alt.trim() ); const AUX = document.createElement( 'div' ); function html2text( html = '' ) { AUX.innerHTML = html; return AUX.textContent || ''; }