separate storage for order + important styles (#1390)

* use Proxy for `db`
* don't merge arrays in deepMerge by default
* extract sync and cache from styleMan
This commit is contained in:
tophf 2022-01-29 02:54:56 +03:00 committed by GitHub
parent 46785b0550
commit 26b75e77b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 486 additions and 315 deletions

View File

@ -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"
},

View File

@ -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,

View File

@ -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;
},
};
}

View File

@ -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);
},

View File

@ -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,
});

View File

@ -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<string>
style: StyleObj,
preview?: StyleObj,
appliesTo: Set<string>,
}} StyleMapData */
/** @type {Map<number,StyleMapData>} */
const dataMap = new Map();
const uuidIndex = new Map();
/** @typedef {Object<styleId,{id: number, code: string[]}>} StyleSectionsToApply */
/** @type {Map<string,{maybeMatch: Set<styleId>, 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<number>} 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<number>} 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<StyleObj>} */
async editSave(style) {
if (ready.then) await ready;
@ -154,15 +153,27 @@ const styleMan = (() => {
/** @returns {Promise<StyleObj[]>} */
async getAll() {
if (ready.then) await ready;
return Array.from(dataMap.values(), data2style);
return getAllAsArray();
},
/** @returns {Promise<StyleObj>} */
async getByUUID(uuid) {
/** @returns {Promise<Object<string,StyleObj[]>>}>} */
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<StyleSectionsToApply>} */
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<StyleObj>} */
@ -279,33 +289,13 @@ const styleMan = (() => {
return saveStyle(style, {reason});
},
/** @returns {Promise<?StyleObj>} */
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<number>} 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;
},
};
}

View File

@ -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);
}
},

View File

@ -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)

View File

@ -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;

View File

@ -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;

View File

@ -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,
]);
}
}

View File

@ -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);
}
}

View File

@ -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} */

View File

@ -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<string,T>}
* @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`

View File

@ -314,7 +314,10 @@
</label>
</div>
<button id="manage-options-button" i18n-text="openOptions"></button>
<button id="injection-order-button" i18n-title="styleInjectionOrder">↑↓</button>
<button id="injection-order-button" i18n-title="styleInjectionOrder">
<svg class="svg-icon"><use xlink:href="#svg-icon-reorder"/></svg>
&nbsp;
</button>
</details>
</div>
@ -380,6 +383,10 @@
<symbol id="svg-icon-config" viewBox="0 0 16 16">
<path d="M13.3,12.8l1.5-2.6l-2.2-1.5c0-0.2,0.1-0.5,0.1-0.7c0-0.2,0-0.5-0.1-0.7l2.2-1.5l-1.5-2.6l-2.4,1.2 c-0.4-0.3-0.8-0.5-1.2-0.7L9.5,1h-3L6.3,3.7C5.9,3.8,5.5,4.1,5.1,4.4L2.7,3.2L1.2,5.8l2.2,1.5c0,0.2-0.1,0.5-0.1,0.7 c0,0.2,0,0.5,0.1,0.7l-2.2,1.5l1.5,2.6l2.4-1.2c0.4,0.3,0.8,0.5,1.2,0.7L6.5,15h3l0.2-2.7c0.4-0.2,0.8-0.4,1.2-0.7L13.3,12.8z M8,10.3c-1.3,0-2.3-1-2.3-2.3c0-1.3,1-2.3,2.3-2.3c1.3,0,2.3,1,2.3,2.3C10.3,9.3,9.3,10.3,8,10.3z"/>
</symbol>
<symbol id="svg-icon-reorder" viewBox="0 0 16 16">
<path d="M0,16h7v-6H0V16z M1,11h5v4H1V11z M0,5h7V0H0V5z M1,1h5v3H1V1z M13,11v3l-4-3.5L13,7v3h2V3H8V2h8v9H13z M7,9H0V8h7V9z M7,7H0V6h7V7z"/>
</symbol>
</svg>
</body>

View File

@ -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),
];

View File

@ -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,
}

View File

@ -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) {

View File

@ -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);

View File

@ -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);