@silexlabs/grapesjs-advanced-selector
Version:
A GrapesJS plugin for managing advanced CSS selectors with a visual interface
654 lines (600 loc) • 22.9 kB
text/typescript
import { Component, CssRule, Editor, Selector } from 'grapesjs'
import { ComplexSelector, fromString, noPseudo, toString } from './ComplexSelector'
import { AttributeOperatorType, AttributeSelector, ClassSelector, IdSelector, isSameSelector, SimpleSelector, SimpleSelectorType, TAGS, TagSelector } from './SimpleSelector'
import { OperatorType } from './Operator'
import { CompoundSelector } from './CompoundSelector'
////////////////
// Canvas body remap
// In GrapesJS, the iframe DOM is <body> → <div.wrapper> → [components]. The
// wrapper has a class (set by the silex-lib body-remap plugin) and CSS rules
// targeting `body` are rewritten to target that class so they visually apply.
// For .matches() to also recognize selectors like `body > .x`, we apply the
// same rewrite here before testing against the DOM.
// Kept in sync with silex-lib/src/ts/client/grapesjs/body-remap.ts
const CANVAS_BODY_WRAPPER_CLASS = 'silex-page-body'
const BODY_TAG_SELECTOR_RE = /(^|[,\s>+~])body(?![a-zA-Z0-9_-])/g
function normalizeSelectorForCanvas(selector: string): string {
if (selector.includes(CANVAS_BODY_WRAPPER_CLASS)) return selector
return selector.replace(BODY_TAG_SELECTOR_RE, `$1.${CANVAS_BODY_WRAPPER_CLASS}`)
}
////////////////
// GrapeJs functions
const untranslatedKeys = new Set<string>()
export function getTranslation(editor: Editor, key: string): string {
if(!key) return ''
const translated = editor?.I18n?.t(key)
if (!translated) {
untranslatedKeys.add(key)
console.info(`Untranslated key "${ key }", call editor.runCommand("i18n:info") to see all untranslated keys`)
}
return translated || key
}
export function getUntranslatedKeys(): string[] {
return Array.from(untranslatedKeys)
}
////////////////
// Stye Manager functions
/**
* Get all selectors that match the selected component
* Don't take pseudo class into account
*/
export function getSelectors(editor: Editor): ComplexSelector[] {
return editor.getSelectedAll()
.flatMap((component: Component) => {
return editor
.CssComposer
.getRules()
.reduce<ComplexSelector[]>((acc, _rule: CssRule) => {
const rule = _rule.clone()
// Check if the rule has a style applied
if (Object.keys(rule.getStyle()).length === 0) {
// No style, this is just a selector
return acc
}
const selectorString = rule.getSelectorsString()
if (!selectorString) {
// Empty selector, this must be being edited
console.warn('Empty selector for rule', rule.get('state'), { rule })
return acc
}
try {
// Get a complex selector for this rule
const complex = fromString(selectorString, rule.getAtRule())
// Remove the pseudo class
const complexNoPseudo: ComplexSelector = noPseudo(complex)
// Back to a string
const selectorNoPseudo = toString(complexNoPseudo)
// Add the matching selectors
if (component.view?.el.matches(normalizeSelectorForCanvas(selectorNoPseudo))) {
acc.push(complex)
} else if (component.tagName.toLowerCase() === 'body' && selectorNoPseudo.toLowerCase().startsWith('html')) {
// `html` selector: not present in canvas DOM, match on body component
acc.push(complex)
}
} catch (e) {
console.warn('Error matching selector', selectorString, e)
}
return acc
}, [])
})
}
/**
* Function to edit or add style based on the selector
*/
export function editStyle(editor: Editor, selector: string) {
const currentWidth = editor.DeviceManager.getSelected()?.get('widthMedia')
const opts = {
atRuleType: currentWidth ? 'media' : '',
atRuleParams: currentWidth ? `(max-width: ${currentWidth})` : '',
}
const old = editor.CssComposer.getRule(selector, opts)
const rule = editor.CssComposer.setRule(selector, old?.getStyle(), {
addStyles: !!old?.getStyle(),
...opts,
})
editor.StyleManager.select(rule)
}
/**
* Check if all the selected components are selected by the provided selector
*/
export function matchSelectorAll(selector: string, components: Component[]): boolean {
try {
const normalized = normalizeSelectorForCanvas(selector)
return components.some((component) => {
// `html` selector: not present in canvas DOM, match on body component
if (component.tagName?.toLowerCase() === 'body' && selector.toLowerCase().startsWith('html')) {
return true
}
return component.view?.el.matches(normalized)
})
} catch {
return false
}
}
/**
* Check if some of the selected components are selected by the provided selector
*/
export function matchSelectorSome(selector: string, components: Component[]): boolean {
try {
const normalized = normalizeSelectorForCanvas(selector)
return components.every((component) => {
// `html` selector: not present in canvas DOM, match on body component
if (component.tagName?.toLowerCase() === 'body' && selector.toLowerCase().startsWith('html')) {
return true
}
return component.view?.el.matches(normalized)
})
} catch {
return false
}
}
/**
* Remove the style (rules) for the current selector (edited selector)
*/
export function clearStyle(editor: Editor) {
// Remove the selected rule from the CSS Composer
const rule = editor.StyleManager.getSelected()
if (!rule) {
console.warn('No rule selected')
return
}
editor.CssComposer.remove(rule as CssRule)
}
export function getSelectedStyle(editor: Editor): any {
return editor.StyleManager.getSelected()
?.getStyle()
}
export function setSelectedStyle(editor: Editor, style: any) {
editor.StyleManager.getSelected()
?.setStyle(style)
}
export function renameSelector(editor: Editor, oldSelector: SimpleSelector, newSelector: SimpleSelector) {
// Rename the selector in the Style manager
if (oldSelector.type !== newSelector.type) {
// Cannot rename to a different type
console.warn('Cannot rename to a different types', oldSelector, newSelector)
return
}
if (isSameSelector(oldSelector, newSelector)) {
// No change
console.warn('No change', oldSelector, newSelector)
return
}
switch (oldSelector.type) {
case SimpleSelectorType.ID:
// Will never reach here as a different ID is not allowed (readonly + no suggestion)
// const id = (newSelector as IdSelector).value
// const components = editor.getSelectedAll()
// if (components.length !== 1) {
// console.error('Cannot rename ID selector for multiple components', { oldSelector, newSelector }, 'Only one component will ave the ID', id)
// }
// components[0].setId(id)
throw new Error('Cannot rename ID selector')
case SimpleSelectorType.CLASS:
renameCssClass(editor, (oldSelector as ClassSelector).value, (newSelector as ClassSelector).value)
break
case SimpleSelectorType.TAG:
// Readonly
// const components = editor.getSelectedAll()
// components.forEach((component) => component.set('tagName', (newSelector as ClassSelector).value))
throw new Error('Cannot rename ID selector')
default:
console.error('Cannot rename selector: Unknown Type', { oldSelector, newSelector })
}
}
/**
* Removes the specified CSS class from all selected components in the editor.
* Similar to renameSelector, but instead of renaming, it deletes the class from the components.
*/
export function removeClass(editor: Editor, selector: SimpleSelector) {
if (selector.type !== SimpleSelectorType.CLASS) {
return
}
const className = (selector as ClassSelector).value
if (!className) {
console.warn('Invalid class name provided.')
return
}
// Get all selected components
const components = editor.getSelectedAll()
components.forEach(component => {
// Remove the class from the component
const classes = component.getClasses()
const newClasses = classes.filter((cls: string) => cls !== className)
component.setClass(newClasses)
})
}
/**
* Renames a CSS class in the style manager and updates all components using that class.
* - Updates the selector name in the SelectorManager.
* - Finds all components with the old class name and replaces it with the new class name.
* - Handles undo logic by updating the selector before modifying components.
*/
function renameCssClass(editor: Editor, oldClassName: string, newClassName: string) {
if (!oldClassName || !newClassName || oldClassName === newClassName) {
console.warn('Invalid class names or same names provided.')
return
}
const sm = editor.SelectorManager
const oldSelector = sm.get(oldClassName)
if (!oldSelector) {
console.warn(`Class "${oldClassName}" not found.`)
return
}
// TODO: handle the related selectors
// IMPORTANT: Find components BEFORE renaming the selector
// Because oldSelector.set('name') will automatically update component.getClasses()
// Helper function to recursively find all components with a class in their selector
const findComponentsWithClass = (component: Component, className: string): Component[] => {
const results: Component[] = []
// Check if this component has the class in its selector
const selector = component.get('selector')
if (selector?.mainSelector?.selectors) {
const hasClass = selector.mainSelector.selectors.some(
(sel: any) => sel.type === SimpleSelectorType.CLASS && sel.value === className
)
if (hasClass) {
results.push(component)
}
}
// Recursively check children
component.components().forEach((child: Component) => {
results.push(...findComponentsWithClass(child, className))
})
return results
}
const toUpdate = editor.Pages.getAll()
.flatMap(page => findComponentsWithClass(page.getMainComponent(), oldClassName))
// This has to be before we update component selectors (but after finding them!)
// Otherwise it messes up the undo
oldSelector.set('name', newClassName)
// Rename the class in the components
// TODO: handle the related selectors
// NOTE: No need to manually remove the old class from component.getClasses()
// because oldSelector.set('name', newClassName) already updated it automatically
toUpdate.forEach(component => {
// Update the selector stored in component attributes
const selector = getComponentSelector(component)
const updatedSelector = {
...selector,
mainSelector: {
...selector.mainSelector,
selectors: selector.mainSelector.selectors
// Remove the new class to avoid duplicates
.filter(sel => sel.type !== SimpleSelectorType.CLASS || (sel as ClassSelector).value !== newClassName)
.map(sel =>
sel.type === SimpleSelectorType.CLASS && (sel as ClassSelector).value === oldClassName
? { ...sel, value: newClassName }
: sel
),
},
}
setComponentSelector(component, updatedSelector)
})
}
////////////////
// Component functions
/**
* Stores a selector in a component's attributes.
*/
export function setComponentSelector(component: Component, selector: ComplexSelector) {
component.set('selector', {
...selector,
})
const classes: string[] = []
selector.mainSelector.selectors.forEach((simpleSelector) => {
switch (simpleSelector.type) {
case 'id':
component.setId((simpleSelector as IdSelector).value)
break
case 'class':
classes.push((simpleSelector as ClassSelector).value)
break
case 'tag':
break // Tags are inherent to the component, no model update needed
default:
console.warn('Unhandled selector type', simpleSelector)
}
})
// FIXME: Add back the protected classes
// Replace the classes with the ones from the selector
const existing = component.getClasses()
// Keep existing classes that are not in the selector + add all classes from the selector
const finalClasses = [
...existing.filter((c: string) => !classes.includes(c)),
...classes
]
component.setClass(finalClasses)
}
export function getComponentSelector(component: Component): ComplexSelector {
// FIXME: Backward compatibility should be handled in the app
const oldClasses: string[] = component.getClasses()
const selector = component.get('selector') ?? {
mainSelector: {
selectors: oldClasses
.map(className => ({ type: SimpleSelectorType.CLASS, value: className, active: false })),
},
}
return {
...selector,
}
}
////////////////
// Selector functions
/**
* Get the suggestions for the selected components which have the provided selector
* We want to suggest the main selector:
* - The component tag name and ID
* - Any classes that are in the current website (will be added to the component if selected) but no classes that are already in the component
*/
export function getSuggestionsMain(editor: Editor, components: Component[], selector: ComplexSelector): SimpleSelector[] {
const suggestions: SimpleSelector[] = []
components.forEach(component => {
// Add the component tag name and ID
addTagNames(component, suggestions, selector.mainSelector)
// Add the website classes
const classes = component.getClasses()
editor.CssComposer.getAll().forEach((rule) => {
rule.getSelectors().forEach((sel: Selector) => {
const s = {
type: SimpleSelectorType.CLASS,
value: sel.get('name'),
active: true,
} as ClassSelector
if (sel.get('type') === 1 &&
!sel.get('private') &&
!classes.includes(sel.get('name')) &&
canIadd(s, selector.mainSelector, suggestions)
) {
suggestions.push(s)
}
})
})
// Add attributes (excluding 'id' since it should use ID selector instead)
const attributes = component.getAttributes({ noStyle: true, noClass: true })
Object.keys(attributes).forEach((attr) => {
// Skip 'id' attribute - it should use ID selector instead of attribute selector
if (attr === 'id') return
const s = {
type: SimpleSelectorType.ATTRIBUTE,
value: attr,
active: true,
} as AttributeSelector
if (attributes[attr]) {
s.operator = AttributeOperatorType.EQUALS
s.attributeValue = attributes[attr]
}
if (canIadd(s, selector.mainSelector, suggestions)) {
suggestions.push(s)
}
})
})
return suggestions
}
/**
* Get the suggestions for the related selectors. Depending on the operator (> => look for children, '+' => look for siblings, ' ' => look for parents):
* - Any classes that are in the parents/children/siblings of the selected components
* - Add the classes that are on each parent/child/sibling but not on the selected components
* - Same for IDs, tag names
*/
export function getSuggestionsRelated(editor: Editor, components: Component[], selector: ComplexSelector): SimpleSelector[] {
if (!selector.operator) {
return []
}
const suggestions: SimpleSelector[] = []
switch (selector.operator?.type) {
case OperatorType.HAS:
// Add the children classes, tag names, and IDs inside the selected components
components
.forEach((component) => {
suggestions.push(...getSuggestions(getChildren(component), selector.relatedSelector || { selectors: [] }))
})
break
case OperatorType.NOT:
suggestions.push(...TAGS.map((tag) => ({
type: SimpleSelectorType.TAG,
value: tag,
active: true,
} as TagSelector)))
components.forEach((component) => {
const classes = component.getClasses()
editor.CssComposer.getAll().forEach((rule) => {
rule.getSelectors().forEach((sel: Selector) => {
if (sel.get('private')) {
return
}
if (sel.get('type') === 1 && !classes.includes(sel.get('name'))) {
const classSelector = {
type: SimpleSelectorType.CLASS,
value: sel.get('name'),
active: true,
} as ClassSelector
if (canIadd(classSelector, selector.relatedSelector!, suggestions)) {
suggestions.push(classSelector)
}
} else if (sel.get('type') === 2) {
const idSelector = {
type: SimpleSelectorType.ID,
value: sel.get('name'),
active: true,
} as IdSelector
if (canIadd(idSelector, selector.relatedSelector!, suggestions)) {
suggestions.push(idSelector)
}
}
})
})
})
break
case OperatorType.IS:
case OperatorType.WHERE:
suggestions.push(...getSuggestions(components, selector.relatedSelector || { selectors: [] }))
break
case OperatorType.CHILD:
components.forEach((component) => {
const parent = component.parent()
if (parent) {
// Add the parent tag names, IDs, and classes
suggestions.push(...getSuggestions([parent], selector.relatedSelector || { selectors: [] }))
}
})
break
case OperatorType.DESCENDANT:
components.forEach((component) => {
suggestions.push(...getSuggestions(getParents(component), selector.relatedSelector || { selectors: [] }))
})
break
case OperatorType.ADJACENT:
// Only the sibling immediately before each component
const siblingsAdjacent: Component[] = components
.map((comp) => getSiblingsBefore(comp)[0])
.filter((c): c is Component => !!c)
suggestions.push(...getSuggestions(siblingsAdjacent, selector.relatedSelector || { selectors: [] }))
break
case OperatorType.GENERAL_SIBLING:
// All siblings before each component
const siblingsGeneral: Component[] = components
.flatMap(getSiblingsBefore)
.filter((c): c is Component => !!c)
suggestions.push(...getSuggestions(siblingsGeneral, selector.relatedSelector || { selectors: [] }))
break
default:
console.error('Unhandled operator', selector.operator)
throw new Error(`Unhandled operator: ${selector.operator}`)
}
return suggestions
}
/**
* Add classes, tag names to the suggestions
*/
function getSuggestions(components: Component[], selector: CompoundSelector): SimpleSelector[] {
const suggestions: SimpleSelector[] = []
// Tag names
components.forEach((component) => {
addTagNames(component, suggestions, selector)
})
// Classes
components
.flatMap((comp) => comp.getClasses())
.forEach((cl: string) => {
// Only classes not in the suggestions yet
if (suggestions.some((s) => s.type === SimpleSelectorType.CLASS && (s as ClassSelector).value === cl)) {
return
}
// Only classes not in the related selector yet
if (selector.selectors.some((s) => s.active && s.type === SimpleSelectorType.CLASS && (s as ClassSelector).value === cl)) {
return
}
// Add the class
suggestions.push({
type: SimpleSelectorType.CLASS,
value: cl,
active: true,
} as ClassSelector)
})
// Attributes
components.forEach(component => {
// Check if getAttributes method exists (for compatibility with mock components in tests)
if (typeof component.getAttributes === 'function') {
const attributes = component.getAttributes({ noStyle: true, noClass: true })
Object.keys(attributes).forEach((attr) => {
// Skip 'id' attribute - it should use ID selector instead of attribute selector
if (attr === 'id') return
// Only attributes not in the suggestions yet
if (suggestions.some((s) => s.type === SimpleSelectorType.ATTRIBUTE && (s as AttributeSelector).value === attr)) {
return
}
// Only attributes not in the related selector yet
if (selector.selectors.some((s) => s.active && s.type === SimpleSelectorType.ATTRIBUTE && (s as AttributeSelector).value === attr)) {
return
}
const s = {
type: SimpleSelectorType.ATTRIBUTE,
value: attr,
active: true,
} as AttributeSelector
if (attributes[attr]) {
s.operator = AttributeOperatorType.EQUALS
s.attributeValue = attributes[attr]
}
suggestions.push(s)
})
}
})
return suggestions
}
// Add the component tag name
function addTagNames(component: Component, suggestions: SimpleSelector[], selector: CompoundSelector) {
// Tag names - normalize to lowercase for CSS consistency
const tagName = component.tagName.toLowerCase()
const tagSelector = {
type: SimpleSelectorType.TAG,
value: tagName,
active: true,
}
if (canIadd(tagSelector, selector, suggestions)) {
suggestions.push(tagSelector)
}
// Special case of the HTML container
if(tagName === 'body') {
const htmlSelector = {
type: SimpleSelectorType.TAG,
value: 'html',
active: true,
}
if (canIadd(htmlSelector, selector, suggestions)) {
suggestions.push(htmlSelector)
}
}
// // IDs
// const id = component.getId()
// const idSelector = {
// type: SimpleSelectorType.ID,
// value: id,
// active: true,
// }
// if (canIadd(idSelector, selector, suggestions)) {
// suggestions.push(idSelector as IdSelector)
// }
}
// function sameType(gjs: Selector, sel: SimpleSelector): boolean {
// return (gjs.get('type') === 1 && sel.type === SimpleSelectorType.CLASS) || (gjs.get('type') === 2 && sel.type === SimpleSelectorType.ID)
// }
//
// function sameValue(gjs: Selector, sel: SimpleSelector): boolean {
// if(sel.type !== SimpleSelectorType.CLASS && sel.type !== SimpleSelectorType.ID) return false // Only classes and IDs are supported by grapesjs
// return (gjs.get('name') === (sel as ClassSelector | IdSelector).value)
// }
function canIadd(sel: SimpleSelector, selector: CompoundSelector, suggestions: SimpleSelector[]): boolean {
if(selector.selectors.some((s) => isSameSelector(s, sel))) {
return false
}
if(suggestions.some((s) => isSameSelector(s, sel))) {
return false
}
return true
}
function getChildren(component: Component): Component[] {
return component
.components()
.reduce((acc, child) => [
...acc,
child,
...getChildren(child)
], [] as Component[])
}
function getParents(component: Component): Component[] {
const parents = []
let parent = component.parent()
while (parent) {
parents.push(parent)
parent = parent.parent()
}
return parents
}
function getSiblingsBefore(component: Component): Component[] {
return component
.parent()
?.components()
.filter(c => c !== component) ?? []
}