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", "message": "Disable",
"description": "Label for the button to disable a style" "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": { "dragDropMessage": {
"message": "Drop your backup file anywhere on this page to import.", "message": "Drop your backup file anywhere on this page to import.",
"description": "Drag'n'drop message" "description": "Drag'n'drop message"

View File

@ -1,6 +1,7 @@
/* global API msg */// msg.js /* global API msg */// msg.js
/* global addAPI bgReady */// common.js /* global addAPI bgReady */// common.js
/* global createWorker */// worker-util.js /* global createWorker */// worker-util.js
/* global db */
/* global prefs */ /* global prefs */
/* global styleMan */ /* global styleMan */
/* global syncMan */ /* 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, styles: styleMan,
sync: syncMan, sync: syncMan,
updater: updateMan, updater: updateMan,

View File

@ -2,17 +2,15 @@
'use strict'; 'use strict';
/* exported createChromeStorageDB */ /* exported createChromeStorageDB */
function createChromeStorageDB() { function createChromeStorageDB(PREFIX) {
let INC; let INC;
const PREFIX = 'style-'; return {
const METHODS = {
delete(id) { delete(id) {
return chromeLocal.remove(PREFIX + id); return chromeLocal.remove(PREFIX + id);
}, },
// FIXME: we don't use this method at all. Should we remove this?
get(id) { get(id) {
return chromeLocal.getValue(PREFIX + 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); console.warn('Failed to access indexedDB. Switched to storage API.', err);
} }
await require(['/background/db-chrome-storage']); /* global createChromeStorageDB */ 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) { async function dbExecIndexedDB(method, ...args) {
const mode = method.startsWith('get') ? 'readonly' : 'readwrite'; 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; const fn = method === 'putMany' ? putMany : storeRequest;
return fn(store, method, ...args); return fn(store, method, ...args);
} }
@ -75,9 +81,9 @@ const db = (() => {
return Promise.all(items.map(item => storeRequest(store, 'put', item))); return Promise.all(items.map(item => storeRequest(store, 'put', item)));
} }
function open() { function open(name) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE, 2); const request = indexedDB.open(name, 2);
request.onsuccess = () => resolve(request.result); request.onsuccess = () => resolve(request.result);
request.onerror = reject; request.onerror = reject;
request.onupgradeneeded = create; request.onupgradeneeded = create;

View File

@ -61,7 +61,13 @@ const styleMan = (() => {
let ready = init(); let ready = init();
let order = {}; 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() { // function handleColorScheme() {
colorScheme.onChange(() => { colorScheme.onChange(() => {
for (const {style: data} of dataMap.values()) { for (const {style: data} of dataMap.values()) {
@ -106,6 +112,7 @@ const styleMan = (() => {
// 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);
} }
API.drafts.delete(id);
await msg.broadcast({ await msg.broadcast({
method: 'styleDeleted', method: 'styleDeleted',
style: {id}, style: {id},
@ -371,10 +378,12 @@ const styleMan = (() => {
style); style);
} }
function handleDraft(port) {
const id = port.name.split(':').pop();
port.onDisconnect.addListener(() => API.drafts.delete(Number(id) || id));
}
function handleLivePreview(port) { function handleLivePreview(port) {
if (port.name !== 'livePreview') {
return;
}
let id; let id;
port.onMessage.addListener(style => { port.onMessage.addListener(style => {
if (!id) id = style.id; if (!id) id = style.id;

View File

@ -339,9 +339,15 @@ baseInit.ready.then(() => {
function DirtyReporter() { function DirtyReporter() {
const data = new Map(); const data = new Map();
const listeners = new Set(); const listeners = new Set();
const dataListeners = new Set();
const notifyChange = wasDirty => { const notifyChange = wasDirty => {
if (wasDirty !== (data.size > 0)) { const isDirty = data.size > 0;
listeners.forEach(cb => cb()); const flipped = isDirty !== wasDirty;
if (flipped) {
listeners.forEach(cb => cb(isDirty));
}
if (flipped || isDirty) {
dataListeners.forEach(cb => cb(isDirty));
} }
}; };
/** @namespace DirtyReporter */ /** @namespace DirtyReporter */
@ -358,17 +364,19 @@ function DirtyReporter() {
saved.newValue = value; saved.newValue = value;
saved.type = 'modify'; saved.type = 'modify';
} }
} else {
return;
} }
notifyChange(wasDirty); notifyChange(wasDirty);
}, },
clear(obj) { clear(...objs) {
const wasDirty = data.size > 0; if (data.size && (
if (obj === undefined) { objs.length
data.clear(); ? objs.map(data.delete, data).includes(true)
} else { : (data.clear(), true)
data.delete(obj); )) {
notifyChange(true);
} }
notifyChange(wasDirty);
}, },
has(key) { has(key) {
return data.has(key); return data.has(key);
@ -382,6 +390,8 @@ function DirtyReporter() {
if (!saved) { if (!saved) {
if (oldValue !== newValue) { if (oldValue !== newValue) {
data.set(obj, {type: 'modify', savedValue: oldValue, newValue}); data.set(obj, {type: 'modify', savedValue: oldValue, newValue});
} else {
return;
} }
} else if (saved.type === 'modify') { } else if (saved.type === 'modify') {
if (saved.savedValue === newValue) { if (saved.savedValue === newValue) {
@ -391,12 +401,17 @@ function DirtyReporter() {
} }
} else if (saved.type === 'add') { } else if (saved.type === 'add') {
saved.newValue = newValue; saved.newValue = newValue;
} else {
return;
} }
notifyChange(wasDirty); notifyChange(wasDirty);
}, },
onChange(cb, add = true) { onChange(cb, add = true) {
listeners[add ? 'add' : 'delete'](cb); listeners[add ? 'add' : 'delete'](cb);
}, },
onDataChange(cb, add = true) {
dataListeners[add ? 'add' : 'delete'](cb);
},
remove(obj, value) { remove(obj, value) {
const wasDirty = data.size > 0; const wasDirty = data.size > 0;
const saved = data.get(obj); const saved = data.get(obj);
@ -406,6 +421,8 @@ function DirtyReporter() {
data.delete(obj); data.delete(obj);
} else if (saved.type === 'modify') { } else if (saved.type === 'modify') {
saved.type = 'remove'; saved.type = 'remove';
} else {
return;
} }
notifyChange(wasDirty); 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([ require([
'/edit/autocomplete', '/edit/autocomplete',
'/edit/drafts',
'/edit/global-search', '/edit/global-search',
]); ]);
}); });
@ -139,16 +140,7 @@ window.on('beforeunload', e => {
prefs.set('windowPosition', pos); prefs.set('windowPosition', pos);
} }
sessionStore.windowPos = JSON.stringify(pos || {}); sessionStore.windowPos = JSON.stringify(pos || {});
sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify({ sessionStore['editorScrollInfo' + editor.style.id] = JSON.stringify(editor.makeScrollInfo());
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],
})),
});
const activeElement = document.activeElement; const activeElement = document.activeElement;
if (activeElement) { if (activeElement) {
// blurring triggers 'change' or 'input' event if needed // 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() { async save() {
if (dirty.isDirty()) { if (dirty.isDirty()) {
editor.saving = true; editor.saving = true;

View File

@ -86,15 +86,21 @@ function SectionsEditor() {
: null; : null;
}, },
async replaceStyle(newStyle) { async replaceStyle(newStyle, draft) {
const sameCode = styleSectionsEqual(newStyle, getModel()); const sameCode = styleSectionsEqual(newStyle, getModel());
if (!sameCode && !await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) { if (!sameCode && !draft && !await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) {
return; return;
} }
dirty.clear(); if (!draft) {
dirty.clear();
}
// FIXME: avoid recreating all editors? // FIXME: avoid recreating all editors?
if (!sameCode) { if (!sameCode) {
await initSections(newStyle.sections, {replace: true}); await initSections(newStyle.sections, {
keepDirty: draft,
replace: true,
si: draft && draft.si,
});
} }
editor.useSavedStyle(newStyle); editor.useSavedStyle(newStyle);
updateLivePreview(); updateLivePreview();
@ -468,14 +474,15 @@ function SectionsEditor() {
async function initSections(src, { async function initSections(src, {
focusOn = 0, focusOn = 0,
replace = false, replace = false,
keepDirty = false, // used by import keepDirty = false,
si = editor.scrollInfo,
} = {}) { } = {}) {
editor.ready = false;
if (replace) { if (replace) {
sections.forEach(s => s.remove(true)); sections.forEach(s => s.remove(true));
sections.length = 0; sections.length = 0;
container.textContent = ''; container.textContent = '';
} }
let si = editor.scrollInfo;
if (si && si.cms && si.cms.length === src.length) { if (si && si.cms && si.cms.length === src.length) {
si.scrollY2 = si.scrollY + window.innerHeight; si.scrollY2 = si.scrollY + window.innerHeight;
container.style.height = si.scrollY2 + 'px'; container.style.height = si.scrollY2 + 'px';
@ -503,9 +510,12 @@ function SectionsEditor() {
if (!keepDirty) dirty.clear(); if (!keepDirty) dirty.clear();
if (i === focusOn) sections[i].cm.focus(); 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'); container.style.removeProperty('height');
setGlobalProgress(); setGlobalProgress();
editor.ready = true;
} }
/** @param {EditorSection} section */ /** @param {EditorSection} section */

View File

@ -57,7 +57,13 @@ function SourceEditor() {
closestVisible: () => cm, closestVisible: () => cm,
getEditors: () => [cm], getEditors: () => [cm],
getEditorTitle: () => '', getEditorTitle: () => '',
getValue: () => cm.getValue(), getValue: asObject => asObject
? {
customName: style.customName,
enabled: style.enabled,
sourceCode: cm.getValue(),
}
: cm.getValue(),
getSearchableInputs: () => [], getSearchableInputs: () => [],
prevEditor: nextPrevSection.bind(null, -1), prevEditor: nextPrevSection.bind(null, -1),
nextEditor: nextPrevSection.bind(null, 1), nextEditor: nextPrevSection.bind(null, 1),
@ -195,7 +201,7 @@ function SourceEditor() {
cm.setPreprocessor((style.usercssData || {}).preprocessor); cm.setPreprocessor((style.usercssData || {}).preprocessor);
} }
async function replaceStyle(newStyle) { async function replaceStyle(newStyle, draft) {
dirty.clear('name'); dirty.clear('name');
const sameCode = newStyle.sourceCode === cm.getValue(); const sameCode = newStyle.sourceCode === cm.getValue();
if (sameCode) { if (sameCode) {
@ -207,19 +213,26 @@ function SourceEditor() {
return; return;
} }
if (await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) { if (draft || await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) {
editor.useSavedStyle(newStyle); editor.useSavedStyle(newStyle);
if (!sameCode) { if (!sameCode) {
const cursor = cm.getCursor(); const si0 = draft && draft.si.cms[0];
const cursor = !si0 && cm.getCursor();
cm.setValue(style.sourceCode); cm.setValue(style.sourceCode);
cm.setCursor(cursor); if (si0) {
editor.applyScrollInfo(cm, si0);
} else {
cm.setCursor(cursor);
}
savedGeneration = cm.changeGeneration(); savedGeneration = cm.changeGeneration();
} }
if (sameCode) { if (sameCode) {
// the code is same but the environment is changed // the code is same but the environment is changed
updateLivePreview(); updateLivePreview();
} }
dirty.clear(); if (!draft) {
dirty.clear();
}
} }
} }

View File

@ -1,4 +1,5 @@
/* global $ $create animateElement focusAccessibility moveFocus */// dom.js /* global $ $create animateElement focusAccessibility moveFocus */// dom.js
/* global clamp */// toolbox.js
/* global t */// localization.js /* global t */// localization.js
'use strict'; 'use strict';
@ -84,10 +85,6 @@ messageBox.show = async ({
messageBox._resolve = resolve; messageBox._resolve = resolve;
}); });
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function initOwnListeners() { function initOwnListeners() {
let listening = false; let listening = false;
let offsetX = 0; let offsetX = 0;

View File

@ -1,5 +1,5 @@
/* global $$ $ $create focusAccessibility getEventKeyName moveFocus */// dom.js /* global $$ $ $create focusAccessibility getEventKeyName moveFocus */// dom.js
/* global debounce */// toolbox.js /* global clamp debounce */// toolbox.js
/* global t */// localization.js /* global t */// localization.js
'use strict'; 'use strict';
@ -34,7 +34,7 @@
const key = isSelect ? 'selectedIndex' : 'valueAsNumber'; const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
const old = el[key]; const old = el[key];
const rawVal = old + Math.sign(event.deltaY) * (el.step || 1); 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) { if (el[key] !== old) {
el.dispatchEvent(new Event('change', {bubbles: true})); el.dispatchEvent(new Event('change', {bubbles: true}));
} }

View File

@ -99,6 +99,7 @@
'editor.selectByTokens': true, 'editor.selectByTokens': true,
'editor.appliesToLineWidget': true, // show applies-to line widget on the editor 'editor.appliesToLineWidget': true, // show applies-to line widget on the editor
'editor.autosaveDraft': 10, // seconds
'editor.livePreview': true, 'editor.livePreview': true,
// show CSS colors as clickable colored rectangles // show CSS colors as clickable colored rectangles

View File

@ -4,6 +4,7 @@
CHROME_POPUP_BORDER_BUG CHROME_POPUP_BORDER_BUG
RX_META RX_META
capitalize capitalize
clamp
closeCurrentTab closeCurrentTab
deepEqual deepEqual
download 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) // (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; const openerTabIdSupported = (!FIREFOX || window.AbortController) && chrome.windows != null;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function getOwnTab() { function getOwnTab() {
return browser.tabs.getCurrent(); return browser.tabs.getCurrent();
} }

View File

@ -17,6 +17,7 @@
OPERA OPERA
URLS URLS
capitalize capitalize
clamp
ignoreChromeError ignoreChromeError
openURL openURL
*/// toolbox.js */// toolbox.js
@ -288,7 +289,7 @@ function enforceInputRange(element) {
if (type === 'input' && element.checkValidity()) { if (type === 'input' && element.checkValidity()) {
doNotify(); doNotify();
} else if (type === 'change' && !element.checkValidity()) { } 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(); doNotify();
} }
}; };

View File

@ -10,6 +10,7 @@
FIREFOX FIREFOX
URLS URLS
capitalize capitalize
clamp
getActiveTab getActiveTab
isEmptyObj isEmptyObj
*/// toolbox.js */// toolbox.js
@ -66,7 +67,7 @@ function onRuntimeMessage(msg) {
function setPopupWidth(_key, width) { function setPopupWidth(_key, width) {
document.body.style.width = document.body.style.width =
Math.max(200, Math.min(800, width)) + 'px'; clamp(width, 200, 800) + 'px';
} }
function toggleSideBorders(_key, state) { function toggleSideBorders(_key, state) {