diff --git a/.eslintrc b/.eslintrc index bc0641d0..b4ebf99a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -49,6 +49,7 @@ globals: tNodeList: false tDocLoader: false tWordBreak: false + formatDate: false # dom.js onDOMready: false onDOMscriptReady: false diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 57d078bf..37cb43ba 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -214,6 +214,10 @@ "message": "Disabled", "description": "Used in various lists/options to indicate that something is disabled" }, + "genericEnabledLabel": { + "message": "Enabled", + "description": "Used in various lists/options to indicate that something is enabled" + }, "genericHistoryLabel": { "message": "History", "description": "Used in various places to show a history log of something" @@ -234,6 +238,14 @@ "message": "Saved", "description": "Used in various parts of the UI to indicate that something was saved" }, + "genericTitle": { + "message": "Title", + "description": "Used in various parts of the UI to indicate the title of something" + }, + "genericUnknown": { + "message": "Unknown", + "description": "Used in various parts of the UI to indicate if something is unknown (e.g. an unknown date)" + }, "confirmNo": { "message": "No", "description": "'No' button in a confirm dialog" @@ -262,6 +274,14 @@ "message": "Close", "description": "'Close' button in a confirm dialog" }, + "dateInstalled": { + "message": "Date installed", + "description": "Option text for the user to sort the style by install date" + }, + "dateUpdated": { + "message": "Date updated", + "description": "Option text for the user to sort the style by last update date" + }, "dbError": { "message": "An error has occurred using the Stylus database. Would you like to visit a web page with possible solutions?", "description": "Prompt when a DB error is encountered" @@ -816,6 +836,34 @@ "shortcutsNote": { "message": "Define keyboard shortcuts" }, + "sortLabel": { + "message": "Select a sort to apply to the installed styles", + "description": "Title on the sort select to indicate it is used for sorting entries" + }, + "sortDateNewestFirst": { + "message": "newest first", + "description": "Text added to indicate that sorting a date would add the newest entries at the top" + }, + "sortDateOldestFirst": { + "message": "oldest first", + "description": "Text added to indicate that sorting a date would add the oldest entries at the top" + }, + "sortLabelTitleAsc": { + "message": "Title Ascending", + "description": "Text added to option group to indicate a block of options that apply a title ascending (A to Z) sort" + }, + "sortLabelTitleDesc": { + "message": "Title Descending", + "description": "Text added to option group to indicate a block of options that apply a title descending (Z to A) sort" + }, + "sortStylesHelpTitle": { + "message": "Sort contents", + "description": "Label for the sort info popup on the Manage styles page" + }, + "sortStylesHelp": { + "message": "Choose the type of sort to apply to the installed entries from within the sort dropdown. The default setting applies an ascending sort (A to Z) to the entry titles. Sorts within the \"Title Descending\" group will apply a descending sort (Z to A) to the title.\nThere are other presets that will allow sorting the entries by multiple criteria. Think of this like sorting a table with multiple columns and each category in a select (between the plus signs) represents a column, or group.\nFor example, if the setting is \"Enabled (first) + Title\", the entries would sort so that all the enabled entries are sorted to the top of the list, then an entry title ascending sort (A to Z) is applied to both the enabled and disabled entries separately.", + "description": "Text in the minihelp displayed when clicking (i) icon to the right of the sort input field on the Manage styles page" + }, "styleBadRegexp": { "message": "Regexp is invalid.", "description": "Validation message for a bad regexp in a style" diff --git a/global.css b/global.css index a39c97e9..3351d0ac 100644 --- a/global.css +++ b/global.css @@ -41,7 +41,7 @@ input:not([type]) { padding: 0 3px; font: 400 13.3333px Arial; border: 1px solid hsl(0, 0%, 66%); -} +} .svg-icon.checked { position: absolute; @@ -115,7 +115,7 @@ select { } .select-resizer { - display: inline-flex!important; + display: inline-flex !important; cursor: default; position: relative; } @@ -196,7 +196,7 @@ select[disabled] > option { .moz-appearance-bug input[type="radio"] { -moz-appearance: radio !important; } - + .firefox select { font-size: 13px; padding: 0 20px 0 2px; @@ -228,8 +228,8 @@ select[disabled] > option { .firefox.non-windows .style-name { font-family: Arial, sans-serif; - } - + } + /* Firefox cannot handle fractions in font-size */ .firefox button:not(.install) { font-size: 13px; diff --git a/js/localization.js b/js/localization.js index 7c53cda1..8f1b4ebe 100644 --- a/js/localization.js +++ b/js/localization.js @@ -197,3 +197,14 @@ function tWordBreak(text) { return text.length <= 10 ? text : text.replace(/([\d\w\u007B-\uFFFF]{10}|[\d\w\u007B-\uFFFF]{5,10}[!-/]|((?!\s)\W){10})(?!\b|\s)/g, '$&\u00AD'); } + +function formatDate(date) { + return tryCatch(lang => { + const newDate = new Date(parseInt(date)); + return newDate.toLocaleDateString(lang, { + day: '2-digit', + month: 'short', + year: newDate.getYear() === new Date().getYear() ? undefined : '2-digit', + }); + }, [chrome.i18n.getUILanguage(), 'en']) || ''; +} diff --git a/js/prefs.js b/js/prefs.js index e2ac97eb..27a43531 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -36,6 +36,7 @@ var prefs = new function Prefs() { 'manage.newUI.favicons': false, // show favicons for the sites in applies-to 'manage.newUI.faviconsGray': true, // gray out favicons 'manage.newUI.targets': 3, // max number of applies-to targets visible: 0 = none + 'manage.newUI.sort': 'title,asc', // current applied sort 'editor.options': {}, // CodeMirror.defaults.* 'editor.options.expanded': true, // UI element state: expanded/collapsed diff --git a/manage.html b/manage.html index 0b3d029d..e6b5e223 100644 --- a/manage.html +++ b/manage.html @@ -155,6 +155,7 @@ + @@ -245,9 +246,18 @@ - +
+
+ + +
+ + + +
+

