/* global prefs t $ $$ $create template tWordBreak installed sorter filterAndAppend handleEvent animateElement scrollElementIntoView formatDate */ 'use strict'; const UI = { ENTRY_ID_PREFIX_RAW: 'style-', ENTRY_ID_PREFIX: '#style-', TARGET_TYPES: ['domains', 'urls', 'urlPrefixes', 'regexps'], GET_FAVICON_URL: 'https://www.google.com/s2/favicons?domain=', OWN_ICON: chrome.runtime.getManifest().icons['16'], favicons: prefs.get('manage.newUI.favicons'), faviconsGray: prefs.get('manage.newUI.faviconsGray'), targets: prefs.get('manage.newUI.targets'), labels: { 'usercss': { is: ({style}) => typeof style.usercssData !== 'undefined', text: 'usercss' }, 'disabled': { is: ({entry}) => !$('.entry-state-toggle', entry).checked, text: t('genericDisabledLabel') } }, init: () => { $('.ext-version').textContent = `v${chrome.runtime.getManifest().version}`; // translate CSS manually // #update-all-no-updates[data-skipped-edited="true"]::after { // content: " ${t('updateAllCheckSucceededSomeEdited')}"; // } document.head.appendChild($create('style', ` body.all-styles-hidden-by-filters #installed:after { content: "${t('filteredStylesAllHidden')}"; } `)); }, showStyles: (styles = [], matchUrlIds) => { UI.addHeaderLabels(); const sorted = sorter.sort({ styles: styles.map(style => ({ style, name: (style.name || '').toLocaleLowerCase() + '\n' + style.name, })), }); let index = 0; let firstRun = true; installed.dataset.total = styles.length; const scrollY = (history.state || {}).scrollY; const shouldRenderAll = scrollY > window.innerHeight || sessionStorage.justEditedStyleId; const renderBin = document.createDocumentFragment(); if (scrollY) { renderStyles(); } else { requestAnimationFrame(renderStyles); } function renderStyles() { const t0 = performance.now(); let rendered = 0; while ( index < sorted.length && // eslint-disable-next-line no-unmodified-loop-condition (shouldRenderAll || ++rendered < 20 || performance.now() - t0 < 10) ) { const info = sorted[index++]; const entry = UI.createStyleElement(info); if (matchUrlIds && !matchUrlIds.includes(info.style.id)) { entry.classList.add('not-matching'); rendered--; } renderBin.appendChild(entry); } filterAndAppend({container: renderBin}).then(sorter.updateStripes); if (index < sorted.length) { requestAnimationFrame(renderStyles); if (firstRun) setTimeout(UI.getFaviconImgSrc); firstRun = false; return; } setTimeout(UI.getFaviconImgSrc); if (sessionStorage.justEditedStyleId) { UI.highlightEditedStyle(); } else if ('scrollY' in (history.state || {})) { setTimeout(window.scrollTo, 0, 0, history.state.scrollY); } } }, createStyleElement: ({style, name}) => { // query the sub-elements just once, then reuse the references if (!(UI._parts || {}).UI) { const entry = template['style']; UI._parts = { UI: true, entry, entryClassBase: entry.className, checker: $('.entry-state-toggle', entry) || {}, nameLink: $('.entry-name', entry), editLink: $('.entry-edit', entry) || {}, editHrefBase: 'edit.html?id=', appliesTo: $('.entry-applies-to', entry), targets: $('.targets', entry), decorations: { urlPrefixesAfter: '*', regexpsBefore: '/', regexpsAfter: '/', }, }; } const parts = UI._parts; const configurable = style.usercssData && style.usercssData.vars && Object.keys(style.usercssData.vars).length > 0; parts.checker.checked = style.enabled; $('.entry-name-text', parts.nameLink).textContent = tWordBreak(style.name); parts.nameLink.href = parts.editLink.href = parts.editHrefBase + style.id; // clear the code to free up some memory // (note, style is already a deep copy) style.sourceCode = null; style.sections.forEach(section => (section.code = null)); const entry = parts.entry.cloneNode(true); entry.id = UI.ENTRY_ID_PREFIX_RAW + style.id; entry.styleId = style.id; entry.styleNameLowerCase = name || style.name.toLocaleLowerCase(); entry.styleMeta = style; entry.className = parts.entryClassBase + ' ' + (style.enabled ? 'enabled' : 'disabled') + (style.updateUrl ? ' updatable' : '') + (style.usercssData ? ' usercss' : ''); $('.entry-id', entry).textContent = style.injectionOrder || style.id; let el = $('.entry-homepage', entry); el.classList.toggle('invisible', !style.url); el.href = style.url || ''; el.dataset.title = style.url ? `${t('externalHomepage')}: ${style.url}` : ''; const support = style.usercssData && style.usercssData.supportURL || ''; el = $('.entry-support', entry); el.classList.toggle('invisible', !support); el.href = support; el.dataset.title = support ? `${t('externalSupport')}: ${support}` : ''; $('.entry-configure-usercss', entry).classList.toggle('invisible', !configurable); if (style.updateUrl) { $('.entry-updater-placeholder', entry).replaceWith(template.updaterIcons.cloneNode(true)); } $('.entry-version', entry).textContent = style.usercssData && style.usercssData.version || ''; const lastUpdate = $('.entry-last-update', entry); lastUpdate.textContent = UI.getDateString(style.updateDate); // Show install & last update in title lastUpdate.dataset.title = [ {prop: 'installDate', name: 'dateInstalled'}, {prop: 'updateDate', name: 'dateUpdated'}, ].map(({prop, name}) => t(name) + ': ' + (formatDate(entry.styleMeta[prop]) || '—')).join('\n'); UI.createStyleTargetsElement({entry, style}); UI.addLabels(entry); return entry; }, getDateString: date => { const newDate = new Date(date); return newDate instanceof Date && isFinite(newDate) ? newDate.toISOString().split('T')[0].replace(/-/g, '.') : ''; }, createStyleTargetsElement: ({entry, style}) => { const parts = UI._parts; const entryTargets = $('.targets', entry); const targets = parts.targets.cloneNode(true); let container = targets; let numTargets = 0; const displayed = new Set(); for (const type of UI.TARGET_TYPES) { for (const section of style.sections) { for (const targetValue of section[type] || []) { if (displayed.has(targetValue)) { continue; } displayed.add(targetValue); const element = template.appliesToTarget.cloneNode(true); if (numTargets === UI.targets) { container = container.appendChild(template.extraAppliesTo.cloneNode(true)); container.classList.remove('hidden'); } element.dataset.type = type; element.dataset.index = numTargets; element.dataset.title = (parts.decorations[type + 'Before'] || '') + targetValue + (parts.decorations[type + 'After'] || ''); container.appendChild(element); numTargets++; } } } if (numTargets <= UI.targets) { // Include hidden expander in case user changes UI.targets container.appendChild(template.extraAppliesTo.cloneNode(true)); } if (numTargets) { entryTargets.parentElement.replaceChild(targets, entryTargets); } else if (!entry.classList.contains('global') || !entryTargets.firstElementChild) { if (entryTargets.firstElementChild) { entryTargets.textContent = ''; } entryTargets.appendChild(template.appliesToEverything.cloneNode(true)); } entry.classList.toggle('global', !numTargets); }, getFaviconImgSrc: (container = installed) => { if (!UI.favicons) return; const regexpRemoveNegativeLookAhead = /(\?!([^)]+\))|\(\?![\w(]+[^)]+[\w|)]+)/g; // replace extra characters & all but the first group entry "(abc|def|ghi)xyz" => abcxyz const regexpReplaceExtraCharacters = /[\\(]|((\|\w+)+\))/g; const regexpMatchRegExp = /[\w-]+[.(]+(com|org|co|net|im|io|edu|gov|biz|info|de|cn|uk|nl|eu|ru)\b/g; const regexpMatchDomain = /^.*?:\/\/([^/]+)/; for (const target of $$('.target', container)) { const type = target.dataset.type; const targetValue = target.dataset.title; if (!targetValue) continue; let favicon = ''; if (type === 'domains') { favicon = UI.GET_FAVICON_URL + targetValue; } else if (targetValue.includes('chrome-extension:') || targetValue.includes('moz-extension:')) { favicon = UI.OWN_ICON; } else if (type === 'regexps') { favicon = targetValue .replace(regexpRemoveNegativeLookAhead, '') .replace(regexpReplaceExtraCharacters, '') .match(regexpMatchRegExp); favicon = favicon ? UI.GET_FAVICON_URL + favicon.shift() : ''; } else { favicon = targetValue.includes('://') && targetValue.match(regexpMatchDomain); favicon = favicon ? UI.GET_FAVICON_URL + favicon[1] : ''; } if (favicon) { const el = $('img[src], svg', target); if (!el || el.localName === 'svg') { const img = $('img', target); img.dataset.src = favicon; } else if ((target.dataset.src || target.src) !== favicon) { delete el.src; el.dataset.src = favicon; } } } handleEvent.loadFavicons(); }, highlightEditedStyle: () => { if (!sessionStorage.justEditedStyleId) return; const entry = $(UI.ENTRY_ID_PREFIX + sessionStorage.justEditedStyleId); delete sessionStorage.justEditedStyleId; if (entry) { animateElement(entry); requestAnimationFrame(() => scrollElementIntoView(entry)); } }, addHeaderLabels: () => { const header = $('.header-name'); const span = document.createElement('span'); let labels = $('.header-labels', header); if (labels) { labels.textContent = ''; } else { labels = span.cloneNode(); } const label = document.createElement('a'); labels.className = 'header-labels'; label.className = 'header-label sortable tt-s'; label.href = '#'; Object.keys(UI.labels).forEach(item => { const newLabel = label.cloneNode(true); const text = UI.labels[item].text; newLabel.dataset.type = item; newLabel.textContent = text; newLabel.appendChild(span.cloneNode()); newLabel.dataset.title = t('sortLabel', text); labels.appendChild(newLabel); }); header.appendChild(labels); }, addLabels: entry => { const style = entry.styleMeta; const container = $('.entry-labels', entry); const label = document.createElement('span'); const labels = document.createElement('span'); labels.className = 'entry-labels'; label.className = 'entry-label'; Object.keys(UI.labels).forEach(item => { if (UI.labels[item].is({entry, style})) { const newLabel = label.cloneNode(true); newLabel.dataset.type = item; newLabel.textContent = UI.labels[item].text; labels.appendChild(newLabel); } }); container.replaceWith(labels); } };