516 lines
16 KiB
JavaScript
516 lines
16 KiB
JavaScript
|
/*
|
||
|
global CodeMirror
|
||
|
global editors propertyToCss CssToProperty
|
||
|
global onChange indicateCodeChange initHooks setCleanGlobal
|
||
|
global fromMozillaFormat maximizeCodeHeight toggleContextMenuDelete
|
||
|
global setCleanItem updateTitle updateLintReportIfEnabled renderLintReport
|
||
|
global showAppliesToHelp beautify regExpTester setGlobalProgress setCleanSection
|
||
|
*/
|
||
|
'use strict';
|
||
|
|
||
|
function initWithSectionStyle({style, codeIsUpdated}) {
|
||
|
$('#name').value = style.name || '';
|
||
|
$('#enabled').checked = style.enabled !== false;
|
||
|
$('#url').href = style.url || '';
|
||
|
if (codeIsUpdated !== false) {
|
||
|
editors.length = 0;
|
||
|
getSections().forEach(div => div.remove());
|
||
|
addSections(style.sections.length ? style.sections : [{code: ''}]);
|
||
|
initHooks();
|
||
|
}
|
||
|
setCleanGlobal();
|
||
|
updateTitle();
|
||
|
}
|
||
|
|
||
|
function addSections(sections, onAdded = () => {}) {
|
||
|
if (addSections.running) {
|
||
|
console.error('addSections cannot be re-entered: please report to the developers');
|
||
|
// TODO: handle this properly e.g. on update/import
|
||
|
return;
|
||
|
}
|
||
|
addSections.running = true;
|
||
|
maximizeCodeHeight.stats = null;
|
||
|
// make a shallow copy since we might run asynchronously
|
||
|
// and the original array might get modified
|
||
|
sections = sections.slice();
|
||
|
const t0 = performance.now();
|
||
|
const divs = [];
|
||
|
let index = 0;
|
||
|
|
||
|
return new Promise(function run(resolve) {
|
||
|
while (index < sections.length) {
|
||
|
const div = addSection(null, sections[index]);
|
||
|
maximizeCodeHeight(div, index === sections.length - 1);
|
||
|
onAdded(div, index);
|
||
|
divs.push(div);
|
||
|
maybeFocusFirstCM();
|
||
|
index++;
|
||
|
const elapsed = performance.now() - t0;
|
||
|
if (elapsed > 500) {
|
||
|
setGlobalProgress(index, sections.length);
|
||
|
}
|
||
|
if (elapsed > 100) {
|
||
|
// after 100ms the sections are added asynchronously
|
||
|
setTimeout(run, 0, resolve);
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
editors.last.state.renderLintReportNow = true;
|
||
|
addSections.running = false;
|
||
|
setGlobalProgress();
|
||
|
resolve(divs);
|
||
|
});
|
||
|
|
||
|
function maybeFocusFirstCM() {
|
||
|
const isPageLocked = document.documentElement.style.pointerEvents;
|
||
|
if (divs[0] && (isPageLocked ? divs.length === sections.length : index === 0)) {
|
||
|
makeSectionVisible(divs[0].CodeMirror);
|
||
|
divs[0].CodeMirror.focus();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function addSection(event, section) {
|
||
|
const div = template.section.cloneNode(true);
|
||
|
$('.applies-to-help', div).addEventListener('click', showAppliesToHelp, false);
|
||
|
$('.remove-section', div).addEventListener('click', removeSection, false);
|
||
|
$('.add-section', div).addEventListener('click', addSection, false);
|
||
|
$('.beautify-section', div).addEventListener('click', beautify);
|
||
|
|
||
|
const code = (section || {}).code || '';
|
||
|
|
||
|
const appliesTo = $('.applies-to-list', div);
|
||
|
let appliesToAdded = false;
|
||
|
|
||
|
if (section) {
|
||
|
for (const i in propertyToCss) {
|
||
|
if (section[i]) {
|
||
|
section[i].forEach(url => {
|
||
|
addAppliesTo(appliesTo, propertyToCss[i], url);
|
||
|
appliesToAdded = true;
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
if (!appliesToAdded) {
|
||
|
addAppliesTo(appliesTo);
|
||
|
}
|
||
|
|
||
|
appliesTo.addEventListener('change', onChange);
|
||
|
appliesTo.addEventListener('input', onChange);
|
||
|
|
||
|
toggleTestRegExpVisibility();
|
||
|
appliesTo.addEventListener('change', toggleTestRegExpVisibility);
|
||
|
$('.test-regexp', div).onclick = () => {
|
||
|
regExpTester.toggle();
|
||
|
regExpTester.update(getRegExps());
|
||
|
};
|
||
|
|
||
|
function getRegExps() {
|
||
|
return [...appliesTo.children]
|
||
|
.map(item =>
|
||
|
!item.matches('.applies-to-everything') &&
|
||
|
$('.applies-type', item).value === 'regexp' &&
|
||
|
$('.applies-value', item).value.trim()
|
||
|
)
|
||
|
.filter(item => item);
|
||
|
}
|
||
|
|
||
|
function toggleTestRegExpVisibility() {
|
||
|
const show = getRegExps().length > 0;
|
||
|
div.classList.toggle('has-regexp', show);
|
||
|
appliesTo.oninput = appliesTo.oninput || show && (event => {
|
||
|
if (event.target.matches('.applies-value') &&
|
||
|
$('.applies-type', event.target.parentElement).value === 'regexp') {
|
||
|
regExpTester.update(getRegExps());
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
const sections = $('#sections');
|
||
|
let cm;
|
||
|
if (event) {
|
||
|
const clickedSection = getSectionForChild(event.target);
|
||
|
sections.insertBefore(div, clickedSection.nextElementSibling);
|
||
|
const newIndex = getSections().indexOf(clickedSection) + 1;
|
||
|
cm = setupCodeMirror(div, code, newIndex);
|
||
|
makeSectionVisible(cm);
|
||
|
renderLintReport();
|
||
|
cm.focus();
|
||
|
} else {
|
||
|
sections.appendChild(div);
|
||
|
cm = setupCodeMirror(div, code);
|
||
|
}
|
||
|
div.CodeMirror = cm;
|
||
|
setCleanSection(div);
|
||
|
return div;
|
||
|
}
|
||
|
|
||
|
function addAppliesTo(list, name, value) {
|
||
|
const showingEverything = $('.applies-to-everything', list) !== null;
|
||
|
// blow away 'Everything' if it's there
|
||
|
if (showingEverything) {
|
||
|
list.removeChild(list.firstChild);
|
||
|
}
|
||
|
let e;
|
||
|
if (name) {
|
||
|
e = template.appliesTo.cloneNode(true);
|
||
|
$('[name=applies-type]', e).value = name;
|
||
|
$('[name=applies-value]', e).value = value;
|
||
|
$('.remove-applies-to', e).addEventListener('click', removeAppliesTo, false);
|
||
|
} else if (showingEverything || list.hasChildNodes()) {
|
||
|
e = template.appliesTo.cloneNode(true);
|
||
|
if (list.hasChildNodes()) {
|
||
|
$('[name=applies-type]', e).value = $('li:last-child [name="applies-type"]', list).value;
|
||
|
}
|
||
|
$('.remove-applies-to', e).addEventListener('click', removeAppliesTo, false);
|
||
|
} else {
|
||
|
e = template.appliesToEverything.cloneNode(true);
|
||
|
}
|
||
|
$('.add-applies-to', e).addEventListener('click', function () {
|
||
|
addAppliesTo(this.parentNode.parentNode);
|
||
|
}, false);
|
||
|
list.appendChild(e);
|
||
|
}
|
||
|
|
||
|
function setupCodeMirror(sectionDiv, code, index) {
|
||
|
const cm = CodeMirror(wrapper => {
|
||
|
$('.code-label', sectionDiv).insertAdjacentElement('afterend', wrapper);
|
||
|
}, {
|
||
|
value: code,
|
||
|
});
|
||
|
const wrapper = cm.display.wrapper;
|
||
|
|
||
|
let onChangeTimer;
|
||
|
cm.on('changes', (cm, changes) => {
|
||
|
clearTimeout(onChangeTimer);
|
||
|
onChangeTimer = setTimeout(indicateCodeChange, 200, cm, changes);
|
||
|
});
|
||
|
if (prefs.get('editor.autocompleteOnTyping')) {
|
||
|
setupAutocomplete(cm);
|
||
|
}
|
||
|
wrapper.addEventListener('keydown', event => nextPrevEditorOnKeydown(cm, event), true);
|
||
|
cm.on('blur', () => {
|
||
|
editors.lastActive = cm;
|
||
|
cm.rerouteHotkeys(true);
|
||
|
setTimeout(() => {
|
||
|
wrapper.classList.toggle('CodeMirror-active', wrapper.contains(document.activeElement));
|
||
|
});
|
||
|
});
|
||
|
cm.on('focus', () => {
|
||
|
cm.rerouteHotkeys(false);
|
||
|
wrapper.classList.add('CodeMirror-active');
|
||
|
});
|
||
|
cm.on('paste', (cm, event) => {
|
||
|
const text = event.clipboardData.getData('text') || '';
|
||
|
if (
|
||
|
text.includes('@-moz-document') &&
|
||
|
text.replace(/\/\*[\s\S]*?\*\//g, '')
|
||
|
.match(/@-moz-document[\s\r\n]+(url|url-prefix|domain|regexp)\(/)
|
||
|
) {
|
||
|
event.preventDefault();
|
||
|
fromMozillaFormat();
|
||
|
$('#help-popup').codebox.setValue(text);
|
||
|
$('#help-popup').codebox.clearHistory();
|
||
|
$('#help-popup').codebox.markClean();
|
||
|
}
|
||
|
if (editors.length === 1) {
|
||
|
setTimeout(() => {
|
||
|
if (cm.display.sizer.clientHeight > cm.display.wrapper.clientHeight) {
|
||
|
maximizeCodeHeight.stats = null;
|
||
|
maximizeCodeHeight(cm.getSection(), true);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
if (!FIREFOX) {
|
||
|
cm.on('mousedown', (cm, event) => toggleContextMenuDelete.call(cm, event));
|
||
|
}
|
||
|
|
||
|
wrapper.classList.add('resize-grip-enabled');
|
||
|
let lastClickTime = 0;
|
||
|
const resizeGrip = wrapper.appendChild(template.resizeGrip.cloneNode(true));
|
||
|
resizeGrip.onmousedown = event => {
|
||
|
if (event.button !== 0) {
|
||
|
return;
|
||
|
}
|
||
|
event.preventDefault();
|
||
|
if (Date.now() - lastClickTime < 500) {
|
||
|
lastClickTime = 0;
|
||
|
toggleSectionHeight(cm);
|
||
|
return;
|
||
|
}
|
||
|
lastClickTime = Date.now();
|
||
|
const minHeight = cm.defaultTextHeight() +
|
||
|
/* .CodeMirror-lines padding */
|
||
|
cm.display.lineDiv.offsetParent.offsetTop +
|
||
|
/* borders */
|
||
|
wrapper.offsetHeight - wrapper.clientHeight;
|
||
|
wrapper.style.pointerEvents = 'none';
|
||
|
document.body.style.cursor = 's-resize';
|
||
|
function resize(e) {
|
||
|
const cmPageY = wrapper.getBoundingClientRect().top + window.scrollY;
|
||
|
const height = Math.max(minHeight, e.pageY - cmPageY);
|
||
|
if (height !== wrapper.clientHeight) {
|
||
|
cm.setSize(null, height);
|
||
|
}
|
||
|
}
|
||
|
document.addEventListener('mousemove', resize);
|
||
|
document.addEventListener('mouseup', function resizeStop() {
|
||
|
document.removeEventListener('mouseup', resizeStop);
|
||
|
document.removeEventListener('mousemove', resize);
|
||
|
wrapper.style.pointerEvents = '';
|
||
|
document.body.style.cursor = '';
|
||
|
});
|
||
|
};
|
||
|
|
||
|
editors.splice(index || editors.length, 0, cm);
|
||
|
return cm;
|
||
|
}
|
||
|
|
||
|
function indicateCodeChange(cm) {
|
||
|
const section = cm.getSection();
|
||
|
setCleanItem(section, cm.isClean(section.savedValue));
|
||
|
updateTitle();
|
||
|
updateLintReportIfEnabled(cm);
|
||
|
}
|
||
|
|
||
|
function setupAutocomplete(cm, enable = true) {
|
||
|
const onOff = enable ? 'on' : 'off';
|
||
|
cm[onOff]('changes', autocompleteOnTyping);
|
||
|
cm[onOff]('pick', autocompletePicked);
|
||
|
}
|
||
|
|
||
|
function autocompleteOnTyping(cm, [info], debounced) {
|
||
|
if (
|
||
|
cm.state.completionActive ||
|
||
|
info.origin && !info.origin.includes('input') ||
|
||
|
!info.text.last
|
||
|
) {
|
||
|
return;
|
||
|
}
|
||
|
if (cm.state.autocompletePicked) {
|
||
|
cm.state.autocompletePicked = false;
|
||
|
return;
|
||
|
}
|
||
|
if (!debounced) {
|
||
|
debounce(autocompleteOnTyping, 100, cm, [info], true);
|
||
|
return;
|
||
|
}
|
||
|
if (info.text.last.match(/[-a-z!]+$/i)) {
|
||
|
cm.state.autocompletePicked = false;
|
||
|
cm.options.hintOptions.completeSingle = false;
|
||
|
cm.execCommand('autocomplete');
|
||
|
setTimeout(() => {
|
||
|
cm.options.hintOptions.completeSingle = true;
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function autocompletePicked(cm) {
|
||
|
cm.state.autocompletePicked = true;
|
||
|
}
|
||
|
|
||
|
function nextPrevEditorOnKeydown(cm, event) {
|
||
|
const key = event.which;
|
||
|
if (key < 37 || key > 40 || event.shiftKey || event.altKey || event.metaKey) {
|
||
|
return;
|
||
|
}
|
||
|
const {line, ch} = cm.getCursor();
|
||
|
switch (key) {
|
||
|
case 37:
|
||
|
// arrow Left
|
||
|
if (line || ch) {
|
||
|
return;
|
||
|
}
|
||
|
// fallthrough to arrow Up
|
||
|
case 38:
|
||
|
// arrow Up
|
||
|
if (line > 0 || cm === editors[0]) {
|
||
|
return;
|
||
|
}
|
||
|
event.preventDefault();
|
||
|
event.stopPropagation();
|
||
|
cm = CodeMirror.commands.prevEditor.call(cm);
|
||
|
cm.setCursor(cm.doc.size - 1, key === 37 ? 1e20 : ch);
|
||
|
break;
|
||
|
case 39:
|
||
|
// arrow Right
|
||
|
if (line < cm.doc.size - 1 || ch < cm.getLine(line).length - 1) {
|
||
|
return;
|
||
|
}
|
||
|
// fallthrough to arrow Down
|
||
|
case 40:
|
||
|
// arrow Down
|
||
|
if (line < cm.doc.size - 1 || cm === editors.last) {
|
||
|
return;
|
||
|
}
|
||
|
event.preventDefault();
|
||
|
event.stopPropagation();
|
||
|
cm = CodeMirror.commands.nextEditor.call(cm);
|
||
|
cm.setCursor(0, 0);
|
||
|
break;
|
||
|
}
|
||
|
const animation = (cm.getSection().firstElementChild.getAnimations() || [])[0];
|
||
|
if (animation) {
|
||
|
animation.playbackRate = -1;
|
||
|
animation.currentTime = 2000;
|
||
|
animation.play();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function toggleSectionHeight(cm) {
|
||
|
if (cm.state.toggleHeightSaved) {
|
||
|
// restore previous size
|
||
|
cm.setSize(null, cm.state.toggleHeightSaved);
|
||
|
cm.state.toggleHeightSaved = 0;
|
||
|
} else {
|
||
|
// maximize
|
||
|
const wrapper = cm.display.wrapper;
|
||
|
const allBounds = $('#sections').getBoundingClientRect();
|
||
|
const pageExtrasHeight = allBounds.top + window.scrollY +
|
||
|
parseFloat(getComputedStyle($('#sections')).paddingBottom);
|
||
|
const sectionExtrasHeight = cm.getSection().clientHeight - wrapper.offsetHeight;
|
||
|
cm.state.toggleHeightSaved = wrapper.clientHeight;
|
||
|
cm.setSize(null, window.innerHeight - sectionExtrasHeight - pageExtrasHeight);
|
||
|
const bounds = cm.getSection().getBoundingClientRect();
|
||
|
if (bounds.top < 0 || bounds.bottom > window.innerHeight) {
|
||
|
window.scrollBy(0, bounds.top);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function getSectionForChild(e) {
|
||
|
return e.closest('#sections > div');
|
||
|
}
|
||
|
|
||
|
function getSections() {
|
||
|
return $$('#sections > div');
|
||
|
}
|
||
|
|
||
|
function getSectionsHashes() {
|
||
|
const sections = [];
|
||
|
getSections().forEach(div => {
|
||
|
const meta = {urls: [], urlPrefixes: [], domains: [], regexps: []};
|
||
|
for (const li of $('.applies-to-list', div).childNodes) {
|
||
|
if (li.className === template.appliesToEverything.className) {
|
||
|
return;
|
||
|
}
|
||
|
const type = $('[name=applies-type]', li).value;
|
||
|
const value = $('[name=applies-value]', li).value;
|
||
|
if (type && value) {
|
||
|
meta[CssToProperty[type]].push(value);
|
||
|
}
|
||
|
}
|
||
|
const code = div.CodeMirror.getValue();
|
||
|
if (/^\s*$/.test(code) && Object.keys(meta).length === 0) {
|
||
|
return;
|
||
|
}
|
||
|
meta.code = code;
|
||
|
sections.push(meta);
|
||
|
});
|
||
|
return sections;
|
||
|
}
|
||
|
|
||
|
function removeAppliesTo(event) {
|
||
|
const appliesTo = event.target.parentNode;
|
||
|
const appliesToList = appliesTo.parentNode;
|
||
|
removeAreaAndSetDirty(appliesTo);
|
||
|
if (!appliesToList.hasChildNodes()) {
|
||
|
addAppliesTo(appliesToList);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function removeSection(event) {
|
||
|
const section = getSectionForChild(event.target);
|
||
|
const cm = section.CodeMirror;
|
||
|
removeAreaAndSetDirty(section);
|
||
|
editors.splice(editors.indexOf(cm), 1);
|
||
|
renderLintReport();
|
||
|
}
|
||
|
|
||
|
function removeAreaAndSetDirty(area) {
|
||
|
const contributors = $$('.style-contributor', area);
|
||
|
if (!contributors.length) {
|
||
|
setCleanItem(area, false);
|
||
|
}
|
||
|
contributors.some(node => {
|
||
|
if (node.savedValue) {
|
||
|
// it's a saved section, so make it dirty and stop the enumeration
|
||
|
setCleanItem(area, false);
|
||
|
return true;
|
||
|
} else {
|
||
|
// it's an empty section, so undirty the applies-to items,
|
||
|
// otherwise orphaned ids would keep the style dirty
|
||
|
setCleanItem(node, true);
|
||
|
}
|
||
|
});
|
||
|
updateTitle();
|
||
|
area.parentNode.removeChild(area);
|
||
|
}
|
||
|
|
||
|
function makeSectionVisible(cm) {
|
||
|
const section = cm.getSection();
|
||
|
const bounds = section.getBoundingClientRect();
|
||
|
if (
|
||
|
(bounds.bottom > window.innerHeight && bounds.top > 0) ||
|
||
|
(bounds.top < 0 && bounds.bottom < window.innerHeight)
|
||
|
) {
|
||
|
if (bounds.top < 0) {
|
||
|
window.scrollBy(0, bounds.top - 1);
|
||
|
} else {
|
||
|
window.scrollBy(0, bounds.bottom - window.innerHeight + 1);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function maximizeCodeHeight(sectionDiv, isLast) {
|
||
|
const cm = sectionDiv.CodeMirror;
|
||
|
const stats = maximizeCodeHeight.stats = maximizeCodeHeight.stats || {totalHeight: 0, deltas: []};
|
||
|
if (!stats.cmActualHeight) {
|
||
|
stats.cmActualHeight = getComputedHeight(cm.display.wrapper);
|
||
|
}
|
||
|
if (!stats.sectionMarginTop) {
|
||
|
stats.sectionMarginTop = parseFloat(getComputedStyle(sectionDiv).marginTop);
|
||
|
}
|
||
|
const sectionTop = sectionDiv.getBoundingClientRect().top - stats.sectionMarginTop;
|
||
|
if (!stats.firstSectionTop) {
|
||
|
stats.firstSectionTop = sectionTop;
|
||
|
}
|
||
|
const extrasHeight = getComputedHeight(sectionDiv) - stats.cmActualHeight;
|
||
|
const cmMaxHeight = window.innerHeight - extrasHeight - sectionTop - stats.sectionMarginTop;
|
||
|
const cmDesiredHeight = cm.display.sizer.clientHeight + 2 * cm.defaultTextHeight();
|
||
|
const cmGrantableHeight = Math.max(stats.cmActualHeight, Math.min(cmMaxHeight, cmDesiredHeight));
|
||
|
stats.deltas.push(cmGrantableHeight - stats.cmActualHeight);
|
||
|
stats.totalHeight += cmGrantableHeight + extrasHeight;
|
||
|
if (!isLast) {
|
||
|
return;
|
||
|
}
|
||
|
stats.totalHeight += stats.firstSectionTop;
|
||
|
if (stats.totalHeight <= window.innerHeight) {
|
||
|
editors.forEach((cm, index) => {
|
||
|
cm.setSize(null, stats.deltas[index] + stats.cmActualHeight);
|
||
|
});
|
||
|
return;
|
||
|
}
|
||
|
// scale heights to fill the gap between last section and bottom edge of the window
|
||
|
const sections = $('#sections');
|
||
|
const available = window.innerHeight - sections.getBoundingClientRect().bottom -
|
||
|
parseFloat(getComputedStyle(sections).marginBottom);
|
||
|
if (available <= 0) {
|
||
|
return;
|
||
|
}
|
||
|
const totalDelta = stats.deltas.reduce((sum, d) => sum + d, 0);
|
||
|
const q = available / totalDelta;
|
||
|
const baseHeight = stats.cmActualHeight - stats.sectionMarginTop;
|
||
|
stats.deltas.forEach((delta, index) => {
|
||
|
editors[index].setSize(null, baseHeight + Math.floor(q * delta));
|
||
|
});
|
||
|
|
||
|
function getComputedHeight(el) {
|
||
|
const compStyle = getComputedStyle(el);
|
||
|
return el.getBoundingClientRect().height +
|
||
|
parseFloat(compStyle.marginTop) + parseFloat(compStyle.marginBottom);
|
||
|
}
|
||
|
}
|