diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 4b801626..a02490d1 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -749,6 +749,14 @@ "message": "Number of styles active for the current site", "description": "Label for the checkbox controlling toolbar badge text." }, + "previewLabel": { + "message": "Live preview", + "description": "Label for the checkbox in style editor to enable live preview while editing." + }, + "previewTooltip": { + "message": "Temporarily applies the changes without saving.\nSave the style to make the changes permanent.", + "description": "Tooltip for the checkbox in style editor to enable live preview while editing." + }, "replace": { "message": "Replace", "description": "Label before the replace input field in the editor shown on Ctrl-H" @@ -927,7 +935,7 @@ "description": "Label for the enabled state of styles" }, "styleEnabledToggleHint": { - "message": "Press Alt-Enter to toggle enabled/disabled state and save the style", + "message": "Press Alt-Enter to toggle the enabled/disabled state", "description": "Help text for the '[x] enable' checkbox in the editor" }, "styleInstall": { diff --git a/background/background.js b/background/background.js index 70dbc8a6..f957df67 100644 --- a/background/background.js +++ b/background/background.js @@ -21,7 +21,6 @@ window.API_METHODS = Object.assign(window.API_METHODS || {}, { detectSloppyRegexps, openEditor, updateIcon, - refreshAllTabs, closeTab: (msg, sender, respond) => { chrome.tabs.remove(msg.tabId || sender.tab.id, () => { @@ -306,40 +305,6 @@ function webNavUsercssInstallerFF(data) { } -function refreshAllTabs(msg, sender = {}) { - return Promise.all([ - sender.tab || getActiveTab(), - queryTabs(), - ]).then(([ownTab, tabs]) => new Promise(resolve => { - if (FIREFOX) tabs = tabs.filter(tab => tab.width); - const last = tabs.length - 1; - for (let i = 0; i < last; i++) { - refreshTab(tabs[i], ownTab); - } - if (tabs.length) { - refreshTab(tabs[last], ownTab, resolve); - } else { - resolve(); - } - })); - - function refreshTab(tab, ownTab, resolve) { - const {id: tabId, url: matchUrl} = tab; - chrome.webNavigation.getAllFrames({tabId}, (frames = []) => { - ignoreChromeError(); - for (const {frameId} of frames[0] ? frames : [{frameId: 0}]) { - getStyles({matchUrl, enabled: true, asHash: true}).then(styles => { - const message = {method: 'styleReplaceAll', tabId, frameId, styles}; - invokeOrPostpone(tab.active, sendMessage, message, ignoreChromeError); - if (!frameId) setTimeout(updateIcon, 0, {tab, styles}); - if (resolve) resolve(); - }); - } - }); - } -} - - function updateIcon({tab, styles}) { if (tab.id < 0) { return; diff --git a/background/refresh-all-tabs.js b/background/refresh-all-tabs.js new file mode 100644 index 00000000..a7615fd2 --- /dev/null +++ b/background/refresh-all-tabs.js @@ -0,0 +1,228 @@ +/* +global API_METHODS cachedStyles +global getStyles filterStyles invalidateCache normalizeStyleSections +global updateIcon +*/ +'use strict'; + +(() => { + const previewFromTabs = new Map(); + + /** + * When style id and state is provided, only that style is propagated. + * Otherwise all styles are replaced and the toolbar icon is updated. + * @param {Object} [msg] + * @param {{id:Number, enabled?:Boolean, sections?: (Array|String)}} [msg.style] - + * style to propagate + * @param {Boolean} [msg.codeIsUpdated] + * @returns {Promise} + */ + API_METHODS.refreshAllTabs = (msg = {}) => + Promise.all([ + queryTabs(), + maybeParseUsercss(msg), + getStyles(), + ]).then(([tabs, style]) => + new Promise(resolve => { + if (style) msg.style.sections = normalizeStyleSections(style); + run(tabs, msg, resolve); + })); + + + function run(tabs, msg, resolve) { + const {style, codeIsUpdated} = msg; + + // the style was updated/saved so we need to remove the old copy of the original style + if (msg.method === 'styleUpdated' && msg.reason !== 'editPreview') { + for (const [tabId, original] of previewFromTabs.entries()) { + if (style.id === original.id) { + previewFromTabs.delete(tabId); + } + } + if (!previewFromTabs.size) { + unregisterTabListeners(); + } + } + + if (!style) { + msg = {method: 'styleReplaceAll'}; + + // simple style update: + // * if disabled, apply.js will remove the element + // * if toggled and code is unchanged, apply.js will toggle the element + } else if (!style.enabled || codeIsUpdated === false) { + msg = { + method: 'styleUpdated', + reason: msg.reason, + style: { + id: style.id, + enabled: style.enabled, + }, + codeIsUpdated, + }; + + // live preview puts the code in cachedStyles, saves the original in previewFromTabs, + // and if preview is being disabled, but the style is already deleted, we bail out + } else if (msg.reason === 'editPreview' && !updateCache(msg)) { + return; + + // live preview normal operation, the new code is already in cachedStyles + } else { + msg.method = 'styleApply'; + msg.style = {id: msg.style.id}; + } + + if (!tabs || !tabs.length) { + resolve(); + return; + } + + const last = tabs[tabs.length - 1]; + for (const tab of tabs) { + if (FIREFOX && !tab.width) continue; + chrome.webNavigation.getAllFrames({tabId: tab.id}, frames => + refreshFrame(tab, frames, msg, tab === last && resolve)); + } + } + + function refreshFrame(tab, frames, msg, resolve) { + ignoreChromeError(); + if (!frames || !frames.length) { + frames = [{ + frameId: 0, + url: tab.url, + }]; + } + msg.tabId = tab.id; + const styleId = msg.style && msg.style.id; + + for (const frame of frames) { + + const styles = filterStyles({ + matchUrl: getFrameUrl(frame, frames), + asHash: true, + id: styleId, + }); + + msg = Object.assign({}, msg); + msg.frameId = frame.frameId; + + if (msg.method !== 'styleUpdated') { + msg.styles = styles; + } + + if (msg.method === 'styleApply' && !styles.length) { + // remove the style from a previously matching frame + invokeOrPostpone(tab.active, sendMessage, { + method: 'styleUpdated', + reason: 'editPreview', + style: { + id: styleId, + enabled: false, + }, + tabId: tab.id, + frameId: frame.frameId, + }, ignoreChromeError); + } else { + invokeOrPostpone(tab.active, sendMessage, msg, ignoreChromeError); + } + + if (msg.method === 'styleReplaceAll' && !frame.frameId) { + setTimeout(updateIcon, 0, { + tab, + styles, + }); + } + } + + if (resolve) resolve(); + } + + + function getFrameUrl(frame, frames) { + while (frame.url === 'about:blank' && frame.frameId > 0) { + for (const f of frames) { + if (f.frameId === frame.parentFrameId) { + frame.url = f.url; + frame = f; + break; + } + } + } + return (frame || frames[0]).url; + } + + + function maybeParseUsercss({style}) { + if (style && typeof style.sections === 'string') { + return API_METHODS.parseUsercss({sourceCode: style.sections}); + } + } + + + function updateCache(msg) { + const {style, tabId, restoring} = msg; + const spoofed = !restoring && previewFromTabs.get(tabId); + const original = cachedStyles.byId.get(style.id); + + if (style.sections && !restoring) { + if (!previewFromTabs.size) { + registerTabListeners(); + } + if (!spoofed) { + previewFromTabs.set(tabId, Object.assign({}, original)); + } + + } else { + previewFromTabs.delete(tabId); + if (!previewFromTabs.size) { + unregisterTabListeners(); + } + if (!original) { + return; + } + if (!restoring) { + msg.style = spoofed || original; + } + } + invalidateCache({updated: msg.style}); + return true; + } + + + function registerTabListeners() { + chrome.tabs.onRemoved.addListener(onTabRemoved); + chrome.tabs.onReplaced.addListener(onTabReplaced); + chrome.webNavigation.onCommitted.addListener(onTabNavigated); + } + + + function unregisterTabListeners() { + chrome.tabs.onRemoved.removeListener(onTabRemoved); + chrome.tabs.onReplaced.removeListener(onTabReplaced); + chrome.webNavigation.onCommitted.removeListener(onTabNavigated); + } + + + function onTabRemoved(tabId) { + const style = previewFromTabs.get(tabId); + if (style) { + API_METHODS.refreshAllTabs({ + style, + tabId, + reason: 'editPreview', + restoring: true, + }); + } + } + + + function onTabReplaced(addedTabId, removedTabId) { + onTabRemoved(removedTabId); + } + + + function onTabNavigated({tabId}) { + onTabRemoved(tabId); + } +})(); diff --git a/background/usercss-helper.js b/background/usercss-helper.js index 99c0f66e..c6835805 100644 --- a/background/usercss-helper.js +++ b/background/usercss-helper.js @@ -7,7 +7,8 @@ API_METHODS.saveUsercssUnsafe = style => save(style, true); API_METHODS.buildUsercss = build; API_METHODS.installUsercss = install; - API_METHODS.findUsercss = findUsercss; + API_METHODS.parseUsercss = parse; + API_METHODS.findUsercss = find; const TEMP_CODE_PREFIX = 'tempUsercssCode'; const TEMP_CODE_CLEANUP_DELAY = 60e3; @@ -49,50 +50,55 @@ } } + function assignVars(style) { + if (style.reason === 'config' && style.id) { + return style; + } + const dup = find(style); + if (dup) { + style.id = dup.id; + if (style.reason !== 'config') { + // preserve style.vars during update + usercss.assignVars(style, dup); + } + } + return style; + } + // Parse the source and find the duplication function build({sourceCode, checkDup = false}) { return buildMeta({sourceCode}) .then(usercss.buildCode) .then(style => ({ style, - dup: checkDup && findUsercss(style), + dup: checkDup && find(style), })); } - function save(style, allowErrors = false) { + // Parse the source, apply customizations, report fatal/syntax errors + function parse(style, allowErrors = false) { // restore if stripped by getStyleWithNoCode if (typeof style.sourceCode !== 'string') { style.sourceCode = cachedStyles.byId.get(style.id).sourceCode; } return buildMeta(style) .then(assignVars) - .then(style => usercss.buildCode(style, allowErrors)) + .then(style => usercss.buildCode(style, allowErrors)); + } + + function save(style, allowErrors = false) { + return parse(style, allowErrors) .then(result => allowErrors ? saveStyle(result.style).then(style => ({style, errors: result.errors})) : saveStyle(result)); - - function assignVars(style) { - if (style.reason === 'config' && style.id) { - return style; - } - const dup = findUsercss(style); - if (dup) { - style.id = dup.id; - if (style.reason !== 'config') { - // preserve style.vars during update - usercss.assignVars(style, dup); - } - } - return style; - } } /** * @param {Style|{name:string, namespace:string}} styleOrData * @returns {Style} */ - function findUsercss(styleOrData) { + function find(styleOrData) { if (styleOrData.id) return cachedStyles.byId.get(styleOrData.id); const {name, namespace} = styleOrData.usercssData || styleOrData; for (const dup of cachedStyles.list) { diff --git a/content/apply.js b/content/apply.js index 0ae7d10a..69496418 100644 --- a/content/apply.js +++ b/content/apply.js @@ -519,7 +519,7 @@ function moveAfter(el, expected) { if (!sorting) { sorting = true; - if (observer) observer.stop(); + stop(); } expected.insertAdjacentElement('afterend', el); if (el.disabled !== disableAll) { diff --git a/edit.html b/edit.html index 07f99b89..c6bc7f27 100644 --- a/edit.html +++ b/edit.html @@ -249,13 +249,15 @@
-
diff --git a/edit/codemirror-editing-hooks.js b/edit/codemirror-editing-hooks.js index f01c5eb1..b8688e25 100644 --- a/edit/codemirror-editing-hooks.js +++ b/edit/codemirror-editing-hooks.js @@ -1,7 +1,9 @@ /* global CodeMirror linterConfig loadScript -global editors editor styleId +global editors editor styleId ownTabId global save toggleStyle setupAutocomplete makeSectionVisible getSectionForChild +global getSectionsHashes +global messageBox */ 'use strict'; @@ -41,8 +43,9 @@ onDOMscriptReady('/codemirror.js').then(() => { addEventListener('showHotkeyInTooltip', showHotkeyInTooltip); showHotkeyInTooltip(); - // N.B. the event listener should be registered before setupLivePrefs() + // N.B. the onchange event listeners should be registered before setupLivePrefs() $('#options').addEventListener('change', onOptionElementChanged); + setupLivePreview(); buildThemeElement(); buildKeymapElement(); setupLivePrefs(); @@ -531,4 +534,49 @@ onDOMscriptReady('/codemirror.js').then(() => { } return ''; } + + function setupLivePreview() { + if (!prefs.get('editor.livePreview') && !editors.length) { + setTimeout(setupLivePreview); + return; + } + $('#editor.livePreview').onchange = function () { + const previewing = this.checked; + editors.forEach(cm => cm[previewing ? 'on' : 'off']('changes', updatePreview)); + const addRemove = previewing ? 'addEventListener' : 'removeEventListener'; + $('#enabled')[addRemove]('change', updatePreview); + $('#sections')[addRemove]('change', updatePreview); + if (!previewing || document.body.classList.contains('dirty')) { + updatePreview(null, previewing); + } + }; + CodeMirror.defineInitHook(cm => { + if (prefs.get('editor.livePreview')) { + cm.on('changes', updatePreview); + } + }); + } + + function updatePreview(data, previewing) { + if (previewing !== true && previewing !== false) { + if (data instanceof Event && !event.target.matches('.style-contributor')) return; + debounce(updatePreview, data && data.id === 'enabled' ? 0 : 400, null, true); + return; + } + const errors = $('#preview-errors'); + API.refreshAllTabs({ + reason: 'editPreview', + tabId: ownTabId, + style: { + id: styleId, + enabled: $('#enabled').checked, + sections: previewing && (editor ? editors[0].getValue() : getSectionsHashes()), + }, + }).then(() => { + errors.classList.add('hidden'); + }).catch(err => { + errors.classList.remove('hidden'); + errors.onclick = () => messageBox.alert(String(err)); + }); + } }); diff --git a/edit/edit.css b/edit/edit.css index dcef236e..5afd7bd4 100644 --- a/edit/edit.css +++ b/edit/edit.css @@ -41,10 +41,6 @@ body { margin-bottom: 12px; } -#basic-info-enabled { - margin-top: 2px; -} - label { padding-left: 16px; position: relative; @@ -102,6 +98,43 @@ label { #url:not([href^="http"]) { display: none; } + +#basic-info-enabled { + margin-top: 2px; + display: flex; + align-items: center; + line-height: 16px; +} + +#basic-info-enabled > * { + margin-right: 1em; + margin-left: 0; +} + +#basic-info-enabled > :last-child { + margin-right: 0; +} + +#basic-info-enabled input, +#basic-info-enabled svg { + margin: auto 0; + bottom: 0; +} + +#basic-info-enabled svg { + left: 2px; +} + +#preview-errors { + background-color: red; + color: white; + padding: 0 6px; + border-radius: 9px; + margin-left: -.5em; + font-weight: bold; + cursor: pointer; +} + .svg-icon { cursor: pointer; vertical-align: middle; @@ -117,9 +150,6 @@ label { #mozilla-format-heading .svg-inline-wrapper { margin-left: 0; } -#basic-info-enabled .svg-inline-wrapper { - margin-left: .1rem; -} #colorpicker-settings.svg-inline-wrapper { margin: -2px 0 0 .1rem; } @@ -155,10 +185,6 @@ input:invalid { } #enabled { margin-left: 0; - vertical-align: middle; -} -#enabled-label { - vertical-align: middle; } /* collapsibles */ #header summary { diff --git a/edit/edit.js b/edit/edit.js index f2bd4773..6bbf23e2 100644 --- a/edit/edit.js +++ b/edit/edit.js @@ -16,6 +16,7 @@ let dirty = {}; // array of all CodeMirror instances const editors = []; let saveSizeOnClose; +let ownTabId; // direct & reverse mapping of @-moz-document keywords and internal property names const propertyToCss = {urls: 'url', urlPrefixes: 'url-prefix', domains: 'domain', regexps: 'regexp'}; @@ -38,6 +39,8 @@ Promise.all([ $('#name').placeholder = t(usercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName'); $('#name').title = usercss ? t('usercssReplaceTemplateName') : ''; + $('#preview-label').classList.toggle('hidden', !styleId); + $('#beautify').onclick = beautify; $('#lint').addEventListener('scroll', hideLintHeaderOnScroll, {passive: true}); window.addEventListener('resize', () => debounce(rememberWindowSize, 100)); @@ -110,7 +113,7 @@ function preinit() { } getOwnTab().then(tab => { - const ownTabId = tab.id; + ownTabId = tab.id; // use browser history back when 'back to manage' is clicked if (sessionStorageHash('manageStylesHistory').value[ownTabId] === location.href) { @@ -153,6 +156,7 @@ function onRuntimeMessage(request) { switch (request.method) { case 'styleUpdated': if (styleId && styleId === request.style.id && + request.reason !== 'editPreview' && request.reason !== 'editSave' && request.reason !== 'config') { // code-less style from notifyAllTabs @@ -258,7 +262,6 @@ function initHooks() { node.addEventListener('change', onChange); node.addEventListener('input', onChange); }); - $('#toggle-style-help').addEventListener('click', showToggleStyleHelp); $('#to-mozilla').addEventListener('click', showMozillaFormat, false); $('#to-mozilla-help').addEventListener('click', showToMozillaHelp, false); $('#from-mozilla').addEventListener('click', fromMozillaFormat); @@ -365,6 +368,7 @@ function save() { $('#heading').textContent = t('editStyleHeading'); } updateTitle(); + $('#preview-label').classList.remove('hidden'); }); } diff --git a/edit/source-editor.js b/edit/source-editor.js index 568fb4e0..e2def9ba 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -1,7 +1,10 @@ -/* global CodeMirror dirtyReporter initLint */ -/* global showToggleStyleHelp goBackToManage updateLintReportIfEnabled */ -/* global editors linterConfig updateLinter regExpTester sectionsToMozFormat */ -/* global createAppliesToLineWidget messageBox */ +/* +global editors styleId: true +global CodeMirror dirtyReporter +global updateLintReportIfEnabled initLint linterConfig updateLinter +global createAppliesToLineWidget messageBox +global sectionsToMozFormat +*/ 'use strict'; function createSourceEditor(style) { @@ -9,7 +12,6 @@ function createSourceEditor(style) { $('#save-button').disabled = true; $('#mozilla-format-container').remove(); $('#save-button').onclick = save; - $('#toggle-style-help').onclick = showToggleStyleHelp; $('#header').addEventListener('wheel', headerOnScroll, {passive: true}); $('#sections').textContent = ''; $('#sections').appendChild($create('.single-editor')); @@ -176,6 +178,8 @@ function createSourceEditor(style) { } sessionStorage.justEditedStyleId = newStyle.id; style = newStyle; + styleId = style.id; + $('#preview-label').classList.remove('hidden'); updateMeta(); } } diff --git a/js/messaging.js b/js/messaging.js index f2a33ee2..40075974 100644 --- a/js/messaging.js +++ b/js/messaging.js @@ -144,7 +144,8 @@ var API = (() => { function notifyAllTabs(msg) { const originalMessage = msg; - if (msg.method === 'styleUpdated' || msg.method === 'styleAdded') { + const styleUpdated = msg.method === 'styleUpdated'; + if (styleUpdated || msg.method === 'styleAdded') { // apply/popup/manage use only meta for these two methods, // editor may need the full code but can fetch it directly, // so we send just the meta to avoid spamming lots of tabs with huge styles @@ -167,7 +168,8 @@ function notifyAllTabs(msg) { if (affectsTabs || affectsIcon) { const notifyTab = tab => { // own pages will be notified via runtime.sendMessage later - if ((affectsTabs || URLS.optionsUI.includes(tab.url)) + if (!styleUpdated + && (affectsTabs || URLS.optionsUI.includes(tab.url)) && !(affectsSelf && tab.url.startsWith(URLS.ownOrigin)) // skip lazy-loaded aka unloaded tabs that seem to start loading on message in FF && (!FIREFOX || tab.width)) { @@ -198,6 +200,10 @@ function notifyAllTabs(msg) { if (typeof applyOnMessage !== 'undefined') { applyOnMessage(originalMessage); } + // propagate saved style state/code efficiently + if (styleUpdated) { + API.refreshAllTabs(msg); + } } @@ -404,7 +410,7 @@ const debounce = Object.assign((fn, delay, ...args) => { function deepCopy(obj) { if (!obj || typeof obj !== 'object') return obj; - // N.B. a copy should be an explicitly literal + // N.B. the copy should be an explicit literal if (Array.isArray(obj)) { const copy = []; for (const v of obj) { diff --git a/js/prefs.js b/js/prefs.js index 27e810de..f1adffcb 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -68,6 +68,7 @@ var prefs = new function Prefs() { 'editor.contextDelete': contextDeleteMissing(), // "Delete" item in context menu 'editor.appliesToLineWidget': true, // show applies-to line widget on the editor + 'editor.livePreview': true, // show CSS colors as clickable colored rectangles 'editor.colorpicker': true, diff --git a/manage/manage.js b/manage/manage.js index 44ec144f..c2dbd3d4 100644 --- a/manage/manage.js +++ b/manage/manage.js @@ -515,6 +515,7 @@ Object.assign(handleEvent, { function handleUpdate(style, {reason, method} = {}) { + if (reason === 'editPreview') return; let entry; let oldEntry = $(ENTRY_ID_PREFIX + style.id); if (oldEntry && method === 'styleUpdated') { diff --git a/manifest.json b/manifest.json index 1b20e6a4..88511889 100644 --- a/manifest.json +++ b/manifest.json @@ -33,6 +33,7 @@ "background/style-via-api.js", "background/search-db.js", "background/update.js", + "background/refresh-all-tabs.js", "vendor/node-semver/semver.js", "vendor-overwrites/colorpicker/colorconverter.js" ] diff --git a/popup/popup.js b/popup/popup.js index 7e45de2f..821af770 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -32,6 +32,7 @@ function onRuntimeMessage(msg) { switch (msg.method) { case 'styleAdded': case 'styleUpdated': + if (msg.reason === 'editPreview') return; handleUpdate(msg.style); break; case 'styleDeleted':