stylus/edit/sections.js

524 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);
setTimeout(() => {
if ((document.activeElement || {}).localName !== 'input') {
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(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(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 = [];
for (const div of getSections()) {
const meta = {urls: [], urlPrefixes: [], domains: [], regexps: []};
for (const li of $('.applies-to-list', div).childNodes) {
if (li.className === template.appliesToEverything.className) {
break;
}
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) {
continue;
}
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;
setCleanItem($('#sections'), false);
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) {
if (editors.length === 1) {
return;
}
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);
}
}