draft recovery in editor (#1388)

+ use toolbox::clamp() more
This commit is contained in:
tophf 2022-01-23 12:44:25 +03:00 committed by GitHub
parent 60f59e9f06
commit e54178a43c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 203 additions and 56 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

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

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

69
edit/drafts.js Normal file
View File

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

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,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 */

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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