UNPKG

@silexlabs/grapesjs-advanced-selector

Version:

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

226 lines (209 loc) 9.07 kB
import { Component, Editor } from 'grapesjs' import { html, render } from 'lit' import { editStyle, getComponentSelector, getSelectedStyle, getSelectors, getSuggestionsMain, getSuggestionsRelated, getTranslation, getUntranslatedKeys, matchSelectorAll, matchSelectorSome, removeClass, renameSelector, setComponentSelector, setSelectedStyle } from './model/GrapesJsSelectors' import { activateSelectors, ComplexSelector, EMPTY_SELECTOR, fromString, merge, toString, getSelector, noPseudo } from './model/ComplexSelector' import './components/complex-selector' import './components/current-selector-display' // Debounce utility (no semicolons, matches code style) function debounce(fn: (...args: any[]) => void, delay: number = 100) { let timer: ReturnType<typeof setTimeout> return function(this: any, ...args: any[]) { clearTimeout(timer) timer = setTimeout(() => fn.apply(this, args), delay) } } //////////////// // Types export type AdvancedSelectorOptions = { i18n: { [key: string]: string } helpLinks: { actionBar: string } } type CustomSelectorEventProps = { container: HTMLElement } //////////////// // Globals const container = document.createElement('div') container.id = 'asm-container' //////////////// // Plugin functions export function initListeners(editor: Editor, options: AdvancedSelectorOptions) { editor.Commands.add('i18n:info', () => { console.log('i18n', getUntranslatedKeys()) }) editor.on('undo redo', () => { requestAnimationFrame(() => updateUi(editor, options)) }) // editor.on('component:selected', () => { // console.log('============> component:selected') // requestAnimationFrame(() => updateUi(editor, options)) // }) // // When a class changes // editor.on('selector', (...args) => { // console.log('============> component:classes', args) // requestAnimationFrame(() => updateUi(editor, options)) // }) // editor.on('component:update:classes', (...args) => { // console.log('============ component:update:classes', args) // requestAnimationFrame(() => updateUi(editor, options)) // Delay to avoid flicker of warning // // console.log("========== Component class list updated:", component, component.getC); // // // Get the class that changed // // const changedClass = component // // .getClasses() // // .filter((cls) => component.previous('classes') !== component.get(cls))[0]; // // const classNames = component.getClasses() // // console.log("============ Updated class names:", classNames); // }) } export function initASM(editor: Editor, options: AdvancedSelectorOptions, props?: CustomSelectorEventProps) { if (props && props.container) { props.container.appendChild(container) editor.on('selector:custom component:update', debounce(() => updateUi(editor, options))) } else { // Keep listening editor.once('selector:custom', (props) => initASM(editor, options, props)) } } function updateUi(editor: Editor, options: AdvancedSelectorOptions) { const components: Component[] = editor.getSelectedAll() const widthMedia = editor.DeviceManager.getSelected()?.get('widthMedia') const selectorInitial = getSelector(components) if(selectorInitial) { const selector = { ...selectorInitial, atRule: widthMedia ? `@media (max-width: ${widthMedia})` : undefined, } const [errors, warnings] = getErrorsAndWarnings(selector, components) requestAnimationFrame(() => editStyle(editor, toString(selector))) render(html` <current-selector-display .t=${(key: string) => getTranslation(editor, key)} .value=${selector || undefined} .selectors=${getSelectors(editor)} .helpLink=${options.helpLinks.actionBar} .error=${getTranslation(editor, errors || '')} .warning=${getTranslation(editor, warnings || '')} @change=${(event: CustomEvent) => mergeSelector(event.detail as ComplexSelector, editor, components)} @delete=${() => deleteSelector(editor, selector)} @copy=${() => copyStyle(editor)} @paste=${() => pasteStyle(editor)} ></current-selector-display> <complex-selector .t=${(key: string) => getTranslation(editor, key)} .hasBody=${components.some(c => c.tagName.toLocaleLowerCase() === 'body')} .value=${selector} .suggestions=${getSuggestionsMain(editor, components, selector)} .relations=${getSuggestionsRelated(editor, components, selector)} @change=${(event: CustomEvent) => changedSelector(event.detail as ComplexSelector, editor, components)} @rename=${(event: CustomEvent) => renameSelector(editor, event.detail.oldValue, event.detail.value) } @removeClass=${(event: CustomEvent) => removeClass(editor, event.detail) } ></complex-selector> `, container) } else { render(html` <p>Select a component to edit its selector</p> `, container) } } function changedSelector(selector: ComplexSelector, editor: Editor, components: Component[]) { const isBody = components.some(c => c.tagName?.toLowerCase() === 'body') // For body: body tag and ID are mutually exclusive if (isBody) { const bodyTagActive = selector.mainSelector.selectors .some(s => s.type === 'tag' && (s as any).value === 'body' && s.active) if (bodyTagActive) { selector = { ...selector, mainSelector: { ...selector.mainSelector, selectors: selector.mainSelector.selectors.map(s => s.type === 'id' ? { ...s, active: false } : s ), }, } } } components.forEach((component) => { setComponentSelector(component, selector) }) editStyle(editor, toString(selector)) } /** * Merge the selector with the current selector of the components * If a simple selector is missing, just deactivate it * Handles the atRule and pseudoClass */ function mergeSelector(selector: ComplexSelector, editor: Editor, components: Component[]) { components.forEach((component) => { const oldSelector = getComponentSelector(component) || EMPTY_SELECTOR const newSelector = merge(oldSelector, selector) const activated = activateSelectors(newSelector, selector) activated.atRule = selector.atRule // We merged but want to keep the atRule activated.mainSelector.pseudoClass = selector.mainSelector.pseudoClass // We merged but want to keep the pseudoClass if (activated.relatedSelector) activated.relatedSelector.pseudoClass = selector.relatedSelector?.pseudoClass // We merged but want to keep the pseudoClass setComponentSelector(component, activated) }) // Select the device if the selector contains the device if(selector.atRule) { const device = editor.DeviceManager.getAll().find(device => { const width = device.get('widthMedia') return !!width && selector.atRule!.includes(width) })?.get('id') if(device) editor.DeviceManager.select(device) } else { editor.DeviceManager.select(editor.DeviceManager.getAll().first()) } editStyle(editor, toString(selector)) } /** * Deletes all styles associated with the current selector from the editor. * Removes rules for the base selector as well as any pseudo-class or * relation variants (e.g. `.foo:hover`, `.foo:has(.bar)`), across all * media queries. */ function deleteSelector(editor: Editor, selector: ComplexSelector) { const selectorString = toString(selector) if (!selectorString) return // Find the rule that exactly matches the current selector (including pseudo-class/relation) const toRemove = editor.CssComposer.getRules().filter((rule: any) => { const selStr = rule.getSelectorsString() if (!selStr) return false try { const complex = fromString(selStr, rule.getAtRule()) const ruleString = toString(complex) return ruleString === selectorString } catch { return false } }) toRemove.forEach((rule: any) => { // Clear styles before removing — getRule() keeps a stale cached reference // after remove(), so editStyle() would re-select the ghost rule with old styles rule.setStyle({}) editor.CssComposer.remove(rule) }) editStyle(editor, toString(selector)) } function copyStyle(editor: Editor) { localStorage?.setItem('asm:clipboard', JSON.stringify(getSelectedStyle(editor))) } function pasteStyle(editor: Editor) { const clipboard = localStorage?.getItem('asm:clipboard') if(clipboard) { const style = JSON.parse(clipboard) setSelectedStyle(editor, style) } } function getErrorsAndWarnings(selector: ComplexSelector, components: Component[]): [string | null, string | null] { const selectorNoPseudo: ComplexSelector = noPseudo(selector) if (!matchSelectorAll(toString(selectorNoPseudo, true), components)) { return ['Current selector does not match the selected elements', null] } else if (!matchSelectorSome(toString(selectorNoPseudo, true), components)) { return [null, 'Current selector does not match all the selected elements'] } return [null, null] }