From 16edf57400174825e854e8afd38c894c015041b5 Mon Sep 17 00:00:00 2001 From: tophf Date: Fri, 21 Jan 2022 20:24:48 +0300 Subject: [PATCH] autosave in editor --- _locales/en/messages.json | 13 ++++++ background/background.js | 6 +++ background/db-chrome-storage.js | 10 +---- background/db.js | 14 +++++-- background/style-manager.js | 1 + edit/base.js | 35 ++++++++++++----- edit/drafts.js | 70 +++++++++++++++++++++++++++++++++ edit/edit.js | 25 +++++++----- edit/sections-editor.js | 22 +++++++---- edit/source-editor.js | 25 +++++++++--- js/prefs.js | 1 + js/toolbox.js | 5 +++ 12 files changed, 183 insertions(+), 44 deletions(-) create mode 100644 edit/drafts.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 58006e22..0eee2ca0 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..6d804e81 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -106,6 +106,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}, 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..6f3e53f9 --- /dev/null +++ b/edit/drafts.js @@ -0,0 +1,70 @@ +/* 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 NEW = 'new'; + let delay; + let draftId = editor.style.id || NEW; + + const draft = await API.drafts.get(draftId); + if (draft && draft.isUsercss === editor.isUsercss) { + const date = makeRelativeDate(draft.date); + if (await messageBoxProxy.confirm(t('draftAction'), 'danger pre-line', t('draftTitle', date))) { + await editor.replaceStyle(draft.style, draft); + } else { + updateDraft(false); + } + } + + editor.dirty.onDataChange(isDirty => { + debounce(updateDraft, isDirty ? delay : 0); + }); + + prefs.subscribe('editor.autosaveDelay', (key, val) => { + delay = clamp(val * 1000 | 0, 1000, 2 ** 32 - 1); + const t = debounce.timers.get(updateDraft); + if (t != null) debounce(updateDraft, t); + }, {runNow: true}); + + 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()) { + const newDraftId = editor.style.id || NEW; + if (isDirty) { + API.drafts.put({ + date: Date.now(), + id: newDraftId, + isUsercss: editor.isUsercss, + style: editor.getValue(true), + si: editor.makeScrollInfo(), + }); + } else { + API.drafts.delete(draftId); // the old id may have been 0 when a new style is saved now + } + draftId = newDraftId; + } +})(); 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..affe04c5 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,14 @@ function SectionsEditor() { async function initSections(src, { focusOn = 0, replace = false, - keepDirty = false, // used by import + keepDirty = false, + si = editor.scrollInfo, } = {}) { 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,7 +509,9 @@ 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(); } diff --git a/edit/source-editor.js b/edit/source-editor.js index fb118b1c..59d1e4c0 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -48,7 +48,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), @@ -193,7 +199,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) { @@ -205,19 +211,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/prefs.js b/js/prefs.js index 1ce401c8..693c1fab 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.autosaveDelay': 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(); }