diff --git a/_locales/en/messages.json b/_locales/en/messages.json index cda0cd07..038d2917 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1693,6 +1693,9 @@ "styleIncludeLabel": { "message": "Custom included sites" }, + "styleInjectionImportance": { + "message": "Toggle style's importance" + }, "styleInjectionOrder": { "message": "Style injection order", "description": "Tooltip for the button in the manager to open the dialog and also the title of this dialog" @@ -1701,6 +1704,10 @@ "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 listed below 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/background.js b/background/background.js index 7c9168c9..95679239 100644 --- a/background/background.js +++ b/background/background.js @@ -39,11 +39,7 @@ addAPI(/** @namespace API */ { }, }))(), - /** @type IDBObjectStore */ - drafts: new Proxy({}, { - get: (_, cmd) => (...args) => db.exec.call('drafts', cmd, ...args), - }), - + drafts: db.open('drafts'), styles: styleMan, sync: syncMan, updater: updateMan, diff --git a/background/common.js b/background/common.js index a08174fa..ef1c0ffd 100644 --- a/background/common.js +++ b/background/common.js @@ -5,16 +5,19 @@ * Common stuff that's loaded first so it's immediately available to all background scripts */ -/* exported - addAPI - bgReady - compareRevision -*/ - const bgReady = {}; bgReady.styles = new Promise(r => (bgReady._resolveStyles = r)); bgReady.all = new Promise(r => (bgReady._resolveAll = r)); +const uuidIndex = Object.assign(new Map(), { + custom: {}, + /** `obj` must have a unique `id`, a UUIDv4 `_id`, and Date.now() for `_rev`. */ + addCustomId(obj, {get = () => obj, set}) { + Object.defineProperty(uuidIndex.custom, obj.id, {get, set}); + }, +}); + +/* exported addAPI */ function addAPI(methods) { for (const [key, val] of Object.entries(methods)) { const old = API[key]; @@ -26,6 +29,64 @@ function addAPI(methods) { } } -function compareRevision(rev1, rev2) { - return rev1 - rev2; +/* exported createCache */ +/** Creates a FIFO limit-size map. */ +function createCache({size = 1000, onDeleted} = {}) { + const map = new Map(); + const buffer = Array(size); + let index = 0; + let lastIndex = 0; + return { + get(id) { + const item = map.get(id); + return item && item.data; + }, + set(id, data) { + if (map.size === size) { + // full + map.delete(buffer[lastIndex].id); + if (onDeleted) { + onDeleted(buffer[lastIndex].id, buffer[lastIndex].data); + } + lastIndex = (lastIndex + 1) % size; + } + const item = {id, data, index}; + map.set(id, item); + buffer[index] = item; + index = (index + 1) % size; + }, + delete(id) { + const item = map.get(id); + if (!item) { + return false; + } + map.delete(item.id); + const lastItem = buffer[lastIndex]; + lastItem.index = item.index; + buffer[item.index] = lastItem; + lastIndex = (lastIndex + 1) % size; + if (onDeleted) { + onDeleted(item.id, item.data); + } + return true; + }, + clear() { + map.clear(); + index = lastIndex = 0; + }, + has: id => map.has(id), + *entries() { + for (const [id, item] of map) { + yield [id, item.data]; + } + }, + *values() { + for (const item of map.values()) { + yield item.data; + } + }, + get size() { + return map.size; + }, + }; } diff --git a/background/db-chrome-storage.js b/background/db-chrome-storage.js index 685f0905..416e9c09 100644 --- a/background/db-chrome-storage.js +++ b/background/db-chrome-storage.js @@ -4,6 +4,8 @@ /* exported createChromeStorageDB */ function createChromeStorageDB(PREFIX) { let INC; + const isMain = !PREFIX; + if (!PREFIX) PREFIX = 'style-'; return { @@ -19,7 +21,9 @@ function createChromeStorageDB(PREFIX) { const all = await chromeLocal.get(); if (!INC) prepareInc(all); return Object.entries(all) - .map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val) + .map(([key, val]) => key.startsWith(PREFIX) && + (!isMain || Number(key.slice(PREFIX.length))) && + val) .filter(Boolean); }, diff --git a/background/db.js b/background/db.js index 51d74cde..4c5f112a 100644 --- a/background/db.js +++ b/background/db.js @@ -1,5 +1,7 @@ +/* global addAPI */// common.js /* global chromeLocal */// storage-util.js /* global cloneError */// worker-util.js +/* global prefs */ 'use strict'; /* @@ -11,16 +13,29 @@ /* exported db */ const db = (() => { - const DATABASE = 'stylish'; - const STORE = 'styles'; + let exec = async (...args) => ( + exec = await tryUsingIndexedDB().catch(useChromeStorage) + )(...args); + const DB = 'stylish'; const FALLBACK = 'dbInChromeStorage'; - const dbApi = { - async exec(...args) { - dbApi.exec = await tryUsingIndexedDB().catch(useChromeStorage); - return dbApi.exec(...args); - }, + const getStoreName = dbName => dbName === DB ? 'styles' : 'data'; + const proxies = {}; + const proxyHandler = { + get: ({dbName}, cmd) => (...args) => exec(dbName, cmd, ...args), + }; + /** @return {IDBObjectStore | {putMany: function(items:?[]):Promise}} */ + const getProxy = (dbName = DB) => proxies[dbName] || ( + proxies[dbName] = new Proxy({dbName}, proxyHandler) + ); + addAPI(/** @namespace API */ { + /** Storage for big items that may exceed 8kB limit of chrome.storage.sync. + * To make an item syncable register it with uuidIndex.addCustomId. */ + prefsDb: getProxy(prefs.STORAGE_KEY), + }); + return { + styles: getProxy(), + open: getProxy, }; - return dbApi; async function tryUsingIndexedDB() { // we use chrome.storage.local fallback if IndexedDB doesn't save data, @@ -40,9 +55,9 @@ const db = (() => { async function testDB() { const id = `${performance.now()}.${Math.random()}.${Date.now()}`; - await dbExecIndexedDB('put', {id}); - const e = await dbExecIndexedDB('get', id); - await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null + await dbExecIndexedDB(DB, 'put', {id}); + const e = await dbExecIndexedDB(DB, 'get', id); + await dbExecIndexedDB(DB, 'delete', e.id); // throws if `e` or id is null } async function useChromeStorage(err) { @@ -53,17 +68,17 @@ const db = (() => { } await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */ const BASES = {}; - return function dbExecChromeStorage(method, ...args) { - const prefix = Object(this) instanceof String ? `${this}-` : 'style-'; - const baseApi = BASES[prefix] || (BASES[prefix] = createChromeStorageDB(prefix)); - return baseApi[method](...args); - }; + return (dbName, method, ...args) => ( + BASES[dbName] || ( + BASES[dbName] = createChromeStorageDB(dbName !== DB && `${dbName}-`) + ) + )[method](...args); } - async function dbExecIndexedDB(method, ...args) { + async function dbExecIndexedDB(dbName, method, ...args) { const mode = method.startsWith('get') ? 'readonly' : 'readwrite'; - const dbName = Object(this) instanceof String ? `${this}` : DATABASE; - const store = (await open(dbName)).transaction([STORE], mode).objectStore(STORE); + const storeName = getStoreName(dbName); + const store = (await open(dbName)).transaction([storeName], mode).objectStore(storeName); const fn = method === 'putMany' ? putMany : storeRequest; return fn(store, method, ...args); } @@ -92,7 +107,8 @@ const db = (() => { function create(event) { if (event.oldVersion === 0) { - event.target.result.createObjectStore(STORE, { + const idb = event.target.result; + idb.createObjectStore(getStoreName(idb.name), { keyPath: 'id', autoIncrement: true, }); diff --git a/background/style-manager.js b/background/style-manager.js index 52e5b080..b976ef20 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 compareRevision */// 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 */ @@ -18,18 +18,26 @@ The live preview feature relies on `runtime.connect` and `port.onDisconnect` to cleanup the temporary code. See livePreview in /edit. */ +const styleUtil = {}; + +/* exported styleMan */ const styleMan = (() => { + Object.assign(styleUtil, { + id2style, + handleSave, + uuid2style, + }); + //#region Declarations /** @typedef {{ - style: StyleObj - preview?: StyleObj - appliesTo: Set + style: StyleObj, + preview?: StyleObj, + appliesTo: Set, }} StyleMapData */ /** @type {Map} */ const dataMap = new Map(); - const uuidIndex = new Map(); /** @typedef {Object} StyleSectionsToApply */ /** @type {Map, sections: StyleSectionsToApply}>} */ const cachedStyleForUrl = createCache({ @@ -57,9 +65,17 @@ const styleMan = (() => { _rev: () => Date.now(), }; const DELETE_IF_NULL = ['id', 'customName', 'md5Url', 'originalMd5']; + const INJ_ORDER = 'injectionOrder'; + const order = {main: {}, prio: {}}; + const orderWrap = { + id: INJ_ORDER, + value: mapObj(order, () => []), + _id: `${chrome.runtime.id}-${INJ_ORDER}`, + _rev: 0, + }; + uuidIndex.addCustomId(orderWrap, {set: setOrder}); /** @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') { @@ -77,17 +93,6 @@ const styleMan = (() => { } }); - 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 @@ -96,18 +101,23 @@ const styleMan = (() => { /** @returns {Promise} style id */ async delete(id, reason) { if (ready.then) await ready; - const data = id2data(id); - const {style, appliesTo} = data; - await db.exec('delete', id); - if (reason !== 'sync') { - API.sync.delete(style._id, Date.now()); - } + const {style, appliesTo} = dataMap.get(id); + const sync = reason !== 'sync'; + const uuid = style._id; + db.styles.delete(id); + if (sync) API.sync.delete(uuid, Date.now()); for (const url of appliesTo) { const cache = cachedStyleForUrl.get(url); if (cache) delete cache.sections[id]; } dataMap.delete(id); - uuidIndex.delete(style._id); + uuidIndex.delete(uuid); + mapObj(orderWrap.value, (group, type) => { + delete order[type][id]; + const i = group.indexOf(uuid); + if (i >= 0) group.splice(i, 1); + }); + setOrder(orderWrap, {calc: false}); if (style._usw && style._usw.token) { // Must be called after the style is deleted from dataMap API.usw.revoke(id); @@ -120,17 +130,6 @@ const styleMan = (() => { return id; }, - /** @returns {Promise} style id */ - async deleteByUUID(_id, rev) { - if (ready.then) await ready; - const id = uuidIndex.get(_id); - const oldDoc = id && id2style(id); - if (oldDoc && compareRevision(oldDoc._rev, rev) <= 0) { - // FIXME: does it make sense to set reason to 'sync' in deleteByUUID? - return styleMan.delete(id, 'sync'); - } - }, - /** @returns {Promise} */ async editSave(style) { if (ready.then) await ready; @@ -154,15 +153,27 @@ const styleMan = (() => { /** @returns {Promise} */ async getAll() { if (ready.then) await ready; - return Array.from(dataMap.values(), data2style); + return getAllAsArray(); }, - /** @returns {Promise} */ - async getByUUID(uuid) { + /** @returns {Promise>}>} */ + async getAllOrdered(keys) { if (ready.then) await ready; - return id2style(uuidIndex.get(uuid)); + const res = mapObj(orderWrap.value, group => group.map(uuid2style)); + 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 keys + ? mapObj(res, group => group.map(style => mapObj(style, null, keys))) + : res; }, + getOrder: () => orderWrap.value, + /** @returns {Promise} */ async getSectionsByUrl(url, id, isInitialApply) { if (ready.then) await ready; @@ -210,7 +221,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; @@ -262,11 +273,10 @@ const styleMan = (() => { await usercssMan.buildCode(style); } } - const events = await db.exec('putMany', items); - return Promise.all(items.map((item, i) => { - afterSave(item, events[i]); - return handleSave(item, {reason: 'import'}); - })); + const events = await db.styles.putMany(items); + return Promise.all(items.map((item, i) => + handleSave(item, {reason: 'import'}, events[i]) + )); }, /** @returns {Promise} */ @@ -279,33 +289,13 @@ const styleMan = (() => { return saveStyle(style, {reason}); }, - /** @returns {Promise} */ - async putByUUID(doc) { - if (ready.then) await ready; - const id = uuidIndex.get(doc._id); - if (id) { - doc.id = id; - } else { - delete doc.id; - } - const oldDoc = id && id2style(id); - let diff = -1; - if (oldDoc) { - diff = compareRevision(oldDoc._rev, doc._rev); - if (diff > 0) { - API.sync.put(oldDoc._id, oldDoc._rev); - return; - } - } - if (diff < 0) { - doc.id = await db.exec('put', doc); - uuidIndex.set(doc._id, doc.id); - return handleSave(doc, {reason: 'sync'}); - } - }, - save: saveStyle, + async setOrder(value) { + if (ready.then) await ready; + return setOrder({value}, {broadcast: true, sync: true}); + }, + /** @returns {Promise} style id */ async toggle(id, enabled) { if (ready.then) await ready; @@ -343,12 +333,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; + function uuid2style(uuid) { + return id2style(uuidIndex.get(uuid)); } /** @returns {StyleObj} */ @@ -369,6 +359,7 @@ const styleMan = (() => { style, appliesTo: new Set(), }); + uuidIndex.set(style._id, style.id); } /** @returns {StyleObj} */ @@ -472,29 +463,24 @@ const styleMan = (() => { fixKnownProblems(style); } - function afterSave(style, newId) { - if (style.id == null) { - style.id = newId; - } - uuidIndex.set(style._id, style.id); - API.sync.put(style._id, style._rev); - } - async function saveStyle(style, handlingOptions) { beforeSave(style); - const newId = await db.exec('put', style); - afterSave(style, newId); - return handleSave(style, handlingOptions); + const newId = await db.styles.put(style); + return handleSave(style, handlingOptions, newId); } - function handleSave(style, {reason, broadcast = true}) { - const data = id2data(style.id); + function handleSave(style, {reason, broadcast = true}, id = style.id) { + if (style.id == null) style.id = id; + const data = id2data(id); const method = data ? 'styleUpdated' : 'styleAdded'; if (!data) { storeInMap(style); } else { data.style = style; } + if (reason !== 'sync') { + API.sync.putDoc(style); + } if (broadcast) broadcastStyleUpdated(style, reason, method); return style; } @@ -519,15 +505,14 @@ const styleMan = (() => { } async function init() { - const styles = await db.exec('getAll') || []; + const orderPromise = API.prefsDb.get(INJ_ORDER); + const styles = await db.styles.getAll() || []; const updated = await Promise.all(styles.map(fixKnownProblems).filter(Boolean)); if (updated.length) { - await db.exec('putMany', updated); - } - for (const style of styles) { - storeInMap(style); - uuidIndex.set(style._id, style.id); + await db.styles.putMany(updated); } + setOrder(await orderPromise, {store: false}); + styles.forEach(storeInMap); ready = true; bgReady._resolveStyles(); } @@ -725,71 +710,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(data, {broadcast, calc = true, store = true, sync} = {}) { + if (!data || !data.value || deepEqual(data.value, orderWrap.value)) { + return; + } + Object.assign(orderWrap, data, sync && {_rev: Date.now()}); + if (calc) { + for (const [type, group] of Object.entries(data.value)) { + const dst = order[type] = {}; + group.forEach((uuid, i) => { + const id = uuidIndex.get(uuid); + if (id) dst[id] = i; + }); + } + } + if (broadcast) { + msg.broadcast({method: 'styleSort', order}); + } + if (store) { + await API.prefsDb.put(orderWrap); + } + if (sync) { + API.sync.putDoc(orderWrap); + } + } + //#endregion })(); - -/** Creates a FIFO limit-size map. */ -function createCache({size = 1000, onDeleted} = {}) { - const map = new Map(); - const buffer = Array(size); - let index = 0; - let lastIndex = 0; - return { - get(id) { - const item = map.get(id); - return item && item.data; - }, - set(id, data) { - if (map.size === size) { - // full - map.delete(buffer[lastIndex].id); - if (onDeleted) { - onDeleted(buffer[lastIndex].id, buffer[lastIndex].data); - } - lastIndex = (lastIndex + 1) % size; - } - const item = {id, data, index}; - map.set(id, item); - buffer[index] = item; - index = (index + 1) % size; - }, - delete(id) { - const item = map.get(id); - if (!item) { - return false; - } - map.delete(item.id); - const lastItem = buffer[lastIndex]; - lastItem.index = item.index; - buffer[item.index] = lastItem; - lastIndex = (lastIndex + 1) % size; - if (onDeleted) { - onDeleted(item.id, item.data); - } - return true; - }, - clear() { - map.clear(); - index = lastIndex = 0; - }, - has: id => map.has(id), - *entries() { - for (const [id, item] of map) { - yield [id, item.data]; - } - }, - *values() { - for (const item of map.values()) { - yield item.data; - } - }, - get size() { - return map.size; - }, - }; -} diff --git a/background/sync-manager.js b/background/sync-manager.js index e1aafd4e..b8f32cce 100644 --- a/background/sync-manager.js +++ b/background/sync-manager.js @@ -1,8 +1,10 @@ /* global API msg */// msg.js +/* global bgReady uuidIndex */// common.js /* global chromeLocal chromeSync */// storage-util.js -/* global compareRevision */// common.js +/* global db */ /* global iconMan */ /* global prefs */ +/* global styleUtil */ /* global tokenMan */ 'use strict'; @@ -28,11 +30,12 @@ const syncMan = (() => { errorMessage: null, login: false, }; + 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(() => { + let ready = bgReady.styles.then(() => { ready = true; prefs.subscribe('sync.enabled', (_, val) => val === 'none' @@ -79,11 +82,11 @@ const syncMan = (() => { } }, - async put(...args) { + async putDoc({_id, _rev}) { if (ready.then) await ready; if (!currentDrive) return; schedule(); - return ctrl.put(...args); + return ctrl.put(_id, _rev); }, async setDriveOptions(driveName, options) { @@ -178,17 +181,33 @@ const syncMan = (() => { async function initController() { await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */ ctrl = dbToCloud.dbToCloud({ - onGet(id) { - return API.styles.getByUUID(id); + onGet: styleUtil.uuid2style, + async onPut(doc) { + const id = uuidIndex.get(doc._id); + const oldCust = uuidIndex.custom[id]; + const oldDoc = oldCust || styleUtil.id2style(id); + const diff = oldDoc ? compareRevision(oldDoc._rev, doc._rev) : -1; + if (!diff) return; + if (diff > 0) { + syncMan.putDoc(oldDoc); + } else if (oldCust) { + uuidIndex.custom[id] = doc; + } else { + delete doc.id; + if (id) doc.id = id; + doc.id = await db.styles.put(doc); + await styleUtil.handleSave(doc, {reason: 'sync'}); + } }, - onPut(doc) { - return API.styles.putByUUID(doc); - }, - onDelete(id, rev) { - return API.styles.deleteByUUID(id, rev); + onDelete(_id, rev) { + const id = uuidIndex.get(_id); + const oldDoc = styleUtil.id2style(id); + return oldDoc && + compareRevision(oldDoc._rev, rev) <= 0 && + API.styles.delete(id, 'sync'); }, async onFirstSync() { - for (const i of await API.styles.getAll()) { + for (const i of Object.values(uuidIndex.custom).concat(await API.styles.getAll())) { ctrl.put(i._id, i._rev); } }, diff --git a/background/update-manager.js b/background/update-manager.js index 0796e4af..2e1261d2 100644 --- a/background/update-manager.js +++ b/background/update-manager.js @@ -234,7 +234,7 @@ const updateMan = (() => { if (err && etag && !style.etag) { // first check of ETAG, gonna write it directly to DB as it's too trivial to sync or announce style.etag = etag; - await db.exec('put', style); + await db.styles.put(style); } return err ? Promise.reject(err) 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..f864b3b6 100644 --- a/injection-order/injection-order.css +++ b/injection-order/injection-order.css @@ -1,13 +1,20 @@ -#message-box.injection-order > div { +.injection-order > div { height: 100%; max-width: 80vw; } -#message-box.injection-order #message-box-contents { +.injection-order #incremental-search { + transform: scaleY(.55); + transform-origin: top; +} +.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 +23,77 @@ box-sizing: border-box; } .injection-order ol { - height: 100%; - padding: 1px 0 0; /* 1px for keyboard-focused element's outline */ + padding: 0; 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; + position: relative; /* for incremental-search */ + padding: 1px 1px 1px 1rem; /* keyboard focus outline */ 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; + 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: .5; + transition: .15s; } -.injection-order a::before { - content: "\2261"; - padding: 0.6em; - font-weight: normal; +.injection-order-toggle::after { + content: '\2606'; + font-size: 20px; + line-height: 1; + transition: .15s; +} +.injection-order-entry:hover .injection-order-toggle { + opacity: 1; +} +[data-prio] .injection-order-toggle::after { + content: '\2605'; +} +.injection-order-toggle:hover::after { + color: hsl(30, 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..6d325697 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,75 @@ 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(['_id', 'id', 'name', 'enabled']); + const ols = {}; + const parts = {}; + const entry = $create('li' + SEL_ENTRY, [ + parts.name = $create('a', { + target: '_blank', + draggable: false, + }), + $create('a.injection-order-toggle', { + tabIndex: 0, + draggable: false, + title: t('styleInjectionImportance'), + }), + ]); 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) { + entry.classList.toggle('enabled', style.enabled); + parts.name.href = '/edit.html?id=' + style.id; + parts.name.textContent = style.name; + return Object.assign(entry.cloneNode(true), { + styleNameLowerCase: style.name.toLocaleLowerCase(), + }); } - function makeEntry(style) { - return { - style, - el: $create('a', { - className: style.enabled ? 'enabled' : '', - href: '/edit.html?id=' + style.id, - target: '_blank', - }, style.name), - }; + function makeList([type, styles]) { + const ids = groups[type] = styles.map(s => s._id); + const ol = ols[type] = $create('ol.scroller'); + 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 === 'main' ? '' : '_' + type}`)), + ol, + ]); } } diff --git a/js/dom.js b/js/dom.js index 155deae6..241c8b12 100644 --- a/js/dom.js +++ b/js/dom.js @@ -278,6 +278,12 @@ function onDOMready() { : new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true})); } +/** + * Scrolls `window` or the closest parent with `class="scroller"` if the element is not visible, + * centering the element in the view + * @param {HTMLElement} element + * @param {number} [invalidMarginRatio] - for example, 0.10 will center the element if it's in the top/bottom 10% of the scroller + */ function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) { // align to the top/bottom of the visible area if wasn't visible if (!element.parentNode) return; @@ -286,7 +292,8 @@ function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) { const windowHeight = window.innerHeight; if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) || top > Math.min(parentBottom, windowHeight) - height - windowHeight * invalidMarginRatio) { - window.scrollBy(0, top - windowHeight / 2 + height); + const scroller = element.closest('.scroller'); + scroller.scrollBy(0, top - (scroller ? scroller.clientHeight : windowHeight) / 2 + height); } } diff --git a/js/prefs.js b/js/prefs.js index 5a33f9bf..364308fb 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 336f9c71..91583403 100644 --- a/js/toolbox.js +++ b/js/toolbox.js @@ -14,6 +14,7 @@ getTab ignoreChromeError isEmptyObj + mapObj onTabReady openURL sessionStore @@ -288,6 +289,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 @@ -349,13 +368,13 @@ Object.assign(debounce, { }, }); -function deepMerge(src, dst) { +function deepMerge(src, dst, mergeArrays) { if (!src || typeof src !== 'object') { return src; } if (Array.isArray(src)) { // using `Array` that belongs to this `window`; not using Array.from as it's slower - if (!dst) dst = Array.prototype.map.call(src, deepCopy); + if (!dst || !mergeArrays) dst = Array.prototype.map.call(src, deepCopy); else for (const v of src) dst.push(deepMerge(v)); } else { // using an explicit {} that belongs to this `window` diff --git a/manage.html b/manage.html index 761404cf..919e0387 100644 --- a/manage.html +++ b/manage.html @@ -314,7 +314,10 @@ - + @@ -380,6 +383,10 @@ + + + + diff --git a/manage/import-export.js b/manage/import-export.js index 57d269ab..02b16599 100644 --- a/manage/import-export.js +++ b/manage/import-export.js @@ -105,6 +105,7 @@ async function importFromString(jsonString) { const oldStyles = Array.isArray(json) && json.length ? await API.styles.getAll() : []; const oldStylesById = new Map(oldStyles.map(style => [style.id, style])); const oldStylesByName = new Map(oldStyles.map(style => [style.name.trim(), style])); + const oldOrder = await API.styles.getOrder(); const items = []; const infos = []; const stats = { @@ -116,11 +117,14 @@ async function importFromString(jsonString) { codeOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedCode', dirty: true}, invalid: {names: [], legend: 'importReportLegendInvalid'}, }; + let order; await Promise.all(json.map(analyze)); changeQueue.length = 0; changeQueue.time = performance.now(); (await API.styles.importMany(items)) .forEach((style, i) => updateStats(style, infos[i])); + // TODO: set each style's order during import on-the-fly + await API.styles.setOrder(order); return done(); function analyze(item, index) { @@ -168,6 +172,8 @@ async function importFromString(jsonString) { async function analyzeStorage(storage) { analyzePrefs(storage[prefs.STORAGE_KEY], prefs.knownKeys, prefs.values, true); delete storage[prefs.STORAGE_KEY]; + order = storage.order; + delete storage.order; if (!isEmptyObj(storage)) { analyzePrefs(storage, Object.values(chromeSync.LZ_KEY), await chromeSync.getLZValues()); } @@ -285,7 +291,7 @@ async function importFromString(jsonString) { }; } - function undo() { + async function undo() { const newIds = [ ...stats.metaAndCode.ids, ...stats.metaOnly.ids, @@ -293,6 +299,8 @@ async function importFromString(jsonString) { ...stats.added.ids, ]; let tasks = Promise.resolve(); + // TODO: delete all deletable at once + // TODO: import all importable at once for (const id of newIds) { tasks = tasks.then(() => API.styles.delete(id)); const oldStyle = oldStylesById.get(id); @@ -300,13 +308,13 @@ async function importFromString(jsonString) { tasks = tasks.then(() => API.styles.importMany([oldStyle])); } } - // taskUI is superfast and updates style list only in this page, - // which should account for 99.99999999% of cases, supposedly - return tasks.then(() => messageBoxProxy.show({ + await tasks; + await API.styles.setOrder(oldOrder); + await messageBoxProxy.show({ title: t('importReportUndoneTitle'), contents: newIds.length + ' ' + t('importReportUndone'), buttons: [t('confirmClose')], - })); + }); } function bindClick() { @@ -344,6 +352,7 @@ async function exportToFile(e) { const data = [ Object.assign({ [prefs.STORAGE_KEY]: prefs.values, + order: await API.styles.getOrder(), }, await chromeSync.getLZValues()), ...(await API.styles.getAll()).map(cleanupStyle), ]; diff --git a/manage/incremental-search.css b/manage/incremental-search.css new file mode 100644 index 00000000..e11c58e6 --- /dev/null +++ b/manage/incremental-search.css @@ -0,0 +1,12 @@ +#incremental-search { + position: absolute; + color: transparent; + border: 1px solid hsla(180, 100%, 50%, .25); + margin: -1px -2px; + overflow: hidden; + resize: none; + background-color: hsla(180, 100%, 50%, .1); + box-sizing: content-box; + pointer-events: none; + z-index: 2147483647, +} diff --git a/manage/incremental-search.js b/manage/incremental-search.js index 8b7ff460..f0b1e01f 100644 --- a/manage/incremental-search.js +++ b/manage/incremental-search.js @@ -1,6 +1,7 @@ /* global debounce */// toolbox.js /* global installed */// manage.js /* global + $$ $ $create $isTextInput @@ -9,44 +10,41 @@ */// dom.js 'use strict'; -(() => { +(async () => { + await require(['/manage/incremental-search.css']); let prevText, focusedLink, focusedEntry; let prevTime = performance.now(); let focusedName = ''; const input = $create('textarea', { + id: 'incremental-search', spellcheck: false, attributes: {tabindex: -1}, oninput: incrementalSearch, }); replaceInlineStyle({ opacity: '0', - position: 'absolute', - color: 'transparent', - border: '1px solid hsla(180, 100%, 100%, .5)', - margin: '-1px -2px', - overflow: 'hidden', - resize: 'none', - 'background-color': 'hsla(180, 100%, 100%, .2)', - 'box-sizing': 'content-box', - 'pointer-events': 'none', }); document.body.appendChild(input); window.on('keydown', maybeRefocus, true); - function incrementalSearch({key}, immediately) { + function incrementalSearch(event, immediately) { + const {key} = event; if (!immediately) { debounce(incrementalSearch, 100, {}, true); return; } const direction = key === 'ArrowUp' ? -1 : key === 'ArrowDown' ? 1 : 0; const text = input.value.toLocaleLowerCase(); + if (direction) { + event.preventDefault(); + } if (!text.trim() || !direction && (text === prevText || focusedName.startsWith(text))) { prevText = text; return; } let textAtPos = 1e6; let rotated; - const entries = [...installed.children]; + const entries = $('#message-box') ? $$('.injection-order-entry') : [...installed.children]; const focusedIndex = entries.indexOf(focusedEntry); if (focusedIndex > 0) { if (direction > 0) { @@ -74,7 +72,7 @@ } if (found && found !== focusedEntry) { focusedEntry = found; - focusedLink = $('.style-name-link', found); + focusedLink = $('a', found); focusedName = found.styleNameLowerCase; scrollElementIntoView(found, {invalidMarginRatio: .25}); animateElement(found, 'highlight-quick'); @@ -84,12 +82,17 @@ opacity: '1', }); focusedLink.prepend(input); + input.focus(); return true; } } function maybeRefocus(event) { - if (event.altKey || event.metaKey || $('#message-box')) { + if (event.altKey || event.metaKey) { + return; + } + const modal = $('#message-box'); + if (modal && !modal.classList.contains('injection-order')) { return; } const inTextInput = $isTextInput(event.target); @@ -99,7 +102,7 @@ (code === 'Slash' || key === '/') && !ctrl && !inTextInput) { // focus search field on "/" or Ctrl-F key event.preventDefault(); - $('#search').focus(); + if (!modal) $('#search').focus(); return; } if (ctrl || inTextInput && event.target !== input) { diff --git a/manage/manage.css b/manage/manage.css index 79231224..9196026c 100644 --- a/manage/manage.css +++ b/manage/manage.css @@ -112,6 +112,19 @@ a:hover { left: 2px; } +#injection-order-button { + --w: 16px; + width: var(--w); + box-sizing: content-box; + display: inline-flex; + align-items: center; +} +#injection-order-button > svg { + position: absolute; + width: var(--w); + height: var(--w); +} + #installed { position: relative; padding-left: var(--header-width); diff --git a/manage/manage.js b/manage/manage.js index 86ae60c7..96f91ffb 100644 --- a/manage/manage.js +++ b/manage/manage.js @@ -93,11 +93,11 @@ newUI.renderClass(); showStyles(styles, ids); - require([ + window.on('load', () => require([ '/manage/import-export', '/manage/incremental-search', '/manage/updater-ui', - ]); + ]), {once: true}); })(); msg.onExtension(onRuntimeMessage);