diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2e28bc2d..74fa7bb9 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1697,10 +1697,14 @@ "message": "Style injection order", "description": "Tooltip for the button in the manager to open the dialog and also the title of this dialog" }, - "styleInjectionOrderHint": { + "styleInjectionOrderHint_main": { "message": "Drag'n'drop a style to change its position. Styles are injected sequentially in the order shown below so a style further down the list can override the earlier styles.", "description": "Hint in the injection order dialog in the manager" }, + "styleInjectionOrderHint_prio": { + "message": "Important styles are listed below and are always injected last so they can override any newly installed styles. Click the style's mark to toggle its importance.", + "description": "Hint at the bottom of the injection order dialog in the manager" + }, "styleExcludeLabel": { "message": "Custom excluded sites" }, diff --git a/background/common.js b/background/common.js index 7fbdd53f..87de1c10 100644 --- a/background/common.js +++ b/background/common.js @@ -9,8 +9,10 @@ addAPI bgReady createCache + uuidIndex */ +const uuidIndex = new Map(); const bgReady = {}; bgReady.styles = new Promise(r => (bgReady._resolveStyles = r)); bgReady.all = new Promise(r => (bgReady._resolveAll = r)); diff --git a/background/style-manager.js b/background/style-manager.js index 8b85d813..a072313e 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -1,6 +1,6 @@ /* global API msg */// msg.js -/* global CHROME URLS isEmptyObj stringAsRegExp tryRegExp tryURL */// toolbox.js -/* global bgReady createCache */// common.js +/* global CHROME URLS deepEqual isEmptyObj mapObj stringAsRegExp tryRegExp tryURL */// toolbox.js +/* global bgReady createCache uuidIndex */// common.js /* global calcStyleDigest styleCodeEmpty styleSectionGlobal */// sections-util.js /* global db */ /* global prefs */ @@ -20,6 +20,7 @@ to cleanup the temporary code. See livePreview in /edit. const styleUtil = {}; +/* exported styleMan */ const styleMan = (() => { Object.assign(styleUtil, { @@ -30,9 +31,9 @@ const styleMan = (() => { //#region Declarations /** @typedef {{ - style: StyleObj - preview?: StyleObj - appliesTo: Set + style: StyleObj, + preview?: StyleObj, + appliesTo: Set, }} StyleMapData */ /** @type {Map} */ const dataMap = new Map(); @@ -63,9 +64,20 @@ const styleMan = (() => { _rev: () => Date.now(), }; const DELETE_IF_NULL = ['id', 'customName', 'md5Url', 'originalMd5']; + const INJ_ORDER = 'injectionOrder'; + const order = {main: {}, prio: {}}; + const orderForDb = { + id: INJ_ORDER, + _id: `${chrome.runtime.id}-${INJ_ORDER}`, + get value() { + return mapObj(order, group => sortObjectKeysByValue(group, id2uuid)); + }, + set value(val) { + setOrderFromArray(val); + }, + }; /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */ let ready = init(); - let order = {}; chrome.runtime.onConnect.addListener(port => { if (port.name === 'livePreview') { @@ -83,18 +95,6 @@ const styleMan = (() => { } }); - // TODO: will fix in subsequent commit - // prefs.subscribe(['injectionOrder'], (key, value) => { - // order = {}; - // value.forEach((uid, i) => { - // const id = uuidIndex.get(uid); - // if (id) { - // order[id] = i; - // } - // }); - // msg.broadcast({method: 'styleSort', order}); - // }); - //#endregion //#region Exports @@ -103,17 +103,17 @@ const styleMan = (() => { /** @returns {Promise} style id */ async delete(id, reason) { if (ready.then) await ready; - const data = id2data(id); - const {style, appliesTo} = data; + const {style, appliesTo} = dataMap.get(id); + const sync = reason !== 'sync'; db.styles.delete(id); - if (reason !== 'sync') { - API.sync.delete(style._id, Date.now()); - } + if (sync) API.sync.delete(style._id, Date.now()); for (const url of appliesTo) { const cache = cachedStyleForUrl.get(url); if (cache) delete cache.sections[id]; } dataMap.delete(id); + mapObj(order, val => delete val[id]); + setOrder(orderForDb.value, {sync}); if (style._usw && style._usw.token) { // Must be called after the style is deleted from dataMap API.usw.revoke(id); @@ -149,7 +149,21 @@ const styleMan = (() => { /** @returns {Promise} */ async getAll() { if (ready.then) await ready; - return Array.from(dataMap.values(), data2style); + return getAllAsArray(); + }, + + /** @returns {Promise>}>} */ + async getAllOrdered() { + if (ready.then) await ready; + const res = mapObj(order, group => sortObjectKeysByValue(group, id2style)); + if (res.main.length + res.prio.length < dataMap.size) { + for (const {style} of dataMap.values()) { + if (!(style.id in order.main) && !(style.id in order.prio)) { + res.main.push(style); + } + } + } + return res; }, /** @returns {Promise} */ @@ -199,7 +213,7 @@ const styleMan = (() => { const result = []; const styles = id ? [id2style(id)].filter(Boolean) - : Array.from(dataMap.values(), data2style); + : getAllAsArray(); const query = createMatchQuery(url); for (const style of styles) { let excluded = false; @@ -269,6 +283,13 @@ const styleMan = (() => { save: saveStyle, + async setOrder(val) { + if (ready.then) await ready; + return val && + !deepEqual(val, order) && + setOrder(val, {broadcast: true, sync: true}); + }, + /** @returns {Promise} style id */ async toggle(id, enabled) { if (ready.then) await ready; @@ -306,12 +327,12 @@ const styleMan = (() => { /** @returns {?StyleObj} */ function id2style(id) { - return (dataMap.get(id) || {}).style; + return (dataMap.get(Number(id)) || {}).style; } - /** @returns {?StyleObj} */ - function data2style(data) { - return data && data.style; + /** @returns {?string} */ + function id2uuid(id) { + return (id2style(id) || {})._id; } /** @returns {StyleObj} */ @@ -332,6 +353,7 @@ const styleMan = (() => { style, appliesTo: new Set(), }); + uuidIndex.set(style._id, style.id); } /** @returns {StyleObj} */ @@ -451,7 +473,7 @@ const styleMan = (() => { data.style = style; } if (reason !== 'sync') { - API.sync.putStyle(style); + API.sync.putDoc(style); } if (broadcast) broadcastStyleUpdated(style, reason, method); return style; @@ -477,12 +499,18 @@ const styleMan = (() => { } async function init() { + const orderPromise = db.open(prefs.STORAGE_KEY).get(INJ_ORDER); const styles = await db.styles.getAll() || []; const updated = await Promise.all(styles.map(fixKnownProblems).filter(Boolean)); if (updated.length) { await db.styles.putMany(updated); } styles.forEach(storeInMap); + Object.assign(orderForDb, await orderPromise); + API.sync.registerDoc(orderForDb, doc => { + Object.assign(orderForDb, doc); + setOrder(); + }); ready = true; bgReady._resolveStyles(); } @@ -680,10 +708,40 @@ const styleMan = (() => { } } + /** @returns {StyleObj[]} */ + function getAllAsArray() { + return Array.from(dataMap.values(), v => v.style); + } + /** uuidv4 helper: converts to a 4-digit hex string and adds "-" at required positions */ function hex4dashed(num, i) { return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : ''); } + async function setOrder(val, {broadcast, store = true, sync} = {}) { + if (val) { + setOrderFromArray(val); + orderForDb._rev = Date.now(); + } + if (broadcast) msg.broadcast({method: 'styleSort', order}); + if (store) await db.open(prefs.STORAGE_KEY).put(orderForDb); + if (sync) API.sync.putDoc(orderForDb); + } + + function setOrderFromArray(newOrder) { + mapObj(order, (_, type) => { + const res = order[type] = {}; + (newOrder && newOrder[type] || []).forEach((uid, i) => { + const id = uuidIndex.get(uid); + if (id) res[id] = i; + }); + }); + } + + /** Since JS object's numeric keys are sorted in ascending order, we have to re-sort by value */ + function sortObjectKeysByValue(obj, map) { + return Object.entries(obj).sort((a, b) => a[1] - b[1]).map(e => map(e[0])); + } + //#endregion })(); diff --git a/background/sync-manager.js b/background/sync-manager.js index 1d448d8c..2c949bcb 100644 --- a/background/sync-manager.js +++ b/background/sync-manager.js @@ -1,4 +1,5 @@ /* global API msg */// msg.js +/* global uuidIndex */// common.js /* global chromeLocal chromeSync */// storage-util.js /* global db */ /* global iconMan */ @@ -29,17 +30,13 @@ const syncMan = (() => { errorMessage: null, login: false, }; - const uuidIndex = new Map(); - const uuid2style = uuid => styleUtil.id2style(uuidIndex.get(uuid)); + const customDocs = {}; const compareRevision = (rev1, rev2) => rev1 - rev2; let lastError = null; let ctrl; let currentDrive; /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */ let ready = prefs.ready.then(async () => { - for (const {id, _id} of await API.styles.getAll()) { - uuidIndex.set(_id, id); - } ready = true; prefs.subscribe('sync.enabled', (_, val) => val === 'none' @@ -87,16 +84,20 @@ const syncMan = (() => { } }, - async put(...args) { + async putDoc({id, _id, _rev}) { if (ready.then) await ready; + uuidIndex.set(_id, id); if (!currentDrive) return; schedule(); - return ctrl.put(...args); + return ctrl.put(_id, _rev); }, - putStyle({id, _id, _rev}) { - uuidIndex.set(_id, id); - return syncMan.put(_id, _rev); + registerDoc(doc, setter) { + uuidIndex.set(doc._id, doc.id); + Object.defineProperty(customDocs, doc.id, { + get: () => doc, + set: setter, + }); }, async setDriveOptions(driveName, options) { @@ -191,10 +192,13 @@ const syncMan = (() => { async function initController() { await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */ ctrl = dbToCloud.dbToCloud({ - onGet: uuid2style, + onGet(uuid) { + return styleUtil.id2style(uuidIndex.get(uuid)); + }, async onPut(doc) { const id = uuidIndex.get(doc._id); - const oldDoc = uuid2style(doc._id); + const style = styleUtil.id2style(id); + const oldDoc = style || customDocs[id]; if (id) { doc.id = id; } else { @@ -204,27 +208,30 @@ const syncMan = (() => { if (oldDoc) { diff = compareRevision(oldDoc._rev, doc._rev); if (diff > 0) { - syncMan.put(oldDoc); + syncMan.putDoc(oldDoc); return; } } - if (diff < 0) { + if (diff >= 0) { + return; + } + if (style) { doc.id = await db.styles.put(doc); uuidIndex.set(doc._id, doc.id); return styleUtil.handleSave(doc, {reason: 'sync'}); } + if (oldDoc) oldDoc[id] = doc; }, onDelete(_id, rev) { const id = uuidIndex.get(_id); - const oldDoc = uuid2style(_id); + const oldDoc = styleUtil.id2style(id); if (oldDoc && compareRevision(oldDoc._rev, rev) <= 0) { - // FIXME: does it make sense to set reason to 'sync' in deleteByUUID? uuidIndex.delete(id); return API.styles.delete(id, 'sync'); } }, async onFirstSync() { - for (const i of await API.styles.getAll()) { + for (const i of Object.values(customDocs).concat(await API.styles.getAll())) { ctrl.put(i._id, i._rev); } }, diff --git a/content/apply.js b/content/apply.js index 1176ea50..a7c232cc 100644 --- a/content/apply.js +++ b/content/apply.js @@ -13,19 +13,16 @@ let hasStyles = false; let isDisabled = false; let isTab = !chrome.tabs || location.pathname !== '/popup.html'; - let order = {}; + const order = {main: [], prio: []}; + const calcOrder = ({id}) => + (order.prio[id] || 0) * 1e6 || + order.main[id] || + id + .5e6; // no order = at the end of `main` const isFrame = window !== parent; const isFrameAboutBlank = isFrame && location.href === 'about:blank'; const isUnstylable = !chrome.app && document instanceof XMLDocument; const styleInjector = StyleInjector({ - compare: (a, b) => { - const ia = order[a.id]; - const ib = order[b.id]; - if (ia === ib) return 0; - if (ia == null) return 1; - if (ib == null) return -1; - return ia - ib; - }, + compare: (a, b) => calcOrder(a) - calcOrder(b), onUpdate: onInjectorUpdate, }); // dynamic iframes don't have a URL yet so we'll use their parent's URL (hash isn't inherited) @@ -110,7 +107,7 @@ await API.styles.getSectionsByUrl(matchUrl, null, true); if (styles.cfg) { isDisabled = styles.cfg.disableAll; - order = styles.cfg.order || {}; + Object.assign(order, styles.cfg.order); delete styles.cfg; } hasStyles = !isDisabled; @@ -179,7 +176,7 @@ break; case 'styleSort': - order = request.order; + Object.assign(order, request.order); styleInjector.sort(); break; diff --git a/injection-order/injection-order.css b/injection-order/injection-order.css index 43e83954..6fd040f7 100644 --- a/injection-order/injection-order.css +++ b/injection-order/injection-order.css @@ -1,13 +1,16 @@ -#message-box.injection-order > div { +.injection-order > div { height: 100%; max-width: 80vw; } -#message-box.injection-order #message-box-contents { +.injection-order #message-box-contents, +.injection-order section { padding: 0; overflow: hidden; display: flex; flex-direction: column; - --border: 1px solid rgba(128, 128, 128, .25); +} +.injection-order section[data-main] { + flex: 1 0; } .injection-order header { padding: 1rem; @@ -16,39 +19,81 @@ box-sizing: border-box; } .injection-order ol { - height: 100%; - padding: 1px 0 0; /* 1px for keyboard-focused element's outline */ + padding: 1px 0; /* 1px for keyboard-focused element's outline */ margin: 0; font-size: 14px; overflow-y: auto; - border-top: var(--border); } -.injection-order a { - display: block; +.injection-order ol:empty { + display: none; +} +.injection-order [data-prio] header { + background-color: hsla(40, 80%, 50%, 0.4); +} +.injection-order [data-prio] { + height: min-content; + min-height: 2em; + max-height: 50%; +} +.injection-order-entry { + display: flex; + justify-content: space-between; color: #000; - text-decoration: none; transition: transform .25s ease-in-out; z-index: 1; user-select: none; - padding: 0.3em .5em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: move; } -.injection-order a.enabled { +.injection-order-entry a[href] { + padding: .4em 0 .4em 1rem; + cursor: inherit; +} +.injection-order-entry.enabled a[href] { font-weight: bold; } -.injection-order a:hover { +.injection-order-entry a { + text-decoration: none; +} +.injection-order-entry a[href]:hover { text-decoration: underline; } -.injection-order a:not(:first-child) { - border-top: var(--border); +.injection-order-toggle { + display: flex; + align-items: center; + padding: 0 .5rem; + cursor: pointer; + opacity: .33; + transition: .2s; } -.injection-order a::before { - content: "\2261"; - padding: 0.6em; - font-weight: normal; +.injection-order-toggle::after { + content: ''; + width: .75em; + height: .75em; + border-radius: 100%; + border: 2px solid currentColor; +} +.injection-order-entry:hover .injection-order-toggle { + opacity: 1; +} +[data-prio] .injection-order-toggle::after { + background-color: currentColor; + background-clip: content-box; + width: .5em; + height: .5em; + padding: 2px; + transition: .2s; +} +.injection-order-toggle:hover::after { + background-color: hsl(40, 80%, 50%); +} +.injection-order [data-prio] header, +.injection-order ol, +.injection-order #message-box-buttons, +.injection-order-entry:nth-child(n + 2) { + border-top: 1px solid rgba(128, 128, 128, .25); } .injection-order .draggable-list-target { position: relative; diff --git a/injection-order/injection-order.js b/injection-order/injection-order.js index 5268fc32..b74e9114 100644 --- a/injection-order/injection-order.js +++ b/injection-order/injection-order.js @@ -1,7 +1,6 @@ /* global $create messageBoxProxy */// dom.js /* global API */// msg.js /* global DraggableList */ -/* global prefs */ /* global t */// localization.js 'use strict'; @@ -10,70 +9,68 @@ async function InjectionOrder(show = true) { if (!show) { return messageBoxProxy.close(); } - const entries = (await getOrderedStyles()).map(makeEntry); - const ol = $create('ol'); - let maxTranslateY; - ol.append(...entries.map(l => l.el)); - ol.on('d:dragstart', ({detail: d}) => { - d.origin.dataTransfer.setDragImage(new Image(), 0, 0); - maxTranslateY = ol.scrollHeight + ol.offsetTop - d.dragTarget.offsetHeight - d.dragTarget.offsetTop; - }); - ol.on('d:dragmove', ({detail: d}) => { - d.origin.stopPropagation(); // preserves dropEffect - d.origin.dataTransfer.dropEffect = 'move'; - const y = Math.min(d.currentPos.y - d.startPos.y, maxTranslateY); - d.dragTarget.style.transform = `translateY(${y}px)`; - }); - ol.on('d:dragend', ({detail: d}) => { - const [item] = entries.splice(d.originalIndex, 1); - entries.splice(d.spliceIndex, 0, item); - ol.insertBefore(d.dragTarget, d.insertBefore); - prefs.set('injectionOrder', entries.map(l => l.style._id)); - }); - DraggableList(ol, {scrollContainer: ol}); - + const SEL_ENTRY = '.injection-order-entry'; + const groups = await API.styles.getAllOrdered(); + const ols = {}; await messageBoxProxy.show({ title: t('styleInjectionOrder'), - contents: $create('fragment', [ - $create('header', t('styleInjectionOrderHint')), - ol, - ]), + contents: $create('fragment', Object.entries(groups).map(makeList)), className: 'injection-order center-dialog', blockScroll: true, buttons: [t('confirmClose')], }); - async function getOrderedStyles() { - const [styles] = await Promise.all([ - API.styles.getAll(), - prefs.ready, - ]); - const styleSet = new Set(styles); - const uuidIndex = new Map(); - for (const s of styleSet) { - uuidIndex.set(s._id, s); - } - const orderedStyles = []; - for (const uid of prefs.get('injectionOrder')) { - const s = uuidIndex.get(uid); - if (s) { - uuidIndex.delete(uid); - orderedStyles.push(s); - styleSet.delete(s); - } - } - orderedStyles.push(...styleSet); - return orderedStyles; - } - function makeEntry(style) { - return { - style, - el: $create('a', { - className: style.enabled ? 'enabled' : '', + return $create('li' + SEL_ENTRY + (style.enabled ? '.enabled' : ''), [ + $create('a', { href: '/edit.html?id=' + style.id, target: '_blank', + draggable: false, }, style.name), - }; + $create('a.injection-order-toggle', { + tabIndex: 0, + draggable: false, + }), + ]); + } + + function makeList([type, styles]) { + const ids = groups[type] = styles.map(s => s._id); + const ol = ols[type] = $create('ol'); + let maxTranslateY; + ol.append(...styles.map(makeEntry)); + ol.on('d:dragstart', ({detail: d}) => { + d.origin.dataTransfer.setDragImage(new Image(), 0, 0); + maxTranslateY = + ol.scrollHeight + ol.offsetTop - d.dragTarget.offsetHeight - d.dragTarget.offsetTop; + }); + ol.on('d:dragmove', ({detail: d}) => { + d.origin.stopPropagation(); // preserves dropEffect + d.origin.dataTransfer.dropEffect = 'move'; + const y = Math.min(d.currentPos.y - d.startPos.y, maxTranslateY); + d.dragTarget.style.transform = `translateY(${y}px)`; + }); + ol.on('d:dragend', ({detail: d}) => { + const [item] = ids.splice(d.originalIndex, 1); + ids.splice(d.spliceIndex, 0, item); + ol.insertBefore(d.dragTarget, d.insertBefore); + API.styles.setOrder(groups); + }); + ol.on('click', e => { + if (e.target.closest('.injection-order-toggle')) { + const el = e.target.closest(SEL_ENTRY); + const i = [].indexOf.call(el.parentNode.children, el); + const [item] = ids.splice(i, 1); + const type2 = type === 'main' ? 'prio' : 'main'; + groups[type2].push(item); + ols[type2].appendChild(el); + API.styles.setOrder(groups); + } + }); + DraggableList(ol, {scrollContainer: ol}); + return $create('section', {dataset: {[type]: ''}}, [ + $create('header', t('styleInjectionOrderHint_' + type)), + ol, + ]); } } diff --git a/js/prefs.js b/js/prefs.js index cceae0a3..5c032c58 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -134,8 +134,6 @@ 'popupWidth': 246, // popup width in pixels 'updateInterval': 24, // user-style automatic update interval, hours (0 = disable) - - 'injectionOrder': [], }; const knownKeys = Object.keys(defaults); /** @type {PrefsValues} */ diff --git a/js/toolbox.js b/js/toolbox.js index e7b9cb08..00e38eff 100644 --- a/js/toolbox.js +++ b/js/toolbox.js @@ -13,6 +13,7 @@ getTab ignoreChromeError isEmptyObj + mapObj onTabReady openURL sessionStore @@ -272,6 +273,24 @@ function isEmptyObj(obj) { return true; } +/** + * @param {?Object} obj + * @param {function(val:?, key:string, obj:Object):T} [fn] + * @param {string[]} [keys] + * @returns {?Object} + * @template T + */ +function mapObj(obj, fn, keys) { + if (!obj) return obj; + const res = {}; + for (const k of keys || Object.keys(obj)) { + if (!keys || k in obj) { + res[k] = fn ? fn(obj[k], k, obj) : obj[k]; + } + } + return res; +} + /** * js engine can't optimize the entire function if it contains try-catch * so we should keep it isolated from normal code in a minimal wrapper