UNPKG

use-theme-editor

Version:

Zero configuration CSS variables based theme editor

484 lines (442 loc) 15.6 kB
import React, {useState, useMemo, Fragment, useContext} from 'react'; import {isColorProperty, TypedControl} from './TypedControl'; import { PSEUDO_REGEX, ACTIONS, ROOT_SCOPE} from '../../hooks/useThemeEditor'; import classnames from 'classnames'; import {COLOR_VALUE_REGEX, GRADIENT_REGEX, PREVIEW_SIZE} from '../properties/ColorControl'; import {match} from 'css-mediaquery'; import {isOverridden, VariableScreenSwitcher} from './VariableScreenSwitcher'; import {ThemeEditorContext} from '../ThemeEditor'; import {IdeLink} from './IdeLink'; import { definedValues } from '../../functions/collectRuleVars'; import { VariableReferences } from './VariableReferences'; import { Checkbox } from "../controls/Checkbox"; import { ScrollInViewButton } from './ScrollInViewButton'; import { FilterableVariableList } from '../ui/FilterableVariableList'; import { VariableUsages } from './VariableUsages'; import { rootScopes } from '../../functions/extractPageVariables'; import { useResumableState } from '../../hooks/useResumableReducer'; import { get } from '../../state'; const capitalize = string => string.charAt(0).toUpperCase() + string.slice(1); const format = name => { // todo: make this make more sense const raw = name.replace(/^--/, '').replace(/--/g, ': ').replace(/[-_]/g, ' '); const parts = raw.split(':'); return [ parts.slice(0, - 1).join(' — '), parts[parts.length - 1].trim().replace(/ /g, '-') ]; }; export const formatTitle = (name, annoyingPrefix, nameReplacements) => { const [prefix, prop] = format(name); let formattedProp = prop.replaceAll(/-/g, ' ').trim(); if (annoyingPrefix) { formattedProp = formattedProp.replace( new RegExp(`^${annoyingPrefix} `), '').trim(); } formattedProp = !nameReplacements ? formattedProp : nameReplacements .filter((r) => r.active && r.to.length > 0 && r.from.length > 1) .reduce( (prop, { from, to }) => { try { return prop.replace(new RegExp(from), to); } catch(e) { console.log(`Failed replacing ${from} to ${to}`) return prop; } }, formattedProp ); const annoyingRegex = new RegExp(`^${annoyingPrefix}\\s*\\—\\s*`); const cleanedPrefix = prefix.trim().replace(annoyingRegex, ''); return <Fragment> <span style={{ fontSize: '13px', fontStyle: 'italic', color: 'black', display: 'block', }} >{capitalize(annoyingPrefix ? cleanedPrefix : prefix)}</span> <span style={{fontWeight: 'bold'}} className={'var-control-property'}>{formattedProp}</span> </Fragment>; }; const previewValue = (value, cssVar, onClick, isDefault, referencedVariable, isOpen) => { const size = PREVIEW_SIZE; const title = `${value}${!isDefault ? '' : ' (default)'}`; const isUrl = /url\(/.test(value); const property = cssVar.usages && cssVar.usages[0]?.property; const isColor = isColorProperty(property) || COLOR_VALUE_REGEX.test(value) || GRADIENT_REGEX.test(value); const presentable = isColor || isUrl; if (value && presentable) { if (!!referencedVariable && isOpen) { // Both value and preview are shown on the referenced variable when open. return null; } return ( <Fragment> <span key={1} onClick={onClick} title={title} style={{ width: size, height: size, border: '1px solid black', borderRadius: '6px', backgroundImage: `${value}`, backgroundColor: `${value}`, backgroundRepeat: `no-repeat`, backgroundSize: 'cover', // background:, float: 'right', textShadow: 'white 0px 10px', // backgroundSize: 'cover', }} > {/var\(/.test(value) && 'var'} </span> <span style={{ float: 'right', marginRight: '4px' }}> {(referencedVariable?.name || '').replaceAll(/-+/g, ' ').trim() || (isUrl ? null : value)} </span> </Fragment> ); } return <span key={ 1 } onClick={ onClick } title={ title } style={ { // width: size, fontSize: '14px', float: 'right', } } > { value } </span>; }; // Get values from specificity ordered scopes. export function getValueFromDefaultScopes(scopes, cssVar) { if (!scopes) { return null; } for (const {selector, scopeVars} of scopes) { if (!scopeVars) { console.log('A scope with no vars?!', selector, cssVar) } if (scopeVars && scopeVars.some(v=>v.name === cssVar.name)) { return definedValues[selector][cssVar.name]; } } return null; } function referenceChainKey(references, cssVar) { return [...references, cssVar].map(v=>v.name).join(); } export const VariableControl = (props) => { const { cssVar, onChange, onUnset, initialOpen = false, referenceChain = [], scopes: elementScopes, parentVar, element, currentScope = ROOT_SCOPE, } = props; const { width, annoyingPrefix, nameReplacements, showCssProperties } = get; const { scopes, dispatch, defaultValues, allVars, } = useContext(ThemeEditorContext); const theme = scopes[ROOT_SCOPE] || {}; const { name, usages, maxSpecific, positions, properties, } = cssVar; const defaultValue = definedValues[':root'][name] || definedValues[':where(html)'][name] || defaultValues[name] || getValueFromDefaultScopes(elementScopes, cssVar); const [ showSelectors, setShowSelectors ] = useState(false); const [overwriteVariable, setOverwriteVariable] = useState(false); const toggleSelectors = () => setShowSelectors(!showSelectors); const valueFromScope = !scopes || !scopes[currentScope] ? null : scopes[currentScope][name]; const value = valueFromScope || defaultValue; const isDefault = value === defaultValue; const {media} = maxSpecific || {}; const varMatches = value && value.match(/^var\(\s*(\-\-[\w-]+)\s*[\,\)]/); const referencedVariable = useMemo(() => { return !varMatches || varMatches.length === 0 ? null : allVars.find((cssVar) => cssVar.name === varMatches[1]) || { name: varMatches[1], usages: [ { property: cssVar.usages[0].property, isFake: true, }, ], properties: {}, positions: [], }; }, [value]); const {overridingMedia} = cssVar.allVar || cssVar; const matchesQuery = !media || match(media, { type: 'screen', width: width || window.screen.width }); const matchesScreen = matchesQuery && (!overridingMedia || !isOverridden({media, cssVar, width})); let currentLevel = referenceChain.length; const key = referenceChainKey(referenceChain, cssVar); const [ isOpen, setIsOpen // Open all variables that refer to variables immediately. ] = useResumableState(initialOpen || (currentLevel > 0 && !!referencedVariable), `OPEN${key}`); const toggleOpen = () => setIsOpen(!isOpen ); const excludedVarName = parentVar?.name; const references = useMemo(() => { // Prevent much unneeded work on large lists. if (!isOpen) { return null; } const regexp = new RegExp( `var\\(\\s*${cssVar.name.replaceAll(/-/g, "\\-")}[\\s\\,\\)]` ); return allVars.filter(({ name, usages }) => { if (name === excludedVarName) { return false; } if (theme[name]) { return regexp.test(theme[name]); } if (definedValues[name]) { return regexp.test(definedValues[name]); } return regexp.test(usages[0].defaultValue); }); }, [theme, excludedVarName, isOpen]); const [showReferences, setShowReferences] = useState(false); const [openVariablePicker, setOpenVariablePicker] = useState(false); const cssFunc = cssVar.usages.find((u) => u.cssFunc !== null)?.cssFunc; if (currentLevel > 20) { // Very long dependency chain, probably cyclic, let's break it here. // I'll prevent setting cyclic references in the first place. // Though this could also be an error in the source CSS. return null; } const isInTheme = name in theme || name in (scopes[currentScope] || {}); return ( <li data-nesting-level={currentLevel} key={name} className={classnames('var-control', { 'var-control-in-theme': isInTheme, 'var-control-no-match-screen': !matchesScreen, })} onClick={() => !isOpen && toggleOpen()} style={{ // userSelect: 'none', position: 'relative', listStyleType: 'none', fontSize: '15px', clear: 'both', cursor: isOpen ? 'auto' : 'pointer', paddingTop: 0, }} > {!matchesScreen && <VariableScreenSwitcher {...{ cssVar, media }} />} <div style={{ paddingTop: '6px' }} onClick={() => isOpen && toggleOpen()}> <h5 style={{ display: 'inline-block', fontSize: '16px', padding: '0 4px 0', fontWeight: '400', userSelect: 'none', cursor: 'pointer', clear: 'left', }} > {formatTitle(name, annoyingPrefix, nameReplacements)} </h5> {previewValue(value, cssVar, toggleOpen, isDefault, referencedVariable, isOpen)} <div> {!!showCssProperties && <Fragment> {!!cssFunc && <span style={{color: 'darkcyan'}}>{cssFunc}</span>} {Object.entries(properties).map(([property, {isFullProperty, fullValue, isImportant}]) => ( <span key={property} className="monospace-code" style={{ fontSize: '14px', ...(property !== maxSpecific?.property ? {background: 'grey'} : {}) }} title={ isFullProperty ? '' : fullValue } > {property} {!isFullProperty && <b style={{ color: 'red' }}>*</b>} {!!isImportant && <b style={{fontWeight: 'bold', color: 'darkorange'}}>!important</b>} </span> ))} </Fragment>} </div> </div> {!!positions[0] && <IdeLink {...(positions[0] || {})} />} {isOpen && ( <Fragment> {references.length > 0 && ( <div> <Checkbox title={references.map((r) => r.name).join('\n')} style={{ fontSize: '14px' }} controls={[showReferences, setShowReferences]} > Used by {references.length} other </Checkbox> {showReferences && <VariableReferences {...{ references }} />} </div> )} <div style={{ display: 'flex', clear: 'both', justifyContent: 'flex-end', }} > {isDefault && ( <span style={{ margin: '6px 6px 0', color: 'grey', }} > default{' '} </span> )} {isInTheme && defaultValue !== null && ( <button title={`Remove from current theme? The value from the default theme will be used, which is currently: "${defaultValue}"`} onClick={() => { onUnset(); }} > Revert </button> )} {referencedVariable && ( <button style={{borderWidth: overwriteVariable ? '4px' : '1px'}} onClick={() => { setOverwriteVariable(!overwriteVariable); }} > Raw </button> )} <button style={{borderWidth: openVariablePicker ? '4px' : '1px'}} onClick={(event) => { setOpenVariablePicker(!openVariablePicker); event.stopPropagation(); }}> Link </button> {!usages[0].isFake && ( <button onClick={toggleSelectors}> Selectors ({usages.length}) </button> )} {typeof element !== 'undefined' && ( <span key="foobar"> <ScrollInViewButton {...{ element }} /> </span> )} </div> {openVariablePicker && ( <FilterableVariableList {...{value, elementScopes}} onChange={(value) => { // setOpenVariablePicker(false); onChange(value); }} /> )} {showSelectors && !usages[0].isFake && ( <Fragment> <div>{name}</div> <VariableUsages {...{ usages, maxSpecificSelector: maxSpecific?.selector, winningSelector: maxSpecific?.winningSelector, scope: currentScope, }} /> </Fragment> )} {(!referencedVariable || overwriteVariable) && !openVariablePicker && ( <div // onMouseEnter={() => { // PSEUDO_REGEX.test(name) && // dispatch({ // type: ACTIONS.startPreviewPseudoState, // payload: { name }, // }); // }} // onMouseLeave={() => { // PSEUDO_REGEX.test(name) && // dispatch({ // type: ACTIONS.endPreviewPseudoState, // payload: { name }, // }); // }} > <br /> <TypedControl {...{ cssVar, value, onChange, cssFunc }} /> </div> )} {!!referencedVariable && !overwriteVariable && ( <ul style={{ margin: 0 }}> <VariableControl {...{ scopes: elementScopes }} cssVar={referencedVariable} onChange={(value) => { dispatch({ type: ACTIONS.set, payload: { name: referencedVariable.name, value }, }); }} onUnset={() => { dispatch({ type: ACTIONS.unset, payload: { name: referencedVariable.name }, }); }} key={referencedVariable.name} referenceChain={[...referenceChain, cssVar]} parentVar={cssVar} /> </ul> )} </Fragment> )} </li> ); };