UNPKG

@silexlabs/grapesjs-advanced-selector

Version:

A GrapesJS plugin for managing advanced CSS selectors with a visual interface

242 lines (225 loc) 8.25 kB
import { property } from 'lit/decorators.js' import StylableElement from '../StylableElement' import { CompoundSelector, toString } from '../model/CompoundSelector' import { isSameSelector, SimpleSelector, SimpleSelectorType, specificity } from '../model/SimpleSelector' import { css, html, TemplateResult } from 'lit' import SimpleSelectorComponent from './simple-selector' import { PSEUDO_CLASSES } from '../model/PseudoClass' import { FOCUS_VISIBLE } from '../styles' import './inline-select' import './simple-selector' /** * A component to display and edit a compound selector * A compound selector is a list of simple selectors and a pseudo class * @emits change * @emits rename (when a simple selector in the selector is renamed) * @emits delete * @emits add */ export default class CompoundSelectorComponent extends StylableElement { // ///////////////// // Attributes /** * The selector to display */ @property({ type: Object, attribute: true, reflect: false }) public get value(): CompoundSelector | undefined { return this._value } public set value(value: CompoundSelector | string | undefined) { try { this._value = typeof value === 'string' ? JSON.parse(value) : value } catch (error) { console.error('Error parsing value for selector', { value, error }) } this.requestUpdate() } private _value: CompoundSelector | undefined /** * A list of all the classes, IDs, tags, custom tags, attributes, custom attributes * that are available in the document, applicable to the current selection */ @property({ type: Array, attribute: true, reflect: false }) public suggestions: SimpleSelector[] = [] @property({ type: Boolean, attribute: 'disable-pseudo-class' }) public disablePseudoClass: boolean = false // ///////////////// // Properties // ///////////////// // Element overrides static override styles = css` :host { ${ FOCUS_VISIBLE } button:hover, a:hover { transform: translateX(1px); font-weight: bold; } .asm-compound__selectors { display: flex; gap: 0.35rem; align-items: center; flex-wrap: wrap; padding: 0.3rem 0.5rem; background-color: var(--gjs-main-dark-color); border-radius: 4px; } .asm-compound__add { color: var(--gjs-secondary-color, #b9a5a6); opacity: 0.6; transition: opacity 0.15s ease; } .asm-compound__add:hover { opacity: 1; } .asm__add-inline { font-size: 0.75rem; background: transparent; opacity: 0.6; padding: 2px 4px; } } ` // override dispatchEvent(event: Event): boolean { // console.info('[COMPOUND] Dispatching event', event) // return super.dispatchEvent(event) // } override toString(): string { return toString(this.value!) } override render(): TemplateResult { return html` <section> <div class="asm-compound__selectors" > ${ this.value?.selectors .sort((a, b) => specificity(b, true) - specificity(a, true)) .map((selector, idx) => html` <simple-selector .t=${ this.t } .value=${ selector } .suggestions=${ this.suggestions } ?readonly=${ ![SimpleSelectorType.CLASS, SimpleSelectorType.UNKNOWN].includes(selector.type) } @change=${ (event: CustomEvent<SimpleSelector>) => this.changeSelector(event, idx) } @delete=${ (event: CustomEvent) => this.deleteSelector(event, idx) } ></simple-selector> `) } <button id="gjs-clm-add-tag" class="gjs-clm-tags-btn gjs-clm-tags-btn__add asm-compound__add" .title=${ this.t('Add a new selector') } @click=${ this.addSelector } > <svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"></path></svg> </button> </div> ${ this.disablePseudoClass ? '' : html` ${ this.value?.pseudoClass ? html` <div> <inline-select .t=${ this.t } .value=${ this.value?.pseudoClass } .options=${ PSEUDO_CLASSES } @change=${ this.changePseudoClass } placeholder="" ></inline-select> </div> ` : html` <button class="gjs-btn-prim asm__add-inline" @click=${ this.addPseudoClass } >\u2192 ${ this.t('Pseudo Class') }</button> ` } `} </section> ` } // ///////////////// // Methods private changeSelector(event: CustomEvent<SimpleSelector>, idx: number) { const oldValue: SimpleSelector = this.value!.selectors[idx] const onlyId = event.detail.type === SimpleSelectorType.ID && event.detail.active // Update the selector at the given index let updatedSelectors = this.value!.selectors .map((selector, i) => i === idx ? event.detail : selector) // When ID is activated, deactivate all other selectors if (onlyId) { updatedSelectors = updatedSelectors.map(selector => selector.type !== SimpleSelectorType.ID ? { ...selector, active: false } : selector ) } // When a NEW class is created (type changed from UNKNOWN to CLASS), deactivate all other classes // But allow multiple existing classes to be manually activated for selectors like .a.b const isNewlyCreatedClass = oldValue?.type === SimpleSelectorType.UNKNOWN && event.detail.type === SimpleSelectorType.CLASS && event.detail.active if (isNewlyCreatedClass) { updatedSelectors = updatedSelectors.map(selector => selector.type === SimpleSelectorType.CLASS && !isSameSelector(selector, event.detail) ? { ...selector, active: false } : selector ) } // When all selectors are deactivated but pseudo class exists, activate ID selector const hasActiveSelectors = updatedSelectors.some(selector => selector.active) if (!hasActiveSelectors && this.value!.pseudoClass) { updatedSelectors = updatedSelectors.map(selector => selector.type === SimpleSelectorType.ID ? { ...selector, active: true } : selector ) } this.value = { ...this.value!, selectors: updatedSelectors, } if(!isSameSelector(oldValue, event.detail, false)) { this.dispatchEvent(new CustomEvent('rename', { detail: { oldValue, value: event.detail, } })) this.dispatchEvent(new CustomEvent('change', { detail: this.value })) } event.stopPropagation() this.requestUpdate() } private addSelector(event: MouseEvent) { this.value = this.value ?? { selectors: [] } this.value.selectors.push({ type: SimpleSelectorType.UNKNOWN, active: true }) event.stopPropagation() this.requestUpdate() // Make the last selector editable requestAnimationFrame(() => this.focusLastSelector()) } private focusLastSelector() { if (!this.value) { return } const selector = this.shadowRoot!.querySelectorAll('simple-selector')[this.value!.selectors.length - 1] as SimpleSelectorComponent selector.editing = true requestAnimationFrame(() => { selector.focus() }) } private deleteSelector(event: CustomEvent, idx: number) { const removed: SimpleSelector | undefined = this.value?.selectors.splice(idx, 1)[0] this.dispatchEvent(new CustomEvent('delete', { detail: removed })) this.dispatchEvent(new CustomEvent('change', { detail: this.value })) event.stopPropagation() this.requestUpdate() } private addPseudoClass(event: MouseEvent) { this.value = { ...this.value!, pseudoClass: PSEUDO_CLASSES[0] } this.dispatchEvent(new CustomEvent('change', { detail: this.value })) event.stopPropagation() } private changePseudoClass(event: CustomEvent) { this.value = { ...this.value!, pseudoClass: event.detail, } this.dispatchEvent(new CustomEvent('change', { detail: this.value })) event.stopPropagation() } } if (!customElements.get('compound-selector')) { customElements.define('compound-selector', CompoundSelectorComponent) }