autosave in editor

This commit is contained in:
tophf 2022-01-21 20:24:48 +03:00
parent ce9e74e2a0
commit 16edf57400
12 changed files with 183 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

70
edit/drafts.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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