/** * WordPress dependencies */ import { useCallback, useEffect, useState } from '@safe-wordpress/element'; /** * External dependencies */ import { langs } from '@uiw/codemirror-extensions-langs'; import { color } from '@uiw/codemirror-extensions-color'; import { lintGutter, linter } from '@codemirror/lint'; import { syntaxTree } from '@codemirror/language'; import CodeMirror, { EditorView, ViewUpdate } from '@uiw/react-codemirror'; import { CSSLint } from 'csslint'; import { isEqual } from 'lodash'; import { isDefined } from '@nab/utils'; import type { Diagnostic } from '@codemirror/lint'; import type { SyntaxNode } from '@lezer/common'; import type { CSSProperties } from 'react'; import type { Maybe } from '@nab/types'; /** * Internal dependencies */ import type { Props } from './props'; const CSS_LINTER_RULES: Record< string, boolean > = { ...CSSLint.getRules() .map( ( r ) => r.id ) .reduce( ( acc, id ) => ( { ...acc, [ id ]: true } ), {} ), 'overqualified-elements': false, 'qualified-headings': false, }; export const CssEditor = ( { value, className, before, after, placeholder, focusedSelector, readOnly, onBlur, onChange, onCreateEditor, onChangeSelectedRule, onFocus, }: Props & { readonly onChangeSelectedRule?: ( selectors?: ReadonlyArray< string > ) => void; readonly focusedSelector?: string; } ): JSX.Element => { const [ activeSelectors, setActiveSelectors ] = useState< Maybe< Array< string > > >( [] ); const [ editorView, setEditorView ] = useState< EditorView | null >( null ); useEffect( () => { if ( ! editorView || ! focusedSelector ) { return; } if ( editorView.hasFocus ) { return; } const anchor = findSelectorInEditor( editorView, focusedSelector ); if ( undefined === anchor ) { return; } editorView.focus(); editorView.dispatch( { selection: { anchor }, effects: EditorView.scrollIntoView( anchor, { y: 'center' } ), } ); }, [ editorView, focusedSelector ] ); function cssLinter() { return linter( ( view ): Diagnostic[] => { const text = view.state.doc.toString(); const results = CSSLint.verify( text, CSS_LINTER_RULES ); const diagnostics: Diagnostic[] = []; for ( const message of results.messages ) { if ( message.rule?.id === 'ids' ) { continue; } if ( message.line && message.col ) { diagnostics.push( { from: view.state.doc.line( message.line ).from + ( message.col - 1 ), to: view.state.doc.line( message.line ).from + message.col, severity: message.type === 'error' ? 'error' : 'warning', message: message.message, } ); } } return diagnostics; } ); } const handleRuleSelected = useCallback( ( viewUpdate: ViewUpdate ) => { if ( ! onChangeSelectedRule ) { return; } if ( viewUpdate.transactions.some( ( tr ) => tr.docChanged ) ) { return; } const state = viewUpdate.state; const cursorPos = state.selection.main.head; const tree = syntaxTree( state ); let node: SyntaxNode | null = tree.resolve( cursorPos, -1 ); // Find the parent RuleSet node while ( node && node.name !== 'RuleSet' ) { node = node.parent; } if ( ! node ) { return; } const text = state.doc.sliceString( node.from, node.to ); if ( ! text.includes( '{' ) ) { return; } const selectors = text .split( '{' )[ 0 ] ?.trim() .split( ',' ) .map( ( s ) => s.trim() ) .filter( ( s ) => !! s.length ); if ( ! isEqual( selectors, activeSelectors ) ) { onChangeSelectedRule( selectors ); setActiveSelectors( selectors ); } }, [ activeSelectors, onChangeSelectedRule ] ); return ( { setEditorView( view ); onCreateEditor?.( view, ...args ); } } basicSetup={ { lineNumbers: false, foldGutter: false, highlightActiveLine: ! readOnly, } } extensions={ [ EditorView.lineWrapping, color, langs.css?.(), ...[ readOnly ? [] : [ cssLinter(), lintGutter() ] ], ].filter( isDefined ) } onChange={ readOnly ? undefined : onChange } onFocus={ onFocus } onBlur={ onBlur } onUpdate={ handleRuleSelected } style={ { '--nab-code-before': JSON.stringify( before ), '--nab-code-after': JSON.stringify( after ), } as unknown as CSSProperties } /> ); }; // ======= // HELPERS // ======= function findSelectorInEditor( view: EditorView, selector: string ): Maybe< number > { const selectors = selector .split( ',' ) .map( ( s ) => s.trim() ) .filter( ( s ) => !! s ); let found = -1; syntaxTree( view.state ).iterate( { enter( node ) { if ( node.name !== 'RuleSet' ) { return; } const foundSelectors = view.state.doc .sliceString( node.from, node.to ) .split( '{' )[ 0 ] ?.trim() .split( ',' ) .map( ( s ) => s.trim() ) .filter( ( s ) => !! s ) ?? []; if ( foundSelectors.length !== selectors.length || ! selectors.every( ( s ) => foundSelectors.includes( s ) ) ) { return; } found = node.from; }, } ); if ( found === -1 ) { return undefined; } const docText = view.state.doc.toString(); const openBrace = docText.indexOf( '{', found ); if ( openBrace === -1 ) { return undefined; } return openBrace + 1; }