@@ -344,7 +354,6 @@

-
diff --git a/manage/filters.js b/manage/filters.js index fb12d179..82f1dcfe 100644 --- a/manage/filters.js +++ b/manage/filters.js @@ -1,4 +1,5 @@ /* global installed messageBox */ +/* global sorter */ 'use strict'; const filtersSelector = { @@ -154,6 +155,7 @@ function filterOnChange({target: el, forceRefilter}) { }); if (installed) { reapplyFilter(); + sorter().update(); } } @@ -190,17 +192,12 @@ function reapplyFilter(container = installed) { filterContainer({hide: false}); } // filtering needed or a single-element job from handleUpdate() - const entries = installed.children; - const numEntries = entries.length; - let numVisible = numEntries - $$('.entry.hidden').length; for (const entry of toUnhide.children || toUnhide) { - const next = findInsertionPoint(entry); - if (entry.nextElementSibling !== next) { - installed.insertBefore(entry, next); + if (!entry.parentNode) { + installed.appendChild(entry); } if (entry.classList.contains('hidden')) { entry.classList.remove('hidden'); - numVisible++; } } // B: hide @@ -218,18 +215,10 @@ function reapplyFilter(container = installed) { // 1. add all hidden entries to the end // 2. add the visible entries before the first hidden entry if (container instanceof DocumentFragment) { - for (const entry of toHide) { - installed.appendChild(entry); - } - installed.insertBefore(container, $('.entry.hidden')); + installed.appendChild(container); showFiltersStats(); return; } - // normal filtering of the page or a single-element job from handleUpdate() - // we need to keep the visible entries together at the start - // first pass only moves one hidden entry in hidden groups with odd number of items - shuffle(false); - setTimeout(shuffle, 0, true); // single-element job from handleEvent(): add the last wraith if (toHide.length === 1 && toHide[0].parentElement !== installed) { installed.appendChild(toHide[0]); @@ -256,95 +245,6 @@ function reapplyFilter(container = installed) { toUnhide = $$(selector, container); } } - - function shuffle(fullPass) { - if (fullPass && !document.body.classList.contains('update-in-progress')) { - $('#check-all-updates').disabled = !$('.updatable:not(.can-update)'); - } - // 1. skip the visible group on top - let firstHidden = $('#installed > .hidden'); - let entry = firstHidden; - let i = [...entries].indexOf(entry); - let horizon = entries[numVisible]; - const skipGroup = state => { - const start = i; - const first = entry; - while (entry && entry.classList.contains('hidden') === state) { - entry = entry.nextElementSibling; - i++; - } - return {first, start, len: i - start}; - }; - let prevGroup = i ? {first: entries[0], start: 0, len: i} : skipGroup(true); - // eslint-disable-next-line no-unmodified-loop-condition - while (entry) { - // 2a. find the next hidden group's start and end - // 2b. find the next visible group's start and end - const isHidden = entry.classList.contains('hidden'); - const group = skipGroup(isHidden); - const hidden = isHidden ? group : prevGroup; - const visible = isHidden ? prevGroup : group; - // 3. move the shortest group; repeat 2-3 - if (hidden.len < visible.len && (fullPass || hidden.len % 2)) { - // 3a. move hidden under the horizon - for (let j = 0; j < (fullPass ? hidden.len : 1); j++) { - const entry = entries[hidden.start]; - installed.insertBefore(entry, horizon); - horizon = entry; - i--; - } - prevGroup = isHidden ? skipGroup(false) : group; - firstHidden = entry; - } else if (isHidden || !fullPass) { - prevGroup = group; - } else { - // 3b. move visible above the horizon - for (let j = 0; j < visible.len; j++) { - const entry = entries[visible.start + j]; - installed.insertBefore(entry, firstHidden); - } - prevGroup = { - first: firstHidden, - start: hidden.start + visible.len, - len: hidden.len + skipGroup(true).len, - }; - } - } - if (fullPass) { - showFiltersStats({immediately: true}); - } - } - - function findInsertionPoint(entry) { - const nameLLC = entry.styleNameLowerCase; - let a = 0; - let b = Math.min(numEntries, numVisible) - 1; - if (b < 0) { - return entries[numVisible]; - } - if (entries[0].styleNameLowerCase > nameLLC) { - return entries[0]; - } - if (entries[b].styleNameLowerCase <= nameLLC) { - return entries[numVisible]; - } - // bisect - while (a < b - 1) { - const c = (a + b) / 2 | 0; - if (nameLLC < entries[c].styleNameLowerCase) { - b = c; - } else { - a = c; - } - } - if (entries[a].styleNameLowerCase > nameLLC) { - return entries[a]; - } - while (a <= b && entries[a].styleNameLowerCase < nameLLC) { - a++; - } - return entries[entries[a].styleNameLowerCase <= nameLLC ? a + 1 : a]; - } } diff --git a/manage/manage.css b/manage/manage.css index fe48a509..2b322474 100644 --- a/manage/manage.css +++ b/manage/manage.css @@ -264,11 +264,14 @@ select { } #add-style-wrapper, -#options :last-child, #backup :last-child { margin-bottom: 0; } +#options p:last-of-type { + margin-top: 0; +} + #header details:not([open]), #header details:not([open]) h2 { padding-bottom: 0; @@ -309,9 +312,10 @@ select { .newUI .entry { display: table-row; + contain: strict; } -.newUI .entry:nth-child(2n) { +.newUI .entry.odd { background-color: rgba(128, 128, 128, 0.05); } @@ -320,14 +324,20 @@ select { margin: 0; display: table-cell; vertical-align: middle; + contain: contents; } + +.newUI .entry .actions { + contain: layout; +} + /************ checkbox & select************/ .newUI .checker { margin: 0; } -#newUIoptions > div { +#newUIoptions > div, #newUIoptions > label { margin: 4px 0; } @@ -454,9 +464,18 @@ select { color: hsla(180, 100%, 15%, 1); } -.newUI .homepage:not([href=""]) { - position: absolute; - margin-left: -28px; +.newUI .style-name:after { + text-indent: 1.2rem; +} + +.newUI .actions:after { + text-indent: -25px; +} + +.newUI .actions .homepage[href=""] { + display: inline-block; + visibility: hidden; + height: 0; } .newUI .actions { @@ -588,6 +607,7 @@ select { box-sizing: border-box; padding-right: 1rem; line-height: 18px; + contain: layout style; } .newUI .applies-to .expander { @@ -626,6 +646,7 @@ select { backface-visibility: hidden; opacity: .25; display: none; + contain: layout style size; } .newUI .has-favicons .target { @@ -833,13 +854,23 @@ input[id^="manage.newUI"] { display: none; } -#search-wrapper { +#search-wrapper, #sort-wrapper { display: flex; align-items: center; flex-wrap: wrap; } -#search { +#sort-wrapper { + margin-top: .25em; +} + +#sort-wrapper .sorter-selection { + position: relative; + width: calc(100% - 15px); +} + +#search, #sort-select { + max-width: 100%; flex-grow: 1; margin: 0.25rem 0 0; background: #fff; @@ -849,13 +880,26 @@ input[id^="manage.newUI"] { font: 400 12px Arial; color: #000; border: 1px solid hsl(0, 0%, 66%); - border-radius: 0.25rem; } -#search-help { +#sort-select { + padding-right: 18px; + width: 100%; +} + +.firefox #sort-select { + padding: 0; +} + +#search-help, #sorter-help { margin: 4px -5px 0 2px; } +#sort-wrapper .select-arrow { + top: 7px; + right: 4px; +} + #message-box.help-text > div { max-width: 26rem; } @@ -941,6 +985,21 @@ input[id^="manage.newUI"] { text-overflow: ellipsis; } +/* sort font */ +@font-face { + font-family: 'sorticon'; + src: url('data:application/x-font-ttf;base64,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') format('truetype'); + font-weight: normal; + font-style: normal; + unicode-range: U+21E9; +} + +#sort-select { + font-family: 'sorticon', Arial; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + @keyframes fadein { from { opacity: 0; @@ -1026,6 +1085,7 @@ input[id^="manage.newUI"] { } #installed { + margin-top: 0; padding-left: 0; } diff --git a/manage/manage.js b/manage/manage.js index 19426204..20f4a5b6 100644 --- a/manage/manage.js +++ b/manage/manage.js @@ -3,6 +3,7 @@ /* global checkUpdate, handleUpdateInstalled */ /* global objectDiff */ /* global configDialog */ +/* global sorter */ 'use strict'; let installed; @@ -58,6 +59,9 @@ function initGlobalEvents() { $('#manage-options-button').onclick = () => chrome.runtime.openOptionsPage(); $('#manage-shortcuts-button').onclick = () => openURL({url: URLS.configureCommands}); $$('#header a[href^="http"]').forEach(a => (a.onclick = handleEvent.external)); + // show date installed & last update on hover + installed.addEventListener('mouseover', debounceEntryTitle); + installed.addEventListener('mouseout', debounceEntryTitle); // remember scroll position on normal history navigation window.onbeforeunload = rememberScrollPosition; @@ -81,6 +85,7 @@ function initGlobalEvents() { // N.B. triggers existing onchange listeners setupLivePrefs(); + sorter().init(); $$('[id^="manage.newUI"]') .forEach(el => (el.oninput = (el.onchange = switchUI))); @@ -103,10 +108,18 @@ function initGlobalEvents() { function showStyles(styles = []) { - const sorted = styles - .map(style => ({name: style.name.toLocaleLowerCase(), style})) - .sort((a, b) => (a.name < b.name ? -1 : a.name === b.name ? 0 : 1)); + const sorted = sorter().sort({ + parser: 'style', + styles: styles.map(style => ({ + style, + name: style.name.toLocaleLowerCase(), + })), + }).map((info, index) => { + info.index = index; + return info; + }); let index = 0; + installed.dataset.total = styles.length; const scrollY = (history.state || {}).scrollY; const shouldRenderAll = scrollY > window.innerHeight || sessionStorage.justEditedStyleId; const renderBin = document.createDocumentFragment(); @@ -122,11 +135,14 @@ function showStyles(styles = []) { while ( index < sorted.length && // eslint-disable-next-line no-unmodified-loop-condition - (shouldRenderAll || ++rendered < 10 || performance.now() - t0 < 10) + (shouldRenderAll || ++rendered < 20 || performance.now() - t0 < 10) ) { renderBin.appendChild(createStyleElement(sorted[index++])); } filterAndAppend({container: renderBin}); + if (newUI.enabled && newUI.favicons) { + debounce(handleEvent.loadFavicons); + } if (index < sorted.length) { requestAnimationFrame(renderStyles); return; @@ -134,9 +150,6 @@ function showStyles(styles = []) { if ('scrollY' in (history.state || {}) && !sessionStorage.justEditedStyleId) { setTimeout(window.scrollTo, 0, 0, history.state.scrollY); } - if (newUI.enabled && newUI.favicons) { - debounce(handleEvent.loadFavicons, 16); - } if (sessionStorage.justEditedStyleId) { const entry = $(ENTRY_ID_PREFIX + sessionStorage.justEditedStyleId); delete sessionStorage.justEditedStyleId; @@ -149,7 +162,7 @@ function showStyles(styles = []) { } -function createStyleElement({style, name}) { +function createStyleElement({style, name, index}) { // query the sub-elements just once, then reuse the references if ((createStyleElement.parts || {}).newUI !== newUI.enabled) { const entry = template[`style${newUI.enabled ? 'Compact' : ''}`]; @@ -188,6 +201,9 @@ function createStyleElement({style, name}) { (style.enabled ? 'enabled' : 'disabled') + (style.updateUrl ? ' updatable' : '') + (style.usercssData ? ' usercss' : ''); + entry.dataset.installdate = style.installDate || t('genericUnknown'); + entry.dataset.updatedate = style.updateDate || style.installDate || t('genericUnknown'); + if (index !== undefined) entry.classList.add(index % 2 ? 'odd' : 'even'); if (style.url) { $('.homepage', entry).appendChild(parts.homepageIcon.cloneNode(true)); @@ -199,15 +215,40 @@ function createStyleElement({style, name}) { $('.actions', entry).appendChild(template.configureIcon.cloneNode(true)); } - // name being supplied signifies we're invoked by showStyles() - // which debounces its main loop thus loading the postponed favicons - createStyleTargetsElement({entry, style, postponeFavicons: name}); + createStyleTargetsElement({entry, style}); return entry; } -function createStyleTargetsElement({entry, style, postponeFavicons}) { +function debounceEntryTitle(event) { + if (event.target.nodeName === 'H2' && event.target.classList.contains('style-name')) { + const link = $('.style-name-link', event.target); + if (event.type === 'mouseover' && !link.title) { + debounce(addEntryTitle, 50, link); + } else if (debounce.timers.size) { + debounce.unregister(addEntryTitle); + } + } +} + +// Add entry install & updated date to process locales +function addEntryTitle(link) { + const unknown = t('genericUnknown'); + const entry = link.closest('.entry'); + // eslint-disable-next-line no-inner-declarations + function checkValidDate(date) { + const check = formatDate(date); + return (date === unknown || check === 'Invalid Date') ? unknown : check; + } + if (entry) { + link.title = `${t('dateInstalled')}: ${checkValidDate(entry.dataset.installdate)}\n` + + `${t('dateUpdated')}: ${checkValidDate(entry.dataset.updatedate)}`; + } +} + + +function createStyleTargetsElement({entry, style}) { const parts = createStyleElement.parts; const targets = parts.targets.cloneNode(true); let container = targets; @@ -257,9 +298,6 @@ function createStyleTargetsElement({entry, style, postponeFavicons}) { if (numTargets > newUI.targets) { $('.applies-to', entry).classList.add('has-more'); } - if (numIcons && !postponeFavicons) { - debounce(handleEvent.loadFavicons); - } } const entryTargets = $('.targets', entry); if (numTargets) { @@ -382,13 +420,34 @@ Object.assign(handleEvent, { this.closest('.applies-to').classList.toggle('expanded'); }, - loadFavicons(container = document.body) { - for (const img of $$('img', container)) { - if (img.dataset.src) { - img.src = img.dataset.src; - delete img.dataset.src; + loadFavicons({all = false} = {}) { + if (!installed.firstElementChild) return; + let favicons = []; + if (all) { + favicons = $$('img[data-src]', installed); + } else { + const {left, top} = installed.firstElementChild.getBoundingClientRect(); + const x = Math.max(0, left); + const y = Math.max(0, top); + const first = document.elementFromPoint(x, y); + const lastOffset = first.offsetTop + window.innerHeight; + const numTargets = prefs.get('manage.newUI.targets'); + let entry = first && first.closest('.entry') || installed.children[0]; + while (entry && entry.offsetTop <= lastOffset) { + favicons.push(...$$('img', entry).slice(0, numTargets).filter(img => img.dataset.src)); + entry = entry.nextElementSibling; } } + let i = 0; + for (const img of favicons) { + img.src = img.dataset.src; + delete img.dataset.src; + // loading too many icons at once will block the page while the new layout is recalculated + if (++i > 100) break; + } + if ($('img[data-src]', installed)) { + debounce(handleEvent.loadFavicons, 1, {all: true}); + } }, config(event, {styleMeta}) { @@ -416,6 +475,7 @@ function handleUpdate(style, {reason, method} = {}) { handleUpdateInstalled(entry, reason); } filterAndAppend({entry}); + sorter().update(); if (!entry.matches('.hidden') && reason !== 'import') { animateElement(entry); scrollElementIntoView(entry); @@ -514,7 +574,7 @@ function switchUI({styleOnly} = {}) { for (const style of styles) { const entry = $(ENTRY_ID_PREFIX + style.id); if (entry) { - createStyleTargetsElement({entry, style, postponeFavicons: true}); + createStyleTargetsElement({entry, style}); } } debounce(handleEvent.loadFavicons); diff --git a/manage/sort.js b/manage/sort.js new file mode 100644 index 00000000..fb47962d --- /dev/null +++ b/manage/sort.js @@ -0,0 +1,203 @@ +/* global installed updateStripes */ +/* global messageBox */ +'use strict'; + +const sorter = (() => { + + const sorterType = { + alpha: (a, b) => (a < b ? -1 : a === b ? 0 : 1), + number: (a, b) => a - b + }; + + const tagData = { + title: { + text: t('genericTitle'), + parse: { + style: ({name}) => name, + entry: entry => entry.styleNameLowerCase, + }, + sorter: sorterType.alpha + }, + usercss: { + text: 'Usercss', + parse: { + style: ({style}) => (style.usercssData ? 0 : 1), + entry: entry => (entry.classList.contains('usercss') ? 0 : 1) + }, + sorter: sorterType.number + }, + disabled: { + text: '', // added as either "enabled" or "disabled" by the addOptions function + parse: { + style: ({style}) => (style.enabled ? 1 : 0), + entry: entry => (entry.classList.contains('enabled') ? 1 : 0) + }, + sorter: sorterType.number + }, + dateInstalled: { + text: t('dateInstalled'), + parse: { + style: ({style}) => style.installDate, + entry: entry => entry.dataset.installdate + }, + sorter: sorterType.number + }, + dateUpdated: { + text: t('dateUpdated'), + parse: { + style: ({style}) => style.updateDate, + entry: entry => entry.dataset.updatedate + }, + sorter: sorterType.number + } + }; + + // Adding (assumed) most commonly used ('title,asc' should always be first) + // whitespace before & after the comma is ignored + const selectOptions = [ + '{groupAsc}', + 'title,asc', + 'dateInstalled,desc, title,asc', + 'dateInstalled,asc, title,asc', + 'dateUpdated,desc, title,asc', + 'dateUpdated,asc, title,asc', + 'usercss,asc, title,asc', + 'usercss,desc, title,asc', + 'disabled,asc, title,asc', + 'disabled,desc, title,asc', + 'disabled,desc, usercss,asc, title,asc', + '{groupDesc}', + 'title,desc', + 'usercss,asc, title,desc', + 'usercss,desc, title,desc', + 'disabled,desc, title,desc', + 'disabled,desc, usercss,asc, title,desc' + ]; + + const splitRegex = /\s*,\s*/; + + function addOptions() { + let container; + const select = $('#sort-select'); + const renderBin = document.createDocumentFragment(); + const option = $create('option'); + const optgroup = $create('optgroup'); + const meta = { + desc: ' \u21E9', + enabled: t('genericEnabledLabel'), + disabled: t('genericDisabledLabel'), + dateNew: ` (${t('sortDateNewestFirst')})`, + dateOld: ` (${t('sortDateOldestFirst')})`, + groupAsc: t('sortLabelTitleAsc'), + groupDesc: t('sortLabelTitleDesc') + }; + const optgroupRegex = /\{\w+\}/; + selectOptions.forEach(sort => { + if (optgroupRegex.test(sort)) { + if (container) { + renderBin.appendChild(container); + } + container = optgroup.cloneNode(); + container.label = meta[sort.substring(1, sort.length - 1)]; + return; + } + let lastTag = ''; + const opt = option.cloneNode(); + opt.textContent = sort.split(splitRegex).reduce((acc, val) => { + if (tagData[val]) { + lastTag = val; + return acc + (acc !== '' ? ' + ' : '') + tagData[val].text; + } + if (lastTag.indexOf('date') > -1) return acc + meta[val === 'desc' ? 'dateNew' : 'dateOld']; + if (lastTag === 'disabled') return acc + meta[val === 'desc' ? 'enabled' : 'disabled']; + return acc + (meta[val] || ''); + }, ''); + opt.value = sort; + container.appendChild(opt); + }); + renderBin.appendChild(container); + select.appendChild(renderBin); + select.value = prefs.get('manage.newUI.sort'); + } + + function sort({styles, parser}) { + if (!styles) { + styles = [...installed.children]; + parser = 'entry'; + } else { + parser = 'style'; + } + const sortBy = prefs.get('manage.newUI.sort').split(splitRegex); // 'title,asc' + const len = sortBy.length; + return styles.sort((a, b) => { + let types, direction; + let result = 0; + let indx = 0; + // multi-sort + while (result === 0 && indx < len) { + types = tagData[sortBy[indx++]]; + direction = sortBy[indx++] === 'asc' ? 1 : -1; + result = types.sorter(types.parse[parser](a), types.parse[parser](b)) * direction; + } + return result; + }); + } + + function update() { + if (!installed) return; + const current = [...installed.children]; + const sorted = sort({ + styles: current.map(entry => ({ + entry, + name: entry.styleNameLowerCase, + style: BG.cachedStyles.byId.get(entry.styleId), + })) + }); + const isDiffSort = sorted.length !== current.length || + current.find((entry, index) => entry !== sorted[index].entry); + if (isDiffSort) { + const renderBin = document.createDocumentFragment(); + sorted.forEach(({entry}) => renderBin.appendChild(entry)); + installed.appendChild(renderBin); + updateStripes(); + } + } + + function manageChange(event) { + event.preventDefault(); + prefs.set('manage.newUI.sort', this.value); + update(); + } + + function showHelp(event) { + event.preventDefault(); + messageBox({ + className: 'help-text', + title: t('sortStylesHelpTitle'), + contents: + $create('div', + t('sortStylesHelp').split('\n').map(line => + $create('p', line))), + buttons: [t('confirmOK')], + }); + } + + function init() { + $('#sort-select').addEventListener('change', manageChange); + $('#sorter-help').onclick = showHelp; + addOptions(); + } + + function updateStripes() { + let index = 0; + [...installed.children].forEach(entry => { + const list = entry.classList; + if (!list.contains('hidden')) { + list.add(index % 2 ? 'odd' : 'even'); + list.remove(index++ % 2 ? 'even' : 'odd'); + } + }); + } + + return {init, update, sort, updateStripes}; +}); diff --git a/manage/updater-ui.js b/manage/updater-ui.js index 8e58e627..2117d901 100644 --- a/manage/updater-ui.js +++ b/manage/updater-ui.js @@ -1,6 +1,6 @@ /* global messageBox */ /* global ENTRY_ID_PREFIX, newUI */ -/* global filtersSelector, filterAndAppend */ +/* global filtersSelector, filterAndAppend, sorter */ 'use strict'; onDOMready().then(() => { @@ -144,6 +144,7 @@ function reportUpdateState(state, style, details) { } if (filtersSelector.hide) { filterAndAppend({entry}); + sorter().update(); } } diff --git a/msgbox/msgbox.css b/msgbox/msgbox.css index cd6cf48d..a64123a8 100644 --- a/msgbox/msgbox.css +++ b/msgbox/msgbox.css @@ -117,6 +117,14 @@ overflow-wrap: break-word; } +#message-box-contents p:first-child { + margin-top: 0; +} + +#message-box-contents p:last-child { + margin-bottom: 0; +} + #message-box-buttons { padding: .75rem .375rem; background-color: #f0f0f0; diff --git a/msgbox/msgbox.js b/msgbox/msgbox.js index a9470c46..3f40fdaa 100644 --- a/msgbox/msgbox.js +++ b/msgbox/msgbox.js @@ -123,7 +123,7 @@ function messageBox({ function moveFocus(dir) { const elements = [...messageBox.element.getElementsByTagName('*')]; - const activeIndex = elements.indexOf(document.activeElement); + const activeIndex = Math.max(0, elements.indexOf(document.activeElement)); const num = elements.length; for (let i = 1; i < num; i++) { const elementIndex = (activeIndex + i * dir + num) % num; diff --git a/popup/search-results.js b/popup/search-results.js index e3a085a3..efd6283a 100755 --- a/popup/search-results.js +++ b/popup/search-results.js @@ -16,8 +16,6 @@ window.addEventListener('showStyles:done', function _() { const BASE_URL = 'https://userstyles.org'; const UPDATE_URL = 'https://update.userstyles.org/%.md5'; - const UI_LANG = chrome.i18n.getUILanguage(); - // normal category is just one word like 'github' or 'google' // but for some sites we need a fallback // key: category.tld @@ -459,17 +457,9 @@ window.addEventListener('showStyles:done', function _() { Object.assign($('[data-type="updated"] time', entry), { dateTime: result.updated, - textContent: tryCatch(lang => { - const date = new Date(result.updated); - return date.toLocaleDateString(lang, { - day: '2-digit', - month: 'short', - year: date.getYear() === new Date().getYear() ? undefined : '2-digit', - }); - }, [UI_LANG, 'en']) || '', + textContent: formatDate(result.updated) }); - $('[data-type="weekly"] dd', entry).textContent = formatNumber(result.weekly_install_count); $('[data-type="total"] dd', entry).textContent = formatNumber(result.total_install_count);