UNPKG

use-theme-editor

Version:

Zero configuration CSS variables based theme editor

403 lines (354 loc) 13.3 kB
import { renderSelectedVars } from './renderSelectedVars'; import { getMatchingVars } from './functions/getMatchingVars'; import { addHighlight, removeHighlight } from './functions/highlight'; import { groupVars } from './functions/groupVars'; import { extractPageVariables } from './functions/extractPageVariables'; import { filterMostSpecific } from './functions/getOnlyMostSpecific'; import {getLocalStorageNamespace, setLocalStorageNamespace} from './functions/getLocalStorageNamespace'; import {initializeConsumer} from './sourcemap'; import { getAllDefaultValues } from './functions/getAllDefaultValues'; export const LOCAL_STORAGE_KEY = `${getLocalStorageNamespace()}theme`; const isRunningAsFrame = window.self !== window.top; const dependencyReady = initializeConsumer(); const toggleStylesheets = (disabledSheets) => { [...document.styleSheets].forEach(sheet => { if (!sheet.href) { return; } const id = sheet.href.replace(/\?.*/, ''); sheet.disabled = !!disabledSheets[id]; }); }; let scopesStyleElement = scopesStyleElement = document.createElement('style'); document.head.appendChild(scopesStyleElement); let ruleIndexes = {}; function toPropertyString(properties) { let propertyString = ''; for (const prop in properties) { // Leading space on first is needed to match CSS formated by the browser. propertyString += ` ${prop}: ${properties[prop]} !important;` } return propertyString; } // To guarantee a consistent index, rules are not deleted, but emptied instead. function updateRule(selector, properties) { // Leading space is included in first property. Trailing here to ensure right empty behavior of 1 space. const cssText = `${selector} {${toPropertyString(properties, ruleIndexes[selector])} }`; if (!(selector in ruleIndexes)) { // New rule ruleIndexes[selector] = scopesStyleElement.sheet.insertRule(cssText, Object.keys(ruleIndexes).length); return; } if (scopesStyleElement.sheet.cssRules[ruleIndexes[selector]].cssText === cssText) { // Nothing to update. return; } // Add new rule. scopesStyleElement.sheet.insertRule(cssText, ruleIndexes[selector]); // Remove previous, thereby restoring precarious order. scopesStyleElement.sheet.deleteRule(ruleIndexes[selector] + 1) } // Throw away previous style elements and reconstruct new ones with the right values. export function updateScopedVars(scopes, resetAll = false) { if (resetAll) { [...scopesStyleElement.sheet.cssRules].forEach(() => scopesStyleElement.sheet.deleteRule(0)) ruleIndexes = {}; } Object.entries(scopes).forEach( ([selector, scopeVars]) => { updateRule(selector, scopeVars); }); } let destroyedDoc = false; function destroyDoc() { [...document.body.childNodes].forEach(el => { if (el.id === 'theme-editor-root' || ['STYLE', 'LINK', 'SCRIPT', ].includes(el.nodeName)) { return; } document.body.removeChild(el); }); destroyedDoc = true; } export const setupThemeEditor = async (config) => { setLocalStorageNamespace(config.localStorageNamespace || ''); const editorRoot = document.createElement( 'div' ); if (!isRunningAsFrame) { editorRoot.id = 'theme-editor-root'; document.body.appendChild( editorRoot ); } await dependencyReady; const allVars = await extractPageVariables(); updateScopedVars(JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}')); const cssVars = allVars.reduce((cssVars, someVar) => [ ...cssVars, ...( cssVars.some(v => v.name === someVar.name) ? [] : [{ ...someVar, uniqueSelectors: [...new Set(someVar.usages.map(usage => usage.selector))], }] ), ], []); const defaultValues = getAllDefaultValues(cssVars); if (!isRunningAsFrame) { const renderEmptyEditor = () => { document.documentElement.classList.add('hide-wp-admin-bar'); renderSelectedVars(editorRoot, null, [], cssVars, config, defaultValues, -1); // Since the original page can be accessed with a refresh, destroy it to save resources. destroyDoc(); }; if (localStorage.getItem(getLocalStorageNamespace() + 'responsive-on-load') !== 'false') { renderEmptyEditor(); } const customizeMenu = document.getElementById('wp-admin-bar-customize'); if (customizeMenu) { const button = document.createElement('a'); button.textContent = 'Customize'; button.className = 'ab-item fake-wp-button'; button.onclick = () => { renderEmptyEditor(); }; customizeMenu.removeChild(customizeMenu.firstChild); customizeMenu.appendChild(button); } } let requireAlt = !isRunningAsFrame || localStorage.getItem(getLocalStorageNamespace() + 'theme-editor-frame-click-behavior') === 'alt'; let inspectedIndex = -1; let inspectedElements = []; let lastGroups = []; window.addEventListener('message', event => { if (event.data?.type === 'render-vars') { const { payload } = event.data; renderSelectedVars( editorRoot, null, payload.groups, cssVars, config, defaultValues, payload.index ); } }, false); // const inspectedMap = new Map(); function cachedInspection(target) { // if (inspectedMap.has(target)) { // console.log('cached inspection'); // return inspectedMap.get(target); // } const matchedVars = getMatchingVars({ cssVars, target }); const rawGroups = groupVars(matchedVars, target); const groups = filterMostSpecific(rawGroups, target); // inspectedMap.set(target, groups); return groups; } function inspect(targetOrIndex) { const isPrevious = typeof targetOrIndex === 'number'; const target = isPrevious ? inspectedElements[targetOrIndex] : targetOrIndex; if (!isPrevious) { inspectedElements.push(target); ++inspectedIndex } else { target.scrollIntoView({ block: 'center', inline: 'end', behavior: 'smooth'}); } const groups = cachedInspection(target); const currentInspectedIndex = isPrevious ? targetOrIndex : inspectedIndex; if (!isRunningAsFrame) { renderSelectedVars( editorRoot, target, groups, cssVars, config, defaultValues, currentInspectedIndex ); } else { // It's not possible to send a message that includes a reference to a DOM element. // Instead, every time we update the groups, we store the last groups. This // way we still know which element to access when a message gets back from the parent window. lastGroups = groups; const withElementIndexes = groups.map((group, index) => ({...group, element: index})); window.parent.postMessage( { type: 'render-vars', payload: { groups: withElementIndexes, index: currentInspectedIndex, }, }, window.location.href ); } if (groups.length > 0) { const {element} = groups[0]; addHighlight(element); if (lastHighlightTimeout) { const [timeout, handler, timeoutElement] = lastHighlightTimeout; window.clearTimeout(timeout); // If previous timeout was on another element, execute it immediately. // Removes its focus border. if (timeoutElement !== element) { handler(); } } const handler = () => { removeHighlight(element); lastHighlightTimeout = null; }; lastHighlightTimeout = [setTimeout(handler, isPrevious ? 2400 : 700), handler, element]; } } document.addEventListener('click', event => { const ignoreClick = requireAlt && !event.altKey; if (ignoreClick) { return; } event.preventDefault(); inspect(event.target); }); // Below are only listeners for messages sent from the parent frame. if (!isRunningAsFrame) { return; } const storedSheetConfig = localStorage.getItem(getLocalStorageNamespace() + 'set-disabled-sheets'); // This intentionally only runs on the frame. // If this would go wrong in the main window, // it might not be possible for a user to reach the settings to fix it. if (storedSheetConfig) { const disabledSheets = JSON.parse(storedSheetConfig); toggleStylesheets(disabledSheets); } const locatedElements = {}; // Keep 1 timeout as we only want to be highlighting 1 element at a time. let lastHighlightTimeout = null; let ignoreScroll = false; let scrollDebounceTimeout = null; const messageListener = event => { const {type, payload} = event.data; const {index, selector, scopes, resetAll} = payload || {}; const group = lastGroups[index]; switch (type) { case 'highlight-element-start': group && addHighlight(group.element); break; case 'highlight-element-end': group && removeHighlight(group.element); break; case 'scroll-in-view': const element = selector ? locatedElements[selector][index] : group.element; // Quick and dirty way to allow showing an element in the editor by assigning stuff to the ref. let parent = element; while (parent) { parent = parent.parentNode; if (parent && typeof parent.emerge === 'function') { parent.emerge(); } } element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'end', }); addHighlight(element); if (lastHighlightTimeout) { const [timeout, handler, timeoutElement] = lastHighlightTimeout; window.clearTimeout(timeout); // If previous timeout was on another element, execute it immediately. // Removes its focus border. if (timeoutElement !== element) { handler(); } } const handler = () => { removeHighlight(element); lastHighlightTimeout = null; }; lastHighlightTimeout = [setTimeout(handler, 1500), handler, element]; break; case 'theme-edit-alt-click': requireAlt = payload.frameClickBehavior !== 'any'; break; case 'set-sheet-config': toggleStylesheets(JSON.parse(payload)); break; case 'locate-elements': const results = document.querySelectorAll(selector); locatedElements[selector] = [...results].filter(el => { return el.offsetParent !== null && window.getComputedStyle(el).visibility !== 'hidden'; }); window.parent.postMessage( { type: 'elements-located', payload: { selector, elements: locatedElements[selector].map((el, index) => ({ index, tagName: `${el.tagName}`, id: `${el.id}`, className: `${el.className}`, isCurrentlyInspected: !!lastGroups && lastGroups.some(group => group.element === el), })), }, }, window.location.href, ); break; case 'inspect-located': const toInspect = locatedElements[selector][index]; if (toInspect) { inspect(toInspect); toInspect.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'end', }); } break; case 'set-scopes-styles': updateScopedVars(scopes, resetAll); break; case 'force-scroll': ignoreScroll = true; window.scrollTo({top: payload.position, behavior: payload.shouldSmoothScroll ? 'smooth' : 'auto' }); ignoreScroll = false; break; case 'emit-scroll': const notifyParent = () => { window.parent.postMessage( { type: 'frame-scrolled', payload: { scrollPosition: document.documentElement.scrollTop, }, }, window.location.href, ); scrollDebounceTimeout = null; } document.addEventListener('scroll', () => { if (ignoreScroll) { return; } if (!scrollDebounceTimeout) { scrollDebounceTimeout = setTimeout(notifyParent, 40); } }, {passive: true}) window.parent.postMessage( { type: 'window-height', payload: Math.max( document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight ), }, window.location.href ); break; case 'inspect-previous': inspect(index); } }; window.addEventListener('message', messageListener, false); };