stylus/edit/edit.js

662 lines
19 KiB
JavaScript
Raw Normal View History

/*
global CodeMirror parserlib loadScript
global CSSLint initLint linterConfig updateLintReport renderLintReport updateLinter
global mozParser createSourceEditor
global closeCurrentTab regExpTester messageBox
global setupCodeMirror
global beautify
global initWithSectionStyle addSections removeSection getSectionsHashes
*/
2017-07-12 20:44:59 +00:00
'use strict';
2017-07-12 20:44:59 +00:00
let styleId = null;
2017-08-20 18:56:18 +00:00
// only the actually dirty items here
let dirty = {};
// array of all CodeMirror instances
const editors = [];
2017-07-12 20:44:59 +00:00
let saveSizeOnClose;
2015-01-30 17:05:06 +00:00
// direct & reverse mapping of @-moz-document keywords and internal property names
2017-07-12 20:44:59 +00:00
const propertyToCss = {urls: 'url', urlPrefixes: 'url-prefix', domains: 'domain', regexps: 'regexp'};
const CssToProperty = {'url': 'urls', 'url-prefix': 'urlPrefixes', 'domain': 'domains', 'regexp': 'regexps'};
2017-09-11 16:09:25 +00:00
let editor;
window.onbeforeunload = beforeUnload;
chrome.runtime.onMessage.addListener(onRuntimeMessage);
preinit();
Promise.all([
initStyleData(),
onDOMready(),
])
.then(([style]) => {
setupLivePrefs();
const usercss = isUsercss(style);
$('#heading').textContent = t(styleId ? 'editStyleHeading' : 'addStyleTitle');
$('#name').placeholder = t(usercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
$('#name').title = usercss ? t('usercssReplaceTemplateName') : '';
$('#beautify').onclick = beautify;
$('#lint').addEventListener('scroll', hideLintHeaderOnScroll, {passive: true});
window.addEventListener('resize', () => debounce(rememberWindowSize, 100));
if (usercss) {
editor = createSourceEditor(style);
} else {
initWithSectionStyle({style});
document.addEventListener('wheel', scrollEntirePageOnCtrlShift);
}
});
function preinit() {
// make querySelectorAll enumeration code readable
['forEach', 'some', 'indexOf', 'map'].forEach(method => {
NodeList.prototype[method] = Array.prototype[method];
});
// eslint-disable-next-line no-extend-native
Object.defineProperties(Array.prototype, {
last: {
get() {
return this[this.length - 1];
},
},
rotate: {
value: function (amount) {
// negative amount == rotate left
const r = this.slice(-amount, this.length);
Array.prototype.push.apply(r, this.slice(0, this.length - r.length));
return r;
},
},
});
// preload the theme so that CodeMirror can calculate its metrics in DOMContentLoaded->setupLivePrefs()
2017-12-05 00:54:43 +00:00
document.head.appendChild(
$create('link#cm-theme', {
rel: 'stylesheet',
href: prefs.get('editor.theme') === 'default' ? '' :
'vendor/codemirror/theme/' + prefs.get('editor.theme') + '.css'
}));
if (chrome.windows) {
queryTabs({currentWindow: true}).then(tabs => {
const windowId = tabs[0].windowId;
if (prefs.get('openEditInWindow')) {
if (
/true/.test(sessionStorage.saveSizeOnClose) &&
'left' in prefs.get('windowPosition', {}) &&
!isWindowMaximized()
) {
// window was reopened via Ctrl-Shift-T etc.
chrome.windows.update(windowId, prefs.get('windowPosition'));
}
if (tabs.length === 1 && window.history.length === 1) {
chrome.windows.getAll(windows => {
if (windows.length > 1) {
sessionStorageHash('saveSizeOnClose').set(windowId, true);
saveSizeOnClose = true;
}
});
} else {
saveSizeOnClose = sessionStorageHash('saveSizeOnClose').value[windowId];
}
2017-07-12 19:50:13 +00:00
}
});
2017-07-12 19:50:13 +00:00
}
getOwnTab().then(tab => {
const ownTabId = tab.id;
// use browser history back when 'back to manage' is clicked
if (sessionStorageHash('manageStylesHistory').value[ownTabId] === location.href) {
onDOMready().then(() => {
$('#cancel-button').onclick = event => {
event.stopPropagation();
event.preventDefault();
history.back();
};
});
}
// no windows on android
if (!chrome.windows) {
return;
}
// When an edit page gets attached or detached, remember its state
// so we can do the same to the next one to open.
chrome.tabs.onAttached.addListener((tabId, info) => {
if (tabId !== ownTabId) {
return;
}
if (info.newPosition !== 0) {
prefs.set('openEditInWindow', false);
return;
}
chrome.windows.get(info.newWindowId, {populate: true}, win => {
// If there's only one tab in this window, it's been dragged to new window
const openEditInWindow = win.tabs.length === 1;
if (openEditInWindow && FIREFOX) {
// FF-only because Chrome retardedly resets the size during dragging
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
}
prefs.set('openEditInWindow', openEditInWindow);
});
});
});
}
function onRuntimeMessage(request) {
switch (request.method) {
case 'styleUpdated':
if (styleId && styleId === request.style.id &&
request.reason !== 'editSave' &&
request.reason !== 'config') {
// code-less style from notifyAllTabs
if ((request.style.sections[0] || {}).code === null) {
request.style = BG.cachedStyles.byId.get(request.style.id);
}
if (isUsercss(request.style)) {
editor.replaceStyle(request.style, request.codeIsUpdated);
} else {
initWithSectionStyle(request);
2017-07-12 19:50:13 +00:00
}
}
break;
case 'styleDeleted':
if (styleId === request.id || editor && editor.getStyle().id === request.id) {
window.onbeforeunload = () => {};
closeCurrentTab();
2017-07-12 19:50:13 +00:00
break;
}
2017-08-15 19:13:58 +00:00
break;
case 'prefChanged':
if ('editor.smartIndent' in request.prefs) {
CodeMirror.setOption('smartIndent', request.prefs['editor.smartIndent']);
2017-08-27 14:36:20 +00:00
}
break;
case 'editDeleteText':
document.execCommand('delete');
break;
2017-07-12 19:50:13 +00:00
}
}
function beforeUnload() {
if (saveSizeOnClose) {
rememberWindowSize();
}
2017-07-12 19:50:13 +00:00
document.activeElement.blur();
if (isClean()) {
2017-07-12 19:50:13 +00:00
return;
}
2017-08-20 14:06:17 +00:00
updateLintReportIfEnabled(null, 0);
// neither confirm() nor custom messages work in modern browsers but just in case
return t('styleChangesNotSaved');
function isClean() {
if (editor) {
return !editor.isDirty();
} else {
return isCleanGlobal();
}
}
}
function isUsercss(style) {
return (
style.usercssData ||
!style.id && prefs.get('newStyleAsUsercss')
);
2015-06-23 16:24:53 +00:00
}
function initStyleData() {
// TODO: remove .replace(/^\?/, '') when minimum_chrome_version >= 52 (https://crbug.com/601425)
const params = new URLSearchParams(location.search.replace(/^\?/, ''));
const id = params.get('id');
const createEmptyStyle = () => ({
id: null,
name: '',
enabled: true,
sections: [
Object.assign({code: ''},
...Object.keys(CssToProperty)
.map(name => ({
[CssToProperty[name]]: params.get(name) && [params.get(name)] || []
}))
)
],
2017-09-12 11:47:32 +00:00
});
return getStylesSafe({id: id || -1})
.then(([style = createEmptyStyle()]) => {
styleId = sessionStorage.justEditedStyleId = style.id;
// we set "usercss" class on <html> when <body> is empty
// so there'll be no flickering of the elements that depend on it
if (isUsercss(style)) {
document.documentElement.classList.add('usercss');
}
// strip URL parameters when invoked for a non-existent id
if (!styleId) {
history.replaceState({}, document.title, location.pathname);
}
return style;
});
2015-01-30 17:05:06 +00:00
}
function initHooks() {
if (initHooks.alreadyDone) {
return;
}
initHooks.alreadyDone = true;
2017-08-21 00:05:08 +00:00
$$('#header .style-contributor').forEach(node => {
2017-07-12 20:44:59 +00:00
node.addEventListener('change', onChange);
node.addEventListener('input', onChange);
2017-07-12 19:50:13 +00:00
});
2017-08-21 00:05:08 +00:00
$('#toggle-style-help').addEventListener('click', showToggleStyleHelp);
$('#to-mozilla').addEventListener('click', showMozillaFormat, false);
$('#to-mozilla-help').addEventListener('click', showToMozillaHelp, false);
$('#from-mozilla').addEventListener('click', fromMozillaFormat);
$('#save-button').addEventListener('click', save, false);
$('#sections-help').addEventListener('click', showSectionHelp, false);
2017-08-17 19:08:48 +00:00
initLint();
2017-07-12 19:50:13 +00:00
if (!FIREFOX) {
$$([
'input:not([type])',
'input[type="text"]',
'input[type="search"]',
'input[type="number"]',
].join(','))
.forEach(e => e.addEventListener('mousedown', toggleContextMenuDelete));
}
2015-01-30 17:05:06 +00:00
}
// common for usercss and classic
function onChange(event) {
const node = event.target;
if ('savedValue' in node) {
const currentValue = node.type === 'checkbox' ? node.checked : node.value;
setCleanItem(node, node.savedValue === currentValue);
} else {
// the manually added section's applies-to is dirty only when the value is non-empty
setCleanItem(node, node.localName !== 'input' || !node.value.trim());
// only valid when actually saved
delete node.savedValue;
2017-07-12 19:50:13 +00:00
}
updateTitle();
}
// Set .dirty on stylesheet contributors that have changed
function setDirtyClass(node, isDirty) {
node.classList.toggle('dirty', isDirty);
}
function setCleanItem(node, isClean) {
if (!node.id) {
node.id = Date.now().toString(32).substr(-6);
2017-07-12 19:50:13 +00:00
}
if (isClean) {
delete dirty[node.id];
// code sections have .CodeMirror property
if (node.CodeMirror) {
node.savedValue = node.CodeMirror.changeGeneration();
} else {
node.savedValue = node.type === 'checkbox' ? node.checked : node.value;
}
} else {
dirty[node.id] = true;
2017-07-12 19:50:13 +00:00
}
setDirtyClass(node, !isClean);
}
function isCleanGlobal() {
const clean = Object.keys(dirty).length === 0;
setDirtyClass(document.body, !clean);
// let saveBtn = $('#save-button')
// if (clean){
// //saveBtn.removeAttribute('disabled');
// }else{
// //saveBtn.setAttribute('disabled', true);
// }
return clean;
2015-01-30 17:05:06 +00:00
}
function setCleanGlobal() {
$$('#header, #sections > div').forEach(setCleanSection);
// forget the dirty applies-to ids from a deleted section after the style was saved
dirty = {};
2015-01-30 17:05:06 +00:00
}
function setCleanSection(section) {
$$('.style-contributor', section).forEach(node => setCleanItem(node, true));
// #header section has no codemirror
const cm = section.CodeMirror;
if (cm) {
section.savedValue = cm.changeGeneration();
updateTitle();
2017-08-20 14:06:17 +00:00
}
}
function toggleStyle() {
$('#enabled').checked = !$('#enabled').checked;
save();
2017-09-11 16:09:25 +00:00
}
function save() {
2017-08-20 14:06:17 +00:00
updateLintReportIfEnabled(null, 0);
if (!validate()) {
2017-07-12 19:50:13 +00:00
return;
}
2017-07-12 19:50:13 +00:00
saveStyleSafe({
id: styleId,
2017-12-03 17:32:50 +00:00
name: $('#name').value.trim(),
enabled: $('#enabled').checked,
2017-07-12 19:50:13 +00:00
reason: 'editSave',
sections: getSectionsHashes()
})
.then(style => {
styleId = style.id;
sessionStorage.justEditedStyleId = styleId;
setCleanGlobal();
// Go from new style URL to edit style URL
if (location.href.indexOf('id=') === -1) {
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
$('#heading').textContent = t('editStyleHeading');
2017-07-12 19:50:13 +00:00
}
updateTitle();
2017-07-12 19:50:13 +00:00
});
2015-01-30 17:05:06 +00:00
}
function validate() {
const name = $('#name').value.trim();
if (!name) {
$('#name').focus();
messageBox.alert(t('styleMissingName'));
return false;
}
if ($$('.applies-to-list li:not(.applies-to-everything)')
.some(li => {
const type = $('[name=applies-type]', li).value;
const value = $('[name=applies-value]', li);
const rx = value.value.trim();
if (type === 'regexp' && rx && !tryRegExp(rx)) {
value.focus();
value.select();
return true;
}
})) {
messageBox.alert(t('styleBadRegexp'));
return false;
}
return true;
2015-01-30 17:05:06 +00:00
}
function updateTitle() {
const DIRTY_TITLE = '* $';
const name = $('#name').savedValue;
const clean = isCleanGlobal();
const title = styleId === null ? t('addStyleTitle') : t('editStyleTitle', [name]);
document.title = clean ? title : DIRTY_TITLE.replace('$', title);
}
2017-07-12 19:50:13 +00:00
function updateLintReportIfEnabled(...args) {
if (CodeMirror.defaults.lint) {
updateLintReport(...args);
2017-07-12 19:50:13 +00:00
}
2015-01-30 17:05:06 +00:00
}
function showMozillaFormat() {
2017-07-12 20:44:59 +00:00
const popup = showCodeMirrorPopup(t('styleToMozillaFormatTitle'), '', {readOnly: true});
2017-07-12 19:50:13 +00:00
popup.codebox.setValue(toMozillaFormat());
2017-07-12 20:44:59 +00:00
popup.codebox.execCommand('selectAll');
2015-01-30 17:05:06 +00:00
}
function toMozillaFormat() {
Add: install styles from *.user.css file Fix: handle dup name+namespace Fix: eslint eqeqeq Fix: trim @name's spaces Add: check update for userstyle Add: build CSS variable Fix: only check dup when id is not provided Refactor: userStyle2json -> userstyle.json Add: style for input Add: config dialog Fix: preserve config during update Fix: onchange doesn't fire on keyboard enter event Fix: remove empty file Add: validator. Metas must stay in the same line Add: warn the user if installation failed Fix: add some delay before starting installation Add: open the editor after first installation Fix: add openEditor to globals Fix: i18n Add: preprocessor. Move userstyle.build to background page. Fix: remove unused global Fix: preserved unknown prop in saveStyleSource() like saveStyle() Add: edit userstyle source Fix: load preprocessor dynamically Fix: load content script dynamically Fix: buildCode is async function Fix: drop Object.entries Fix: style.sections is undefined Fix: don't hide the name input but disable it Fix: query the style before installation Revert: changes to editor, editor.html Refactor: use term `usercss` instead of `userstyle` Fix: don't show homepage action for usercss Refactor: move script-loader to js/ Refactor: pull out mozParser Fix: code style Fix: we don't need to build meta anymore Fix: use saveUsercss instead of saveStyle to get responsed error Fix: last is undefined, load script error Fix: switch to moz-format Fix: drop injectContentScript. Move usercss check into install-user-css Fix: response -> respond Fix: globals -> global Fix: queryUsercss -> filterUsercss Fix: add processUsercss function Fix: only open editor for usercss Fix: remove findupUsercss fixme Fix: globals -> global Fix: globals -> global Fix: global pollution Revert: update.js Refactor: checkStyle Add: support usercss Fix: no need to getURL in background page Fix: merget semver.js into usercss.js Fix: drop all_urls in match pattern Fix: drop respondWithError Move stylus -> stylus-lang Add stylus-lang/readme Fix: use include_globs Fix: global pollution
2017-08-05 16:49:25 +00:00
return mozParser.format({sections: getSectionsHashes()});
2015-01-30 17:05:06 +00:00
}
function fromMozillaFormat() {
const popup = showCodeMirrorPopup(t('styleFromMozillaFormatPrompt'),
$create([
$create('button', {
name: 'import-append',
textContent: t('importAppendLabel'),
title: 'Ctrl-Enter:\n' + t('importAppendTooltip'),
onclick: doImport,
}),
$create('button', {
name: 'import-replace',
textContent: t('importReplaceLabel'),
title: 'Ctrl-Shift-Enter:\n' + t('importReplaceTooltip'),
onclick: () => doImport({replaceOldStyle: true}),
}),
]));
2017-08-21 00:05:08 +00:00
const contents = $('.contents', popup);
2017-07-12 19:50:13 +00:00
contents.insertBefore(popup.codebox.display.wrapper, contents.firstElementChild);
popup.codebox.focus();
popup.codebox.on('changes', cm => {
popup.classList.toggle('ready', !cm.isBlank());
cm.markClean();
2017-07-12 19:50:13 +00:00
});
// overwrite default extraKeys as those are inapplicable in popup context
popup.codebox.options.extraKeys = {
'Ctrl-Enter': doImport,
'Shift-Ctrl-Enter': () => doImport({replaceOldStyle: true}),
};
2017-07-12 19:50:13 +00:00
function doImport({replaceOldStyle = false}) {
lockPageUI(true);
new Promise(setTimeout)
.then(() => mozParser.parse(popup.codebox.getValue().trim()))
.then(sections => {
removeOldSections(replaceOldStyle);
return addSections(sections, div => setCleanItem(div, false));
})
.then(sectionDivs => {
sectionDivs.forEach(div => updateLintReportIfEnabled(div.CodeMirror, 1));
$('.dismiss', popup).onclick();
})
.catch(showError)
.then(() => lockPageUI(false));
}
function removeOldSections(removeAll) {
let toRemove;
if (removeAll) {
toRemove = editors.slice().reverse();
} else if (editors.last.isBlank() && $('.applies-to-everything', editors.last.getSection())) {
toRemove = [editors.last];
} else {
return;
}
toRemove.forEach(cm => removeSection({target: cm.getSection()}));
}
function lockPageUI(locked) {
document.documentElement.style.pointerEvents = locked ? 'none' : '';
popup.classList.toggle('ready', locked ? false : !popup.codebox.isBlank());
popup.codebox.options.readOnly = locked;
popup.codebox.display.wrapper.style.opacity = locked ? '.5' : '';
2017-07-12 19:50:13 +00:00
}
function showError(errors) {
2017-12-09 16:04:19 +00:00
messageBox({
className: 'center danger',
title: t('styleFromMozillaFormatError'),
contents: $create('pre', Array.isArray(errors) ? errors.join('\n') : errors),
buttons: [t('confirmClose')],
});
2017-07-12 19:50:13 +00:00
}
}
2015-01-30 17:05:06 +00:00
function showSectionHelp() {
2017-07-12 20:44:59 +00:00
showHelp(t('styleSectionsTitle'), t('sectionHelp'));
2015-01-30 17:05:06 +00:00
}
function showAppliesToHelp() {
2017-07-12 20:44:59 +00:00
showHelp(t('appliesLabel'), t('appliesHelp'));
2015-01-30 17:05:06 +00:00
}
function showToMozillaHelp() {
2017-07-12 20:44:59 +00:00
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
}
function showToggleStyleHelp() {
2017-07-12 20:44:59 +00:00
showHelp(t('helpAlt'), t('styleEnabledToggleHint'));
}
function showHelp(title = '', body) {
const div = $('#help-popup');
div.className = '';
const contents = $('.contents', div);
contents.textContent = '';
if (body) {
contents.appendChild(typeof body === 'string' ? tHTML(body) : body);
}
$('.title', div).textContent = title;
2017-07-12 19:50:13 +00:00
2017-07-16 18:02:00 +00:00
if (getComputedStyle(div).display === 'none') {
window.addEventListener('keydown', closeHelp, true);
2017-08-20 18:56:18 +00:00
// avoid chaining on multiple showHelp() calls
2017-08-21 00:05:08 +00:00
$('.dismiss', div).onclick = closeHelp;
2017-07-12 19:50:13 +00:00
}
// reset any inline styles
div.style = 'display: block';
2017-07-12 19:50:13 +00:00
return div;
function closeHelp(event) {
const canClose =
!event ||
event.type === 'click' ||
(
event.which === 27 &&
!event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey &&
!$('.CodeMirror-hints, #message-box') &&
(
!document.activeElement ||
document.activeElement.matches(':not(input), .can-close-on-esc')
)
);
if (!canClose) {
return;
}
if (event && div.codebox && !div.codebox.options.readOnly && !div.codebox.isClean()) {
messageBox.confirm(t('confirmDiscardChanges')).then(ok => ok && closeHelp());
return;
2017-07-12 19:50:13 +00:00
}
div.style.display = '';
contents.textContent = '';
clearTimeout(contents.timer);
window.removeEventListener('keydown', closeHelp, true);
window.dispatchEvent(new Event('closeHelp'));
(editors.lastActive || editors[0]).focus();
2017-07-12 19:50:13 +00:00
}
2015-01-30 17:05:06 +00:00
}
function showCodeMirrorPopup(title, html, options) {
2017-07-12 20:44:59 +00:00
const popup = showHelp(title, html);
popup.classList.add('big');
2017-07-12 19:50:13 +00:00
let cm = popup.codebox = CodeMirror($('.contents', popup), Object.assign({
2017-07-12 20:44:59 +00:00
mode: 'css',
2017-07-12 19:50:13 +00:00
lineNumbers: true,
lineWrapping: true,
foldGutter: true,
2017-07-12 20:44:59 +00:00
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
2017-07-12 19:50:13 +00:00
matchBrackets: true,
lint: linterConfig.getForCodeMirror(),
2017-07-12 19:50:13 +00:00
styleActiveLine: true,
2017-07-12 20:44:59 +00:00
theme: prefs.get('editor.theme'),
keyMap: prefs.get('editor.keyMap')
2017-07-12 19:50:13 +00:00
}, options));
cm.focus();
cm.on('focus', () => cm.rerouteHotkeys(false));
cm.on('blur', () => cm.rerouteHotkeys(true));
window.addEventListener('closeHelp', function _() {
window.removeEventListener('closeHelp', _);
cm = popup.codebox = null;
});
2017-07-12 19:50:13 +00:00
return popup;
}
function setGlobalProgress(done, total) {
const progressElement = $('#global-progress') ||
total && document.body.appendChild($create('#global-progress'));
if (total) {
const progress = (done / Math.max(done, total) * 100).toFixed(1);
progressElement.style.borderLeftWidth = progress + 'vw';
setTimeout(() => {
progressElement.title = progress + '%';
});
} else {
$.remove(progressElement);
}
}
function scrollEntirePageOnCtrlShift(event) {
// make Shift-Ctrl-Wheel scroll entire page even when mouse is over a code editor
if (event.shiftKey && event.ctrlKey && !event.altKey && !event.metaKey) {
// Chrome scrolls horizontally when Shift is pressed but on some PCs this might be different
window.scrollBy(0, event.deltaX || event.deltaY);
event.preventDefault();
}
}
function hideLintHeaderOnScroll() {
// workaround part2 for the <details> not showing its toggle icon: hide <summary> on scroll
const newOpacity = this.scrollTop === 0 ? '' : '0';
const style = this.firstElementChild.style;
if (style.opacity !== newOpacity) {
style.opacity = newOpacity;
}
}
function rememberWindowSize() {
if (
document.visibilityState === 'visible' &&
prefs.get('openEditInWindow') &&
!isWindowMaximized()
) {
prefs.set('windowPosition', {
left: window.screenX,
top: window.screenY,
width: window.outerWidth,
height: window.outerHeight,
});
}
}
function isWindowMaximized() {
return (
window.screenX <= 0 &&
window.screenY <= 0 &&
window.outerWidth >= screen.availWidth &&
window.outerHeight >= screen.availHeight &&
window.screenX > -10 &&
window.screenY > -10 &&
window.outerWidth < screen.availWidth + 10 &&
window.outerHeight < screen.availHeight + 10
);
}
function toggleContextMenuDelete(event) {
if (chrome.contextMenus && event.button === 2 && prefs.get('editor.contextDelete')) {
chrome.contextMenus.update('editor.contextDelete', {
enabled: Boolean(
this.selectionStart !== this.selectionEnd ||
this.somethingSelected && this.somethingSelected()
),
}, ignoreChromeError);
}
}