diff --git a/_locales/en/messages.json b/_locales/en/messages.json index caa9ae17..2e28bc2d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -342,6 +342,19 @@ "message": "Disable", "description": "Label for the button to disable a style" }, + "draftTitle": { + "message": "Draft recovery, created $date$", + "placeholders": { + "date": { + "content": "$1" + } + }, + "description": "Title of the modal displayed in the editor when an unsaved draft is found, the $date$ looks like '1 hour ago' in user's current UI language" + }, + "draftAction": { + "message": "Choose 'Yes' to load this draft or 'No' to discard it.", + "description": "Displayed in the editor after the browser/extension crashed" + }, "dragDropMessage": { "message": "Drop your backup file anywhere on this page to import.", "description": "Drag'n'drop message" diff --git a/background/background.js b/background/background.js index f327938c..c8481c9c 100644 --- a/background/background.js +++ b/background/background.js @@ -1,6 +1,7 @@ /* global API msg */// msg.js /* global addAPI bgReady */// common.js /* global createWorker */// worker-util.js +/* global db */ /* global prefs */ /* global styleMan */ /* global syncMan */ @@ -37,6 +38,11 @@ addAPI(/** @namespace API */ { }, }))(), + /** @type IDBObjectStore */ + drafts: new Proxy({}, { + get: (_, cmd) => (...args) => db.exec.call('drafts', cmd, ...args), + }), + styles: styleMan, sync: syncMan, updater: updateMan, diff --git a/background/db-chrome-storage.js b/background/db-chrome-storage.js index fe5ace24..685f0905 100644 --- a/background/db-chrome-storage.js +++ b/background/db-chrome-storage.js @@ -2,17 +2,15 @@ 'use strict'; /* exported createChromeStorageDB */ -function createChromeStorageDB() { +function createChromeStorageDB(PREFIX) { let INC; - const PREFIX = 'style-'; - const METHODS = { + return { delete(id) { return chromeLocal.remove(PREFIX + id); }, - // FIXME: we don't use this method at all. Should we remove this? get(id) { return chromeLocal.getValue(PREFIX + id); }, @@ -59,8 +57,4 @@ function createChromeStorageDB() { } } } - - return function dbExecChromeStorage(method, ...args) { - return METHODS[method](...args); - }; } diff --git a/background/db.js b/background/db.js index 0427a6a8..51d74cde 100644 --- a/background/db.js +++ b/background/db.js @@ -52,12 +52,18 @@ const db = (() => { console.warn('Failed to access indexedDB. Switched to storage API.', err); } await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */ - return 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); + }; } async function dbExecIndexedDB(method, ...args) { const mode = method.startsWith('get') ? 'readonly' : 'readwrite'; - const store = (await open()).transaction([STORE], mode).objectStore(STORE); + const dbName = Object(this) instanceof String ? `${this}` : DATABASE; + const store = (await open(dbName)).transaction([STORE], mode).objectStore(STORE); const fn = method === 'putMany' ? putMany : storeRequest; return fn(store, method, ...args); } @@ -75,9 +81,9 @@ const db = (() => { return Promise.all(items.map(item => storeRequest(store, 'put', item))); } - function open() { + function open(name) { return new Promise((resolve, reject) => { - const request = indexedDB.open(DATABASE, 2); + const request = indexedDB.open(name, 2); request.onsuccess = () => resolve(request.result); request.onerror = reject; request.onupgradeneeded = create; diff --git a/background/style-manager.js b/background/style-manager.js index 802c972f..52e5b080 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -61,7 +61,13 @@ const styleMan = (() => { let ready = init(); let order = {}; - chrome.runtime.onConnect.addListener(handleLivePreview); + chrome.runtime.onConnect.addListener(port => { + if (port.name === 'livePreview') { + handleLivePreview(port); + } else if (port.name.startsWith('draft:')) { + handleDraft(port); + } + }); // function handleColorScheme() { colorScheme.onChange(() => { for (const {style: data} of dataMap.values()) { @@ -106,6 +112,7 @@ const styleMan = (() => { // Must be called after the style is deleted from dataMap API.usw.revoke(id); } + API.drafts.delete(id); await msg.broadcast({ method: 'styleDeleted', style: {id}, @@ -371,10 +378,12 @@ const styleMan = (() => { style); } + function handleDraft(port) { + const id = port.name.split(':').pop(); + port.onDisconnect.addListener(() => API.drafts.delete(Number(id) || id)); + } + function handleLivePreview(port) { - if (port.name !== 'livePreview') { - return; - } let id; port.onMessage.addListener(style => { if (!id) id = style.id; diff --git a/edit/base.js b/edit/base.js index 582f43d7..a42b5f65 100644 --- a/edit/base.js +++ b/edit/base.js @@ -339,9 +339,15 @@ baseInit.ready.then(() => { function DirtyReporter() { const data = new Map(); const listeners = new Set(); + const dataListeners = new Set(); const notifyChange = wasDirty => { - if (wasDirty !== (data.size > 0)) { - listeners.forEach(cb => cb()); + const isDirty = data.size > 0; + const flipped = isDirty !== wasDirty; + if (flipped) { + listeners.forEach(cb => cb(isDirty)); + } + if (flipped || isDirty) { + dataListeners.forEach(cb => cb(isDirty)); } }; /** @namespace DirtyReporter */ @@ -358,17 +364,19 @@ function DirtyReporter() { saved.newValue = value; saved.type = 'modify'; } + } else { + return; } notifyChange(wasDirty); }, - clear(obj) { - const wasDirty = data.size > 0; - if (obj === undefined) { - data.clear(); - } else { - data.delete(obj); + clear(...objs) { + if (data.size && ( + objs.length + ? objs.map(data.delete, data).includes(true) + : (data.clear(), true) + )) { + notifyChange(true); } - notifyChange(wasDirty); }, has(key) { return data.has(key); @@ -382,6 +390,8 @@ function DirtyReporter() { if (!saved) { if (oldValue !== newValue) { data.set(obj, {type: 'modify', savedValue: oldValue, newValue}); + } else { + return; } } else if (saved.type === 'modify') { if (saved.savedValue === newValue) { @@ -391,12 +401,17 @@ function DirtyReporter() { } } else if (saved.type === 'add') { saved.newValue = newValue; + } else { + return; } notifyChange(wasDirty); }, onChange(cb, add = true) { listeners[add ? 'add' : 'delete'](cb); }, + onDataChange(cb, add = true) { + dataListeners[add ? 'add' : 'delete'](cb); + }, remove(obj, value) { const wasDirty = data.size > 0; const saved = data.get(obj); @@ -406,6 +421,8 @@ function DirtyReporter() { data.delete(obj); } else if (saved.type === 'modify') { saved.type = 'remove'; + } else { + return; } notifyChange(wasDirty); }, diff --git a/edit/drafts.js b/edit/drafts.js new file mode 100644 index 00000000..f66e261a --- /dev/null +++ b/edit/drafts.js @@ -0,0 +1,69 @@ +/* global messageBoxProxy */// dom.js +/* global API */// msg.js +/* global clamp debounce */// toolbox.js +/* global editor */ +/* global prefs */ +/* global t */// localization.js +'use strict'; + +(async function AutosaveDrafts() { + const makeId = () => editor.style.id || 'new'; + let delay; + let port; + connectPort(); + + const draft = await API.drafts.get(makeId()); + if (draft && draft.isUsercss === editor.isUsercss) { + const date = makeRelativeDate(draft.date); + if (await messageBoxProxy.confirm(t('draftAction'), 'danger', t('draftTitle', date))) { + await editor.replaceStyle(draft.style, draft); + } else { + API.drafts.delete(makeId()); + } + } + + editor.dirty.onChange(isDirty => isDirty ? connectPort() : port.disconnect()); + editor.dirty.onDataChange(isDirty => debounce(updateDraft, isDirty ? delay : 0)); + + prefs.subscribe('editor.autosaveDraft', (key, val) => { + delay = clamp(val * 1000 | 0, 1000, 2 ** 32 - 1); + const t = debounce.timers.get(updateDraft); + if (t != null) debounce(updateDraft, t ? delay : 0); + }, {runNow: true}); + + function connectPort() { + port = chrome.runtime.connect({name: 'draft:' + makeId()}); + } + + function makeRelativeDate(date) { + let delta = (Date.now() - date) / 1000; + if (delta >= 0 && Intl.RelativeTimeFormat) { + for (const [span, unit, frac = 1] of [ + [60, 'second', 0], + [60, 'minute', 0], + [24, 'hour'], + [7, 'day'], + [4, 'week'], + [12, 'month'], + [1e99, 'year'], + ]) { + if (delta < span) { + return new Intl.RelativeTimeFormat({style: 'short'}).format(-delta.toFixed(frac), unit); + } + delta /= span; + } + } + return date.toLocaleString(); + } + + function updateDraft(isDirty = editor.dirty.isDirty()) { + if (!isDirty) return; + API.drafts.put({ + date: Date.now(), + id: makeId(), + isUsercss: editor.isUsercss, + style: editor.getValue(true), + si: editor.makeScrollInfo(), + }); + } +})(); diff --git a/edit/edit.js b/edit/edit.js index eda76e9b..3790da3b 100644 --- a/edit/edit.js +++ b/edit/edit.js @@ -58,6 +58,7 @@ baseInit.ready.then(async () => { require([ '/edit/autocomplete', + '/edit/drafts', '/edit/global-search', ]); }); @@ -139,16 +140,7 @@ window.on('beforeunload', e => { prefs.set('windowPosition', pos); } sessionStore.windowPos = JSON.stringify(pos || {}); - sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({ - scrollY: window.scrollY, - cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({ - bookmarks: (cm.state.sublimeBookmarks || []).map(b => b.find()), - focus: cm.hasFocus(), - height: cm.display.wrapper.style.height.replace('100vh', ''), - parentHeight: cm.display.wrapper.parentElement.offsetHeight, - sel: cm.isClean() && [cm.doc.sel.ranges, cm.doc.sel.primIndex], - })), - }); + sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify(editor.makeScrollInfo()); const activeElement = document.activeElement; if (activeElement) { // blurring triggers 'change' or 'input' event if needed @@ -195,6 +187,19 @@ window.on('beforeunload', e => { } }, + makeScrollInfo() { + return { + scrollY: window.scrollY, + cms: editor.getEditors().map(cm => /** @namespace EditorScrollInfo */({ + bookmarks: (cm.state.sublimeBookmarks || []).map(b => b.find()), + focus: cm.hasFocus(), + height: cm.display.wrapper.style.height.replace('100vh', ''), + parentHeight: cm.display.wrapper.parentElement.offsetHeight, + sel: [cm.doc.sel.ranges, cm.doc.sel.primIndex], + })), + }; + }, + async save() { if (dirty.isDirty()) { editor.saving = true; diff --git a/edit/sections-editor.js b/edit/sections-editor.js index 854c801a..c77be6df 100644 --- a/edit/sections-editor.js +++ b/edit/sections-editor.js @@ -86,15 +86,21 @@ function SectionsEditor() { : null; }, - async replaceStyle(newStyle) { + async replaceStyle(newStyle, draft) { const sameCode = styleSectionsEqual(newStyle, getModel()); - if (!sameCode && !await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) { + if (!sameCode && !draft && !await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) { return; } - dirty.clear(); + if (!draft) { + dirty.clear(); + } // FIXME: avoid recreating all editors? if (!sameCode) { - await initSections(newStyle.sections, {replace: true}); + await initSections(newStyle.sections, { + keepDirty: draft, + replace: true, + si: draft && draft.si, + }); } editor.useSavedStyle(newStyle); updateLivePreview(); @@ -468,14 +474,15 @@ function SectionsEditor() { async function initSections(src, { focusOn = 0, replace = false, - keepDirty = false, // used by import + keepDirty = false, + si = editor.scrollInfo, } = {}) { + editor.ready = false; if (replace) { sections.forEach(s => s.remove(true)); sections.length = 0; container.textContent = ''; } - let si = editor.scrollInfo; if (si && si.cms && si.cms.length === src.length) { si.scrollY2 = si.scrollY + window.innerHeight; container.style.height = si.scrollY2 + 'px'; @@ -503,9 +510,12 @@ function SectionsEditor() { if (!keepDirty) dirty.clear(); if (i === focusOn) sections[i].cm.focus(); } - if (!si) requestAnimationFrame(fitToAvailableSpace); + if (!si || si.cms.every(cm => !cm.height)) { + requestAnimationFrame(fitToAvailableSpace); + } container.style.removeProperty('height'); setGlobalProgress(); + editor.ready = true; } /** @param {EditorSection} section */ diff --git a/edit/source-editor.js b/edit/source-editor.js index 2de7b701..f74e905c 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -57,7 +57,13 @@ function SourceEditor() { closestVisible: () => cm, getEditors: () => [cm], getEditorTitle: () => '', - getValue: () => cm.getValue(), + getValue: asObject => asObject + ? { + customName: style.customName, + enabled: style.enabled, + sourceCode: cm.getValue(), + } + : cm.getValue(), getSearchableInputs: () => [], prevEditor: nextPrevSection.bind(null, -1), nextEditor: nextPrevSection.bind(null, 1), @@ -195,7 +201,7 @@ function SourceEditor() { cm.setPreprocessor((style.usercssData || {}).preprocessor); } - async function replaceStyle(newStyle) { + async function replaceStyle(newStyle, draft) { dirty.clear('name'); const sameCode = newStyle.sourceCode === cm.getValue(); if (sameCode) { @@ -207,19 +213,26 @@ function SourceEditor() { return; } - if (await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) { + if (draft || await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) { editor.useSavedStyle(newStyle); if (!sameCode) { - const cursor = cm.getCursor(); + const si0 = draft && draft.si.cms[0]; + const cursor = !si0 && cm.getCursor(); cm.setValue(style.sourceCode); - cm.setCursor(cursor); + if (si0) { + editor.applyScrollInfo(cm, si0); + } else { + cm.setCursor(cursor); + } savedGeneration = cm.changeGeneration(); } if (sameCode) { // the code is same but the environment is changed updateLivePreview(); } - dirty.clear(); + if (!draft) { + dirty.clear(); + } } } diff --git a/js/dlg/message-box.js b/js/dlg/message-box.js index f7008d77..d3be66b0 100644 --- a/js/dlg/message-box.js +++ b/js/dlg/message-box.js @@ -1,4 +1,5 @@ /* global $ $create animateElement focusAccessibility moveFocus */// dom.js +/* global clamp */// toolbox.js /* global t */// localization.js 'use strict'; @@ -84,10 +85,6 @@ messageBox.show = async ({ messageBox._resolve = resolve; }); - function clamp(value, min, max) { - return Math.min(Math.max(value, min), max); - } - function initOwnListeners() { let listening = false; let offsetX = 0; diff --git a/js/dom-on-load.js b/js/dom-on-load.js index 89369ef1..58778d8a 100644 --- a/js/dom-on-load.js +++ b/js/dom-on-load.js @@ -1,5 +1,5 @@ /* global $$ $ $create focusAccessibility getEventKeyName moveFocus */// dom.js -/* global debounce */// toolbox.js +/* global clamp debounce */// toolbox.js /* global t */// localization.js 'use strict'; @@ -34,7 +34,7 @@ const key = isSelect ? 'selectedIndex' : 'valueAsNumber'; const old = el[key]; const rawVal = old + Math.sign(event.deltaY) * (el.step || 1); - el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal)); + el[key] = clamp(rawVal, el.min || 0, el.max || el.length - 1); if (el[key] !== old) { el.dispatchEvent(new Event('change', {bubbles: true})); } diff --git a/js/prefs.js b/js/prefs.js index 1ce401c8..cceae0a3 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -99,6 +99,7 @@ 'editor.selectByTokens': true, 'editor.appliesToLineWidget': true, // show applies-to line widget on the editor + 'editor.autosaveDraft': 10, // seconds 'editor.livePreview': true, // show CSS colors as clickable colored rectangles diff --git a/js/toolbox.js b/js/toolbox.js index 0fdb11b6..2b33dd27 100644 --- a/js/toolbox.js +++ b/js/toolbox.js @@ -4,6 +4,7 @@ CHROME_POPUP_BORDER_BUG RX_META capitalize + clamp closeCurrentTab deepEqual download @@ -132,6 +133,10 @@ if (FIREFOX || OPERA || VIVALDI) { // (detecting FF57 by the feature it added, not navigator.ua which may be spoofed in about:config) const openerTabIdSupported = (!FIREFOX || window.AbortController) && chrome.windows != null; +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + function getOwnTab() { return browser.tabs.getCurrent(); } diff --git a/options/options.js b/options/options.js index fbaa7f12..1590fa32 100644 --- a/options/options.js +++ b/options/options.js @@ -17,6 +17,7 @@ OPERA URLS capitalize + clamp ignoreChromeError openURL */// toolbox.js @@ -288,7 +289,7 @@ function enforceInputRange(element) { if (type === 'input' && element.checkValidity()) { doNotify(); } else if (type === 'change' && !element.checkValidity()) { - element.value = Math.max(min, Math.min(max, Number(element.value))); + element.value = clamp(Number(element.value), min, max); doNotify(); } }; diff --git a/popup/popup.js b/popup/popup.js index 1b1f12f3..55d4178a 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -10,6 +10,7 @@ FIREFOX URLS capitalize + clamp getActiveTab isEmptyObj */// toolbox.js @@ -66,7 +67,7 @@ function onRuntimeMessage(msg) { function setPopupWidth(_key, width) { document.body.style.width = - Math.max(200, Math.min(800, width)) + 'px'; + clamp(width, 200, 800) + 'px'; } function toggleSideBorders(_key, state) {