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": { "styleIncludeLabel": {
"message": "Custom included sites" "message": "Custom included sites"
}, },
"styleInjectionImportance": {
"message": "Toggle style's importance"
},
"styleInjectionOrder": { "styleInjectionOrder": {
"message": "Style injection order", "message": "Style injection order",
"description": "Tooltip for the button in the manager to open the dialog and also the title of this dialog" "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.", "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" "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": { "styleExcludeLabel": {
"message": "Custom excluded sites" "message": "Custom excluded sites"
}, },

View File

@ -39,11 +39,7 @@ addAPI(/** @namespace API */ {
}, },
}))(), }))(),
/** @type IDBObjectStore */ drafts: db.open('drafts'),
drafts: new Proxy({}, {
get: (_, cmd) => (...args) => db.exec.call('drafts', cmd, ...args),
}),
styles: styleMan, styles: styleMan,
sync: syncMan, sync: syncMan,
updater: updateMan, updater: updateMan,

View File

@ -5,16 +5,19 @@
* Common stuff that's loaded first so it's immediately available to all background scripts * Common stuff that's loaded first so it's immediately available to all background scripts
*/ */
/* exported
addAPI
bgReady
compareRevision
*/
const bgReady = {}; const bgReady = {};
bgReady.styles = new Promise(r => (bgReady._resolveStyles = r)); bgReady.styles = new Promise(r => (bgReady._resolveStyles = r));
bgReady.all = new Promise(r => (bgReady._resolveAll = 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) { function addAPI(methods) {
for (const [key, val] of Object.entries(methods)) { for (const [key, val] of Object.entries(methods)) {
const old = API[key]; const old = API[key];
@ -26,6 +29,64 @@ function addAPI(methods) {
} }
} }
function compareRevision(rev1, rev2) { /* exported createCache */
return rev1 - rev2; /** 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 */ /* exported createChromeStorageDB */
function createChromeStorageDB(PREFIX) { function createChromeStorageDB(PREFIX) {
let INC; let INC;
const isMain = !PREFIX;
if (!PREFIX) PREFIX = 'style-';
return { return {
@ -19,7 +21,9 @@ function createChromeStorageDB(PREFIX) {
const all = await chromeLocal.get(); const all = await chromeLocal.get();
if (!INC) prepareInc(all); if (!INC) prepareInc(all);
return Object.entries(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); .filter(Boolean);
}, },

View File

@ -1,5 +1,7 @@
/* global addAPI */// common.js
/* global chromeLocal */// storage-util.js /* global chromeLocal */// storage-util.js
/* global cloneError */// worker-util.js /* global cloneError */// worker-util.js
/* global prefs */
'use strict'; 'use strict';
/* /*
@ -11,16 +13,29 @@
/* exported db */ /* exported db */
const db = (() => { const db = (() => {
const DATABASE = 'stylish'; let exec = async (...args) => (
const STORE = 'styles'; exec = await tryUsingIndexedDB().catch(useChromeStorage)
)(...args);
const DB = 'stylish';
const FALLBACK = 'dbInChromeStorage'; const FALLBACK = 'dbInChromeStorage';
const dbApi = { const getStoreName = dbName => dbName === DB ? 'styles' : 'data';
async exec(...args) { const proxies = {};
dbApi.exec = await tryUsingIndexedDB().catch(useChromeStorage); const proxyHandler = {
return dbApi.exec(...args); 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() { async function tryUsingIndexedDB() {
// we use chrome.storage.local fallback if IndexedDB doesn't save data, // we use chrome.storage.local fallback if IndexedDB doesn't save data,
@ -40,9 +55,9 @@ const db = (() => {
async function testDB() { async function testDB() {
const id = `${performance.now()}.${Math.random()}.${Date.now()}`; const id = `${performance.now()}.${Math.random()}.${Date.now()}`;
await dbExecIndexedDB('put', {id}); await dbExecIndexedDB(DB, 'put', {id});
const e = await dbExecIndexedDB('get', id); const e = await dbExecIndexedDB(DB, 'get', id);
await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null await dbExecIndexedDB(DB, 'delete', e.id); // throws if `e` or id is null
} }
async function useChromeStorage(err) { async function useChromeStorage(err) {
@ -53,17 +68,17 @@ const db = (() => {
} }
await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */ await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */
const BASES = {}; const BASES = {};
return function dbExecChromeStorage(method, ...args) { return (dbName, method, ...args) => (
const prefix = Object(this) instanceof String ? `${this}-` : 'style-'; BASES[dbName] || (
const baseApi = BASES[prefix] || (BASES[prefix] = createChromeStorageDB(prefix)); BASES[dbName] = createChromeStorageDB(dbName !== DB && `${dbName}-`)
return baseApi[method](...args); )
}; )[method](...args);
} }
async function dbExecIndexedDB(method, ...args) { async function dbExecIndexedDB(dbName, method, ...args) {
const mode = method.startsWith('get') ? 'readonly' : 'readwrite'; const mode = method.startsWith('get') ? 'readonly' : 'readwrite';
const dbName = Object(this) instanceof String ? `${this}` : DATABASE; const storeName = getStoreName(dbName);
const store = (await open(dbName)).transaction([STORE], mode).objectStore(STORE); const store = (await open(dbName)).transaction([storeName], mode).objectStore(storeName);
const fn = method === 'putMany' ? putMany : storeRequest; const fn = method === 'putMany' ? putMany : storeRequest;
return fn(store, method, ...args); return fn(store, method, ...args);
} }
@ -92,7 +107,8 @@ const db = (() => {
function create(event) { function create(event) {
if (event.oldVersion === 0) { if (event.oldVersion === 0) {
event.target.result.createObjectStore(STORE, { const idb = event.target.result;
idb.createObjectStore(getStoreName(idb.name), {
keyPath: 'id', keyPath: 'id',
autoIncrement: true, autoIncrement: true,
}); });

View File

@ -1,6 +1,6 @@
/* global API msg */// msg.js /* global API msg */// msg.js
/* global CHROME URLS isEmptyObj stringAsRegExp tryRegExp tryURL */// toolbox.js /* global CHROME URLS deepEqual isEmptyObj mapObj stringAsRegExp tryRegExp tryURL */// toolbox.js
/* global bgReady compareRevision */// common.js /* global bgReady createCache uuidIndex */// common.js
/* global calcStyleDigest styleCodeEmpty styleSectionGlobal */// sections-util.js /* global calcStyleDigest styleCodeEmpty styleSectionGlobal */// sections-util.js
/* global db */ /* global db */
/* global prefs */ /* 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. to cleanup the temporary code. See livePreview in /edit.
*/ */
const styleUtil = {};
/* exported styleMan */
const styleMan = (() => { const styleMan = (() => {
Object.assign(styleUtil, {
id2style,
handleSave,
uuid2style,
});
//#region Declarations //#region Declarations
/** @typedef {{ /** @typedef {{
style: StyleObj style: StyleObj,
preview?: StyleObj preview?: StyleObj,
appliesTo: Set<string> appliesTo: Set<string>,
}} StyleMapData */ }} StyleMapData */
/** @type {Map<number,StyleMapData>} */ /** @type {Map<number,StyleMapData>} */
const dataMap = new Map(); const dataMap = new Map();
const uuidIndex = new Map();
/** @typedef {Object<styleId,{id: number, code: string[]}>} StyleSectionsToApply */ /** @typedef {Object<styleId,{id: number, code: string[]}>} StyleSectionsToApply */
/** @type {Map<string,{maybeMatch: Set<styleId>, sections: StyleSectionsToApply}>} */ /** @type {Map<string,{maybeMatch: Set<styleId>, sections: StyleSectionsToApply}>} */
const cachedStyleForUrl = createCache({ const cachedStyleForUrl = createCache({
@ -57,9 +65,17 @@ const styleMan = (() => {
_rev: () => Date.now(), _rev: () => Date.now(),
}; };
const DELETE_IF_NULL = ['id', 'customName', 'md5Url', 'originalMd5']; 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` */ /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
let ready = init(); let ready = init();
let order = {};
chrome.runtime.onConnect.addListener(port => { chrome.runtime.onConnect.addListener(port => {
if (port.name === 'livePreview') { 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 //#endregion
//#region Exports //#region Exports
@ -96,18 +101,23 @@ const styleMan = (() => {
/** @returns {Promise<number>} style id */ /** @returns {Promise<number>} style id */
async delete(id, reason) { async delete(id, reason) {
if (ready.then) await ready; if (ready.then) await ready;
const data = id2data(id); const {style, appliesTo} = dataMap.get(id);
const {style, appliesTo} = data; const sync = reason !== 'sync';
await db.exec('delete', id); const uuid = style._id;
if (reason !== 'sync') { db.styles.delete(id);
API.sync.delete(style._id, Date.now()); if (sync) API.sync.delete(uuid, Date.now());
}
for (const url of appliesTo) { for (const url of appliesTo) {
const cache = cachedStyleForUrl.get(url); const cache = cachedStyleForUrl.get(url);
if (cache) delete cache.sections[id]; if (cache) delete cache.sections[id];
} }
dataMap.delete(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) { if (style._usw && style._usw.token) {
// Must be called after the style is deleted from dataMap // Must be called after the style is deleted from dataMap
API.usw.revoke(id); API.usw.revoke(id);
@ -120,17 +130,6 @@ const styleMan = (() => {
return id; 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>} */ /** @returns {Promise<StyleObj>} */
async editSave(style) { async editSave(style) {
if (ready.then) await ready; if (ready.then) await ready;
@ -154,15 +153,27 @@ const styleMan = (() => {
/** @returns {Promise<StyleObj[]>} */ /** @returns {Promise<StyleObj[]>} */
async getAll() { async getAll() {
if (ready.then) await ready; if (ready.then) await ready;
return Array.from(dataMap.values(), data2style); return getAllAsArray();
}, },
/** @returns {Promise<StyleObj>} */ /** @returns {Promise<Object<string,StyleObj[]>>}>} */
async getByUUID(uuid) { async getAllOrdered(keys) {
if (ready.then) await ready; 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>} */ /** @returns {Promise<StyleSectionsToApply>} */
async getSectionsByUrl(url, id, isInitialApply) { async getSectionsByUrl(url, id, isInitialApply) {
if (ready.then) await ready; if (ready.then) await ready;
@ -210,7 +221,7 @@ const styleMan = (() => {
const result = []; const result = [];
const styles = id const styles = id
? [id2style(id)].filter(Boolean) ? [id2style(id)].filter(Boolean)
: Array.from(dataMap.values(), data2style); : getAllAsArray();
const query = createMatchQuery(url); const query = createMatchQuery(url);
for (const style of styles) { for (const style of styles) {
let excluded = false; let excluded = false;
@ -262,11 +273,10 @@ const styleMan = (() => {
await usercssMan.buildCode(style); await usercssMan.buildCode(style);
} }
} }
const events = await db.exec('putMany', items); const events = await db.styles.putMany(items);
return Promise.all(items.map((item, i) => { return Promise.all(items.map((item, i) =>
afterSave(item, events[i]); handleSave(item, {reason: 'import'}, events[i])
return handleSave(item, {reason: 'import'}); ));
}));
}, },
/** @returns {Promise<StyleObj>} */ /** @returns {Promise<StyleObj>} */
@ -279,33 +289,13 @@ const styleMan = (() => {
return saveStyle(style, {reason}); 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, save: saveStyle,
async setOrder(value) {
if (ready.then) await ready;
return setOrder({value}, {broadcast: true, sync: true});
},
/** @returns {Promise<number>} style id */ /** @returns {Promise<number>} style id */
async toggle(id, enabled) { async toggle(id, enabled) {
if (ready.then) await ready; if (ready.then) await ready;
@ -343,12 +333,12 @@ const styleMan = (() => {
/** @returns {?StyleObj} */ /** @returns {?StyleObj} */
function id2style(id) { function id2style(id) {
return (dataMap.get(id) || {}).style; return (dataMap.get(Number(id)) || {}).style;
} }
/** @returns {?StyleObj} */ /** @returns {?StyleObj} */
function data2style(data) { function uuid2style(uuid) {
return data && data.style; return id2style(uuidIndex.get(uuid));
} }
/** @returns {StyleObj} */ /** @returns {StyleObj} */
@ -369,6 +359,7 @@ const styleMan = (() => {
style, style,
appliesTo: new Set(), appliesTo: new Set(),
}); });
uuidIndex.set(style._id, style.id);
} }
/** @returns {StyleObj} */ /** @returns {StyleObj} */
@ -472,29 +463,24 @@ const styleMan = (() => {
fixKnownProblems(style); 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) { async function saveStyle(style, handlingOptions) {
beforeSave(style); beforeSave(style);
const newId = await db.exec('put', style); const newId = await db.styles.put(style);
afterSave(style, newId); return handleSave(style, handlingOptions, newId);
return handleSave(style, handlingOptions);
} }
function handleSave(style, {reason, broadcast = true}) { function handleSave(style, {reason, broadcast = true}, id = style.id) {
const data = id2data(style.id); if (style.id == null) style.id = id;
const data = id2data(id);
const method = data ? 'styleUpdated' : 'styleAdded'; const method = data ? 'styleUpdated' : 'styleAdded';
if (!data) { if (!data) {
storeInMap(style); storeInMap(style);
} else { } else {
data.style = style; data.style = style;
} }
if (reason !== 'sync') {
API.sync.putDoc(style);
}
if (broadcast) broadcastStyleUpdated(style, reason, method); if (broadcast) broadcastStyleUpdated(style, reason, method);
return style; return style;
} }
@ -519,15 +505,14 @@ const styleMan = (() => {
} }
async function init() { 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)); const updated = await Promise.all(styles.map(fixKnownProblems).filter(Boolean));
if (updated.length) { if (updated.length) {
await db.exec('putMany', updated); await db.styles.putMany(updated);
}
for (const style of styles) {
storeInMap(style);
uuidIndex.set(style._id, style.id);
} }
setOrder(await orderPromise, {store: false});
styles.forEach(storeInMap);
ready = true; ready = true;
bgReady._resolveStyles(); 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 */ /** uuidv4 helper: converts to a 4-digit hex string and adds "-" at required positions */
function hex4dashed(num, i) { function hex4dashed(num, i) {
return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : ''); 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 //#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 API msg */// msg.js
/* global bgReady uuidIndex */// common.js
/* global chromeLocal chromeSync */// storage-util.js /* global chromeLocal chromeSync */// storage-util.js
/* global compareRevision */// common.js /* global db */
/* global iconMan */ /* global iconMan */
/* global prefs */ /* global prefs */
/* global styleUtil */
/* global tokenMan */ /* global tokenMan */
'use strict'; 'use strict';
@ -28,11 +30,12 @@ const syncMan = (() => {
errorMessage: null, errorMessage: null,
login: false, login: false,
}; };
const compareRevision = (rev1, rev2) => rev1 - rev2;
let lastError = null; let lastError = null;
let ctrl; let ctrl;
let currentDrive; let currentDrive;
/** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */ /** @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; ready = true;
prefs.subscribe('sync.enabled', prefs.subscribe('sync.enabled',
(_, val) => val === 'none' (_, val) => val === 'none'
@ -79,11 +82,11 @@ const syncMan = (() => {
} }
}, },
async put(...args) { async putDoc({_id, _rev}) {
if (ready.then) await ready; if (ready.then) await ready;
if (!currentDrive) return; if (!currentDrive) return;
schedule(); schedule();
return ctrl.put(...args); return ctrl.put(_id, _rev);
}, },
async setDriveOptions(driveName, options) { async setDriveOptions(driveName, options) {
@ -178,17 +181,33 @@ const syncMan = (() => {
async function initController() { async function initController() {
await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */ await require(['/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */
ctrl = dbToCloud.dbToCloud({ ctrl = dbToCloud.dbToCloud({
onGet(id) { onGet: styleUtil.uuid2style,
return API.styles.getByUUID(id); 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) { onDelete(_id, rev) {
return API.styles.putByUUID(doc); const id = uuidIndex.get(_id);
}, const oldDoc = styleUtil.id2style(id);
onDelete(id, rev) { return oldDoc &&
return API.styles.deleteByUUID(id, rev); compareRevision(oldDoc._rev, rev) <= 0 &&
API.styles.delete(id, 'sync');
}, },
async onFirstSync() { 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); ctrl.put(i._id, i._rev);
} }
}, },

View File

@ -234,7 +234,7 @@ const updateMan = (() => {
if (err && etag && !style.etag) { if (err && etag && !style.etag) {
// first check of ETAG, gonna write it directly to DB as it's too trivial to sync or announce // first check of ETAG, gonna write it directly to DB as it's too trivial to sync or announce
style.etag = etag; style.etag = etag;
await db.exec('put', style); await db.styles.put(style);
} }
return err return err
? Promise.reject(err) ? Promise.reject(err)

View File

@ -13,19 +13,16 @@
let hasStyles = false; let hasStyles = false;
let isDisabled = false; let isDisabled = false;
let isTab = !chrome.tabs || location.pathname !== '/popup.html'; 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 isFrame = window !== parent;
const isFrameAboutBlank = isFrame && location.href === 'about:blank'; const isFrameAboutBlank = isFrame && location.href === 'about:blank';
const isUnstylable = !chrome.app && document instanceof XMLDocument; const isUnstylable = !chrome.app && document instanceof XMLDocument;
const styleInjector = StyleInjector({ const styleInjector = StyleInjector({
compare: (a, b) => { compare: (a, b) => calcOrder(a) - calcOrder(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;
},
onUpdate: onInjectorUpdate, onUpdate: onInjectorUpdate,
}); });
// dynamic iframes don't have a URL yet so we'll use their parent's URL (hash isn't inherited) // 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); await API.styles.getSectionsByUrl(matchUrl, null, true);
if (styles.cfg) { if (styles.cfg) {
isDisabled = styles.cfg.disableAll; isDisabled = styles.cfg.disableAll;
order = styles.cfg.order || {}; Object.assign(order, styles.cfg.order);
delete styles.cfg; delete styles.cfg;
} }
hasStyles = !isDisabled; hasStyles = !isDisabled;
@ -179,7 +176,7 @@
break; break;
case 'styleSort': case 'styleSort':
order = request.order; Object.assign(order, request.order);
styleInjector.sort(); styleInjector.sort();
break; break;

View File

@ -1,13 +1,20 @@
#message-box.injection-order > div { .injection-order > div {
height: 100%; height: 100%;
max-width: 80vw; 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; padding: 0;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
--border: 1px solid rgba(128, 128, 128, .25); }
.injection-order section[data-main] {
flex: 1 0;
} }
.injection-order header { .injection-order header {
padding: 1rem; padding: 1rem;
@ -16,39 +23,77 @@
box-sizing: border-box; box-sizing: border-box;
} }
.injection-order ol { .injection-order ol {
height: 100%; padding: 0;
padding: 1px 0 0; /* 1px for keyboard-focused element's outline */
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
overflow-y: auto; overflow-y: auto;
border-top: var(--border);
} }
.injection-order a { .injection-order ol:empty {
display: block; 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; color: #000;
text-decoration: none;
transition: transform .25s ease-in-out; transition: transform .25s ease-in-out;
z-index: 1; z-index: 1;
user-select: none; user-select: none;
padding: 0.3em .5em;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
cursor: move; 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; font-weight: bold;
} }
.injection-order a:hover { .injection-order-entry a {
text-decoration: none;
}
.injection-order-entry a[href]:hover {
text-decoration: underline; text-decoration: underline;
} }
.injection-order a:not(:first-child) { .injection-order-toggle {
border-top: var(--border); display: flex;
align-items: center;
padding: 0 .5rem;
cursor: pointer;
opacity: .5;
transition: .15s;
} }
.injection-order a::before { .injection-order-toggle::after {
content: "\2261"; content: '\2606';
padding: 0.6em; font-size: 20px;
font-weight: normal; 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 { .injection-order .draggable-list-target {
position: relative; position: relative;

View File

@ -1,7 +1,6 @@
/* global $create messageBoxProxy */// dom.js /* global $create messageBoxProxy */// dom.js
/* global API */// msg.js /* global API */// msg.js
/* global DraggableList */ /* global DraggableList */
/* global prefs */
/* global t */// localization.js /* global t */// localization.js
'use strict'; 'use strict';
@ -10,70 +9,75 @@ async function InjectionOrder(show = true) {
if (!show) { if (!show) {
return messageBoxProxy.close(); return messageBoxProxy.close();
} }
const entries = (await getOrderedStyles()).map(makeEntry); const SEL_ENTRY = '.injection-order-entry';
const ol = $create('ol'); const groups = await API.styles.getAllOrdered(['_id', 'id', 'name', 'enabled']);
let maxTranslateY; const ols = {};
ol.append(...entries.map(l => l.el)); const parts = {};
ol.on('d:dragstart', ({detail: d}) => { const entry = $create('li' + SEL_ENTRY, [
d.origin.dataTransfer.setDragImage(new Image(), 0, 0); parts.name = $create('a', {
maxTranslateY = ol.scrollHeight + ol.offsetTop - d.dragTarget.offsetHeight - d.dragTarget.offsetTop; target: '_blank',
}); draggable: false,
ol.on('d:dragmove', ({detail: d}) => { }),
d.origin.stopPropagation(); // preserves dropEffect $create('a.injection-order-toggle', {
d.origin.dataTransfer.dropEffect = 'move'; tabIndex: 0,
const y = Math.min(d.currentPos.y - d.startPos.y, maxTranslateY); draggable: false,
d.dragTarget.style.transform = `translateY(${y}px)`; title: t('styleInjectionImportance'),
}); }),
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});
await messageBoxProxy.show({ await messageBoxProxy.show({
title: t('styleInjectionOrder'), title: t('styleInjectionOrder'),
contents: $create('fragment', [ contents: $create('fragment', Object.entries(groups).map(makeList)),
$create('header', t('styleInjectionOrderHint')),
ol,
]),
className: 'injection-order center-dialog', className: 'injection-order center-dialog',
blockScroll: true, blockScroll: true,
buttons: [t('confirmClose')], buttons: [t('confirmClose')],
}); });
async function getOrderedStyles() { function makeEntry(style) {
const [styles] = await Promise.all([ entry.classList.toggle('enabled', style.enabled);
API.styles.getAll(), parts.name.href = '/edit.html?id=' + style.id;
prefs.ready, parts.name.textContent = style.name;
]); return Object.assign(entry.cloneNode(true), {
const styleSet = new Set(styles); styleNameLowerCase: style.name.toLocaleLowerCase(),
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) { function makeList([type, styles]) {
return { const ids = groups[type] = styles.map(s => s._id);
style, const ol = ols[type] = $create('ol.scroller');
el: $create('a', { let maxTranslateY;
className: style.enabled ? 'enabled' : '', ol.append(...styles.map(makeEntry));
href: '/edit.html?id=' + style.id, ol.on('d:dragstart', ({detail: d}) => {
target: '_blank', d.origin.dataTransfer.setDragImage(new Image(), 0, 0);
}, style.name), 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})); : 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} = {}) { function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
// align to the top/bottom of the visible area if wasn't visible // align to the top/bottom of the visible area if wasn't visible
if (!element.parentNode) return; if (!element.parentNode) return;
@ -286,7 +292,8 @@ function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) || if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) ||
top > Math.min(parentBottom, windowHeight) - height - 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 'popupWidth': 246, // popup width in pixels
'updateInterval': 24, // user-style automatic update interval, hours (0 = disable) 'updateInterval': 24, // user-style automatic update interval, hours (0 = disable)
'injectionOrder': [],
}; };
const knownKeys = Object.keys(defaults); const knownKeys = Object.keys(defaults);
/** @type {PrefsValues} */ /** @type {PrefsValues} */

View File

@ -14,6 +14,7 @@
getTab getTab
ignoreChromeError ignoreChromeError
isEmptyObj isEmptyObj
mapObj
onTabReady onTabReady
openURL openURL
sessionStore sessionStore
@ -288,6 +289,24 @@ function isEmptyObj(obj) {
return true; 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 * 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 * 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') { if (!src || typeof src !== 'object') {
return src; return src;
} }
if (Array.isArray(src)) { if (Array.isArray(src)) {
// using `Array` that belongs to this `window`; not using Array.from as it's slower // 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 for (const v of src) dst.push(deepMerge(v));
} else { } else {
// using an explicit {} that belongs to this `window` // using an explicit {} that belongs to this `window`

View File

@ -314,7 +314,10 @@
</label> </label>
</div> </div>
<button id="manage-options-button" i18n-text="openOptions"></button> <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> </details>
</div> </div>
@ -380,6 +383,10 @@
<symbol id="svg-icon-config" viewBox="0 0 16 16"> <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"/> <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>
<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> </svg>
</body> </body>

View File

@ -105,6 +105,7 @@ async function importFromString(jsonString) {
const oldStyles = Array.isArray(json) && json.length ? await API.styles.getAll() : []; const oldStyles = Array.isArray(json) && json.length ? await API.styles.getAll() : [];
const oldStylesById = new Map(oldStyles.map(style => [style.id, style])); const oldStylesById = new Map(oldStyles.map(style => [style.id, style]));
const oldStylesByName = new Map(oldStyles.map(style => [style.name.trim(), style])); const oldStylesByName = new Map(oldStyles.map(style => [style.name.trim(), style]));
const oldOrder = await API.styles.getOrder();
const items = []; const items = [];
const infos = []; const infos = [];
const stats = { const stats = {
@ -116,11 +117,14 @@ async function importFromString(jsonString) {
codeOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedCode', dirty: true}, codeOnly: {names: [], ids: [], legend: 'importReportLegendUpdatedCode', dirty: true},
invalid: {names: [], legend: 'importReportLegendInvalid'}, invalid: {names: [], legend: 'importReportLegendInvalid'},
}; };
let order;
await Promise.all(json.map(analyze)); await Promise.all(json.map(analyze));
changeQueue.length = 0; changeQueue.length = 0;
changeQueue.time = performance.now(); changeQueue.time = performance.now();
(await API.styles.importMany(items)) (await API.styles.importMany(items))
.forEach((style, i) => updateStats(style, infos[i])); .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(); return done();
function analyze(item, index) { function analyze(item, index) {
@ -168,6 +172,8 @@ async function importFromString(jsonString) {
async function analyzeStorage(storage) { async function analyzeStorage(storage) {
analyzePrefs(storage[prefs.STORAGE_KEY], prefs.knownKeys, prefs.values, true); analyzePrefs(storage[prefs.STORAGE_KEY], prefs.knownKeys, prefs.values, true);
delete storage[prefs.STORAGE_KEY]; delete storage[prefs.STORAGE_KEY];
order = storage.order;
delete storage.order;
if (!isEmptyObj(storage)) { if (!isEmptyObj(storage)) {
analyzePrefs(storage, Object.values(chromeSync.LZ_KEY), await chromeSync.getLZValues()); 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 = [ const newIds = [
...stats.metaAndCode.ids, ...stats.metaAndCode.ids,
...stats.metaOnly.ids, ...stats.metaOnly.ids,
@ -293,6 +299,8 @@ async function importFromString(jsonString) {
...stats.added.ids, ...stats.added.ids,
]; ];
let tasks = Promise.resolve(); let tasks = Promise.resolve();
// TODO: delete all deletable at once
// TODO: import all importable at once
for (const id of newIds) { for (const id of newIds) {
tasks = tasks.then(() => API.styles.delete(id)); tasks = tasks.then(() => API.styles.delete(id));
const oldStyle = oldStylesById.get(id); const oldStyle = oldStylesById.get(id);
@ -300,13 +308,13 @@ async function importFromString(jsonString) {
tasks = tasks.then(() => API.styles.importMany([oldStyle])); tasks = tasks.then(() => API.styles.importMany([oldStyle]));
} }
} }
// taskUI is superfast and updates style list only in this page, await tasks;
// which should account for 99.99999999% of cases, supposedly await API.styles.setOrder(oldOrder);
return tasks.then(() => messageBoxProxy.show({ await messageBoxProxy.show({
title: t('importReportUndoneTitle'), title: t('importReportUndoneTitle'),
contents: newIds.length + ' ' + t('importReportUndone'), contents: newIds.length + ' ' + t('importReportUndone'),
buttons: [t('confirmClose')], buttons: [t('confirmClose')],
})); });
} }
function bindClick() { function bindClick() {
@ -344,6 +352,7 @@ async function exportToFile(e) {
const data = [ const data = [
Object.assign({ Object.assign({
[prefs.STORAGE_KEY]: prefs.values, [prefs.STORAGE_KEY]: prefs.values,
order: await API.styles.getOrder(),
}, await chromeSync.getLZValues()), }, await chromeSync.getLZValues()),
...(await API.styles.getAll()).map(cleanupStyle), ...(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 debounce */// toolbox.js
/* global installed */// manage.js /* global installed */// manage.js
/* global /* global
$$
$ $
$create $create
$isTextInput $isTextInput
@ -9,44 +10,41 @@
*/// dom.js */// dom.js
'use strict'; 'use strict';
(() => { (async () => {
await require(['/manage/incremental-search.css']);
let prevText, focusedLink, focusedEntry; let prevText, focusedLink, focusedEntry;
let prevTime = performance.now(); let prevTime = performance.now();
let focusedName = ''; let focusedName = '';
const input = $create('textarea', { const input = $create('textarea', {
id: 'incremental-search',
spellcheck: false, spellcheck: false,
attributes: {tabindex: -1}, attributes: {tabindex: -1},
oninput: incrementalSearch, oninput: incrementalSearch,
}); });
replaceInlineStyle({ replaceInlineStyle({
opacity: '0', 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); document.body.appendChild(input);
window.on('keydown', maybeRefocus, true); window.on('keydown', maybeRefocus, true);
function incrementalSearch({key}, immediately) { function incrementalSearch(event, immediately) {
const {key} = event;
if (!immediately) { if (!immediately) {
debounce(incrementalSearch, 100, {}, true); debounce(incrementalSearch, 100, {}, true);
return; return;
} }
const direction = key === 'ArrowUp' ? -1 : key === 'ArrowDown' ? 1 : 0; const direction = key === 'ArrowUp' ? -1 : key === 'ArrowDown' ? 1 : 0;
const text = input.value.toLocaleLowerCase(); const text = input.value.toLocaleLowerCase();
if (direction) {
event.preventDefault();
}
if (!text.trim() || !direction && (text === prevText || focusedName.startsWith(text))) { if (!text.trim() || !direction && (text === prevText || focusedName.startsWith(text))) {
prevText = text; prevText = text;
return; return;
} }
let textAtPos = 1e6; let textAtPos = 1e6;
let rotated; let rotated;
const entries = [...installed.children]; const entries = $('#message-box') ? $$('.injection-order-entry') : [...installed.children];
const focusedIndex = entries.indexOf(focusedEntry); const focusedIndex = entries.indexOf(focusedEntry);
if (focusedIndex > 0) { if (focusedIndex > 0) {
if (direction > 0) { if (direction > 0) {
@ -74,7 +72,7 @@
} }
if (found && found !== focusedEntry) { if (found && found !== focusedEntry) {
focusedEntry = found; focusedEntry = found;
focusedLink = $('.style-name-link', found); focusedLink = $('a', found);
focusedName = found.styleNameLowerCase; focusedName = found.styleNameLowerCase;
scrollElementIntoView(found, {invalidMarginRatio: .25}); scrollElementIntoView(found, {invalidMarginRatio: .25});
animateElement(found, 'highlight-quick'); animateElement(found, 'highlight-quick');
@ -84,12 +82,17 @@
opacity: '1', opacity: '1',
}); });
focusedLink.prepend(input); focusedLink.prepend(input);
input.focus();
return true; return true;
} }
} }
function maybeRefocus(event) { 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; return;
} }
const inTextInput = $isTextInput(event.target); const inTextInput = $isTextInput(event.target);
@ -99,7 +102,7 @@
(code === 'Slash' || key === '/') && !ctrl && !inTextInput) { (code === 'Slash' || key === '/') && !ctrl && !inTextInput) {
// focus search field on "/" or Ctrl-F key // focus search field on "/" or Ctrl-F key
event.preventDefault(); event.preventDefault();
$('#search').focus(); if (!modal) $('#search').focus();
return; return;
} }
if (ctrl || inTextInput && event.target !== input) { if (ctrl || inTextInput && event.target !== input) {

View File

@ -112,6 +112,19 @@ a:hover {
left: 2px; 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 { #installed {
position: relative; position: relative;
padding-left: var(--header-width); padding-left: var(--header-width);

View File

@ -93,11 +93,11 @@ newUI.renderClass();
showStyles(styles, ids); showStyles(styles, ids);
require([ window.on('load', () => require([
'/manage/import-export', '/manage/import-export',
'/manage/incremental-search', '/manage/incremental-search',
'/manage/updater-ui', '/manage/updater-ui',
]); ]), {once: true});
})(); })();
msg.onExtension(onRuntimeMessage); msg.onExtension(onRuntimeMessage);