UNPKG

@silexlabs/grapesjs-advanced-selector

Version:

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

380 lines (343 loc) 14.8 kB
/** * @fileoverview The model types and functions for the simple selector * A Simple selector is made of a list of simple selectors, e.g `div`, `.class`, `#id`, `[attr=^value]` */ // ////////////// // Types /** * The type of the simple selector */ export enum SimpleSelectorType { TAG = 'tag', CUSTOM_TAG = 'custom-tag', CLASS = 'class', ID = 'id', ATTRIBUTE = 'attribute', UNIVERSAL = 'universal', UNKNOWN = 'unknown', } /** * The type for tag selectors */ export type TAG = 'a' | 'abbr' | 'address' | 'area' | 'article' | 'aside' | 'audio' | 'b' | 'base' | 'bdi' | 'bdo' | 'blockquote' | 'body' | 'br' | 'button' | 'canvas' | 'caption' | 'cite' | 'code' | 'col' | 'colgroup' | 'data' | 'datalist' | 'dd' | 'del' | 'details' | 'dfn' | 'dialog' | 'div' | 'dl' | 'dt' | 'em' | 'embed' | 'fieldset' | 'figcaption' | 'figure' | 'footer' | 'form' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'head' | 'header' | 'hgroup' | 'hr' | 'html' | 'i' | 'iframe' | 'img' | 'input' | 'ins' | 'kbd' | 'label' | 'legend' | 'li' | 'link' | 'main' | 'map' | 'mark' | 'meta' | 'meter' | 'nav' | 'noscript' | 'object' | 'ol' | 'optgroup' | 'option' | 'output' | 'p' | 'param' | 'picture' | 'pre' | 'progress' | 'q' | 'rb' | 'rp' | 'rt' | 'rtc' | 'ruby' | 's' | 'samp' | 'script' | 'section' | 'select' | 'slot' | 'small' | 'source' | 'span' | 'strong' | 'style' | 'sub' | 'summary' | 'sup' | 'table' | 'tbody' | 'td' | 'template' | 'textarea' | 'tfoot' | 'th' | 'thead' | 'time' | 'title' | 'tr' | 'track' | 'u' | 'ul' | 'var' | 'video' | 'wbr' /** * A simple selector interface * This is a virtual interface to be overridden by the specific simple selector types */ export interface SimpleSelector { type: SimpleSelectorType active: boolean } export interface SimpleSelectorSuggestion extends SimpleSelector { createText?: string createValue?: string keepEditing?: boolean } export interface TagSelector extends SimpleSelector { type: SimpleSelectorType.TAG value: TAG } export interface IdSelector extends SimpleSelector { type: SimpleSelectorType.ID value: string } export interface ClassSelector extends SimpleSelector { type: SimpleSelectorType.CLASS value: string } export enum AttributeOperatorType { EQUALS = '=', INCLUDES = '~=', DASH_MATCH = '|=', PREFIX_MATCH = '^=', SUFFIX_MATCH = '$=', SUBSTRING_MATCH = '*=', } export interface AttributeSelector extends SimpleSelector { type: SimpleSelectorType.ATTRIBUTE value: string operator?: AttributeOperatorType attributeValue?: string } export interface UniversalSelector extends SimpleSelector { type: SimpleSelectorType.UNIVERSAL } export interface CustomTagSelector extends SimpleSelector { type: SimpleSelectorType.CUSTOM_TAG value: string } // ////////////// // Constants export const ATTRIBUTES = ['id', 'class', 'style', 'name', 'type', 'value', 'placeholder', 'href', 'src', 'alt', 'title', 'width', 'height', 'disabled', 'checked', 'selected', 'hidden', 'readonly', 'multiple', 'required', 'min', 'max', 'step', 'pattern', 'autocomplete', 'autofocus', 'spellcheck', 'contenteditable', 'dir', 'lang', 'tabindex', 'accesskey', 'role'] export const ATTRIBUTE_OPERATORS = ['=', '~=', '|=', '^=', '$=', '*='] export const SELECTOR_PREFIXES = ['.', '#', '[', '*'] export const TAGS: TAG[] = [ 'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio', 'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'link', 'main', 'map', 'mark', 'meta', 'meter', 'nav', 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'param', 'picture', 'pre', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'script', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'u', 'ul', 'var', 'video', 'wbr' ] export const ESCAPE_CHARS = [' '] export const STYLE_FOR_TYPE = { [SimpleSelectorType.TAG]: { color: 'var(--gjs-color-blue, #3b97e3)' }, [SimpleSelectorType.CUSTOM_TAG]: { color: 'var(--gjs-color-blue, #3b97e3)' }, [SimpleSelectorType.CLASS]: { color: 'var(--gjs-color-green, #62c462)' }, [SimpleSelectorType.ID]: { color: 'var(--gjs-tertiary-color, #8873FE)' }, [SimpleSelectorType.ATTRIBUTE]: { color: 'var(--gjs-color-yellow, #ffca6f)' }, [SimpleSelectorType.UNIVERSAL]: { color: 'var(--gjs-color-red, #dd3636)' }, [SimpleSelectorType.UNKNOWN]: { color: 'var(--gjs-color-red, #dd3636)', }, } const CLASS_SYMBOL = '•' const ID_SYMBOL = '#' const ATTRIBUTE_SYMBOL = '[ ]' const UNIVERSAL_SYMBOL = '' const CUSTOM_TAG_SYMBOL = '⚛' const TAG_SYMBOL = '⚛' const UNKNOWN_SYMBOL = '?' // ////////////// // Functions /** * Compare two simple selectors to see if they are the same */ export function isSameSelector(a: SimpleSelector, b: SimpleSelector, ignoreActive = true): boolean { if (a.type !== b.type) return false if (!ignoreActive && a.active !== b.active) return false switch (a.type) { case SimpleSelectorType.TAG: case SimpleSelectorType.CUSTOM_TAG: case SimpleSelectorType.CLASS: case SimpleSelectorType.ID: { const typedA = a as AttributeSelector const typedB = b as AttributeSelector return typedA.value === typedB.value } case SimpleSelectorType.ATTRIBUTE: { const typedA = a as AttributeSelector const typedB = b as AttributeSelector if(typedA.operator !== typedB.operator) return false if(typedA.operator && typedA.attributeValue !== typedB.attributeValue) return false return typedA.value === typedB.value } case SimpleSelectorType.UNIVERSAL: { return true } default: return false } } export function toString(selector: SimpleSelector): string { switch (selector.type) { case SimpleSelectorType.ATTRIBUTE: { const typed = selector as AttributeSelector return `[${typed.value || ''}${typed.operator ? `${typed.operator}"${typed.attributeValue ?? ''}"` : ''}]` } case SimpleSelectorType.CLASS: { const typed = selector as ClassSelector return `.${typed.value || ''}` } case SimpleSelectorType.ID: { const typed = selector as IdSelector return `#${typed.value || ''}` } case SimpleSelectorType.TAG: case SimpleSelectorType.CUSTOM_TAG: { const typed = selector as TagSelector | CustomTagSelector return typed.value || '' } case SimpleSelectorType.UNIVERSAL: return '*' default: return '' } } export function getDisplayType(selector: SimpleSelector): string { switch (selector.type) { case SimpleSelectorType.ATTRIBUTE: return ATTRIBUTE_SYMBOL case SimpleSelectorType.CLASS: return CLASS_SYMBOL case SimpleSelectorType.ID: return ID_SYMBOL case SimpleSelectorType.TAG: return TAG_SYMBOL case SimpleSelectorType.UNIVERSAL: return UNIVERSAL_SYMBOL case SimpleSelectorType.CUSTOM_TAG: return CUSTOM_TAG_SYMBOL default: return UNKNOWN_SYMBOL } } export function getDisplayName(selector: SimpleSelector): string { switch (selector.type) { case SimpleSelectorType.CLASS: case SimpleSelectorType.ID: case SimpleSelectorType.TAG: case SimpleSelectorType.ATTRIBUTE: case SimpleSelectorType.CUSTOM_TAG: return (selector as TagSelector).value case SimpleSelectorType.UNIVERSAL: return '⭐' default: return '' } } export function getEditableName(selector: SimpleSelector): string { if (selector.type === SimpleSelectorType.ATTRIBUTE) { const typed = selector as AttributeSelector return `[${typed.value || ''}]` } return toString(selector) } /** * Determines if the value is valid for the selector * If not valid, will try to suggest a valid value */ export function validate(_value: string): string | false { // Special cases if (_value === '') return _value if (_value === '*') return _value // Escape special characters for the regular expression const escapedOperators = ESCAPE_CHARS.map(op => `\\${op}`).join('|') // Lowercased version for tags/attributes validation (case-insensitive by spec) const valueLower = _value .toLowerCase() .replace(new RegExp(escapedOperators, 'g'), '-') // Case-preserving version for classes/IDs (case-sensitive by spec) const value = _value .replace(new RegExp(escapedOperators, 'g'), '-') // No starting with number or - if (valueLower.match(/^[0-9-]/)) return false if (valueLower.startsWith('-')) return false // Attributes should be from the ATTRIBUTES list if (valueLower.match(/^\[[a-z-]*\]?$/) && ATTRIBUTES.includes(valueLower.replace('[', '').replace(']', ''))) return valueLower.replace(']', '') + ']' // Attribute with a value const attrWithVal = _value // Keep the original value, not escaped because the attr value could contain spaces .match(/^\[([\w-]+)\s*=\s*"([^"]*)"?\]?$/) if (attrWithVal) { const [, name, val] = attrWithVal if(ATTRIBUTES.includes(name)) return `[${name}="${val}"]` } // Custom attributes should start with data- if (valueLower.match(/^\[data-[_a-zA-Z]+[_a-zA-Z0-9]*\]?$/)) return valueLower.replace(']', '') + ']' // Custom tags should contain a - and be defined as web components if(valueLower.match(/^[a-z-]*[a-z]$/) && valueLower.includes('-') && window.customElements.get(valueLower)) return valueLower // Tags should be from the TAGS list if (valueLower.match(/^[a-z]*$/) && TAGS.includes(valueLower as TAG)) return valueLower // Classes should start with . then a letter (case-sensitive) if (value.match(/^\.-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/)) return value // IDs should start with # then a letter (case-sensitive) if (value.match(/^#-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/)) return value // Check if it is a valid css class when adding a dot at the beginning (case-sensitive) if (value.match(/^-?[_a-zA-Z]+[_a-zA-Z0-9-]*[_a-zA-Z0-9]$/)) return '.' + value // Not a valid selector and not fixable return false } /** * Get a list of suggestions, filtered and with creation suggestions */ export function suggest(filter: string, suggestions: SimpleSelector[]): SimpleSelectorSuggestion[] { if (filter === '*') { // The universal selector will be suggested in the creation suggestions (getCreationSuggestions) return [] } // Make sure suggestions is an array of SimpleSelector // Or throw suggestions.forEach((suggestion) => { if (!suggestion.type) { throw new Error('Invalid suggestion') } }) // Limit the number of suggestions by type of selector //const typeCount = new Map<SimpleSelectorType, number>() return suggestions // Filter by the filter .filter(selector => { const value = getEditableName(selector) return value.toLowerCase().includes(filter.toLowerCase()) }) } export function getCreationSuggestions(validated: string | false, originalInput?: string): SimpleSelectorSuggestion[] { // Creation suggestions const creationSuggestions = [] as SimpleSelectorSuggestion[] if (validated) { const active = true if (validated === '*') { creationSuggestions.push({ createText: '*', type: SimpleSelectorType.UNIVERSAL, active, } as UniversalSelector) } else if (validated.startsWith('.')) { creationSuggestions.push({ createText: validated, type: SimpleSelectorType.CLASS, value: validated.slice(1), active, } as ClassSelector) } else if (validated.startsWith('[')) { const [name, val] = validated .substring(1, validated.length - 1) // Remove the brackets [] .split('=') // Split the name and value if(val) { creationSuggestions.push({ createText: `[${name}="${val}"]`, type: SimpleSelectorType.ATTRIBUTE, value: name, operator: '=', attributeValue: val.replace(/"/g, ''), active, } as AttributeSelector) } else { creationSuggestions.push({ createText: validated, type: SimpleSelectorType.ATTRIBUTE, value: name, active, } as AttributeSelector) } } else if (validated.match(/^[a-z-]*-[a-z]*$/)) { creationSuggestions.push({ createText: validated, type: SimpleSelectorType.CUSTOM_TAG, value: validated, active, } as CustomTagSelector) } } else if (originalInput && originalInput.startsWith('[')) { // Handle incomplete attribute selectors like "[check" -> suggest "[checked]" const active = true const partialAttr = originalInput.substring(1).toLowerCase() // Remove [ and lowercase for matching // Find matching attributes from ATTRIBUTES list ATTRIBUTES.forEach(attr => { if (attr.toLowerCase().startsWith(partialAttr)) { creationSuggestions.push({ createText: `[${attr}]`, createValue: `[${attr}]`, type: SimpleSelectorType.ATTRIBUTE, value: attr, active, } as SimpleSelectorSuggestion & AttributeSelector) } }) // Find matching data attributes (data-*) if (partialAttr.startsWith('data-') && partialAttr.length > 5) { const dataAttrName = partialAttr creationSuggestions.push({ createText: `[${dataAttrName}]`, createValue: `[${dataAttrName}]`, type: SimpleSelectorType.ATTRIBUTE, value: dataAttrName, active, } as SimpleSelectorSuggestion & AttributeSelector) } } return creationSuggestions } export function specificity(selector: SimpleSelector, ignoreActive = false): number { if (!ignoreActive && !selector.active) return 0 switch (selector.type) { case SimpleSelectorType.TAG: return 1 case SimpleSelectorType.CLASS: case SimpleSelectorType.ATTRIBUTE: case SimpleSelectorType.CUSTOM_TAG: return 10 case SimpleSelectorType.ID: return 100 case SimpleSelectorType.UNIVERSAL: return 0 default: console.warn(`Unknown selector type: ${selector.type}`) return 0 } } /** * Returns a sorting priority for selectors based on CSS specificity rules. */ export function getSelectorPriority(selector: SimpleSelector): number { switch (selector.type) { case SimpleSelectorType.TAG: case SimpleSelectorType.CUSTOM_TAG: return 1 // Tags come first case SimpleSelectorType.ID: return 2 // IDs come second case SimpleSelectorType.CLASS: return 3 // Classes come next case SimpleSelectorType.ATTRIBUTE: return 4 // Attributes should come after classes case SimpleSelectorType.UNIVERSAL: return 5 // Universal selector has the lowest specificity default: return 6 } }