diff --git a/_locales/en/messages.json b/_locales/en/messages.json index bab7d3b9..b9e9c971 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -205,6 +205,18 @@ "configOnChangeTooltip": { "message": "Autosave and apply changes automatically" }, + "genericAdd": { + "message": "Add", + "description": "Used in various places to select/perform an add action." + }, + "genericDelete": { + "message": "Delete", + "description": "Used in various places to select/perform a delete action." + }, + "genericEdit": { + "message": "Edit", + "description": "Used in various places to select/perform an edit action." + }, "genericError": { "message": "Error", "description": "Used in various places to indicate some error occurred." @@ -359,6 +371,68 @@ "message": "Delete", "description": "Label for the context menu item in the editor to delete selected text" }, + "excludedDomain": { + "message": "Domain", + "description": "Label for a domain or subdomain portion of an URL used to exclude a style" + }, + "excludedPrefix": { + "message": "Prefix", + "description": "Label for a full url with a subdirectory to be used as the beginning portion of a URL to match to exclude a style" + }, + "exclusionsAddTitle": { + "message": "Add excluded page", + "description": "Title of popup to add an excluded page (URL)" + }, + "exclusionsEditTitle": { + "message": "Edit excluded page(s)", + "description": "Title of popup to edit an excluded page (URL)" + }, + "exclusionsEmpty": { + "message": "No exclusions", + "description": "Label shown when there are no global exclusions" + }, + "exclusionsDeleteConfirmation": { + "message": "Are you sure you want to delete $number$ entries?", + "description": "Delete confirmation dialog message", + "placeholders": { + "number": { + "content": "$1" + } + } + }, + "exclusionsHeader": { + "message": "Excluded Pages", + "description": "Title of user configurable lists of site urls to exclude per style" + }, + "exclusionsHelp": { + "message": "Add one or more exclusions for each style. An exclusion is a string that will match a web location (URL). If a match is found, the given style (and all internal sections) will not be applied to that page. A list of exclusions is set separately from the userstyle so that it will not be effected when updating or editing the style itself.\n\nThis is useful because you can exclude websites that would be effected by a global style.\n\nThe exclusion string may contain wildcards (\"*\") to match any portion of the URL, e.g. \"forum.*.com\" will exclude the forum sub-domains of all dot-com top level domains.\n\nRegular expressions are not supported.", + "description": "Help text for user set style exclusions" + }, + "exclusionsHelpTitle": { + "message": "Set Style Exclusions", + "description": "Header text for help modal" + }, + "exclusionsInvalidUrl": { + "message": "Enter a unique and valid URL", + "description": "Text for an alert notifying the user that an entered URL is not unique or invalid" + }, + "exclusionsPopupTip": { + "message": "Right-click to edit exclusions on this page", + "description": "Title on the checkbox in the popup to let the user know how to edit exclusions on the current page" + }, + "exclusionsPrefix": { + "message": "Excluded on: ", + "description": "Prefix label added to the applies to column in the style manager" + }, + "exclusionsStatus": { + "message": "$number$ sites", + "description": "Label added next to the Excluded Pages header when 'number' is not zero", + "placeholders": { + "number": { + "content": "$1" + } + } + }, "exportLabel": { "message": "Export", "description": "Label for the button to export a style ('edit' page) or all styles ('manage' page)" diff --git a/background/storage.js b/background/storage.js index b57cdf62..8e828654 100644 --- a/background/storage.js +++ b/background/storage.js @@ -349,7 +349,6 @@ function saveStyle(style) { } let existed; let codeIsUpdated; - return maybeCalcDigest() .then(maybeImportFix) .then(decide); @@ -383,7 +382,9 @@ function saveStyle(style) { if (reason === 'update-digest' && oldStyle.originalDigest === style.originalDigest) { return style; } - codeIsUpdated = !existed || 'sections' in style && !styleSectionsEqual(style, oldStyle); + codeIsUpdated = !existed + || 'sections' in style && !styleSectionsEqual(style, oldStyle) + || reason === 'exclusionsUpdate'; style = Object.assign({installDate: Date.now()}, oldStyle, style); return write(style, store); }); @@ -398,6 +399,7 @@ function saveStyle(style) { url: null, originalMd5: null, installDate: Date.now(), + exclusions: {} }, style); return write(style); } @@ -442,6 +444,11 @@ function deleteStyle({id, notify = true}) { }); } +function checkExclusions(matchUrl, exclusions = {}) { + const values = Object.values(exclusions); + return values.length && + values.reduce((acc, exclude) => acc || tryRegExp(exclude).test(matchUrl), false); +} function getApplicableSections({ style, @@ -456,7 +463,7 @@ function getApplicableSections({ // but the spec is outdated and doesn't account for SPA sites // so we only respect it in case of url("http://exact.url/without/hash") }) { - if (!skipUrlCheck && !URLS.supported(matchUrl)) { + if (!skipUrlCheck && !URLS.supported(matchUrl) || checkExclusions(matchUrl, style.exclusions)) { return []; } const sections = []; diff --git a/edit.html b/edit.html index fd7124b7..90eac15b 100644 --- a/edit.html +++ b/edit.html @@ -24,6 +24,7 @@ + @@ -399,6 +400,20 @@ +
+ +

:

+
+ +
+ + + + + + +
+
+ + diff --git a/manage/manage.css b/manage/manage.css index 7366c1e9..1792f04c 100644 --- a/manage/manage.css +++ b/manage/manage.css @@ -180,6 +180,7 @@ select { } .applies-to, +.excluded-on, .actions { padding-left: 15px; margin-bottom: 0; @@ -643,6 +644,14 @@ select { line-height: 18px; } +.target[data-type="exclusions"] { + color: #d22; +} + +.newUI .target[data-type="exclusions"] img { + float: left; +} + .newUI .applies-to .expander { margin: 0; cursor: pointer; diff --git a/manage/manage.js b/manage/manage.js index eb190ddc..fbd002df 100644 --- a/manage/manage.js +++ b/manage/manage.js @@ -25,7 +25,7 @@ const newUI = { newUI.renderClass(); requestAnimationFrame(usePrefsDuringPageLoad); -const TARGET_TYPES = ['domains', 'urls', 'urlPrefixes', 'regexps']; +const TARGET_TYPES = ['domains', 'urls', 'urlPrefixes', 'regexps', 'exclusions']; const GET_FAVICON_URL = 'https://www.google.com/s2/favicons?domain='; const OWN_ICON = chrome.runtime.getManifest().icons['16']; @@ -121,7 +121,7 @@ function showStyles(styles = [], matchUrlIds) { const sorted = sorter.sort({ styles: styles.map(style => ({ style, - name: style.name.toLocaleLowerCase() + '\n' + style.name, + name: (style.name || '').toLocaleLowerCase() + '\n' + style.name, })), }); let index = 0; @@ -255,8 +255,16 @@ function createStyleTargetsElement({entry, style, iconsOnly}) { let numTargets = 0; const displayed = new Set(); for (const type of TARGET_TYPES) { - for (const section of style.sections) { - for (const targetValue of section[type] || []) { + const isExcluded = type === 'exclusions'; + const sections = isExcluded ? [''] : style.sections; + if (isExcluded && !newUI.enabled && Object.keys(style.exclusions || {}).length > 0) { + $('.applies-to', entry).insertAdjacentElement('afterend', template.excludedOn.cloneNode(true)); + container = $('.excluded-on .targets', entry); + numTargets = 1; + } + for (const section of sections) { + const target = isExcluded ? Object.keys(style.exclusions || {}) : section[type] || []; + for (const targetValue of target) { if (displayed.has(targetValue)) { continue; } @@ -336,7 +344,7 @@ function getFaviconImgSrc(container = installed) { favicon = GET_FAVICON_URL + targetValue; } else if (targetValue.includes('chrome-extension:') || targetValue.includes('moz-extension:')) { favicon = OWN_ICON; - } else if (type === 'regexps') { + } else if (type === 'regexps' || type === 'exclusions') { favicon = targetValue .replace(regexpRemoveNegativeLookAhead, '') .replace(regexpReplaceExtraCharacters, '') @@ -630,6 +638,9 @@ function switchUI({styleOnly} = {}) { .newUI .targets { max-height: ${newUI.targets * 18}px; } + .newUI .target[data-type="exclusions"]:before { + content: '${t('exclusionsPrefix')}'; + } ` + (newUI.faviconsGray ? ` .newUI .target img { -webkit-filter: grayscale(1); diff --git a/msgbox/msgbox.css b/msgbox/msgbox.css index 3f8c25ba..748f7870 100644 --- a/msgbox/msgbox.css +++ b/msgbox/msgbox.css @@ -40,7 +40,8 @@ text-align: center; } -#message-box.center #message-box-contents pre { +#message-box.center #message-box-contents pre, +#message-box.center.content-left #message-box-contents { text-align: left; } @@ -55,6 +56,10 @@ text-align: left; } +#message-box-contents h2 { + margin-top: 0; +} + #message-box-title { font-weight: bold; background-color: rgb(145, 208, 198); diff --git a/popup.html b/popup.html index 6c96fdfd..8b99a887 100644 --- a/popup.html +++ b/popup.html @@ -164,6 +164,8 @@ + + diff --git a/popup/popup-exclusions.js b/popup/popup-exclusions.js new file mode 100644 index 00000000..4bbc839d --- /dev/null +++ b/popup/popup-exclusions.js @@ -0,0 +1,123 @@ +/* +global messageBox +global exclusions +*/ +'use strict'; + +const popupExclusions = (() => { + + const popupWidth = '400px'; + + // return matches on url ending to prevent duplicates in the exclusion list + // e.g. http://test.com and http://test.com/* are equivalent + // this function would return ['', '/*'] + function exclusionExists(array, value) { + const match = []; + ['', '*', '/', '/*'].forEach(ending => { + if (array.includes(value + ending)) { + match.push(ending); + } + }); + return match; + } + + /* Modal in Popup.html */ + function createPopupContent(url) { + const results = []; + const protocol = url.match(/\w+:\/\//); + const parts = url.replace(/(\w+:\/\/|[#?].*$)/g, '').split('/'); + const domain = parts[0].split('.'); + /* + Domain: a.b.com + Domain: b.com + Prefix: https://a.b.com + Prefix: https://a.b.com/current + Prefix: https://a.b.com/current/page + */ + while (parts.length > 1) { + results.push([t('excludedPrefix'), protocol + parts.join('/')]); + parts.pop(); + } + while (domain.length > 1) { + results.push([t('excludedDomain'), domain.join('.')]); + domain.shift(); + } + return [ + $create('h2', {textContent: t('exclusionsEditTitle')}), + $create('select', { + id: 'popup-exclusions', + size: results.length, + multiple: 'true', + value: '' + }, [ + ...results.reverse().map(link => $create('option', { + value: link[1], + title: link[1], + textContent: `${link[0]}: ${link[1]}` + })) + ]) + ]; + } + + function openPopupDialog(style, tabURL) { + const msgBox = messageBox({ + title: style.name, + className: 'center content-left', + contents: createPopupContent(tabURL), + buttons: [t('confirmOK'), t('confirmCancel')], + onshow: box => { + const contents = box.firstElementChild; + contents.style = `max-width: calc(${popupWidth} - 20px); max-height: none;`; + document.body.style.minWidth = popupWidth; + document.body.style.minHeight = popupWidth; + const select = $('select', messageBox.element); + const exclusions = Object.keys(style.exclusions || {}); + [...select.children].forEach(option => { + if (exclusionExists(exclusions, option.value).length) { + option.selected = true; + } + }, []); + $('#message-box-buttons button', messageBox.element).onclick = function () { + handlePopupSave(style, this); + }; + } + }) + .then(() => { + document.body.style.minWidth = ''; + document.body.style.minHeight = ''; + }); + return msgBox; + } + + function handlePopupSave(style, button) { + const current = Object.keys(style.exclusions); + const select = $('#popup-exclusions', messageBox.element); + const all = exclusions.getMultiOptions({select}); + const selected = exclusions.getMultiOptions({select, selectedOnly: true}); + // Add exclusions + selected.forEach(value => { + let exists = exclusionExists(current, value); + if (!exists.length) { + style.exclusions[value] = exclusions.createRegExp(value); + exists = ['']; + } + exists.forEach(ending => { + const index = all.indexOf(value + ending); + if (index > -1) { + all.splice(index, 1); + } + }); + }); + // Remove exclusions (unselected in popup modal) + all.forEach(value => { + exclusionExists(current, value).forEach(ending => { + delete style.exclusions[value + ending]; + }); + }); + exclusions.save(style); + messageBox.listeners.button.apply(button); + } + + return {openPopupDialog}; + +})(); diff --git a/popup/popup.css b/popup/popup.css index 15e11918..d6860987 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -89,7 +89,6 @@ body > div:not(#installed):not(#message-box):not(.colorpicker-popup) { position: absolute; top: 7px; left: var(--outer-padding); - pointer-events: none; } #disable-all-wrapper { @@ -118,6 +117,17 @@ body > div:not(#installed):not(#message-box):not(.colorpicker-popup) { margin-right: .5em; } +#popup-exclusions { + height: auto; + overflow: hidden; + max-width: 95%; + padding: 0 6px; +} + +#popup-exclusions option { + overflow: hidden; +} + .checker { display: inline; } diff --git a/popup/popup.js b/popup/popup.js index 5db20042..1e151182 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,4 +1,7 @@ -/* global configDialog hotkeys */ +/* +global configDialog hotkeys +global popupExclusions +*/ 'use strict'; @@ -257,13 +260,16 @@ function createStyleElement({ styleIsUsercss: Boolean(style.usercssData), className: entry.className + ' ' + (style.enabled ? 'enabled' : 'disabled'), onmousedown: handleEvent.maybeEdit, + styleMeta: style }); const checkbox = $('.checker', entry); Object.assign(checkbox, { id: ENTRY_ID_PREFIX_RAW + style.id, + title: t('exclusionsPopupTip'), checked: style.enabled, onclick: handleEvent.toggle, + oncontextmenu: handleEvent.openExcludeMenu }); const editLink = $('.style-edit-link', entry); @@ -328,6 +334,7 @@ Object.assign(handleEvent, { }, toggle(event) { + event.stopPropagation(); API.saveStyle({ id: handleEvent.getClickedStyleId(event), enabled: this.matches('.enable') || this.checked, @@ -410,6 +417,12 @@ Object.assign(handleEvent, { event.button === 2)) { return; } + // open exclude page config dialog on right-click + if (event.target.classList.contains('checker')) { + this.oncontextmenu = handleEvent.openExcludeMenu; + event.preventDefault(); + return; + } // open an editor on middleclick if (event.target.matches('.entry, .style-name, .style-edit-link')) { this.onmouseup = () => $('.style-edit-link', this).click(); @@ -445,6 +458,23 @@ Object.assign(handleEvent, { handleEvent.openURLandHide.call(this, event); } }, + + openExcludeMenu(event) { + event.preventDefault(); + event.stopPropagation(); + const chkbox = this; + const entry = event.target.closest('.entry'); + if (!chkbox.eventHandled) { + chkbox.eventHandled = true; + const style = entry.styleMeta; + popupExclusions + .openPopupDialog(style, tabURL) + .then(() => { + entry.styleMeta = style; + chkbox.eventHandled = null; + }); + } + } });