editor: section labels, TOC, tweaks (#1086)
* section labels, TOC, speedups and fixes * show section numbers in widgets * debounce livePreview in usercss editor * better fixed header and compact layout compatibility * fix section sizing for compact layout + layout speedup * DocFuncMapper + cosmetics + fix Clone button * don't run linter during initSections * remove unused/unnecessary DOM polyfills * report invalid @document function as parser error * rewrite section finder * simplify focusedViaClick * simplify setPreprocessor and make it synchronous * throttle offscreen line widgets in usercss with lots of sections * add on, off aliases for add/removeEventListener + onOff * use on/off aliases in changed files * use getters in more places
This commit is contained in:
parent
71cabc2029
commit
5e5fecbcfe
|
@ -1267,6 +1267,10 @@
|
|||
"message": "Restore removed section",
|
||||
"description": "Label for the button to restore a removed section"
|
||||
},
|
||||
"sections": {
|
||||
"message": "Sections",
|
||||
"description": "Header for the table of contents block listing style section names in the left panel of the classic editor"
|
||||
},
|
||||
"shortcuts": {
|
||||
"message": "Shortcuts",
|
||||
"description": "Go to shortcut configuration"
|
||||
|
|
41
edit.html
41
edit.html
|
@ -32,12 +32,14 @@
|
|||
<script src="content/style-injector.js"></script>
|
||||
<script src="content/apply.js"></script>
|
||||
|
||||
<script src="edit/util.js"></script>
|
||||
<script src="edit/edit.js"></script> <!-- run it ASAP to send a request for the style -->
|
||||
|
||||
<link href="vendor/codemirror/lib/codemirror.css" rel="stylesheet">
|
||||
<script src="vendor/codemirror/lib/codemirror.js"></script>
|
||||
|
||||
<script src="vendor/codemirror/mode/css/css.js"></script>
|
||||
<script src="vendor/codemirror/mode/stylus/stylus.js"></script>
|
||||
|
||||
<link href="vendor/codemirror/addon/dialog/dialog.css" rel="stylesheet">
|
||||
<script src="vendor/codemirror/addon/dialog/dialog.js"></script>
|
||||
|
@ -84,10 +86,10 @@
|
|||
<link href="edit/codemirror-default.css" rel="stylesheet">
|
||||
<script src="edit/codemirror-default.js"></script>
|
||||
<script src="edit/codemirror-factory.js"></script>
|
||||
<script src="edit/util.js"></script>
|
||||
<script src="edit/regexp-tester.js"></script>
|
||||
<script src="edit/live-preview.js"></script>
|
||||
<script src="edit/applies-to-line-widget.js"></script>
|
||||
<script src="edit/moz-section-finder.js"></script>
|
||||
<script src="edit/moz-section-widget.js"></script>
|
||||
<script src="edit/reroute-hotkeys.js"></script>
|
||||
<link href="edit/global-search.css" rel="stylesheet">
|
||||
<script src="edit/global-search.js"></script>
|
||||
|
@ -311,19 +313,17 @@
|
|||
<button id="beautify" i18n-text="styleBeautify"></button>
|
||||
<a href="manage.html" tabindex="-1"><button id="cancel-button" i18n-text="styleCancelEditLabel"></button></a>
|
||||
</div>
|
||||
<div id="mozilla-format-container">
|
||||
<h2 id="mozilla-format-heading" i18n-text="styleMozillaFormatHeading">
|
||||
<a id="to-mozilla-help" class="svg-inline-wrapper" href="#" tabindex="0">
|
||||
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
|
||||
</a>
|
||||
</h2>
|
||||
<div id="mozilla-format-buttons">
|
||||
<div id="mozilla-format-buttons" class="sectioned-only">
|
||||
<button id="from-mozilla" i18n-text="importLabel"></button>
|
||||
<button id="to-mozilla" i18n-text="exportLabel"></button>
|
||||
</div>
|
||||
<a id="to-mozilla-help" class="svg-inline-wrapper" href="#" tabindex="0"
|
||||
i18n-title="styleMozillaFormatHeading">
|
||||
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<details id="options" data-pref="editor.options.expanded">
|
||||
<div id="details-wrapper">
|
||||
<details id="options" data-pref="editor.options.expanded" class="ignore-pref-if-compact">
|
||||
<summary><h2 id="options-heading" i18n-text="optionsHeading"></h2></summary>
|
||||
<div id="options-wrapper">
|
||||
<div class="options-column">
|
||||
|
@ -430,7 +430,11 @@
|
|||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id="lint" class="hidden-unless-compact" data-pref="editor.lint.expanded">
|
||||
<details id="sections-list" data-pref="editor.toc.expanded" class="ignore-pref-if-compact">
|
||||
<summary><h2 i18n-text="sections"></h2></summary>
|
||||
<ol id="toc"></ol>
|
||||
</details>
|
||||
<details id="lint" data-pref="editor.lint.expanded" class="hidden-unless-compact ignore-pref-if-compact">
|
||||
<summary>
|
||||
<h2 i18n-text="linterIssues">: <span id="issue-count"></span>
|
||||
<a id="lint-help" href="#" class="svg-inline-wrapper intercepts-click" tabindex="0">
|
||||
|
@ -442,23 +446,14 @@
|
|||
<div class="lint-report-container"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div id="footer" class="hidden">
|
||||
<a href="https://github.com/openstyles/stylus/wiki/Usercss"
|
||||
i18n-text="externalUsercssDocument"
|
||||
target="_blank"></a>
|
||||
</div>
|
||||
</div>
|
||||
<section id="sections">
|
||||
<!--
|
||||
It seems that we don't use these anymore
|
||||
https://github.com/openstyles/stylus/blob/5cbe8a8d780a6eb9fce11d5846e92bf244c3a3f3/edit/sections.js#L18
|
||||
-->
|
||||
<!-- <h2><span id="sections-heading" i18n-text="styleSectionsTitle"></span>
|
||||
<a id="sections-help" href="#" class="svg-inline-wrapper" tabindex="0">
|
||||
<svg class="svg-icon info"><use xlink:href="#svg-icon-help"/></svg>
|
||||
</a>
|
||||
</h2> -->
|
||||
</section>
|
||||
<section id="sections"></section>
|
||||
<div id="help-popup">
|
||||
<div class="title"></div><svg id="sections-help" class="svg-icon dismiss"><use xlink:href="#svg-icon-close"/></svg>
|
||||
<div class="contents"></div>
|
||||
|
|
|
@ -1,590 +0,0 @@
|
|||
/* global regExpTester debounce messageBox CodeMirror template colorMimicry msg
|
||||
$ $create t prefs tryCatch deepEqual */
|
||||
/* exported createAppliesToLineWidget */
|
||||
'use strict';
|
||||
|
||||
function createAppliesToLineWidget(cm) {
|
||||
const THROTTLE_DELAY = 400;
|
||||
const RX_SPACE = /(?:\s+|\/\*)+/y;
|
||||
let TPL, EVENTS, CLICK_ROUTE;
|
||||
let widgets = [];
|
||||
let fromLine, toLine, actualStyle;
|
||||
let initialized = false;
|
||||
return {toggle};
|
||||
|
||||
function toggle(newState = !initialized) {
|
||||
newState = Boolean(newState);
|
||||
if (newState !== initialized) {
|
||||
if (newState) {
|
||||
init();
|
||||
} else {
|
||||
uninit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initialized = true;
|
||||
|
||||
TPL = {
|
||||
container:
|
||||
$create('div.applies-to', [
|
||||
$create('label', t('appliesLabel')),
|
||||
$create('ul.applies-to-list'),
|
||||
]),
|
||||
listItem: template.appliesTo.cloneNode(true),
|
||||
appliesToEverything:
|
||||
$create('li.applies-to-everything', t('appliesToEverything')),
|
||||
};
|
||||
|
||||
$('.applies-value', TPL.listItem).insertAdjacentElement('afterend',
|
||||
$create('button.test-regexp', t('styleRegexpTestButton')));
|
||||
|
||||
CLICK_ROUTE = {
|
||||
'.test-regexp': showRegExpTester,
|
||||
|
||||
'.remove-applies-to': (item, apply, event) => {
|
||||
event.preventDefault();
|
||||
const applies = item.closest('.applies-to').__applies;
|
||||
const i = applies.indexOf(apply);
|
||||
let repl;
|
||||
let from;
|
||||
let to;
|
||||
if (applies.length < 2) {
|
||||
messageBox({
|
||||
contents: t('appliesRemoveError'),
|
||||
buttons: [t('confirmClose')]
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (i === 0) {
|
||||
from = apply.mark.find().from;
|
||||
to = applies[i + 1].mark.find().from;
|
||||
repl = '';
|
||||
} else if (i === applies.length - 1) {
|
||||
from = applies[i - 1].mark.find().to;
|
||||
to = apply.mark.find().to;
|
||||
repl = '';
|
||||
} else {
|
||||
from = applies[i - 1].mark.find().to;
|
||||
to = applies[i + 1].mark.find().from;
|
||||
repl = ', ';
|
||||
}
|
||||
cm.replaceRange(repl, from, to, 'appliesTo');
|
||||
clearApply(apply);
|
||||
item.remove();
|
||||
applies.splice(i, 1);
|
||||
},
|
||||
|
||||
'.add-applies-to': (item, apply, event) => {
|
||||
event.preventDefault();
|
||||
const applies = item.closest('.applies-to').__applies;
|
||||
const i = applies.indexOf(apply);
|
||||
const pos = apply.mark.find().to;
|
||||
const text = `, ${apply.type.text}("")`;
|
||||
cm.replaceRange(text, pos, pos, 'appliesTo');
|
||||
const newApply = createApply(
|
||||
cm.indexFromPos(pos) + 2,
|
||||
apply.type.text,
|
||||
'',
|
||||
true
|
||||
);
|
||||
setupApplyMarkers(newApply);
|
||||
applies.splice(i + 1, 0, newApply);
|
||||
item.insertAdjacentElement('afterend', buildChildren(applies, newApply));
|
||||
},
|
||||
};
|
||||
|
||||
EVENTS = {
|
||||
onchange({target}) {
|
||||
const typeElement = target.closest('.applies-type');
|
||||
if (typeElement) {
|
||||
const item = target.closest('.applies-to-item');
|
||||
const apply = item.__apply;
|
||||
changeItem(item, apply, 'type', typeElement.value);
|
||||
item.dataset.type = apply.type.text;
|
||||
} else {
|
||||
return EVENTS.oninput.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
oninput({target}) {
|
||||
if (target.matches('.applies-value')) {
|
||||
const item = target.closest('.applies-to-item');
|
||||
const apply = item.__apply;
|
||||
changeItem(item, apply, 'value', target.value);
|
||||
}
|
||||
},
|
||||
onclick(event) {
|
||||
const {target} = event;
|
||||
for (const selector in CLICK_ROUTE) {
|
||||
const routed = target.closest(selector);
|
||||
if (routed) {
|
||||
const item = routed.closest('.applies-to-item');
|
||||
CLICK_ROUTE[selector].call(routed, item, item.__apply, event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
actualStyle = $create('style');
|
||||
fromLine = 0;
|
||||
toLine = cm.doc.size;
|
||||
|
||||
cm.on('change', onChange);
|
||||
cm.on('optionChange', onOptionChange);
|
||||
|
||||
msg.onExtension(onRuntimeMessage);
|
||||
|
||||
requestAnimationFrame(updateWidgetStyle);
|
||||
update();
|
||||
}
|
||||
|
||||
function uninit() {
|
||||
initialized = false;
|
||||
|
||||
widgets.forEach(clearWidget);
|
||||
widgets.length = 0;
|
||||
cm.off('change', onChange);
|
||||
cm.off('optionChange', onOptionChange);
|
||||
msg.off(onRuntimeMessage);
|
||||
actualStyle.remove();
|
||||
}
|
||||
|
||||
function onChange(cm, event) {
|
||||
const {from, to, origin} = event;
|
||||
if (origin === 'appliesTo') {
|
||||
return;
|
||||
}
|
||||
const lastChanged = CodeMirror.changeEnd(event).line;
|
||||
fromLine = Math.min(fromLine === null ? from.line : fromLine, from.line);
|
||||
toLine = Math.max(toLine === null ? lastChanged : toLine, to.line);
|
||||
if (origin === 'setValue') {
|
||||
update();
|
||||
} else {
|
||||
debounce(update, THROTTLE_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
function onOptionChange(cm, option) {
|
||||
if (option === 'theme') {
|
||||
updateWidgetStyle();
|
||||
}
|
||||
}
|
||||
|
||||
function onRuntimeMessage(msg) {
|
||||
if (msg.reason === 'editPreview' && !$(`#stylus-${msg.style.id}`)) {
|
||||
// no style element with this id means the style doesn't apply to the editor URL
|
||||
return;
|
||||
}
|
||||
if (msg.style || msg.styles ||
|
||||
msg.prefs && 'disableAll' in msg.prefs ||
|
||||
msg.method === 'styleDeleted') {
|
||||
requestAnimationFrame(updateWidgetStyle);
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
const changed = {fromLine, toLine};
|
||||
fromLine = Math.max(fromLine || 0, cm.display.viewFrom);
|
||||
toLine = Math.min(toLine === null ? cm.doc.size : toLine, cm.display.viewTo || toLine);
|
||||
const visible = {fromLine, toLine};
|
||||
const {curOp} = cm;
|
||||
if (fromLine >= cm.display.viewFrom && toLine <= (cm.display.viewTo || toLine)) {
|
||||
if (!curOp) cm.startOperation();
|
||||
doUpdate();
|
||||
if (!curOp) cm.endOperation();
|
||||
}
|
||||
if (changed.fromLine !== visible.fromLine || changed.toLine !== visible.toLine) {
|
||||
setTimeout(updateInvisible, 0, changed, visible);
|
||||
}
|
||||
}
|
||||
|
||||
function updateInvisible(changed, visible) {
|
||||
let inOp = false;
|
||||
if (changed.fromLine < visible.fromLine) {
|
||||
fromLine = Math.min(fromLine, changed.fromLine);
|
||||
toLine = Math.min(changed.toLine, visible.fromLine);
|
||||
inOp = true;
|
||||
cm.startOperation();
|
||||
doUpdate();
|
||||
}
|
||||
if (changed.toLine > visible.toLine) {
|
||||
fromLine = Math.max(fromLine, changed.toLine);
|
||||
toLine = Math.max(changed.toLine, visible.toLine);
|
||||
if (!inOp) {
|
||||
inOp = true;
|
||||
cm.startOperation();
|
||||
}
|
||||
doUpdate();
|
||||
}
|
||||
if (inOp) {
|
||||
cm.endOperation();
|
||||
}
|
||||
}
|
||||
|
||||
function updateWidgetStyle() {
|
||||
if (prefs.get('editor.theme') !== 'default' &&
|
||||
!tryCatch(() => $('#cm-theme').sheet.cssRules)) {
|
||||
requestAnimationFrame(updateWidgetStyle);
|
||||
return;
|
||||
}
|
||||
const MIN_LUMA = .05;
|
||||
const MIN_LUMA_DIFF = .4;
|
||||
const color = {
|
||||
wrapper: colorMimicry.get(cm.display.wrapper),
|
||||
gutter: colorMimicry.get(cm.display.gutters, {
|
||||
bg: 'backgroundColor',
|
||||
border: 'borderRightColor',
|
||||
}),
|
||||
line: colorMimicry.get('.CodeMirror-linenumber', null, cm.display.lineDiv),
|
||||
comment: colorMimicry.get('span.cm-comment', null, cm.display.lineDiv),
|
||||
};
|
||||
const hasBorder =
|
||||
color.gutter.style.borderRightWidth !== '0px' &&
|
||||
!/transparent|\b0\)/g.test(color.gutter.style.borderRightColor);
|
||||
const diff = {
|
||||
wrapper: Math.abs(color.gutter.bgLuma - color.wrapper.foreLuma),
|
||||
border: hasBorder ? Math.abs(color.gutter.bgLuma - color.gutter.borderLuma) : 0,
|
||||
line: Math.abs(color.gutter.bgLuma - color.line.foreLuma),
|
||||
};
|
||||
const preferLine = diff.line > diff.wrapper || diff.line > MIN_LUMA_DIFF;
|
||||
const fore = preferLine ? color.line.fore : color.wrapper.fore;
|
||||
|
||||
const border = fore.replace(/[\d.]+(?=\))/, MIN_LUMA_DIFF / 2);
|
||||
const borderStyleForced = `1px ${hasBorder ? color.gutter.style.borderRightStyle : 'solid'} ${border}`;
|
||||
|
||||
actualStyle.textContent = `
|
||||
.applies-to {
|
||||
background-color: ${color.gutter.bg};
|
||||
border-top: ${borderStyleForced};
|
||||
border-bottom: ${borderStyleForced};
|
||||
}
|
||||
.applies-to label {
|
||||
color: ${fore};
|
||||
}
|
||||
.applies-to input,
|
||||
.applies-to button,
|
||||
.applies-to select {
|
||||
background: rgba(255, 255, 255, ${
|
||||
Math.max(MIN_LUMA, Math.pow(Math.max(0, color.gutter.bgLuma - MIN_LUMA * 2), 2)).toFixed(2)
|
||||
});
|
||||
border: ${borderStyleForced};
|
||||
transition: none;
|
||||
color: ${fore};
|
||||
}
|
||||
.applies-to .svg-icon.select-arrow {
|
||||
fill: ${fore};
|
||||
transition: none;
|
||||
}
|
||||
`;
|
||||
document.documentElement.appendChild(actualStyle);
|
||||
}
|
||||
|
||||
function doUpdate() {
|
||||
// find which widgets needs to be update
|
||||
// some widgets (lines) might be deleted
|
||||
widgets = widgets.filter(w => w.line.lineNo() !== null);
|
||||
let i = widgets.findIndex(w => w.line.lineNo() > fromLine) - 1;
|
||||
let j = widgets.findIndex(w => w.line.lineNo() > toLine);
|
||||
if (i === -2) {
|
||||
i = widgets.length - 1;
|
||||
}
|
||||
if (j < 0) {
|
||||
j = widgets.length;
|
||||
}
|
||||
|
||||
// decide search range
|
||||
const fromPos = {line: widgets[i] ? widgets[i].line.lineNo() : 0, ch: 0};
|
||||
const toPos = {line: widgets[j] ? widgets[j].line.lineNo() : toLine + 1, ch: 0};
|
||||
|
||||
// calc index->pos lookup table
|
||||
let index = 0;
|
||||
const lineIndexes = [0];
|
||||
cm.doc.iter(0, toPos.line + 1, ({text}) => {
|
||||
lineIndexes.push((index += text.length + 1));
|
||||
});
|
||||
|
||||
// splice
|
||||
i = Math.max(0, i);
|
||||
widgets.splice(i, 0, ...createWidgets(fromPos, toPos, widgets.splice(i, j - i), lineIndexes));
|
||||
|
||||
fromLine = null;
|
||||
toLine = null;
|
||||
}
|
||||
|
||||
function *createWidgets(start, end, removed, lineIndexes) {
|
||||
let i = 0;
|
||||
let itemHeight;
|
||||
for (const section of findAppliesTo(start, end, lineIndexes)) {
|
||||
let removedWidget = removed[i];
|
||||
while (removedWidget && removedWidget.line.lineNo() < section.pos.line) {
|
||||
clearWidget(removed[i]);
|
||||
removedWidget = removed[++i];
|
||||
}
|
||||
if (removedWidget && deepEqual(removedWidget.node.__applies, section.applies, ['mark'])) {
|
||||
yield removedWidget;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
for (const a of section.applies) {
|
||||
setupApplyMarkers(a, lineIndexes);
|
||||
}
|
||||
if (removedWidget && removedWidget.line.lineNo() === section.pos.line) {
|
||||
// reuse old widget
|
||||
removedWidget.section.applies.forEach(apply => {
|
||||
apply.type.mark.clear();
|
||||
apply.value.mark.clear();
|
||||
});
|
||||
removedWidget.section = section;
|
||||
const newNode = buildElement(section);
|
||||
const removedNode = removedWidget.node;
|
||||
if (removedNode.parentNode) {
|
||||
removedNode.parentNode.replaceChild(newNode, removedNode);
|
||||
}
|
||||
removedWidget.node = newNode;
|
||||
removedWidget.changed();
|
||||
yield removedWidget;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// new widget
|
||||
const widget = cm.addLineWidget(section.pos.line, buildElement(section), {
|
||||
coverGutter: true,
|
||||
noHScroll: true,
|
||||
above: true,
|
||||
height: itemHeight ? section.applies.length * itemHeight : undefined,
|
||||
});
|
||||
widget.section = section;
|
||||
itemHeight = itemHeight || widget.node.offsetHeight / (section.applies.length || 1);
|
||||
yield widget;
|
||||
}
|
||||
removed.slice(i).forEach(clearWidget);
|
||||
}
|
||||
|
||||
function clearWidget(widget) {
|
||||
widget.clear();
|
||||
widget.section.applies.forEach(clearApply);
|
||||
}
|
||||
|
||||
function clearApply(apply) {
|
||||
apply.type.mark.clear();
|
||||
apply.value.mark.clear();
|
||||
apply.mark.clear();
|
||||
}
|
||||
|
||||
function setupApplyMarkers(apply, lineIndexes) {
|
||||
apply.type.mark = cm.markText(
|
||||
posFromIndex(cm, apply.type.start, lineIndexes),
|
||||
posFromIndex(cm, apply.type.end, lineIndexes),
|
||||
{clearWhenEmpty: false}
|
||||
);
|
||||
apply.value.mark = cm.markText(
|
||||
posFromIndex(cm, apply.value.start, lineIndexes),
|
||||
posFromIndex(cm, apply.value.end, lineIndexes),
|
||||
{clearWhenEmpty: false}
|
||||
);
|
||||
apply.mark = cm.markText(
|
||||
posFromIndex(cm, apply.start, lineIndexes),
|
||||
posFromIndex(cm, apply.end, lineIndexes),
|
||||
{clearWhenEmpty: false}
|
||||
);
|
||||
}
|
||||
|
||||
function posFromIndex(cm, index, lineIndexes) {
|
||||
if (!lineIndexes) {
|
||||
return cm.posFromIndex(index);
|
||||
}
|
||||
let line = lineIndexes.prev || 0;
|
||||
const prev = lineIndexes[line];
|
||||
const next = lineIndexes[line + 1];
|
||||
if (prev <= index && index < next) {
|
||||
return {line, ch: index - prev};
|
||||
}
|
||||
let a = index < prev ? 0 : line;
|
||||
let b = index < next ? line + 1 : lineIndexes.length - 1;
|
||||
while (a < b - 1) {
|
||||
const mid = (a + b) >> 1;
|
||||
if (lineIndexes[mid] < index) {
|
||||
a = mid;
|
||||
} else {
|
||||
b = mid;
|
||||
}
|
||||
}
|
||||
line = lineIndexes[b] > index ? a : b;
|
||||
Object.defineProperty(lineIndexes, 'prev', {value: line, configurable: true});
|
||||
return {line, ch: index - lineIndexes[line]};
|
||||
}
|
||||
|
||||
function buildElement({applies}) {
|
||||
const container = TPL.container.cloneNode(true);
|
||||
const list = $('.applies-to-list', container);
|
||||
for (const apply of applies) {
|
||||
list.appendChild(buildChildren(applies, apply));
|
||||
}
|
||||
if (!list.children[0]) {
|
||||
list.appendChild(TPL.appliesToEverything.cloneNode(true));
|
||||
}
|
||||
return Object.assign(container, EVENTS, {__applies: applies});
|
||||
}
|
||||
|
||||
function buildChildren(applies, apply) {
|
||||
const el = TPL.listItem.cloneNode(true);
|
||||
el.dataset.type = apply.type.text;
|
||||
el.__apply = apply;
|
||||
$('.applies-type', el).value = apply.type.text;
|
||||
$('.applies-value', el).value = apply.value.text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function changeItem(itemElement, apply, part, newText) {
|
||||
if (!apply) {
|
||||
return;
|
||||
}
|
||||
part = apply[part];
|
||||
const range = part.mark.find();
|
||||
part.mark.clear();
|
||||
newText = unescapeDoubleslash(newText).replace(/\\/g, '\\\\');
|
||||
cm.replaceRange(newText, range.from, range.to, 'appliesTo');
|
||||
part.mark = cm.markText(
|
||||
range.from,
|
||||
cm.findPosH(range.from, newText.length, 'char'),
|
||||
{clearWhenEmpty: false}
|
||||
);
|
||||
part.text = newText;
|
||||
|
||||
if (part === apply.type) {
|
||||
const range = apply.mark.find();
|
||||
apply.mark.clear();
|
||||
apply.mark = cm.markText(
|
||||
part.mark.find().from,
|
||||
range.to,
|
||||
{clearWhenEmpty: false}
|
||||
);
|
||||
}
|
||||
|
||||
if (apply.type.text === 'regexp' && apply.value.text.trim()) {
|
||||
showRegExpTester(itemElement);
|
||||
}
|
||||
}
|
||||
|
||||
function createApply(pos, typeText, valueText, isQuoted = false) {
|
||||
typeText = typeText.toLowerCase();
|
||||
const start = pos;
|
||||
const typeStart = start;
|
||||
const typeEnd = typeStart + typeText.length;
|
||||
const valueStart = typeEnd + 1 + Number(isQuoted);
|
||||
const valueEnd = valueStart + valueText.length;
|
||||
const end = valueEnd + Number(isQuoted) + 1;
|
||||
return {
|
||||
start,
|
||||
type: {
|
||||
text: typeText,
|
||||
start: typeStart,
|
||||
end: typeEnd,
|
||||
},
|
||||
value: {
|
||||
text: unescapeDoubleslash(valueText),
|
||||
start: valueStart,
|
||||
end: valueEnd,
|
||||
},
|
||||
end
|
||||
};
|
||||
}
|
||||
|
||||
function *findAppliesTo(posStart, posEnd, lineIndexes) {
|
||||
const funcRe = /^(url|url-prefix|domain|regexp)$/i;
|
||||
let pos;
|
||||
const eatToken = sticky => {
|
||||
if (!sticky) skipSpace(pos, posEnd);
|
||||
pos.ch++;
|
||||
const token = cm.getTokenAt(pos, true);
|
||||
pos.ch = token.end;
|
||||
return CodeMirror.cmpPos(pos, posEnd) <= 0 ? token : {};
|
||||
};
|
||||
const docCur = cm.getSearchCursor('@-moz-document', posStart);
|
||||
while (docCur.findNext() &&
|
||||
CodeMirror.cmpPos(docCur.pos.to, posEnd) <= 0) {
|
||||
// CM can be nitpicky at token boundary so we'll check the next character
|
||||
const safePos = {line: docCur.pos.from.line, ch: docCur.pos.from.ch + 1};
|
||||
if (/\b(string|comment)\b/.test(cm.getTokenTypeAt(safePos))) continue;
|
||||
const applies = [];
|
||||
pos = docCur.pos.to;
|
||||
do {
|
||||
skipSpace(pos, posEnd);
|
||||
const funcIndex = lineIndexes[pos.line] + pos.ch;
|
||||
const func = eatToken().string;
|
||||
// no space allowed before the opening parenthesis
|
||||
if (!funcRe.test(func) || eatToken(true).string !== '(') break;
|
||||
const url = eatToken();
|
||||
if (url.type !== 'string' || eatToken().string !== ')') break;
|
||||
const unquotedUrl = unquote(url.string);
|
||||
const apply = createApply(
|
||||
funcIndex,
|
||||
func,
|
||||
unquotedUrl,
|
||||
unquotedUrl !== url.string
|
||||
);
|
||||
applies.push(apply);
|
||||
} while (eatToken().string === ',');
|
||||
yield {
|
||||
pos: docCur.pos.from,
|
||||
applies
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function skipSpace(pos, posEnd) {
|
||||
let {ch, line} = pos;
|
||||
let lookForEnd;
|
||||
line--;
|
||||
cm.doc.iter(pos.line, posEnd.line + 1, ({text}) => {
|
||||
line++;
|
||||
while (true) {
|
||||
if (lookForEnd) {
|
||||
ch = text.indexOf('*/', ch) + 1;
|
||||
if (!ch) {
|
||||
return;
|
||||
}
|
||||
ch++;
|
||||
lookForEnd = false;
|
||||
}
|
||||
// EOL is a whitespace so we'll check the next line
|
||||
if (ch >= text.length) {
|
||||
ch = 0;
|
||||
return;
|
||||
}
|
||||
RX_SPACE.lastIndex = ch;
|
||||
const m = RX_SPACE.exec(text);
|
||||
if (!m) {
|
||||
return true;
|
||||
}
|
||||
ch += m[0].length;
|
||||
lookForEnd = m[0].includes('/*');
|
||||
if (ch < text.length && !lookForEnd) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
pos.line = line;
|
||||
pos.ch = ch;
|
||||
}
|
||||
|
||||
function unquote(s) {
|
||||
const first = s.charAt(0);
|
||||
return (first === '"' || first === "'") && s.endsWith(first) ? s.slice(1, -1) : s;
|
||||
}
|
||||
|
||||
function unescapeDoubleslash(s) {
|
||||
const hasSingleEscapes = /([^\\]|^)\\([^\\]|$)/.test(s);
|
||||
return hasSingleEscapes ? s : s.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
function showRegExpTester(item) {
|
||||
regExpTester.toggle(true);
|
||||
regExpTester.update(
|
||||
item.closest('.applies-to').__applies
|
||||
.filter(a => a.type.text === 'regexp')
|
||||
.map(a => unescapeDoubleslash(a.value.text)));
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
/* global CodeMirror prefs loadScript editor $ template */
|
||||
/* global CodeMirror prefs editor $ template */
|
||||
|
||||
'use strict';
|
||||
|
||||
|
@ -117,49 +117,24 @@
|
|||
'lightslategrey': true,
|
||||
'slategrey': true,
|
||||
});
|
||||
|
||||
const MODE = {
|
||||
less: {
|
||||
family: 'css',
|
||||
value: 'text/x-less',
|
||||
isActive: cm =>
|
||||
cm.doc.mode &&
|
||||
cm.doc.mode.name === 'css' &&
|
||||
cm.doc.mode.helperType === 'less',
|
||||
Object.assign(CodeMirror.prototype, {
|
||||
/**
|
||||
* @param {'less' | 'stylus' | ?} [pp] - any value besides `less` or `stylus` sets `css` mode
|
||||
* @param {boolean} [force]
|
||||
*/
|
||||
setPreprocessor(pp, force) {
|
||||
const name = pp === 'less' ? 'text/x-less' : pp === 'stylus' ? pp : 'css';
|
||||
const m = this.doc.mode;
|
||||
if (force || (m.helperType ? m.helperType !== pp : m.name !== name)) {
|
||||
this.setOption('mode', name);
|
||||
}
|
||||
},
|
||||
stylus: 'stylus',
|
||||
uso: 'css'
|
||||
};
|
||||
|
||||
CodeMirror.defineExtension('setPreprocessor', function (preprocessor, force = false) {
|
||||
const mode = MODE[preprocessor] || 'css';
|
||||
const isActive = mode.isActive || (
|
||||
cm => cm.doc.mode === mode ||
|
||||
cm.doc.mode && (cm.doc.mode.name + (cm.doc.mode.helperType || '') === mode)
|
||||
);
|
||||
if (!force && isActive(this)) {
|
||||
return Promise.resolve();
|
||||
/** Superfast GC-friendly check that runs until the first non-space line */
|
||||
isBlank() {
|
||||
let filled;
|
||||
this.eachLine(({text}) => (filled = text && /\S/.test(text)));
|
||||
return !filled;
|
||||
}
|
||||
if ((mode.family || mode) === 'css') {
|
||||
// css.js is always loaded via html
|
||||
this.setOption('mode', mode.value || mode);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return loadScript(`/vendor/codemirror/mode/${mode}/${mode}.js`).then(() => {
|
||||
this.setOption('mode', mode);
|
||||
});
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('isBlank', function () {
|
||||
// superfast checking as it runs only until the first non-blank line
|
||||
let isBlank = true;
|
||||
this.doc.eachLine(line => {
|
||||
if (line.text && line.text.trim()) {
|
||||
isBlank = false;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return isBlank;
|
||||
});
|
||||
|
||||
// editor commands
|
||||
|
|
124
edit/edit.css
124
edit/edit.css
|
@ -1,5 +1,6 @@
|
|||
:root {
|
||||
--header-narrow-min-height: 12em;
|
||||
--fixed-padding: unset;
|
||||
}
|
||||
|
||||
body {
|
||||
|
@ -18,6 +19,7 @@ body {
|
|||
z-index: 2147483647;
|
||||
opacity: 0;
|
||||
transition: opacity 2s;
|
||||
contain: strict;
|
||||
}
|
||||
#global-progress[title] {
|
||||
opacity: 1;
|
||||
|
@ -146,9 +148,6 @@ label {
|
|||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
#mozilla-format-heading .svg-inline-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
#colorpicker-settings.svg-inline-wrapper {
|
||||
margin: -2px 0 0 .1rem;
|
||||
}
|
||||
|
@ -190,8 +189,6 @@ input:invalid {
|
|||
align-items: center;
|
||||
margin-left: -13px;
|
||||
cursor: pointer;
|
||||
margin-top: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
#header summary h2 {
|
||||
|
@ -203,6 +200,9 @@ input:invalid {
|
|||
padding-left: 13px; /* clicking directly on details-marker doesn't set pref so we cover it with h2 */
|
||||
}
|
||||
|
||||
#options-wrapper {
|
||||
padding: .5rem 0;
|
||||
}
|
||||
#header summary:hover h2 {
|
||||
border-color: #bbb;
|
||||
}
|
||||
|
@ -211,18 +211,25 @@ input:invalid {
|
|||
margin-top: -3px;
|
||||
}
|
||||
|
||||
#details-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#header details {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
#actions > * {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#mozilla-format-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#mozilla-format-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#actions > div > a {
|
||||
|
@ -272,6 +279,7 @@ input:invalid {
|
|||
/************ section editor ***********/
|
||||
.CodeMirror-vscrollbar,
|
||||
.CodeMirror-hscrollbar {
|
||||
box-shadow: none !important;
|
||||
pointer-events: auto !important; /* FF bug */
|
||||
}
|
||||
.section-editor .section {
|
||||
|
@ -305,6 +313,9 @@ input:invalid {
|
|||
counter-reset: codebox;
|
||||
}
|
||||
#sections > .section > label {
|
||||
padding: 0 0 4px 0;
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
animation: 2s highlight;
|
||||
animation-play-state: paused;
|
||||
animation-direction: reverse;
|
||||
|
@ -312,9 +323,41 @@ input:invalid {
|
|||
}
|
||||
#sections > .section > label::after {
|
||||
counter-increment: codebox;
|
||||
content: counter(codebox);
|
||||
content: counter(codebox) ": " attr(data-text);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.single-editor .applies-to > label::before {
|
||||
content: attr(data-index) ":";
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.code-label[data-text] {
|
||||
font-weight: bold;
|
||||
}
|
||||
#toc {
|
||||
counter-reset: codelabel;
|
||||
margin: 0;
|
||||
padding: .5rem 0;
|
||||
}
|
||||
#toc li {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
#toc li.current:not(:only-child) {
|
||||
font-weight: bold;
|
||||
}
|
||||
#toc li[tabindex="-1"] {
|
||||
opacity: .25;
|
||||
pointer-events: none;
|
||||
}
|
||||
#toc li:hover {
|
||||
background-color: hsla(180, 50%, 36%, .2);
|
||||
}
|
||||
#toc li[tabindex="0"]::before {
|
||||
counter-increment: codelabel;
|
||||
content: counter(codelabel) ": ";
|
||||
}
|
||||
.section:only-of-type .move-section-up,
|
||||
.section:only-of-type .move-section-down {
|
||||
display: none;
|
||||
|
@ -438,6 +481,10 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
|
|||
min-height: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.applies-to.error {
|
||||
background-color: #f002;
|
||||
border-color: #f008;
|
||||
}
|
||||
.applies-to label {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
|
@ -617,9 +664,12 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
|
|||
#help-popup .CodeMirror {
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
#help-popup .keymap-list input[type="search"] {
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
.keymap-list {
|
||||
font-size: 12px;
|
||||
padding: 0 3px 0 0;
|
||||
border-spacing: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
@ -677,13 +727,13 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
|
|||
padding-left: 4px;
|
||||
}
|
||||
#lint[open]:not(.hidden-unless-compact) {
|
||||
min-height: 130px;
|
||||
min-height: 102px;
|
||||
}
|
||||
#lint summary h2 {
|
||||
margin-left: -16px;
|
||||
text-indent: -2px;
|
||||
}
|
||||
#lint > .lint-scroll-container {
|
||||
margin: 42px 1rem 0;
|
||||
margin: 34px 10px 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
@ -721,7 +771,7 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
|
|||
cursor: pointer;
|
||||
}
|
||||
#lint tr:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
background-color: hsla(180, 50%, 36%, .2);
|
||||
}
|
||||
#lint td[role="severity"] {
|
||||
font-size: 0;
|
||||
|
@ -799,8 +849,7 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high
|
|||
}
|
||||
|
||||
html:not(.usercss) .usercss-only,
|
||||
.usercss #mozilla-format-container,
|
||||
.usercss #sections > h2 {
|
||||
.usercss .sectioned-only {
|
||||
display: none !important; /* hide during page init */
|
||||
}
|
||||
|
||||
|
@ -877,7 +926,7 @@ body.linter-disabled .hidden-unless-compact {
|
|||
padding: 0;
|
||||
}
|
||||
.fixed-header {
|
||||
padding-top: 40px;
|
||||
padding-top: var(--fixed-padding);
|
||||
}
|
||||
.fixed-header #header {
|
||||
min-height: 40px;
|
||||
|
@ -885,10 +934,11 @@ body.linter-disabled .hidden-unless-compact {
|
|||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 0 0;
|
||||
padding: 0;
|
||||
background-color: #fff;
|
||||
}
|
||||
.fixed-header #header > *:not(#lint) {
|
||||
.fixed-header #header > *:not(#details-wrapper),
|
||||
.fixed-header #options {
|
||||
display: none !important;
|
||||
}
|
||||
#actions {
|
||||
|
@ -925,9 +975,31 @@ body.linter-disabled .hidden-unless-compact {
|
|||
#options-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 1rem .5rem;
|
||||
padding: .5rem 1rem 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#toc {
|
||||
padding: .5rem 1rem;
|
||||
}
|
||||
#details-wrapper {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
#options {
|
||||
width: 100%;
|
||||
}
|
||||
#sections-list[open] {
|
||||
height: 102px;
|
||||
}
|
||||
#sections-list[open] #toc {
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#sections-list,
|
||||
#lint {
|
||||
width: 50%;
|
||||
}
|
||||
.options-column {
|
||||
flex-grow: 1;
|
||||
padding-right: .5rem;
|
||||
|
@ -947,7 +1019,7 @@ body.linter-disabled .hidden-unless-compact {
|
|||
position: static;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#options summary {
|
||||
#header summary {
|
||||
margin-left: 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
@ -966,15 +1038,11 @@ body.linter-disabled .hidden-unless-compact {
|
|||
top: 0.2rem;
|
||||
}
|
||||
#lint > .lint-scroll-container {
|
||||
margin: 32px 1rem 0;
|
||||
bottom: 6px;
|
||||
margin: 26px 1rem 0;
|
||||
}
|
||||
#lint {
|
||||
padding: 0;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
#lint > summary {
|
||||
margin-top: 0;
|
||||
margin: .5rem 0 0;
|
||||
}
|
||||
#lint:not([open]) + #footer {
|
||||
margin: .25em 0 -1em .25em;
|
||||
|
|
360
edit/edit.js
360
edit/edit.js
|
@ -1,52 +1,72 @@
|
|||
/* global CodeMirror onDOMready prefs setupLivePrefs $ $$ $create t tHTML
|
||||
createSourceEditor sessionStorageHash getOwnTab FIREFOX API tryCatch
|
||||
closeCurrentTab messageBox debounce tryJSONparse
|
||||
initBeautifyButton ignoreChromeError dirtyReporter linter
|
||||
moveFocus msg createSectionsEditor rerouteHotkeys CODEMIRROR_THEMES */
|
||||
/* exported showCodeMirrorPopup editorWorker toggleContextMenuDelete */
|
||||
/* global
|
||||
$
|
||||
$$
|
||||
$create
|
||||
API
|
||||
clipString
|
||||
closeCurrentTab
|
||||
CodeMirror
|
||||
CODEMIRROR_THEMES
|
||||
debounce
|
||||
deepEqual
|
||||
DirtyReporter
|
||||
DocFuncMapper
|
||||
FIREFOX
|
||||
getOwnTab
|
||||
initBeautifyButton
|
||||
linter
|
||||
messageBox
|
||||
moveFocus
|
||||
msg
|
||||
onDOMready
|
||||
prefs
|
||||
rerouteHotkeys
|
||||
SectionsEditor
|
||||
sessionStorageHash
|
||||
setupLivePrefs
|
||||
SourceEditor
|
||||
t
|
||||
tHTML
|
||||
tryCatch
|
||||
tryJSONparse
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
// direct & reverse mapping of @-moz-document keywords and internal property names
|
||||
const propertyToCss = {urls: 'url', urlPrefixes: 'url-prefix', domains: 'domain', regexps: 'regexp'};
|
||||
const CssToProperty = Object.entries(propertyToCss)
|
||||
.reduce((o, v) => {
|
||||
o[v[1]] = v[0];
|
||||
return o;
|
||||
}, {});
|
||||
|
||||
let editor;
|
||||
/** @type {EditorBase|SourceEditor|SectionsEditor} */
|
||||
const editor = {
|
||||
isUsercss: false,
|
||||
previewDelay: 200, // Chrome devtools uses 200
|
||||
};
|
||||
let isWindowed;
|
||||
let scrollPointTimer;
|
||||
let headerHeight;
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnload);
|
||||
window.on('beforeunload', beforeUnload);
|
||||
msg.onExtension(onRuntimeMessage);
|
||||
|
||||
lazyInit();
|
||||
|
||||
(async function init() {
|
||||
const [style] = await Promise.all([
|
||||
initStyleData(),
|
||||
onDOMready(),
|
||||
prefs.initializing.then(() => new Promise(resolve => {
|
||||
const theme = prefs.get('editor.theme');
|
||||
const el = $('#cm-theme');
|
||||
if (theme === 'default') {
|
||||
resolve();
|
||||
} else {
|
||||
// preload the theme so CodeMirror can use the correct metrics
|
||||
el.href = `vendor/codemirror/theme/${theme}.css`;
|
||||
el.addEventListener('load', resolve, {once: true});
|
||||
}
|
||||
})),
|
||||
]);
|
||||
const usercss = isUsercss(style);
|
||||
const dirty = dirtyReporter();
|
||||
let wasDirty = false;
|
||||
let style;
|
||||
let nameTarget;
|
||||
|
||||
prefs.subscribe(['editor.linter'], updateLinter);
|
||||
prefs.subscribe(['editor.keyMap'], showHotkeyInTooltip);
|
||||
addEventListener('showHotkeyInTooltip', showHotkeyInTooltip);
|
||||
let wasDirty = false;
|
||||
const dirty = new DirtyReporter();
|
||||
await Promise.all([
|
||||
initStyle(),
|
||||
prefs.initializing
|
||||
.then(initTheme),
|
||||
onDOMready(),
|
||||
]);
|
||||
/** @namespace EditorBase */
|
||||
Object.assign(editor, {
|
||||
style,
|
||||
dirty,
|
||||
updateName,
|
||||
updateToc,
|
||||
toggleStyle,
|
||||
});
|
||||
prefs.subscribe('editor.linter', updateLinter);
|
||||
prefs.subscribe('editor.keyMap', showHotkeyInTooltip);
|
||||
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
|
||||
showHotkeyInTooltip();
|
||||
buildThemeElement();
|
||||
buildKeymapElement();
|
||||
|
@ -55,32 +75,57 @@ lazyInit();
|
|||
initBeautifyButton($('#beautify'), () => editor.getEditors());
|
||||
initResizeListener();
|
||||
detectLayout();
|
||||
updateTitle();
|
||||
|
||||
$('#heading').textContent = t(style.id ? 'editStyleHeading' : 'addStyleTitle');
|
||||
$('#preview-label').classList.toggle('hidden', !style.id);
|
||||
|
||||
editor = (usercss ? createSourceEditor : createSectionsEditor)({
|
||||
style,
|
||||
dirty,
|
||||
updateName,
|
||||
toggleStyle,
|
||||
});
|
||||
const toc = [];
|
||||
const elToc = $('#toc');
|
||||
elToc.onclick = e => editor.jumpToEditor([...elToc.children].indexOf(e.target));
|
||||
|
||||
(editor.isUsercss ? SourceEditor : SectionsEditor)();
|
||||
|
||||
prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {now: true});
|
||||
dirty.onChange(updateDirty);
|
||||
await editor.ready;
|
||||
|
||||
// enabling after init to prevent flash of validation failure on an empty name
|
||||
$('#name').required = !usercss;
|
||||
$('#name').required = !editor.isUsercss;
|
||||
$('#save-button').onclick = editor.save;
|
||||
|
||||
async function initStyle() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = Number(params.get('id'));
|
||||
style = id ? await API.getStyle(id) : initEmptyStyle(params);
|
||||
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
|
||||
editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
|
||||
document.documentElement.classList.toggle('usercss', editor.isUsercss);
|
||||
sessionStorage.justEditedStyleId = style.id || '';
|
||||
// no such style so let's clear the invalid URL parameters
|
||||
if (!style.id) history.replaceState({}, '', location.pathname);
|
||||
updateTitle(false);
|
||||
}
|
||||
|
||||
function initEmptyStyle(params) {
|
||||
return {
|
||||
name: params.get('domain') ||
|
||||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
|
||||
'',
|
||||
enabled: true,
|
||||
sections: [
|
||||
DocFuncMapper.toSection([...params], {code: ''}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function initNameArea() {
|
||||
const nameEl = $('#name');
|
||||
const resetEl = $('#reset-name');
|
||||
const isCustomName = style.updateUrl || usercss;
|
||||
const isCustomName = style.updateUrl || editor.isUsercss;
|
||||
nameTarget = isCustomName ? 'customName' : 'name';
|
||||
nameEl.placeholder = t(usercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
|
||||
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
|
||||
nameEl.title = isCustomName ? t('customNameHint') : '';
|
||||
nameEl.addEventListener('input', () => {
|
||||
nameEl.on('input', () => {
|
||||
updateName(true);
|
||||
resetEl.hidden = false;
|
||||
});
|
||||
|
@ -101,6 +146,38 @@ lazyInit();
|
|||
enabledEl.onchange = () => updateEnabledness(enabledEl.checked);
|
||||
}
|
||||
|
||||
function initResizeListener() {
|
||||
const {onBoundsChanged} = chrome.windows || {};
|
||||
if (onBoundsChanged) {
|
||||
// * movement is reported even if the window wasn't resized
|
||||
// * fired just once when done so debounce is not needed
|
||||
onBoundsChanged.addListener(wnd => {
|
||||
// getting the current window id as it may change if the user attached/detached the tab
|
||||
chrome.windows.getCurrent(ownWnd => {
|
||||
if (wnd.id === ownWnd.id) saveWindowPos();
|
||||
});
|
||||
});
|
||||
}
|
||||
window.on('resize', () => {
|
||||
if (!onBoundsChanged) debounce(saveWindowPos, 100);
|
||||
detectLayout();
|
||||
});
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
return new Promise(resolve => {
|
||||
const theme = prefs.get('editor.theme');
|
||||
const el = $('#cm-theme');
|
||||
if (theme === 'default') {
|
||||
resolve();
|
||||
} else {
|
||||
// preload the theme so CodeMirror can use the correct metrics
|
||||
el.href = `vendor/codemirror/theme/${theme}.css`;
|
||||
el.on('load', resolve, {once: true});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findKeyForCommand(command, map) {
|
||||
if (typeof map === 'string') map = CodeMirror.keyMap[map];
|
||||
let key = Object.keys(map).find(k => map[k] === command);
|
||||
|
@ -171,24 +248,6 @@ lazyInit();
|
|||
}
|
||||
}
|
||||
|
||||
function initResizeListener() {
|
||||
const {onBoundsChanged} = chrome.windows || {};
|
||||
if (onBoundsChanged) {
|
||||
// * movement is reported even if the window wasn't resized
|
||||
// * fired just once when done so debounce is not needed
|
||||
onBoundsChanged.addListener(wnd => {
|
||||
// getting the current window id as it may change if the user attached/detached the tab
|
||||
chrome.windows.getCurrent(ownWnd => {
|
||||
if (wnd.id === ownWnd.id) saveWindowPos();
|
||||
});
|
||||
});
|
||||
}
|
||||
window.addEventListener('resize', () => {
|
||||
if (!onBoundsChanged) debounce(saveWindowPos, 100);
|
||||
detectLayout();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStyle() {
|
||||
$('#enabled').checked = !style.enabled;
|
||||
updateEnabledness(!style.enabled);
|
||||
|
@ -217,17 +276,50 @@ lazyInit();
|
|||
dirty.modify('name', style[nameTarget] || style.name, value);
|
||||
style[nameTarget] = value;
|
||||
}
|
||||
updateTitle({});
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
function updateTitle() {
|
||||
document.title = `${dirty.isDirty() ? '* ' : ''}${style.customName || style.name}`;
|
||||
function updateTitle(isDirty = dirty.isDirty()) {
|
||||
document.title = `${isDirty ? '* ' : ''}${style.customName || style.name}`;
|
||||
}
|
||||
|
||||
function updateLinter(key, value) {
|
||||
$('body').classList.toggle('linter-disabled', value === '');
|
||||
linter.run();
|
||||
}
|
||||
|
||||
function updateToc(added = editor.sections) {
|
||||
const {sections} = editor;
|
||||
const first = sections.indexOf(added[0]);
|
||||
let el = elToc.children[first];
|
||||
if (added.focus) {
|
||||
const cls = 'current';
|
||||
const old = $('.' + cls, elToc);
|
||||
if (old && old !== el) old.classList.remove(cls);
|
||||
el.classList.add(cls);
|
||||
return;
|
||||
}
|
||||
if (first >= 0) {
|
||||
for (let i = first; i < sections.length; i++) {
|
||||
const entry = sections[i].tocEntry;
|
||||
if (!deepEqual(entry, toc[i])) {
|
||||
if (!el) el = elToc.appendChild($create('li', {tabIndex: 0}));
|
||||
el.tabIndex = entry.removed ? -1 : 0;
|
||||
toc[i] = Object.assign({}, entry);
|
||||
const s = el.textContent = clipString(entry.label) || (
|
||||
entry.target == null
|
||||
? t('appliesToEverything')
|
||||
: clipString(entry.target) + (entry.numTargets > 1 ? ', ...' : ''));
|
||||
if (s.length > 30) el.title = s;
|
||||
}
|
||||
el = el.nextElementSibling;
|
||||
}
|
||||
}
|
||||
while (toc.length > sections.length) {
|
||||
elToc.lastElementChild.remove();
|
||||
toc.length--;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
/* Stuff not needed for the main init so we can let it run at its own tempo */
|
||||
|
@ -330,53 +422,6 @@ function beforeUnload(e) {
|
|||
}
|
||||
}
|
||||
|
||||
function isUsercss(style) {
|
||||
return (
|
||||
style.usercssData ||
|
||||
!style.id && prefs.get('newStyleAsUsercss')
|
||||
);
|
||||
}
|
||||
|
||||
function initStyleData() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = Number(params.get('id'));
|
||||
const createEmptyStyle = () => ({
|
||||
name: params.get('domain') ||
|
||||
tryCatch(() => new URL(params.get('url-prefix')).hostname) ||
|
||||
'',
|
||||
enabled: true,
|
||||
sections: [
|
||||
Object.assign({code: ''},
|
||||
...Object.keys(CssToProperty)
|
||||
.map(name => ({
|
||||
[CssToProperty[name]]: params.get(name) && [params.get(name)] || []
|
||||
}))
|
||||
)
|
||||
],
|
||||
});
|
||||
return fetchStyle()
|
||||
.then(style => {
|
||||
if (style.id) 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 (!style.id) {
|
||||
history.replaceState({}, document.title, location.pathname);
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
function fetchStyle() {
|
||||
if (id) {
|
||||
return API.getStyle(id);
|
||||
}
|
||||
return Promise.resolve(createEmptyStyle());
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp(title = '', body) {
|
||||
const div = $('#help-popup');
|
||||
div.className = '';
|
||||
|
@ -419,11 +464,11 @@ function showHelp(title = '', body) {
|
|||
div.style.display = '';
|
||||
contents.textContent = '';
|
||||
clearTimeout(contents.timer);
|
||||
window.removeEventListener('keydown', showHelp.close, true);
|
||||
window.off('keydown', showHelp.close, true);
|
||||
window.dispatchEvent(new Event('closeHelp'));
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', showHelp.close, true);
|
||||
window.on('keydown', showHelp.close, true);
|
||||
$('.dismiss', div).onclick = showHelp.close;
|
||||
|
||||
// reset any inline styles
|
||||
|
@ -433,6 +478,7 @@ function showHelp(title = '', body) {
|
|||
return div;
|
||||
}
|
||||
|
||||
/* exported showCodeMirrorPopup */
|
||||
function showCodeMirrorPopup(title, html, options) {
|
||||
const popup = showHelp(title, html);
|
||||
popup.classList.add('big');
|
||||
|
@ -462,10 +508,10 @@ function showCodeMirrorPopup(title, html, options) {
|
|||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
window.on('keydown', onKeyDown, true);
|
||||
|
||||
window.addEventListener('closeHelp', () => {
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.on('closeHelp', () => {
|
||||
window.off('keydown', onKeyDown, true);
|
||||
document.documentElement.style.removeProperty('pointer-events');
|
||||
rerouteHotkeys(true);
|
||||
cm = popup.codebox = null;
|
||||
|
@ -493,59 +539,32 @@ function saveWindowPos() {
|
|||
}
|
||||
|
||||
function fixedHeader() {
|
||||
const scrollPoint = $('#header').clientHeight - 40;
|
||||
const linterEnabled = prefs.get('editor.linter') !== '';
|
||||
if (window.scrollY >= scrollPoint && !$('.fixed-header') && linterEnabled) {
|
||||
const headerFixed = $('.fixed-header');
|
||||
if (!headerFixed) headerHeight = $('#header').clientHeight;
|
||||
const scrollPoint = headerHeight - 43;
|
||||
if (window.scrollY >= scrollPoint && !headerFixed) {
|
||||
$('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
|
||||
$('body').classList.add('fixed-header');
|
||||
} else if (window.scrollY < 40 && linterEnabled) {
|
||||
} else if (window.scrollY < scrollPoint && headerFixed) {
|
||||
$('body').classList.remove('fixed-header');
|
||||
}
|
||||
}
|
||||
|
||||
function detectLayout() {
|
||||
const body = $('body');
|
||||
const options = $('#options');
|
||||
const lint = $('#lint');
|
||||
const compact = window.innerWidth <= 850;
|
||||
const shortViewportLinter = window.innerHeight < 692;
|
||||
const shortViewportNoLinter = window.innerHeight < 554;
|
||||
const linterEnabled = prefs.get('editor.linter') !== '';
|
||||
if (compact) {
|
||||
body.classList.add('compact-layout');
|
||||
options.removeAttribute('open');
|
||||
options.classList.add('ignore-pref');
|
||||
lint.removeAttribute('open');
|
||||
lint.classList.add('ignore-pref');
|
||||
if (!$('.usercss')) {
|
||||
clearTimeout(scrollPointTimer);
|
||||
scrollPointTimer = setTimeout(() => {
|
||||
const scrollPoint = $('#header').clientHeight - 40;
|
||||
if (window.scrollY >= scrollPoint && !$('.fixed-header') && linterEnabled) {
|
||||
body.classList.add('fixed-header');
|
||||
}
|
||||
}, 250);
|
||||
window.addEventListener('scroll', fixedHeader, {passive: true});
|
||||
document.body.classList.add('compact-layout');
|
||||
if (!editor.isUsercss) {
|
||||
debounce(fixedHeader, 250);
|
||||
window.on('scroll', fixedHeader, {passive: true});
|
||||
}
|
||||
} else {
|
||||
body.classList.remove('compact-layout');
|
||||
body.classList.remove('fixed-header');
|
||||
window.removeEventListener('scroll', fixedHeader);
|
||||
if (shortViewportLinter && linterEnabled || shortViewportNoLinter && !linterEnabled) {
|
||||
options.removeAttribute('open');
|
||||
options.classList.add('ignore-pref');
|
||||
if (prefs.get('editor.lint.expanded')) {
|
||||
lint.setAttribute('open', '');
|
||||
}
|
||||
} else {
|
||||
options.classList.remove('ignore-pref');
|
||||
lint.classList.remove('ignore-pref');
|
||||
if (prefs.get('editor.options.expanded')) {
|
||||
options.setAttribute('open', '');
|
||||
}
|
||||
if (prefs.get('editor.lint.expanded')) {
|
||||
lint.setAttribute('open', '');
|
||||
}
|
||||
document.body.classList.remove('compact-layout', 'fixed-header');
|
||||
window.off('scroll', fixedHeader);
|
||||
}
|
||||
for (const type of ['options', 'toc', 'lint']) {
|
||||
const el = $(`details[data-pref="editor.${type}.expanded"]`);
|
||||
el.open = compact ? false : prefs.get(el.dataset.pref);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -562,14 +581,3 @@ function isWindowMaximized() {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,7 +79,7 @@ onDOMready().then(() => {
|
|||
doReplace();
|
||||
return;
|
||||
}
|
||||
return !event.target.closest(focusAccessibility.ELEMENTS.join(','));
|
||||
return !focusAccessibility.closest(event.target);
|
||||
},
|
||||
'Esc': () => {
|
||||
destroyDialog({restoreFocus: true});
|
||||
|
|
|
@ -2,8 +2,11 @@
|
|||
/* exported createMetaCompiler */
|
||||
'use strict';
|
||||
|
||||
function createMetaCompiler(cm) {
|
||||
const updateListeners = [];
|
||||
/**
|
||||
* @param {CodeMirror} cm
|
||||
* @param {function(meta:Object)} onUpdated
|
||||
*/
|
||||
function createMetaCompiler(cm, onUpdated) {
|
||||
let meta = null;
|
||||
let metaIndex = null;
|
||||
let cache = [];
|
||||
|
@ -22,9 +25,7 @@ function createMetaCompiler(cm) {
|
|||
return editorWorker.metalint(match[0])
|
||||
.then(({metadata, errors}) => {
|
||||
if (errors.every(err => err.code === 'unknownMeta')) {
|
||||
for (const cb of updateListeners) {
|
||||
cb(metadata);
|
||||
}
|
||||
onUpdated(metadata);
|
||||
}
|
||||
cache = errors.map(err =>
|
||||
({
|
||||
|
@ -40,8 +41,4 @@ function createMetaCompiler(cm) {
|
|||
return cache;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
onUpdated: cb => updateListeners.push(cb)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
/* exported createLivePreview */
|
||||
'use strict';
|
||||
|
||||
function createLivePreview(preprocess) {
|
||||
function createLivePreview(preprocess, shouldShow) {
|
||||
let data;
|
||||
let previewer;
|
||||
let enabled = prefs.get('editor.livePreview');
|
||||
|
@ -20,6 +20,7 @@ function createLivePreview(preprocess) {
|
|||
}
|
||||
enabled = value;
|
||||
});
|
||||
if (shouldShow != null) show(shouldShow);
|
||||
return {update, show};
|
||||
|
||||
function show(state) {
|
||||
|
|
386
edit/moz-section-finder.js
Normal file
386
edit/moz-section-finder.js
Normal file
|
@ -0,0 +1,386 @@
|
|||
/* global
|
||||
CodeMirror
|
||||
debounce
|
||||
deepEqual
|
||||
trimCommentLabel
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
/* exported MozSectionFinder */
|
||||
function MozSectionFinder(cm) {
|
||||
const KEY = 'MozSectionFinder';
|
||||
const MOZ_DOC_LEN = '@-moz-document'.length;
|
||||
const rxDOC = /@-moz-document(\s+|$)/ig;
|
||||
const rxFUNC = /(url|url-prefix|domain|regexp)\(/iy;
|
||||
const rxQUOTE = /['"]/y;
|
||||
const rxSPACE = /\s+/y;
|
||||
const rxTokDOC = /^(?!comment|string)/;
|
||||
const rxTokCOMMENT = /^comment(\s|$)/;
|
||||
const rxTokSTRING = /^string(\s|$)/;
|
||||
const {cmpPos} = CodeMirror;
|
||||
const minPos = (a, b) => cmpPos(a, b) < 0 ? a : b;
|
||||
const maxPos = (a, b) => cmpPos(a, b) > 0 ? a : b;
|
||||
const keptAlive = new Map();
|
||||
/** @type {CodeMirror.Pos} */
|
||||
let updFrom;
|
||||
/** @type {CodeMirror.Pos} */
|
||||
let updTo;
|
||||
|
||||
const MozSectionFinder = {
|
||||
IGNORE_ORIGIN: KEY,
|
||||
EQ_SKIP_KEYS: [
|
||||
'mark',
|
||||
'valueStart',
|
||||
'valueEnd',
|
||||
'sticky', // added by TextMarker::find()
|
||||
],
|
||||
get sections() {
|
||||
return getState().sections;
|
||||
},
|
||||
keepAliveFor(id, ms) {
|
||||
let data = keptAlive.get(id);
|
||||
if (data) {
|
||||
clearTimeout(data.timer);
|
||||
} else {
|
||||
const NOP = () => 0;
|
||||
data = {fn: NOP};
|
||||
keptAlive.set(id, data);
|
||||
MozSectionFinder.on(NOP);
|
||||
}
|
||||
data.timer = setTimeout(id => keptAlive.delete(id), ms, id);
|
||||
},
|
||||
on(fn) {
|
||||
const {listeners} = getState();
|
||||
const needsInit = !listeners.size;
|
||||
listeners.add(fn);
|
||||
if (needsInit) {
|
||||
cm.on('changes', onCmChanges);
|
||||
update();
|
||||
}
|
||||
},
|
||||
off(fn) {
|
||||
const {listeners, sections} = getState();
|
||||
if (listeners.size) {
|
||||
listeners.delete(fn);
|
||||
if (!listeners.size) {
|
||||
cm.off('changes', onCmChanges);
|
||||
cm.operation(() => sections.forEach(sec => sec.mark.clear()));
|
||||
sections.length = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
onOff(fn, enable) {
|
||||
MozSectionFinder[enable ? 'on' : 'off'](fn);
|
||||
},
|
||||
/** @param {MozSection} [section] */
|
||||
updatePositions(section) {
|
||||
(section ? [section] : getState().sections).forEach(setPositionFromMark);
|
||||
}
|
||||
};
|
||||
return MozSectionFinder;
|
||||
|
||||
/** @returns {MozSectionCmState} */
|
||||
function getState() {
|
||||
let state = cm.state[KEY];
|
||||
if (!state) {
|
||||
state = cm.state[KEY] = /** @namespace MozSectionCmState */ {
|
||||
/** @type {Set<function>} */
|
||||
listeners: new Set(),
|
||||
/** @type {MozSection[]} */
|
||||
sections: [],
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function onCmChanges(cm, changes) {
|
||||
if (!updFrom) updFrom = {line: Infinity, ch: 0};
|
||||
if (!updTo) updTo = {line: -1, ch: 0};
|
||||
for (const c of changes) {
|
||||
if (c.origin !== MozSectionFinder.IGNORE_ORIGIN) {
|
||||
updFrom = minPos(c.from, updFrom);
|
||||
updTo = maxPos(CodeMirror.changeEnd(c), updTo);
|
||||
}
|
||||
}
|
||||
if (updTo.line >= 0) {
|
||||
debounce(update);
|
||||
}
|
||||
}
|
||||
|
||||
function update(
|
||||
from = updFrom || {line: 0, ch: 0},
|
||||
to = updTo || {line: cm.doc.size, ch: 0}
|
||||
) {
|
||||
updFrom = updTo = null;
|
||||
const {sections, listeners} = getState();
|
||||
let cutAt = -1;
|
||||
let cutTo = -1;
|
||||
for (let i = 0, sec; (sec = sections[i]); i++) {
|
||||
if (cmpPos(sec.end, from) >= 0) {
|
||||
if (cutAt < 0) {
|
||||
cutAt = i;
|
||||
from = minPos(from, sec.start);
|
||||
}
|
||||
// Sections that start/end after `from` may have incorrect positions
|
||||
if (setPositionFromMark(sec)) {
|
||||
if (cmpPos(sec.start, to) > 0) {
|
||||
cutTo = i;
|
||||
break;
|
||||
}
|
||||
to = maxPos(sec.end, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cutAt < 0) from.ch = Math.max(0, from.ch - MOZ_DOC_LEN);
|
||||
if (cutTo < 0) to.ch += MOZ_DOC_LEN;
|
||||
const added = findSections(from, to);
|
||||
if (!added.length && cutAt < 0 && cutTo < 0) {
|
||||
return;
|
||||
}
|
||||
if (cutTo < 0) {
|
||||
cutTo = sections.length;
|
||||
}
|
||||
let op;
|
||||
let reusedAtStart = 0;
|
||||
const removed = sections.slice(cutAt, cutTo);
|
||||
for (const sec of added) {
|
||||
const i = removed.findIndex(isSameSection, sec);
|
||||
if (i >= 0) {
|
||||
removed[i].funcs = sec.funcs; // use the new valueStart, valueEnd
|
||||
removed[i] = null;
|
||||
if (!op) reusedAtStart++;
|
||||
} else {
|
||||
if (!op) op = cm.curOp || (cm.startOperation(), true);
|
||||
sec.mark = cm.markText(sec.start, sec.end, {
|
||||
clearWhenEmpty: false,
|
||||
inclusiveRight: true,
|
||||
[KEY]: sec,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (reusedAtStart) {
|
||||
cutAt += reusedAtStart;
|
||||
added.splice(0, reusedAtStart);
|
||||
}
|
||||
for (const sec of removed) {
|
||||
if (sec) {
|
||||
if (!op) op = cm.curOp || (cm.startOperation(), true);
|
||||
sec.mark.clear();
|
||||
}
|
||||
}
|
||||
if (op) {
|
||||
sections.splice(cutAt, cutTo - cutAt, ...added);
|
||||
listeners.forEach(fn => fn(added, removed, cutAt, cutTo));
|
||||
}
|
||||
if (op === true) {
|
||||
cm.endOperation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CodeMirror.Pos} from
|
||||
* @param {CodeMirror.Pos} to
|
||||
* @returns MozSection[]
|
||||
*/
|
||||
function findSections(from, to) {
|
||||
/** @type MozSection[] */
|
||||
const found = [];
|
||||
let line = from.line - 1;
|
||||
let goal = '';
|
||||
let section, func, funcPos, url;
|
||||
/** @type {MozSectionFunc[]} */
|
||||
let funcs;
|
||||
// will stop after to.line if there's no goal anymore, see `return true` below
|
||||
cm.eachLine(from.line, cm.doc.size, handle => {
|
||||
++line;
|
||||
const {text} = handle;
|
||||
const len = text.length;
|
||||
if (!len) {
|
||||
return;
|
||||
}
|
||||
let ch = line === from.line ? from.ch : 0;
|
||||
while (true) {
|
||||
let m;
|
||||
if (!goal) {
|
||||
// useful for minified styles with long lines
|
||||
if ((line - to.line || ch - to.ch) >= 0) {
|
||||
return true;
|
||||
}
|
||||
if ((ch = text.indexOf('@-', ch)) < 0 ||
|
||||
!(rxDOC.lastIndex = ch, m = rxDOC.exec(text))) {
|
||||
return;
|
||||
}
|
||||
ch = m.index + m[0].length;
|
||||
section = /** @namespace MozSection */ {
|
||||
funcs: funcs = [],
|
||||
start: {line, ch: m.index},
|
||||
end: null,
|
||||
mark: null,
|
||||
tocEntry: {
|
||||
label: '',
|
||||
target: null,
|
||||
numTargets: 0,
|
||||
},
|
||||
};
|
||||
if (rxTokDOC.test(cm.getTokenTypeAt(section.start))) {
|
||||
found.push(section);
|
||||
goal = '_func';
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!handle.styles) cm.getTokenTypeAt({line, ch: 0});
|
||||
const {styles} = handle;
|
||||
let j = 1;
|
||||
if (ch) {
|
||||
j += styles.length * ch / len & ~1;
|
||||
while (styles[j - 2] >= ch) j -= 2;
|
||||
while (styles[j] <= ch) j += 2;
|
||||
}
|
||||
let type;
|
||||
for (; goal && j < styles.length; ch = styles[j], j += 2) {
|
||||
let s;
|
||||
type = styles[j + 1];
|
||||
if (goal.startsWith('_')) {
|
||||
if (!type && (rxSPACE.lastIndex = ch, rxSPACE.test(text))) {
|
||||
ch = rxSPACE.lastIndex;
|
||||
if (ch === styles[j]) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const isCmt = type && rxTokCOMMENT.test(type);
|
||||
if (goal === '_cmt') {
|
||||
const cmt = trimCommentLabel(text.slice(ch, styles[j]));
|
||||
if (isCmt && cmt) section.tocEntry.label = cmt;
|
||||
if (!isCmt || cmt) goal = '';
|
||||
continue;
|
||||
}
|
||||
if (isCmt) {
|
||||
continue;
|
||||
}
|
||||
goal = goal.slice(1);
|
||||
}
|
||||
if (goal === 'func') {
|
||||
if (!type || !(rxFUNC.lastIndex = ch, m = rxFUNC.exec(text))) {
|
||||
goal = 'error';
|
||||
break;
|
||||
}
|
||||
func = m[1];
|
||||
funcPos = {line, ch};
|
||||
url = null;
|
||||
goal = '_str';
|
||||
// Tokens in `styles` are split into multiple items due to `overlay`.
|
||||
while (styles[j + 2] <= ch + func.length + 1) j += 2;
|
||||
}
|
||||
if (goal === 'str') {
|
||||
if (!rxTokSTRING.test(type)) {
|
||||
if (url && !url.quote && !type && text[ch] === ')') {
|
||||
goal = ')';
|
||||
} else {
|
||||
goal = 'error';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!url) {
|
||||
s = (rxQUOTE.lastIndex = ch, rxQUOTE.test(text)) && text[ch];
|
||||
url = {
|
||||
chunks: [],
|
||||
start: {line, ch: s ? ch + 1 : ch},
|
||||
end: null,
|
||||
quote: s,
|
||||
};
|
||||
}
|
||||
s = text.slice(ch, styles[j]);
|
||||
url.chunks.push(s);
|
||||
url.end = {line, ch: styles[j]};
|
||||
// CSS strings can span multiple lines.
|
||||
// Tokens in `styles` are split into multiple items due to `overlay`.
|
||||
if (url.quote && s.endsWith(url.quote) && s[s.length - 2] !== '\\') {
|
||||
url.end.ch--;
|
||||
goal = '_)';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (goal === ')') {
|
||||
if (text[ch] !== ')') {
|
||||
goal = 'error';
|
||||
break;
|
||||
}
|
||||
ch++;
|
||||
s = url.chunks.join('');
|
||||
if (url.quote) s = s.slice(1, -1);
|
||||
if (!funcs.length) section.tocEntry.target = s;
|
||||
section.tocEntry.numTargets++;
|
||||
funcs.push(/** @namespace MozSectionFunc */ {
|
||||
type: func,
|
||||
value: s,
|
||||
isQuoted: url.quote,
|
||||
start: funcPos,
|
||||
end: {line, ch},
|
||||
valueStart: url.start,
|
||||
valueEnd: url.end,
|
||||
});
|
||||
s = text.slice(ch, styles[j]).trim();
|
||||
goal = s.startsWith(',') ? '_func' :
|
||||
s.startsWith('{') ? '_cmt' :
|
||||
!s && '_,'; // non-space something at this place = syntax error
|
||||
if (!goal) {
|
||||
goal = 'error';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (goal === ',') {
|
||||
goal = text[ch] === ',' ? '_func' : '';
|
||||
}
|
||||
}
|
||||
section.end = {line, ch: styles[j + 2] || len};
|
||||
// at this point it's either an error...
|
||||
if (goal === 'error') {
|
||||
goal = '';
|
||||
section.funcs.length = 0;
|
||||
}
|
||||
// ...or a EOL, in which case we'll advance to the next line
|
||||
if (goal) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MozSection|MozSectionFunc} obj
|
||||
* @returns {?{from:CodeMirror.Pos, to:CodeMirror.Pos}} falsy if marker was removed
|
||||
*/
|
||||
function setPositionFromMark(obj) {
|
||||
const pos = obj.mark.find();
|
||||
obj.start = pos && pos.from;
|
||||
obj.end = pos && pos.to;
|
||||
return pos;
|
||||
}
|
||||
|
||||
/**
|
||||
* @this {MozSection} new section
|
||||
* @param {MozSection} old
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSameSection(old) {
|
||||
return old &&
|
||||
old.start &&
|
||||
old.tocEntry.label === this.tocEntry.label &&
|
||||
!cmpPos(old.start, this.start) &&
|
||||
!cmpPos(old.end, this.end) &&
|
||||
old.funcs.length === this.funcs.length &&
|
||||
old.funcs.every(isSameFunc, this.funcs);
|
||||
}
|
||||
|
||||
/** @this {MozSectionFunc[]} new functions */
|
||||
function isSameFunc(func, i) {
|
||||
return deepEqual(func, this[i], MozSectionFinder.EQ_SKIP_KEYS);
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef CodeMirror.Pos
|
||||
* @property {number} line
|
||||
* @property {number} ch
|
||||
*/
|
447
edit/moz-section-widget.js
Normal file
447
edit/moz-section-widget.js
Normal file
|
@ -0,0 +1,447 @@
|
|||
/* global
|
||||
$
|
||||
$create
|
||||
CodeMirror
|
||||
colorMimicry
|
||||
messageBox
|
||||
MozSectionFinder
|
||||
msg
|
||||
prefs
|
||||
regExpTester
|
||||
t
|
||||
template
|
||||
tryCatch
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
/* exported MozSectionWidget */
|
||||
function MozSectionWidget(
|
||||
cm,
|
||||
finder = MozSectionFinder(cm),
|
||||
onDirectChange = () => 0
|
||||
) {
|
||||
let TPL, EVENTS, CLICK_ROUTE;
|
||||
const KEY = 'MozSectionWidget';
|
||||
const C_CONTAINER = '.applies-to';
|
||||
const C_LABEL = 'label';
|
||||
const C_LIST = '.applies-to-list';
|
||||
const C_ITEM = '.applies-to-item';
|
||||
const C_TYPE = '.applies-type';
|
||||
const C_VALUE = '.applies-value';
|
||||
/** @returns {MarkedFunc} */
|
||||
const getFuncFor = el => el.closest(C_ITEM)[KEY];
|
||||
/** @returns {MarkedFunc[]} */
|
||||
const getFuncsFor = el => el.closest(C_LIST)[KEY];
|
||||
/** @returns {MozSection} */
|
||||
const getSectionFor = el => el.closest(C_CONTAINER)[KEY];
|
||||
const {cmpPos} = CodeMirror;
|
||||
let enabled = false;
|
||||
let funcHeight = 0;
|
||||
let actualStyle;
|
||||
return {
|
||||
toggle(enable) {
|
||||
if (Boolean(enable) !== enabled) {
|
||||
(enable ? init : destroy)();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function init() {
|
||||
enabled = true;
|
||||
TPL = {
|
||||
container:
|
||||
$create('div' + C_CONTAINER, [
|
||||
$create(C_LABEL, t('appliesLabel')),
|
||||
$create('ul' + C_LIST),
|
||||
]),
|
||||
listItem:
|
||||
template.appliesTo.cloneNode(true),
|
||||
appliesToEverything:
|
||||
$create('li.applies-to-everything', t('appliesToEverything')),
|
||||
};
|
||||
|
||||
$(C_VALUE, TPL.listItem).after(
|
||||
$create('button.test-regexp', t('styleRegexpTestButton')));
|
||||
|
||||
CLICK_ROUTE = {
|
||||
'.test-regexp': showRegExpTester,
|
||||
/**
|
||||
* @param {HTMLElement} elItem
|
||||
* @param {MarkedFunc} func
|
||||
*/
|
||||
'.remove-applies-to'(elItem, func) {
|
||||
const funcs = getFuncsFor(elItem);
|
||||
if (funcs.length < 2) {
|
||||
messageBox({
|
||||
contents: t('appliesRemoveError'),
|
||||
buttons: [t('confirmClose')]
|
||||
});
|
||||
return;
|
||||
}
|
||||
const i = funcs.indexOf(func);
|
||||
const next = funcs[i + 1];
|
||||
const from = i ? funcs[i - 1].item.find(1) : func.item.find(-1);
|
||||
const to = next ? next.item.find(-1) : func.item.find(1);
|
||||
cm.replaceRange(i && next ? ', ' : '', from, to);
|
||||
},
|
||||
/**
|
||||
* @param {HTMLElement} elItem
|
||||
* @param {MarkedFunc} func
|
||||
*/
|
||||
'.add-applies-to'(elItem, func) {
|
||||
const pos = func.item.find(1);
|
||||
cm.replaceRange(`, ${func.typeText}("")`, pos, pos);
|
||||
},
|
||||
};
|
||||
|
||||
EVENTS = {
|
||||
onchange({target: el}) {
|
||||
EVENTS.oninput({target: el.closest(C_TYPE) || el});
|
||||
},
|
||||
oninput({target: el}) {
|
||||
const part =
|
||||
el.matches(C_VALUE) && 'value' ||
|
||||
el.matches(C_TYPE) && 'type';
|
||||
if (!part) return;
|
||||
const func = getFuncFor(el);
|
||||
const pos = func[part].find();
|
||||
if (part === 'type' && el.value !== func.typeText) {
|
||||
func.typeText = func.item[KEY].dataset.type = el.value;
|
||||
}
|
||||
if (part === 'value' && func === getFuncsFor(el)[0]) {
|
||||
const sec = getSectionFor(el);
|
||||
sec.tocEntry.target = el.value;
|
||||
if (!sec.tocEntry.label) onDirectChange([sec]);
|
||||
}
|
||||
cm.replaceRange(toDoubleslash(el.value), pos.from, pos.to, finder.IGNORE_ORIGIN);
|
||||
},
|
||||
onclick(event) {
|
||||
const {target} = event;
|
||||
for (const selector in CLICK_ROUTE) {
|
||||
const routed = target.closest(selector);
|
||||
if (routed) {
|
||||
const elItem = routed.closest(C_ITEM);
|
||||
CLICK_ROUTE[selector](elItem, elItem[KEY], event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
actualStyle = $create('style');
|
||||
|
||||
cm.on('optionChange', onCmOption);
|
||||
msg.onExtension(onRuntimeMessage);
|
||||
if (finder.sections.length) {
|
||||
update(finder.sections, []);
|
||||
}
|
||||
finder.on(update);
|
||||
requestAnimationFrame(updateWidgetStyle);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
enabled = false;
|
||||
cm.off('optionChange', onCmOption);
|
||||
msg.off(onRuntimeMessage);
|
||||
actualStyle.remove();
|
||||
actualStyle = null;
|
||||
cm.operation(() => finder.sections.forEach(killWidget));
|
||||
finder.off(update);
|
||||
}
|
||||
|
||||
function onCmOption(cm, option) {
|
||||
if (option === 'theme') {
|
||||
updateWidgetStyle();
|
||||
}
|
||||
}
|
||||
|
||||
function onRuntimeMessage(msg) {
|
||||
if (msg.reason === 'editPreview' && !$(`#stylus-${msg.style.id}`)) {
|
||||
// no style element with this id means the style doesn't apply to the editor URL
|
||||
return;
|
||||
}
|
||||
if (msg.style || msg.styles ||
|
||||
msg.prefs && 'disableAll' in msg.prefs ||
|
||||
msg.method === 'styleDeleted') {
|
||||
requestAnimationFrame(updateWidgetStyle);
|
||||
}
|
||||
}
|
||||
|
||||
function updateWidgetStyle() {
|
||||
funcHeight = 0;
|
||||
if (prefs.get('editor.theme') !== 'default' &&
|
||||
!tryCatch(() => $('#cm-theme').sheet.cssRules)) {
|
||||
requestAnimationFrame(updateWidgetStyle);
|
||||
return;
|
||||
}
|
||||
const MIN_LUMA = .05;
|
||||
const MIN_LUMA_DIFF = .4;
|
||||
const color = {
|
||||
wrapper: colorMimicry.get(cm.display.wrapper),
|
||||
gutter: colorMimicry.get(cm.display.gutters, {
|
||||
bg: 'backgroundColor',
|
||||
border: 'borderRightColor',
|
||||
}),
|
||||
line: colorMimicry.get('.CodeMirror-linenumber', null, cm.display.lineDiv),
|
||||
comment: colorMimicry.get('span.cm-comment', null, cm.display.lineDiv),
|
||||
};
|
||||
const hasBorder =
|
||||
color.gutter.style.borderRightWidth !== '0px' &&
|
||||
!/transparent|\b0\)/g.test(color.gutter.style.borderRightColor);
|
||||
const diff = {
|
||||
wrapper: Math.abs(color.gutter.bgLuma - color.wrapper.foreLuma),
|
||||
border: hasBorder ? Math.abs(color.gutter.bgLuma - color.gutter.borderLuma) : 0,
|
||||
line: Math.abs(color.gutter.bgLuma - color.line.foreLuma),
|
||||
};
|
||||
const preferLine = diff.line > diff.wrapper || diff.line > MIN_LUMA_DIFF;
|
||||
const fore = preferLine ? color.line.fore : color.wrapper.fore;
|
||||
|
||||
const border = fore.replace(/[\d.]+(?=\))/, MIN_LUMA_DIFF / 2);
|
||||
const borderStyleForced = `1px ${hasBorder ? color.gutter.style.borderRightStyle : 'solid'} ${border}`;
|
||||
|
||||
actualStyle.textContent = `
|
||||
${C_CONTAINER} {
|
||||
background-color: ${color.gutter.bg};
|
||||
border-top: ${borderStyleForced};
|
||||
border-bottom: ${borderStyleForced};
|
||||
}
|
||||
${C_CONTAINER} ${C_LABEL} {
|
||||
color: ${fore};
|
||||
}
|
||||
${C_CONTAINER} input,
|
||||
${C_CONTAINER} button,
|
||||
${C_CONTAINER} select {
|
||||
background: rgba(255, 255, 255, ${
|
||||
Math.max(MIN_LUMA, Math.pow(Math.max(0, color.gutter.bgLuma - MIN_LUMA * 2), 2)).toFixed(2)
|
||||
});
|
||||
border: ${borderStyleForced};
|
||||
transition: none;
|
||||
color: ${fore};
|
||||
}
|
||||
${C_CONTAINER} .svg-icon.select-arrow {
|
||||
fill: ${fore};
|
||||
transition: none;
|
||||
}
|
||||
`;
|
||||
document.documentElement.appendChild(actualStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MozSection[]} added
|
||||
* @param {MozSection[]} removed
|
||||
* @param {number} cutAt
|
||||
*/
|
||||
function update(added, removed, cutAt = finder.sections.indexOf(added[0])) {
|
||||
const isDelayed = added.isDelayed && (cm.startOperation(), true);
|
||||
const toDelay = [];
|
||||
const t0 = performance.now();
|
||||
let {viewFrom, viewTo} = cm.display;
|
||||
for (const sec of added) {
|
||||
const i = removed.findIndex(isReusableWidget, sec);
|
||||
const old = removed[i];
|
||||
if (isDelayed || old || sec.end.line >= viewFrom && sec.start.line < viewTo) {
|
||||
renderWidget(sec, old);
|
||||
viewTo -= (sec.funcs.length || 1) * 1.25;
|
||||
if (old) removed[i] = null;
|
||||
if (performance.now() - t0 > 50) {
|
||||
toDelay.push(...added.slice(added.indexOf(sec) + 1));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
toDelay.push(sec);
|
||||
}
|
||||
}
|
||||
// renumber
|
||||
for (let i = Math.max(0, cutAt), {sections} = finder, sec; (sec = sections[i++]);) {
|
||||
if (!toDelay.includes(sec)) {
|
||||
const data = $(C_LABEL, sec.widget.node).dataset;
|
||||
if (data.index !== `${i}`) data.index = `${i}`;
|
||||
}
|
||||
}
|
||||
if (toDelay.length) {
|
||||
toDelay.isDelayed = true;
|
||||
setTimeout(update, 0, toDelay, removed);
|
||||
} else {
|
||||
removed.forEach(killWidget);
|
||||
}
|
||||
if (isDelayed) cm.endOperation();
|
||||
}
|
||||
|
||||
/** @this {MozSection} */
|
||||
function isReusableWidget(r) {
|
||||
return r &&
|
||||
r.widget &&
|
||||
r.widget.line.parent &&
|
||||
r.start &&
|
||||
!cmpPos(r.start, this.start);
|
||||
}
|
||||
|
||||
function renderWidget(sec, old) {
|
||||
let widget = old && old.widget;
|
||||
const height = funcHeight * (sec.funcs.length || 1) || undefined;
|
||||
const node = renderContainer(sec, widget);
|
||||
if (widget) {
|
||||
widget.node = node;
|
||||
if (height && height !== widget.height) {
|
||||
widget.height = height;
|
||||
widget.changed();
|
||||
}
|
||||
} else {
|
||||
widget = cm.addLineWidget(sec.start.line, node, {
|
||||
coverGutter: true,
|
||||
noHScroll: true,
|
||||
above: true,
|
||||
height,
|
||||
});
|
||||
}
|
||||
if (!funcHeight) {
|
||||
funcHeight = node.offsetHeight / (sec.funcs.length || 1);
|
||||
}
|
||||
setProp(sec, 'widget', widget);
|
||||
return widget;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MozSection} sec
|
||||
* @param {LineWidget} oldWidget
|
||||
* @returns {Node}
|
||||
*/
|
||||
function renderContainer(sec, oldWidget) {
|
||||
const container = oldWidget ? oldWidget.node : TPL.container.cloneNode(true);
|
||||
const elList = $(C_LIST, container);
|
||||
const {funcs} = sec;
|
||||
const oldItems = elList[KEY] || false;
|
||||
const items = funcs.map((f, i) => renderFunc(f, oldItems[i]));
|
||||
let slot = elList.firstChild;
|
||||
for (const {item} of items) {
|
||||
const el = item[KEY];
|
||||
if (el !== slot) {
|
||||
elList.insertBefore(el, slot);
|
||||
if (slot) slot.remove();
|
||||
slot = el;
|
||||
}
|
||||
slot = slot.nextSibling;
|
||||
}
|
||||
for (let i = funcs.length; oldItems && i < oldItems.length; i++) {
|
||||
killFunc(oldItems[i]);
|
||||
if (slot) {
|
||||
const el = slot.nextSibling;
|
||||
slot.remove();
|
||||
slot = el;
|
||||
}
|
||||
}
|
||||
if (!funcs.length && (!oldItems || oldItems.length)) {
|
||||
TPL.appliesToEverything.cloneNode(true);
|
||||
}
|
||||
setProp(sec, 'widgetFuncs', items);
|
||||
elList[KEY] = items;
|
||||
container[KEY] = sec;
|
||||
container.classList.toggle('error', !sec.funcs.length);
|
||||
return Object.assign(container, EVENTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MozSectionFunc} func
|
||||
* @param {MarkedFunc} old
|
||||
* @returns {MarkedFunc}
|
||||
*/
|
||||
function renderFunc(func, old = {}) {
|
||||
const {
|
||||
type,
|
||||
value,
|
||||
isQuoted = false,
|
||||
start,
|
||||
start: {line},
|
||||
typeEnd = {line, ch: start.ch + type.length},
|
||||
valuePos = {line, ch: typeEnd.ch + 1 + Boolean(isQuoted)},
|
||||
valueEnd = {line, ch: valuePos.ch + value.length},
|
||||
end = {line, ch: valueEnd.ch + Boolean(isQuoted) + 1},
|
||||
} = func;
|
||||
const el = (old.item || {})[KEY] || TPL.listItem.cloneNode(true);
|
||||
/** @namespace MarkedFunc */
|
||||
const res = el[KEY] = {
|
||||
typeText: type,
|
||||
item: markFuncPart(start, end, old.item, el),
|
||||
type: markFuncPart(start, typeEnd, old.type, $(C_TYPE, el), type, toLowerCase),
|
||||
value: markFuncPart(valuePos, valueEnd, old.value, $(C_VALUE, el), value, fromDoubleslash),
|
||||
};
|
||||
if (el.dataset.type !== type) {
|
||||
el.dataset.type = type;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CodeMirror.Pos} start
|
||||
* @param {CodeMirror.Pos} end
|
||||
* @param {TextMarker} marker
|
||||
* @param {HTMLElement} el
|
||||
* @param {string} [text]
|
||||
* @param {function} [textTransform]
|
||||
* @returns {TextMarker}
|
||||
*/
|
||||
function markFuncPart(start, end, marker, el, text, textTransform) {
|
||||
if (marker) {
|
||||
const pos = marker.find();
|
||||
if (!pos ||
|
||||
cmpPos(pos.from, start) ||
|
||||
cmpPos(pos.to, end) ||
|
||||
text != null && text !== cm.getRange(start, end)) {
|
||||
marker.clear();
|
||||
marker = null;
|
||||
}
|
||||
}
|
||||
if (!marker) {
|
||||
marker = cm.markText(start, end, {
|
||||
clearWhenEmpty: false,
|
||||
inclusiveLeft: true,
|
||||
inclusiveRight: true,
|
||||
[KEY]: el,
|
||||
});
|
||||
}
|
||||
if (text != null) {
|
||||
text = textTransform(text);
|
||||
if (el.value !== text) el.value = text;
|
||||
}
|
||||
return marker;
|
||||
}
|
||||
|
||||
/** @type {MozSection} sec */
|
||||
function killWidget(sec) {
|
||||
const w = sec && sec.widget;
|
||||
if (w) {
|
||||
w.clear();
|
||||
w.node[KEY].widgetFuncs.forEach(killFunc);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {MarkedFunc} f */
|
||||
function killFunc(f) {
|
||||
f.item.clear();
|
||||
f.type.clear();
|
||||
f.value.clear();
|
||||
}
|
||||
|
||||
function showRegExpTester(el) {
|
||||
const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
|
||||
regExpTester.toggle(true);
|
||||
regExpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
|
||||
}
|
||||
|
||||
function fromDoubleslash(s) {
|
||||
return /([^\\]|^)\\([^\\]|$)/.test(s) ? s : s.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
function toDoubleslash(s) {
|
||||
return fromDoubleslash(s).replace(/\\/g, '\\\\');
|
||||
}
|
||||
|
||||
function toLowerCase(s) {
|
||||
return s.toLowerCase();
|
||||
}
|
||||
|
||||
/** Adds a non-enumerable property so it won't be seen by deepEqual */
|
||||
function setProp(obj, name, value) {
|
||||
return Object.defineProperty(obj, name, {value, configurable: true});
|
||||
}
|
||||
}
|
|
@ -1,112 +1,43 @@
|
|||
/* global template cmFactory $ propertyToCss CssToProperty linter regExpTester
|
||||
FIREFOX toggleContextMenuDelete initBeautifyButton showHelp t tryRegExp */
|
||||
/* exported createSection */
|
||||
/* global
|
||||
$
|
||||
cmFactory
|
||||
debounce
|
||||
DocFuncMapper
|
||||
editor
|
||||
initBeautifyButton
|
||||
linter
|
||||
prefs
|
||||
regExpTester
|
||||
t
|
||||
template
|
||||
trimCommentLabel
|
||||
tryRegExp
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
function createResizeGrip(cm) {
|
||||
const wrapper = cm.display.wrapper;
|
||||
wrapper.classList.add('resize-grip-enabled');
|
||||
const resizeGrip = template.resizeGrip.cloneNode(true);
|
||||
wrapper.appendChild(resizeGrip);
|
||||
let lastClickTime = 0;
|
||||
let initHeight;
|
||||
let initY;
|
||||
resizeGrip.onmousedown = event => {
|
||||
initHeight = wrapper.offsetHeight;
|
||||
initY = event.pageY;
|
||||
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';
|
||||
document.addEventListener('mousemove', resize);
|
||||
document.addEventListener('mouseup', resizeStop);
|
||||
/* exported createSection */
|
||||
|
||||
function resize(e) {
|
||||
const height = Math.max(minHeight, initHeight + e.pageY - initY);
|
||||
if (height !== wrapper.offsetHeight) {
|
||||
cm.setSize(null, height);
|
||||
}
|
||||
}
|
||||
|
||||
function resizeStop() {
|
||||
document.removeEventListener('mouseup', resizeStop);
|
||||
document.removeEventListener('mousemove', resize);
|
||||
wrapper.style.pointerEvents = '';
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
};
|
||||
|
||||
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 sectionEl = wrapper.parentNode;
|
||||
const sectionExtrasHeight = sectionEl.clientHeight - wrapper.offsetHeight;
|
||||
cm.state.toggleHeightSaved = wrapper.clientHeight;
|
||||
cm.setSize(null, window.innerHeight - sectionExtrasHeight - pageExtrasHeight);
|
||||
const bounds = sectionEl.getBoundingClientRect();
|
||||
if (bounds.top < 0 || bounds.bottom > window.innerHeight) {
|
||||
window.scrollBy(0, bounds.top);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSection({
|
||||
// data model
|
||||
originalSection,
|
||||
dirty,
|
||||
// util
|
||||
nextEditor,
|
||||
prevEditor,
|
||||
genId,
|
||||
// emit events
|
||||
// TODO: better names like `onRemoved`? Or make a real event emitter.
|
||||
showMozillaFormatImport,
|
||||
removeSection,
|
||||
insertSectionAfter,
|
||||
moveSectionUp,
|
||||
moveSectionDown,
|
||||
restoreSection,
|
||||
}) {
|
||||
/** @returns {EditorSection} */
|
||||
function createSection(originalSection, genId) {
|
||||
const {dirty} = editor;
|
||||
const sectionId = genId();
|
||||
const el = template.section.cloneNode(true);
|
||||
const elLabel = $('.code-label', el);
|
||||
const cm = cmFactory.create(wrapper => {
|
||||
el.insertBefore(wrapper, $('.code-label', el).nextSibling);
|
||||
}, {value: originalSection.code});
|
||||
// making it tall during initial load so IntersectionObserver sees only one adjacent CM
|
||||
wrapper.style.height = '100vh';
|
||||
elLabel.after(wrapper);
|
||||
}, {
|
||||
value: originalSection.code,
|
||||
});
|
||||
el.CodeMirror = cm; // used by getAssociatedEditor
|
||||
|
||||
const changeListeners = new Set();
|
||||
|
||||
const appliesToContainer = $('.applies-to-list', el);
|
||||
const appliesTo = [];
|
||||
for (const [key, fnName] of Object.entries(propertyToCss)) {
|
||||
if (originalSection[key]) {
|
||||
originalSection[key].forEach(value =>
|
||||
insertApplyAfter({type: fnName, value})
|
||||
);
|
||||
}
|
||||
}
|
||||
DocFuncMapper.forEachProp(originalSection, (type, value) =>
|
||||
insertApplyAfter({type, value}));
|
||||
if (!appliesTo.length) {
|
||||
insertApplyAfter({all: true});
|
||||
}
|
||||
|
@ -118,53 +49,60 @@ function createSection({
|
|||
updateRegexpTester();
|
||||
createResizeGrip(cm);
|
||||
|
||||
linter.enableForEditor(cm);
|
||||
|
||||
/** @namespace EditorSection */
|
||||
const section = {
|
||||
id: sectionId,
|
||||
el,
|
||||
cm,
|
||||
render,
|
||||
getModel,
|
||||
remove,
|
||||
destroy,
|
||||
restore,
|
||||
isRemoved: () => removed,
|
||||
onChange,
|
||||
off,
|
||||
appliesTo
|
||||
};
|
||||
return section;
|
||||
|
||||
function onChange(fn) {
|
||||
appliesTo,
|
||||
getModel() {
|
||||
const items = appliesTo.map(a => !a.all && [a.type, a.value]);
|
||||
return DocFuncMapper.toSection(items, {code: cm.getValue()});
|
||||
},
|
||||
remove() {
|
||||
linter.disableForEditor(cm);
|
||||
el.classList.add('removed');
|
||||
removed = true;
|
||||
appliesTo.forEach(a => a.remove());
|
||||
},
|
||||
render() {
|
||||
cm.refresh();
|
||||
},
|
||||
destroy() {
|
||||
cmFactory.destroy(cm);
|
||||
},
|
||||
restore() {
|
||||
linter.enableForEditor(cm);
|
||||
el.classList.remove('removed');
|
||||
removed = false;
|
||||
appliesTo.forEach(a => a.restore());
|
||||
cm.refresh();
|
||||
},
|
||||
onChange(fn) {
|
||||
changeListeners.add(fn);
|
||||
}
|
||||
|
||||
function off(fn) {
|
||||
},
|
||||
off(fn) {
|
||||
changeListeners.delete(fn);
|
||||
}
|
||||
|
||||
function emitSectionChange() {
|
||||
for (const fn of changeListeners) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
function getModel() {
|
||||
const section = {
|
||||
code: cm.getValue()
|
||||
},
|
||||
get removed() {
|
||||
return removed;
|
||||
},
|
||||
tocEntry: {
|
||||
label: '',
|
||||
get removed() {
|
||||
return removed;
|
||||
},
|
||||
},
|
||||
};
|
||||
for (const apply of appliesTo) {
|
||||
if (apply.all) {
|
||||
continue;
|
||||
}
|
||||
const key = CssToProperty[apply.getType()];
|
||||
if (!section[key]) {
|
||||
section[key] = [];
|
||||
}
|
||||
section[key].push(apply.getValue());
|
||||
}
|
||||
|
||||
prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {now: true});
|
||||
|
||||
return section;
|
||||
|
||||
function emitSectionChange(origin) {
|
||||
for (const fn of changeListeners) {
|
||||
fn(origin);
|
||||
}
|
||||
}
|
||||
|
||||
function registerEvents() {
|
||||
|
@ -172,35 +110,13 @@ function createSection({
|
|||
const newGeneration = cm.changeGeneration();
|
||||
dirty.modify(`section.${sectionId}.code`, changeGeneration, newGeneration);
|
||||
changeGeneration = newGeneration;
|
||||
emitSectionChange();
|
||||
emitSectionChange('code');
|
||||
});
|
||||
cm.on('paste', (cm, event) => {
|
||||
const text = event.clipboardData.getData('text') || '';
|
||||
if (/@-moz-document/i.test(text) &&
|
||||
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
|
||||
.test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
|
||||
) {
|
||||
event.preventDefault();
|
||||
showMozillaFormatImport(text);
|
||||
}
|
||||
});
|
||||
if (!FIREFOX) {
|
||||
cm.on('mousedown', (cm, event) => toggleContextMenuDelete.call(cm, event));
|
||||
}
|
||||
cm.display.wrapper.addEventListener('keydown', event =>
|
||||
handleKeydown(cm, event), true);
|
||||
|
||||
$('.applies-to-help', el).addEventListener('click', showAppliesToHelp);
|
||||
$('.remove-section', el).addEventListener('click', () => removeSection(section));
|
||||
$('.add-section', el).addEventListener('click', () => insertSectionAfter(undefined, section));
|
||||
$('.clone-section', el).addEventListener('click', () => insertSectionAfter(getModel(), section));
|
||||
$('.move-section-up', el).addEventListener('click', () => moveSectionUp(section));
|
||||
$('.move-section-down', el).addEventListener('click', () => moveSectionDown(section));
|
||||
$('.restore-section', el).addEventListener('click', () => restoreSection(section));
|
||||
$('.test-regexp', el).addEventListener('click', () => {
|
||||
cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
|
||||
$('.test-regexp', el).onclick = () => {
|
||||
regExpTester.toggle();
|
||||
updateRegexpTester();
|
||||
});
|
||||
};
|
||||
initBeautifyButton($('.beautify-section', el), () => [cm]);
|
||||
}
|
||||
|
||||
|
@ -217,7 +133,7 @@ function createSection({
|
|||
}
|
||||
// fallthrough
|
||||
case 'ArrowUp':
|
||||
cm = line === 0 && prevEditor(cm, false);
|
||||
cm = line === 0 && editor.prevEditor(cm, false);
|
||||
if (!cm) {
|
||||
return;
|
||||
}
|
||||
|
@ -231,7 +147,7 @@ function createSection({
|
|||
}
|
||||
// fallthrough
|
||||
case 'ArrowDown':
|
||||
cm = line === cm.doc.size - 1 && nextEditor(cm, false);
|
||||
cm = line === cm.doc.size - 1 && editor.nextEditor(cm, false);
|
||||
if (!cm) {
|
||||
return;
|
||||
}
|
||||
|
@ -242,37 +158,9 @@ function createSection({
|
|||
}
|
||||
}
|
||||
|
||||
function showAppliesToHelp(event) {
|
||||
event.preventDefault();
|
||||
showHelp(t('appliesLabel'), t('appliesHelp'));
|
||||
}
|
||||
|
||||
function remove() {
|
||||
linter.disableForEditor(cm);
|
||||
el.classList.add('removed');
|
||||
removed = true;
|
||||
appliesTo.forEach(a => a.remove());
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
cmFactory.destroy(cm);
|
||||
}
|
||||
|
||||
function restore() {
|
||||
linter.enableForEditor(cm);
|
||||
el.classList.remove('removed');
|
||||
removed = false;
|
||||
appliesTo.forEach(a => a.restore());
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
cm.refresh();
|
||||
}
|
||||
|
||||
function updateRegexpTester() {
|
||||
const regexps = appliesTo.filter(a => a.getType() === 'regexp')
|
||||
.map(a => a.getValue());
|
||||
const regexps = appliesTo.filter(a => a.type === 'regexp')
|
||||
.map(a => a.value);
|
||||
if (regexps.length) {
|
||||
el.classList.add('has-regexp');
|
||||
regExpTester.update(regexps);
|
||||
|
@ -282,6 +170,68 @@ function createSection({
|
|||
}
|
||||
}
|
||||
|
||||
function updateTocEntry(origin) {
|
||||
const te = section.tocEntry;
|
||||
let changed;
|
||||
if (origin === 'code' || !origin) {
|
||||
const label = getLabelFromComment();
|
||||
if (te.label !== label) {
|
||||
te.label = elLabel.dataset.text = label;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!te.label) {
|
||||
const target = appliesTo[0].all ? null : appliesTo[0].value;
|
||||
if (te.target !== target) {
|
||||
te.target = target;
|
||||
changed = true;
|
||||
}
|
||||
if (te.numTargets !== appliesTo.length) {
|
||||
te.numTargets = appliesTo.length;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) editor.updateToc([section]);
|
||||
}
|
||||
|
||||
function updateTocEntryLazy(...args) {
|
||||
debounce(updateTocEntry, 0, ...args);
|
||||
}
|
||||
|
||||
function updateTocFocus() {
|
||||
editor.updateToc({focus: true, 0: section});
|
||||
}
|
||||
|
||||
function updateTocPrefToggled(key, val) {
|
||||
changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
|
||||
el.onOff(val, 'focusin', updateTocFocus);
|
||||
if (val) {
|
||||
updateTocEntry();
|
||||
if (el.contains(document.activeElement)) {
|
||||
updateTocFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLabelFromComment() {
|
||||
let cmt = '';
|
||||
let inCmt;
|
||||
cm.eachLine(({text}) => {
|
||||
let i = 0;
|
||||
if (!inCmt) {
|
||||
i = text.search(/\S/);
|
||||
if (i < 0) return;
|
||||
inCmt = text[i] === '/' && text[i + 1] === '*';
|
||||
if (!inCmt) return true;
|
||||
i += 2;
|
||||
}
|
||||
const j = text.indexOf('*/', i);
|
||||
cmt = trimCommentLabel(text.slice(i, j >= 0 ? j : text.length));
|
||||
return j >= 0 || cmt;
|
||||
});
|
||||
return cmt;
|
||||
}
|
||||
|
||||
function insertApplyAfter(init, base) {
|
||||
const apply = createApply(init);
|
||||
appliesTo.splice(base ? appliesTo.indexOf(base) + 1 : appliesTo.length, 0, apply);
|
||||
|
@ -290,7 +240,7 @@ function createSection({
|
|||
if (appliesTo.length > 1 && appliesTo[0].all) {
|
||||
removeApply(appliesTo[0]);
|
||||
}
|
||||
emitSectionChange();
|
||||
emitSectionChange('apply');
|
||||
return apply;
|
||||
}
|
||||
|
||||
|
@ -303,7 +253,7 @@ function createSection({
|
|||
if (!appliesTo.length) {
|
||||
insertApplyAfter({all: true});
|
||||
}
|
||||
emitSectionChange();
|
||||
emitSectionChange('apply');
|
||||
}
|
||||
|
||||
function createApply({type = 'url', value, all = false}) {
|
||||
|
@ -315,14 +265,14 @@ function createSection({
|
|||
const selectEl = !all && $('.applies-type', el);
|
||||
if (selectEl) {
|
||||
selectEl.value = type;
|
||||
selectEl.addEventListener('change', () => {
|
||||
selectEl.on('change', () => {
|
||||
const oldType = type;
|
||||
dirty.modify(`${dirtyPrefix}.type`, type, selectEl.value);
|
||||
type = selectEl.value;
|
||||
if (oldType === 'regexp' || type === 'regexp') {
|
||||
updateRegexpTester();
|
||||
}
|
||||
emitSectionChange();
|
||||
emitSectionChange('apply');
|
||||
validate();
|
||||
});
|
||||
}
|
||||
|
@ -330,15 +280,15 @@ function createSection({
|
|||
const valueEl = !all && $('.applies-value', el);
|
||||
if (valueEl) {
|
||||
valueEl.value = value;
|
||||
valueEl.addEventListener('input', () => {
|
||||
valueEl.on('input', () => {
|
||||
dirty.modify(`${dirtyPrefix}.value`, value, valueEl.value);
|
||||
value = valueEl.value;
|
||||
if (type === 'regexp') {
|
||||
updateRegexpTester();
|
||||
}
|
||||
emitSectionChange();
|
||||
emitSectionChange('apply');
|
||||
});
|
||||
valueEl.addEventListener('change', validate);
|
||||
valueEl.on('change', validate);
|
||||
}
|
||||
|
||||
restore();
|
||||
|
@ -349,19 +299,23 @@ function createSection({
|
|||
remove,
|
||||
restore,
|
||||
el,
|
||||
getType: () => type,
|
||||
getValue: () => value,
|
||||
valueEl // used by validator
|
||||
valueEl, // used by validator
|
||||
get type() {
|
||||
return type;
|
||||
},
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
};
|
||||
|
||||
const removeButton = $('.remove-applies-to', el);
|
||||
if (removeButton) {
|
||||
removeButton.addEventListener('click', e => {
|
||||
removeButton.on('click', e => {
|
||||
e.preventDefault();
|
||||
removeApply(apply);
|
||||
});
|
||||
}
|
||||
$('.add-applies-to', el).addEventListener('click', e => {
|
||||
$('.add-applies-to', el).on('click', e => {
|
||||
e.preventDefault();
|
||||
const newApply = insertApplyAfter({type, value: ''}, apply);
|
||||
$('input', newApply.el).focus();
|
||||
|
@ -395,3 +349,72 @@ function createSection({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createResizeGrip(cm) {
|
||||
const wrapper = cm.display.wrapper;
|
||||
wrapper.classList.add('resize-grip-enabled');
|
||||
const resizeGrip = template.resizeGrip.cloneNode(true);
|
||||
wrapper.appendChild(resizeGrip);
|
||||
let lastClickTime = 0;
|
||||
let initHeight;
|
||||
let initY;
|
||||
resizeGrip.onmousedown = event => {
|
||||
initHeight = wrapper.offsetHeight;
|
||||
initY = event.pageY;
|
||||
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';
|
||||
document.on('mousemove', resize);
|
||||
document.on('mouseup', resizeStop);
|
||||
|
||||
function resize(e) {
|
||||
const height = Math.max(minHeight, initHeight + e.pageY - initY);
|
||||
if (height !== wrapper.offsetHeight) {
|
||||
cm.setSize(null, height);
|
||||
}
|
||||
}
|
||||
|
||||
function resizeStop() {
|
||||
document.off('mouseup', resizeStop);
|
||||
document.off('mousemove', resize);
|
||||
wrapper.style.pointerEvents = '';
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
};
|
||||
|
||||
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 sectionEl = wrapper.parentNode;
|
||||
const sectionExtrasHeight = sectionEl.clientHeight - wrapper.offsetHeight;
|
||||
cm.state.toggleHeightSaved = wrapper.clientHeight;
|
||||
cm.setSize(null, window.innerHeight - sectionExtrasHeight - pageExtrasHeight);
|
||||
const bounds = sectionEl.getBoundingClientRect();
|
||||
if (bounds.top < 0 || bounds.bottom > window.innerHeight) {
|
||||
window.scrollBy(0, bounds.top);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,75 +1,145 @@
|
|||
/* global showHelp toggleContextMenuDelete createSection
|
||||
CodeMirror linter createLivePreview showCodeMirrorPopup
|
||||
sectionsToMozFormat messageBox clipString
|
||||
$ $$ $create t FIREFOX API
|
||||
debounce */
|
||||
/* exported createSectionsEditor */
|
||||
/* global
|
||||
$
|
||||
$$
|
||||
$create
|
||||
API
|
||||
clipString
|
||||
CodeMirror
|
||||
createLivePreview
|
||||
createSection
|
||||
debounce
|
||||
editor
|
||||
FIREFOX
|
||||
ignoreChromeError
|
||||
linter
|
||||
messageBox
|
||||
prefs
|
||||
sectionsToMozFormat
|
||||
showCodeMirrorPopup
|
||||
showHelp
|
||||
t
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
function createSectionsEditor(editorBase) {
|
||||
const {style, dirty} = editorBase;
|
||||
/* exported SectionsEditor */
|
||||
|
||||
function SectionsEditor() {
|
||||
const {style, dirty} = editor;
|
||||
const container = $('#sections');
|
||||
/** @type {EditorSection[]} */
|
||||
const sections = [];
|
||||
const xo = window.IntersectionObserver &&
|
||||
new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'});
|
||||
const livePreview = createLivePreview(null, style.id);
|
||||
|
||||
let INC_ID = 0; // an increment id that is used by various object to track the order
|
||||
|
||||
const container = $('#sections');
|
||||
const sections = [];
|
||||
let sectionOrder = '';
|
||||
let headerOffset; // in compact mode the header is at the top so it reduces the available height
|
||||
|
||||
container.classList.add('section-editor');
|
||||
updateHeader();
|
||||
$('#to-mozilla').addEventListener('click', showMozillaFormat);
|
||||
$('#to-mozilla-help').addEventListener('click', showToMozillaHelp);
|
||||
$('#from-mozilla').addEventListener('click', () => showMozillaFormatImport());
|
||||
|
||||
document.addEventListener('wheel', scrollEntirePageOnCtrlShift, {passive: false});
|
||||
$('#to-mozilla').on('click', showMozillaFormat);
|
||||
$('#to-mozilla-help').on('click', showToMozillaHelp);
|
||||
$('#from-mozilla').on('click', () => showMozillaFormatImport());
|
||||
document.on('wheel', scrollEntirePageOnCtrlShift, {passive: false});
|
||||
CodeMirror.defaults.extraKeys['Shift-Ctrl-Wheel'] = 'scrollWindow';
|
||||
|
||||
if (!FIREFOX) {
|
||||
$$([
|
||||
'input:not([type])',
|
||||
'input[type="text"]',
|
||||
'input[type="search"]',
|
||||
'input[type="number"]',
|
||||
].join(','))
|
||||
.forEach(e => e.addEventListener('mousedown', toggleContextMenuDelete));
|
||||
$$('input:not([type]), input[type=text], input[type=search], input[type=number]')
|
||||
.forEach(e => e.on('mousedown', toggleContextMenuDelete));
|
||||
}
|
||||
|
||||
const xo = window.IntersectionObserver && new IntersectionObserver(entries => {
|
||||
for (const {isIntersecting, target} of entries) {
|
||||
if (isIntersecting) {
|
||||
target.CodeMirror.refresh();
|
||||
xo.unobserve(target);
|
||||
}
|
||||
}
|
||||
}, {rootMargin: '100%'});
|
||||
const refreshOnView = (cm, force) =>
|
||||
force || !xo ?
|
||||
cm.refresh() :
|
||||
xo.observe(cm.display.wrapper);
|
||||
/** @namespace SectionsEditor */
|
||||
Object.assign(editor, {
|
||||
|
||||
let sectionOrder = '';
|
||||
let headerOffset; // in compact mode the header is at the top so it reduces the available height
|
||||
const ready = initSections(style.sections, {pristine: true});
|
||||
sections,
|
||||
|
||||
const livePreview = createLivePreview();
|
||||
livePreview.show(Boolean(style.id));
|
||||
closestVisible,
|
||||
updateLivePreview,
|
||||
|
||||
return Object.assign({}, editorBase, {
|
||||
ready,
|
||||
replaceStyle,
|
||||
getEditors,
|
||||
scrollToEditor,
|
||||
getEditorTitle: cm => {
|
||||
const index = sections.filter(s => !s.isRemoved()).findIndex(s => s.cm === cm);
|
||||
getEditors() {
|
||||
return sections.filter(s => !s.removed).map(s => s.cm);
|
||||
},
|
||||
|
||||
getEditorTitle(cm) {
|
||||
const index = editor.getEditors().indexOf(cm);
|
||||
return `${t('sectionCode')} ${index + 1}`;
|
||||
},
|
||||
save,
|
||||
nextEditor,
|
||||
prevEditor,
|
||||
closestVisible,
|
||||
getSearchableInputs,
|
||||
updateLivePreview,
|
||||
|
||||
getSearchableInputs(cm) {
|
||||
return sections.find(s => s.cm === cm).appliesTo.map(a => a.valueEl).filter(Boolean);
|
||||
},
|
||||
|
||||
jumpToEditor(i) {
|
||||
const {cm} = sections[i] || {};
|
||||
if (cm) {
|
||||
editor.scrollToEditor(cm);
|
||||
cm.focus();
|
||||
}
|
||||
},
|
||||
|
||||
nextEditor(cm, cycle = true) {
|
||||
return cycle || cm !== findLast(sections, s => !s.removed).cm
|
||||
? nextPrevEditor(cm, 1)
|
||||
: null;
|
||||
},
|
||||
|
||||
prevEditor(cm, cycle = true) {
|
||||
return cycle || cm !== sections.find(s => !s.removed).cm
|
||||
? nextPrevEditor(cm, -1)
|
||||
: null;
|
||||
},
|
||||
|
||||
async replaceStyle(newStyle, codeIsUpdated) {
|
||||
dirty.clear('name');
|
||||
// FIXME: avoid recreating all editors?
|
||||
if (codeIsUpdated !== false) {
|
||||
await initSections(newStyle.sections, {replace: true, pristine: true});
|
||||
}
|
||||
Object.assign(style, newStyle);
|
||||
updateHeader();
|
||||
dirty.clear();
|
||||
// Go from new style URL to edit style URL
|
||||
if (location.href.indexOf('id=') === -1 && style.id) {
|
||||
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
|
||||
$('#heading').textContent = t('editStyleHeading');
|
||||
}
|
||||
livePreview.show(Boolean(style.id));
|
||||
updateLivePreview();
|
||||
},
|
||||
|
||||
async save() {
|
||||
if (!dirty.isDirty()) {
|
||||
return;
|
||||
}
|
||||
let newStyle = getModel();
|
||||
if (!validate(newStyle)) {
|
||||
return;
|
||||
}
|
||||
newStyle = await API.editSave(newStyle);
|
||||
destroyRemovedSections();
|
||||
sessionStorage.justEditedStyleId = newStyle.id;
|
||||
editor.replaceStyle(newStyle, false);
|
||||
},
|
||||
|
||||
scrollToEditor(cm) {
|
||||
const section = sections.find(s => s.cm === cm).el;
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
editor.ready = initSections(style.sections, {pristine: true});
|
||||
|
||||
/** @param {EditorSection} section */
|
||||
function fitToContent(section) {
|
||||
const {el, cm, cm: {display: {wrapper, sizer}}} = section;
|
||||
if (cm.display.renderedView) {
|
||||
|
@ -90,18 +160,19 @@ function createSectionsEditor(editorBase) {
|
|||
cm.off('update', resize);
|
||||
const cmHeight = wrapper.offsetHeight;
|
||||
const maxHeight = (window.innerHeight - headerOffset) - (section.el.offsetHeight - cmHeight);
|
||||
cm.setSize(null, Math.min(contentHeight, maxHeight));
|
||||
const fit = Math.min(contentHeight, maxHeight);
|
||||
if (Math.abs(fit - cmHeight) > 1) {
|
||||
cm.setSize(null, fit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fitToAvailableSpace() {
|
||||
const ch = container.offsetHeight;
|
||||
let available = ch - sections[sections.length - 1].el.getBoundingClientRect().bottom + headerOffset;
|
||||
if (available <= 1) available = window.innerHeight - ch - headerOffset;
|
||||
const delta = Math.floor(available / sections.length);
|
||||
const lastSectionBottom = sections[sections.length - 1].el.getBoundingClientRect().bottom;
|
||||
const delta = Math.floor((window.innerHeight - lastSectionBottom) / sections.length);
|
||||
if (delta > 1) {
|
||||
sections.forEach(({cm}) => {
|
||||
cm.setSize(null, cm.display.wrapper.offsetHeight + delta);
|
||||
cm.setSize(null, cm.display.lastWrapHeight + delta);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -129,14 +200,12 @@ function createSectionsEditor(editorBase) {
|
|||
showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp'));
|
||||
}
|
||||
|
||||
function getSearchableInputs(cm) {
|
||||
return sections.find(s => s.cm === cm).appliesTo.map(a => a.valueEl).filter(Boolean);
|
||||
}
|
||||
|
||||
// priority:
|
||||
// 1. associated CM for applies-to element
|
||||
// 2. last active if visible
|
||||
// 3. first visible
|
||||
/**
|
||||
priority:
|
||||
1. associated CM for applies-to element
|
||||
2. last active if visible
|
||||
3. first visible
|
||||
*/
|
||||
function closestVisible(nearbyElement) {
|
||||
const cm =
|
||||
nearbyElement instanceof CodeMirror ? nearbyElement :
|
||||
|
@ -181,7 +250,7 @@ function createSectionsEditor(editorBase) {
|
|||
}
|
||||
|
||||
function findClosest() {
|
||||
const editors = getEditors();
|
||||
const editors = editor.getEditors();
|
||||
const last = editors.length - 1;
|
||||
let a = 0;
|
||||
let b = last;
|
||||
|
@ -206,7 +275,7 @@ function createSectionsEditor(editorBase) {
|
|||
}
|
||||
const cm = editors[b];
|
||||
if (distances[b] > 0) {
|
||||
scrollToEditor(cm);
|
||||
editor.scrollToEditor(cm);
|
||||
}
|
||||
return cm;
|
||||
}
|
||||
|
@ -221,24 +290,6 @@ function createSectionsEditor(editorBase) {
|
|||
}
|
||||
}
|
||||
|
||||
function getEditors() {
|
||||
return sections.filter(s => !s.isRemoved()).map(s => s.cm);
|
||||
}
|
||||
|
||||
function nextEditor(cm, cycle = true) {
|
||||
if (!cycle && findLast(sections, s => !s.isRemoved()).cm === cm) {
|
||||
return;
|
||||
}
|
||||
return nextPrevEditor(cm, 1);
|
||||
}
|
||||
|
||||
function prevEditor(cm, cycle = true) {
|
||||
if (!cycle && sections.find(s => !s.isRemoved()).cm === cm) {
|
||||
return;
|
||||
}
|
||||
return nextPrevEditor(cm, -1);
|
||||
}
|
||||
|
||||
function findLast(arr, match) {
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
if (match(arr[i])) {
|
||||
|
@ -248,32 +299,17 @@ function createSectionsEditor(editorBase) {
|
|||
}
|
||||
|
||||
function nextPrevEditor(cm, direction) {
|
||||
const editors = getEditors();
|
||||
const editors = editor.getEditors();
|
||||
cm = editors[(editors.indexOf(cm) + direction + editors.length) % editors.length];
|
||||
scrollToEditor(cm);
|
||||
editor.scrollToEditor(cm);
|
||||
cm.focus();
|
||||
return cm;
|
||||
}
|
||||
|
||||
function scrollToEditor(cm) {
|
||||
const section = sections.find(s => s.cm === cm).el;
|
||||
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 getLastActivatedEditor() {
|
||||
let result;
|
||||
for (const section of sections) {
|
||||
if (section.isRemoved()) {
|
||||
if (section.removed) {
|
||||
continue;
|
||||
}
|
||||
// .lastActive is initiated by codemirror-factory
|
||||
|
@ -387,16 +423,18 @@ function createSectionsEditor(editorBase) {
|
|||
|
||||
function updateSectionOrder() {
|
||||
const oldOrder = sectionOrder;
|
||||
const validSections = sections.filter(s => !s.isRemoved());
|
||||
const validSections = sections.filter(s => !s.removed);
|
||||
sectionOrder = validSections.map(s => s.id).join(',');
|
||||
dirty.modify('sectionOrder', oldOrder, sectionOrder);
|
||||
container.dataset.sectionCount = validSections.length;
|
||||
linter.refreshReport();
|
||||
editor.updateToc();
|
||||
}
|
||||
|
||||
/** @returns {Style} */
|
||||
function getModel() {
|
||||
return Object.assign({}, style, {
|
||||
sections: sections.filter(s => !s.isRemoved()).map(s => s.getModel())
|
||||
sections: sections.filter(s => !s.removed).map(s => s.getModel())
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -407,7 +445,7 @@ function createSectionsEditor(editorBase) {
|
|||
}
|
||||
for (const section of sections) {
|
||||
for (const apply of section.appliesTo) {
|
||||
if (apply.getType() !== 'regexp') {
|
||||
if (apply.type !== 'regexp') {
|
||||
continue;
|
||||
}
|
||||
if (!apply.valueEl.reportValidity()) {
|
||||
|
@ -419,25 +457,9 @@ function createSectionsEditor(editorBase) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!dirty.isDirty()) {
|
||||
return;
|
||||
}
|
||||
const newStyle = getModel();
|
||||
if (!validate(newStyle)) {
|
||||
return;
|
||||
}
|
||||
API.editSave(newStyle)
|
||||
.then(newStyle => {
|
||||
destroyRemovedSections();
|
||||
sessionStorage.justEditedStyleId = newStyle.id;
|
||||
replaceStyle(newStyle, false);
|
||||
});
|
||||
}
|
||||
|
||||
function destroyRemovedSections() {
|
||||
for (let i = 0; i < sections.length;) {
|
||||
if (!sections[i].isRemoved()) {
|
||||
if (!sections[i].removed) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
@ -451,14 +473,14 @@ function createSectionsEditor(editorBase) {
|
|||
$('#name').value = style.customName || style.name || '';
|
||||
$('#enabled').checked = style.enabled !== false;
|
||||
$('#url').href = style.url || '';
|
||||
editorBase.updateName();
|
||||
editor.updateName();
|
||||
}
|
||||
|
||||
function updateLivePreview() {
|
||||
debounce(_updateLivePreview, 200);
|
||||
debounce(updateLivePreviewNow, editor.previewDelay);
|
||||
}
|
||||
|
||||
function _updateLivePreview() {
|
||||
function updateLivePreviewNow() {
|
||||
livePreview.update(getModel());
|
||||
}
|
||||
|
||||
|
@ -492,7 +514,8 @@ function createSectionsEditor(editorBase) {
|
|||
setGlobalProgress(total - originalSections.length, total);
|
||||
if (!originalSections.length) {
|
||||
setGlobalProgress();
|
||||
fitToAvailableSpace();
|
||||
requestAnimationFrame(fitToAvailableSpace);
|
||||
sections.forEach(({cm}) => setTimeout(linter.enableForEditor, 0, cm));
|
||||
done();
|
||||
} else {
|
||||
setTimeout(chunk);
|
||||
|
@ -500,8 +523,9 @@ function createSectionsEditor(editorBase) {
|
|||
}
|
||||
}
|
||||
|
||||
/** @param {EditorSection} section */
|
||||
function removeSection(section) {
|
||||
if (sections.every(s => s.isRemoved() || s === section)) {
|
||||
if (sections.every(s => s.removed || s === section)) {
|
||||
// TODO: hide remove button when `#sections[data-section-count=1]`
|
||||
throw new Error('Cannot remove last section');
|
||||
}
|
||||
|
@ -528,6 +552,7 @@ function createSectionsEditor(editorBase) {
|
|||
updateLivePreview();
|
||||
}
|
||||
|
||||
/** @param {EditorSection} section */
|
||||
function restoreSection(section) {
|
||||
section.restore();
|
||||
updateSectionOrder();
|
||||
|
@ -535,40 +560,36 @@ function createSectionsEditor(editorBase) {
|
|||
updateLivePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StyleSection} [init]
|
||||
* @param {EditorSection} [base]
|
||||
* @param {boolean} [forceRefresh]
|
||||
*/
|
||||
function insertSectionAfter(init, base, forceRefresh) {
|
||||
if (!init) {
|
||||
init = {code: '', urlPrefixes: ['http://example.com']};
|
||||
}
|
||||
const section = createSection({
|
||||
originalSection: init,
|
||||
genId,
|
||||
dirty,
|
||||
showMozillaFormatImport,
|
||||
removeSection,
|
||||
restoreSection,
|
||||
insertSectionAfter,
|
||||
moveSectionUp,
|
||||
moveSectionDown,
|
||||
prevEditor,
|
||||
nextEditor
|
||||
});
|
||||
const section = createSection(init, genId);
|
||||
const {cm} = section;
|
||||
sections.splice(base ? sections.indexOf(base) + 1 : sections.length, 0, section);
|
||||
container.insertBefore(section.el, base ? base.el.nextSibling : null);
|
||||
refreshOnView(cm, forceRefresh);
|
||||
registerEvents(section);
|
||||
if (!base || init.code) {
|
||||
// Fit a) during startup or b) when the clone button is clicked on a section with some code
|
||||
fitToContent(section);
|
||||
}
|
||||
if (base) {
|
||||
cm.focus();
|
||||
setTimeout(scrollToEditor, 0, cm);
|
||||
setTimeout(editor.scrollToEditor, 0, cm);
|
||||
linter.enableForEditor(cm);
|
||||
}
|
||||
updateSectionOrder();
|
||||
section.onChange(updateLivePreview);
|
||||
updateLivePreview();
|
||||
}
|
||||
|
||||
/** @param {EditorSection} section */
|
||||
function moveSectionUp(section) {
|
||||
const index = sections.indexOf(section);
|
||||
if (index === 0) {
|
||||
|
@ -580,6 +601,7 @@ function createSectionsEditor(editorBase) {
|
|||
updateSectionOrder();
|
||||
}
|
||||
|
||||
/** @param {EditorSection} section */
|
||||
function moveSectionDown(section) {
|
||||
const index = sections.indexOf(section);
|
||||
if (index === sections.length - 1) {
|
||||
|
@ -591,21 +613,56 @@ function createSectionsEditor(editorBase) {
|
|||
updateSectionOrder();
|
||||
}
|
||||
|
||||
async function replaceStyle(newStyle, codeIsUpdated) {
|
||||
dirty.clear('name');
|
||||
// FIXME: avoid recreating all editors?
|
||||
if (codeIsUpdated !== false) {
|
||||
await initSections(newStyle.sections, {replace: true, pristine: true});
|
||||
/** @param {EditorSection} section */
|
||||
function registerEvents(section) {
|
||||
const {el, cm} = section;
|
||||
$('.applies-to-help', el).onclick = () => showHelp(t('appliesLabel'), t('appliesHelp'));
|
||||
$('.remove-section', el).onclick = () => removeSection(section);
|
||||
$('.add-section', el).onclick = () => insertSectionAfter(undefined, section);
|
||||
$('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section);
|
||||
$('.move-section-up', el).onclick = () => moveSectionUp(section);
|
||||
$('.move-section-down', el).onclick = () => moveSectionDown(section);
|
||||
$('.restore-section', el).onclick = () => restoreSection(section);
|
||||
cm.on('paste', maybeImportOnPaste);
|
||||
if (!FIREFOX) {
|
||||
cm.on('mousedown', (cm, event) => toggleContextMenuDelete.call(cm, event));
|
||||
}
|
||||
Object.assign(style, newStyle);
|
||||
updateHeader();
|
||||
dirty.clear();
|
||||
// Go from new style URL to edit style URL
|
||||
if (location.href.indexOf('id=') === -1 && style.id) {
|
||||
history.replaceState({}, document.title, 'edit.html?id=' + style.id);
|
||||
$('#heading').textContent = t('editStyleHeading');
|
||||
}
|
||||
livePreview.show(Boolean(style.id));
|
||||
updateLivePreview();
|
||||
|
||||
function maybeImportOnPaste(cm, event) {
|
||||
const text = event.clipboardData.getData('text') || '';
|
||||
if (/@-moz-document/i.test(text) &&
|
||||
/@-moz-document\s+(url|url-prefix|domain|regexp)\(/i
|
||||
.test(text.replace(/\/\*([^*]|\*(?!\/))*(\*\/|$)/g, ''))
|
||||
) {
|
||||
event.preventDefault();
|
||||
showMozillaFormatImport(text);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshOnView(cm, force) {
|
||||
return force || !xo ?
|
||||
cm.refresh() :
|
||||
xo.observe(cm.display.wrapper);
|
||||
}
|
||||
|
||||
function refreshOnViewListener(entries) {
|
||||
for (const {isIntersecting, target} of entries) {
|
||||
if (isIntersecting) {
|
||||
target.CodeMirror.refresh();
|
||||
xo.unobserve(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,52 +1,86 @@
|
|||
/* global
|
||||
createAppliesToLineWidget messageBox
|
||||
$
|
||||
$$
|
||||
$create
|
||||
API
|
||||
chromeSync
|
||||
cmFactory
|
||||
CodeMirror
|
||||
createLivePreview
|
||||
createMetaCompiler
|
||||
debounce
|
||||
editor
|
||||
linter
|
||||
messageBox
|
||||
MozSectionFinder
|
||||
MozSectionWidget
|
||||
prefs
|
||||
sectionsToMozFormat
|
||||
createMetaCompiler linter createLivePreview cmFactory $ $create API prefs t
|
||||
chromeSync */
|
||||
/* exported createSourceEditor */
|
||||
t
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
function createSourceEditor(editorBase) {
|
||||
const {style, dirty} = editorBase;
|
||||
/* exported SourceEditor */
|
||||
|
||||
function SourceEditor() {
|
||||
const {style, dirty} = editor;
|
||||
let savedGeneration;
|
||||
let placeholderName = '';
|
||||
let prevMode = NaN;
|
||||
|
||||
$('#mozilla-format-container').remove();
|
||||
$('#header').addEventListener('wheel', headerOnScroll);
|
||||
$$.remove('.sectioned-only');
|
||||
$('#header').on('wheel', headerOnScroll);
|
||||
$('#sections').textContent = '';
|
||||
$('#sections').appendChild($create('.single-editor'));
|
||||
|
||||
// normalize style
|
||||
if (!style.id) setupNewStyle(style);
|
||||
|
||||
const cm = cmFactory.create($('.single-editor'), {
|
||||
value: style.sourceCode,
|
||||
const cm = cmFactory.create($('.single-editor'));
|
||||
const sectionFinder = MozSectionFinder(cm);
|
||||
const sectionWidget = MozSectionWidget(cm, sectionFinder, editor.updateToc);
|
||||
const livePreview = createLivePreview(preprocess, style.id);
|
||||
/** @namespace SourceEditor */
|
||||
Object.assign(editor, {
|
||||
sections: sectionFinder.sections,
|
||||
replaceStyle,
|
||||
getEditors: () => [cm],
|
||||
scrollToEditor: () => {},
|
||||
getEditorTitle: () => '',
|
||||
save,
|
||||
prevEditor: nextPrevSection.bind(null, -1),
|
||||
nextEditor: nextPrevSection.bind(null, 1),
|
||||
jumpToEditor(i) {
|
||||
const sec = sectionFinder.sections[i];
|
||||
if (sec) {
|
||||
sectionFinder.updatePositions(sec);
|
||||
jumpToPos(sec.start);
|
||||
}
|
||||
},
|
||||
closestVisible: () => cm,
|
||||
getSearchableInputs: () => [],
|
||||
updateLivePreview,
|
||||
});
|
||||
let savedGeneration = cm.changeGeneration();
|
||||
|
||||
const livePreview = createLivePreview(preprocess);
|
||||
livePreview.show(Boolean(style.id));
|
||||
|
||||
cm.on('changes', () => {
|
||||
dirty.modify('sourceGeneration', savedGeneration, cm.changeGeneration());
|
||||
updateLivePreview();
|
||||
});
|
||||
|
||||
cm.operation(initAppliesToLineWidget);
|
||||
|
||||
const metaCompiler = createMetaCompiler(cm);
|
||||
metaCompiler.onUpdated(meta => {
|
||||
createMetaCompiler(cm, meta => {
|
||||
style.usercssData = meta;
|
||||
style.name = meta.name;
|
||||
style.url = meta.homepageURL || style.installationUrl;
|
||||
updateMeta();
|
||||
});
|
||||
|
||||
updateMeta().then(() => {
|
||||
|
||||
linter.enableForEditor(cm);
|
||||
|
||||
let prevMode = NaN;
|
||||
updateMeta();
|
||||
cm.setValue(style.sourceCode);
|
||||
prefs.subscribeMany({
|
||||
'editor.linter': updateLinterSwitch,
|
||||
'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
|
||||
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
|
||||
}, {now: true});
|
||||
cm.clearHistory();
|
||||
cm.markClean();
|
||||
savedGeneration = cm.changeGeneration();
|
||||
cm.on('changes', () => {
|
||||
dirty.modify('sourceGeneration', savedGeneration, cm.changeGeneration());
|
||||
debounce(updateLivePreview, editor.previewDelay);
|
||||
});
|
||||
cm.on('optionChange', (cm, option) => {
|
||||
if (option !== 'mode') return;
|
||||
const mode = getModeName();
|
||||
|
@ -55,16 +89,10 @@ function createSourceEditor(editorBase) {
|
|||
linter.run();
|
||||
updateLinterSwitch();
|
||||
});
|
||||
|
||||
$('#editor.linter').addEventListener('change', updateLinterSwitch);
|
||||
updateLinterSwitch();
|
||||
|
||||
setTimeout(() => {
|
||||
if ((document.activeElement || {}).localName !== 'input') {
|
||||
debounce(linter.enableForEditor, 0, cm);
|
||||
if (!$.isTextInput(document.activeElement)) {
|
||||
cm.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function preprocess(style) {
|
||||
return API.buildUsercss({
|
||||
|
@ -85,13 +113,6 @@ function createSourceEditor(editorBase) {
|
|||
livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
|
||||
}
|
||||
|
||||
function initAppliesToLineWidget() {
|
||||
const PREF_NAME = 'editor.appliesToLineWidget';
|
||||
const widget = createAppliesToLineWidget(cm);
|
||||
widget.toggle(prefs.get(PREF_NAME));
|
||||
prefs.subscribe([PREF_NAME], (key, value) => widget.toggle(value));
|
||||
}
|
||||
|
||||
function updateLinterSwitch() {
|
||||
const el = $('#editor.linter');
|
||||
el.value = getCurrentLinter();
|
||||
|
@ -158,8 +179,8 @@ function createSourceEditor(editorBase) {
|
|||
}
|
||||
$('#enabled').checked = style.enabled;
|
||||
$('#url').href = style.url;
|
||||
editorBase.updateName();
|
||||
return cm.setPreprocessor((style.usercssData || {}).preprocessor);
|
||||
editor.updateName();
|
||||
cm.setPreprocessor((style.usercssData || {}).preprocessor);
|
||||
}
|
||||
|
||||
function replaceStyle(newStyle, codeIsUpdated) {
|
||||
|
@ -272,68 +293,30 @@ function createSourceEditor(editorBase) {
|
|||
);
|
||||
}
|
||||
|
||||
function nextPrevMozDocument(cm, dir) {
|
||||
const MOZ_DOC = '@-moz-document';
|
||||
const cursor = cm.getCursor();
|
||||
const usePrevLine = dir < 0 && cursor.ch <= MOZ_DOC.length;
|
||||
let line = cursor.line + (usePrevLine ? -1 : 0);
|
||||
let start = usePrevLine ? 1e9 : cursor.ch + (dir > 0 ? 1 : -MOZ_DOC.length);
|
||||
let found;
|
||||
if (dir > 0) {
|
||||
cm.doc.iter(cursor.line, cm.doc.size, goFind);
|
||||
if (!found && cursor.line > 0) {
|
||||
line = 0;
|
||||
cm.doc.iter(0, cursor.line + 1, goFind);
|
||||
function nextPrevSection(dir) {
|
||||
// ensure the data is ready in case the user wants to jump around a lot in a large style
|
||||
sectionFinder.keepAliveFor(nextPrevSection, 10e3);
|
||||
sectionFinder.updatePositions();
|
||||
const {sections} = sectionFinder;
|
||||
const num = sections.length;
|
||||
if (!num) return;
|
||||
dir = dir < 0 ? -1 : 0;
|
||||
const pos = cm.getCursor();
|
||||
let i = sections.findIndex(sec => CodeMirror.cmpPos(sec.start, pos) > Math.min(dir, 0));
|
||||
if (i < 0 && (!dir || CodeMirror.cmpPos(sections[num - 1].start, pos) < 0)) {
|
||||
i = 0;
|
||||
}
|
||||
} else {
|
||||
let handle, parentLines;
|
||||
let passesRemain = line < cm.doc.size - 1 ? 2 : 1;
|
||||
let stopAtLine = 0;
|
||||
while (passesRemain--) {
|
||||
let indexInParent = 0;
|
||||
while (line >= stopAtLine) {
|
||||
if (!indexInParent--) {
|
||||
handle = cm.getLineHandle(line);
|
||||
parentLines = handle.parent.lines;
|
||||
indexInParent = parentLines.indexOf(handle);
|
||||
} else {
|
||||
handle = parentLines[indexInParent];
|
||||
jumpToPos(sections[(i + dir + num) % num].start);
|
||||
}
|
||||
if (goFind(handle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
line = cm.doc.size - 1;
|
||||
stopAtLine = cursor.line;
|
||||
}
|
||||
}
|
||||
function goFind({text}) {
|
||||
// use the initial 'start' on cursor row...
|
||||
let ch = start;
|
||||
// ...and reset it for the rest
|
||||
start = dir > 0 ? 0 : 1e9;
|
||||
while (true) {
|
||||
// indexOf is 1000x faster than toLowerCase().indexOf() so we're trying it first
|
||||
ch = dir > 0 ? text.indexOf('@-', ch) : text.lastIndexOf('@-', ch);
|
||||
if (ch < 0) {
|
||||
line += dir;
|
||||
return;
|
||||
}
|
||||
if (text.substr(ch, MOZ_DOC.length).toLowerCase() === MOZ_DOC &&
|
||||
cm.getTokenTypeAt({line, ch: ch + 1}) === 'def') {
|
||||
break;
|
||||
}
|
||||
ch += dir * 3;
|
||||
}
|
||||
cm.setCursor(line, ch);
|
||||
if (cm.cursorCoords().bottom > cm.display.scroller.clientHeight - 100) {
|
||||
const margin = Math.min(100, cm.display.scroller.clientHeight / 4);
|
||||
line += prefs.get('editor.appliesToLineWidget') ? 1 : 0;
|
||||
cm.scrollIntoView({line, ch}, margin);
|
||||
}
|
||||
found = true;
|
||||
return true;
|
||||
|
||||
function jumpToPos(pos) {
|
||||
const coords = cm.cursorCoords(pos, 'page');
|
||||
const b = cm.display.wrapper.getBoundingClientRect();
|
||||
if (coords.top < b.top + cm.defaultTextHeight() * 2 ||
|
||||
coords.bottom > b.bottom - 100) {
|
||||
cm.scrollIntoView(pos, b.height / 2);
|
||||
}
|
||||
cm.setCursor(pos, null, {scroll: false});
|
||||
}
|
||||
|
||||
function headerOnScroll({target, deltaY, deltaMode, shiftKey}) {
|
||||
|
@ -358,18 +341,4 @@ function createSourceEditor(editorBase) {
|
|||
return (mode.name || mode || '') +
|
||||
(mode.helperType || '');
|
||||
}
|
||||
|
||||
return Object.assign({}, editorBase, {
|
||||
ready: Promise.resolve(),
|
||||
replaceStyle,
|
||||
getEditors: () => [cm],
|
||||
scrollToEditor: () => {},
|
||||
getEditorTitle: () => '',
|
||||
save,
|
||||
prevEditor: cm => nextPrevMozDocument(cm, -1),
|
||||
nextEditor: cm => nextPrevMozDocument(cm, 1),
|
||||
closestVisible: () => cm,
|
||||
getSearchableInputs: () => [],
|
||||
updateLivePreview,
|
||||
});
|
||||
}
|
||||
|
|
176
edit/util.js
176
edit/util.js
|
@ -1,130 +1,161 @@
|
|||
/* global CodeMirror $create prefs */
|
||||
/* exported dirtyReporter memoize clipString sectionsToMozFormat createHotkeyInput */
|
||||
/* global
|
||||
$create
|
||||
CodeMirror
|
||||
prefs
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
function dirtyReporter() {
|
||||
const dirty = new Map();
|
||||
const onchanges = [];
|
||||
/* exported DirtyReporter */
|
||||
class DirtyReporter {
|
||||
constructor() {
|
||||
this._dirty = new Map();
|
||||
this._onchange = new Set();
|
||||
}
|
||||
|
||||
function add(obj, value) {
|
||||
const saved = dirty.get(obj);
|
||||
add(obj, value) {
|
||||
const wasDirty = this.isDirty();
|
||||
const saved = this._dirty.get(obj);
|
||||
if (!saved) {
|
||||
dirty.set(obj, {type: 'add', newValue: value});
|
||||
this._dirty.set(obj, {type: 'add', newValue: value});
|
||||
} else if (saved.type === 'remove') {
|
||||
if (saved.savedValue === value) {
|
||||
dirty.delete(obj);
|
||||
this._dirty.delete(obj);
|
||||
} else {
|
||||
saved.newValue = value;
|
||||
saved.type = 'modify';
|
||||
}
|
||||
}
|
||||
this.notifyChange(wasDirty);
|
||||
}
|
||||
|
||||
function remove(obj, value) {
|
||||
const saved = dirty.get(obj);
|
||||
remove(obj, value) {
|
||||
const wasDirty = this.isDirty();
|
||||
const saved = this._dirty.get(obj);
|
||||
if (!saved) {
|
||||
dirty.set(obj, {type: 'remove', savedValue: value});
|
||||
this._dirty.set(obj, {type: 'remove', savedValue: value});
|
||||
} else if (saved.type === 'add') {
|
||||
dirty.delete(obj);
|
||||
this._dirty.delete(obj);
|
||||
} else if (saved.type === 'modify') {
|
||||
saved.type = 'remove';
|
||||
}
|
||||
this.notifyChange(wasDirty);
|
||||
}
|
||||
|
||||
function modify(obj, oldValue, newValue) {
|
||||
const saved = dirty.get(obj);
|
||||
modify(obj, oldValue, newValue) {
|
||||
const wasDirty = this.isDirty();
|
||||
const saved = this._dirty.get(obj);
|
||||
if (!saved) {
|
||||
if (oldValue !== newValue) {
|
||||
dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue});
|
||||
this._dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue});
|
||||
}
|
||||
} else if (saved.type === 'modify') {
|
||||
if (saved.savedValue === newValue) {
|
||||
dirty.delete(obj);
|
||||
this._dirty.delete(obj);
|
||||
} else {
|
||||
saved.newValue = newValue;
|
||||
}
|
||||
} else if (saved.type === 'add') {
|
||||
saved.newValue = newValue;
|
||||
}
|
||||
this.notifyChange(wasDirty);
|
||||
}
|
||||
|
||||
function clear(obj) {
|
||||
clear(obj) {
|
||||
const wasDirty = this.isDirty();
|
||||
if (obj === undefined) {
|
||||
dirty.clear();
|
||||
this._dirty.clear();
|
||||
} else {
|
||||
dirty.delete(obj);
|
||||
this._dirty.delete(obj);
|
||||
}
|
||||
this.notifyChange(wasDirty);
|
||||
}
|
||||
|
||||
isDirty() {
|
||||
return this._dirty.size > 0;
|
||||
}
|
||||
|
||||
onChange(cb, add = true) {
|
||||
this._onchange[add ? 'add' : 'delete'](cb);
|
||||
}
|
||||
|
||||
notifyChange(wasDirty) {
|
||||
if (wasDirty !== this.isDirty()) {
|
||||
this._onchange.forEach(cb => cb());
|
||||
}
|
||||
}
|
||||
|
||||
function isDirty() {
|
||||
return dirty.size > 0;
|
||||
has(key) {
|
||||
return this._dirty.has(key);
|
||||
}
|
||||
|
||||
function onChange(cb) {
|
||||
// make sure the callback doesn't throw
|
||||
onchanges.push(cb);
|
||||
}
|
||||
|
||||
function wrap(obj) {
|
||||
for (const key of ['add', 'remove', 'modify', 'clear']) {
|
||||
obj[key] = trackChange(obj[key]);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
for (const cb of onchanges) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
function trackChange(fn) {
|
||||
return function () {
|
||||
const dirty = isDirty();
|
||||
const result = fn.apply(null, arguments);
|
||||
if (dirty !== isDirty()) {
|
||||
emitChange();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function has(key) {
|
||||
return dirty.has(key);
|
||||
}
|
||||
|
||||
return wrap({add, remove, modify, clear, isDirty, onChange, has});
|
||||
}
|
||||
|
||||
|
||||
function sectionsToMozFormat(style) {
|
||||
const propertyToCss = {
|
||||
/* exported DocFuncMapper */
|
||||
const DocFuncMapper = {
|
||||
TO_CSS: {
|
||||
urls: 'url',
|
||||
urlPrefixes: 'url-prefix',
|
||||
domains: 'domain',
|
||||
regexps: 'regexp',
|
||||
};
|
||||
},
|
||||
FROM_CSS: {
|
||||
'url': 'urls',
|
||||
'url-prefix': 'urlPrefixes',
|
||||
'domain': 'domains',
|
||||
'regexp': 'regexps',
|
||||
},
|
||||
/**
|
||||
* @param {Object} section
|
||||
* @param {function(func:string, value:string)} fn
|
||||
*/
|
||||
forEachProp(section, fn) {
|
||||
for (const [propName, func] of Object.entries(DocFuncMapper.TO_CSS)) {
|
||||
const props = section[propName];
|
||||
if (props) props.forEach(value => fn(func, value));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Array<?[type,value]>} funcItems
|
||||
* @param {?Object} [section]
|
||||
* @returns {Object} section
|
||||
*/
|
||||
toSection(funcItems, section = {}) {
|
||||
for (const item of funcItems) {
|
||||
const [func, value] = item || [];
|
||||
const propName = DocFuncMapper.FROM_CSS[func];
|
||||
if (propName) {
|
||||
const props = section[propName] || (section[propName] = []);
|
||||
if (Array.isArray(value)) props.push(...value);
|
||||
else props.push(value);
|
||||
}
|
||||
}
|
||||
return section;
|
||||
},
|
||||
};
|
||||
|
||||
/* exported sectionsToMozFormat */
|
||||
function sectionsToMozFormat(style) {
|
||||
return style.sections.map(section => {
|
||||
let cssMds = [];
|
||||
for (const i in propertyToCss) {
|
||||
if (section[i]) {
|
||||
cssMds = cssMds.concat(section[i].map(v =>
|
||||
propertyToCss[i] + '("' + v.replace(/\\/g, '\\\\') + '")'
|
||||
));
|
||||
}
|
||||
}
|
||||
return cssMds.length ?
|
||||
'@-moz-document ' + cssMds.join(', ') + ' {\n' + section.code + '\n}' :
|
||||
const cssFuncs = [];
|
||||
DocFuncMapper.forEachProp(section, (type, value) =>
|
||||
cssFuncs.push(`${type}("${value.replace(/\\/g, '\\\\')}")`));
|
||||
return cssFuncs.length ?
|
||||
`@-moz-document ${cssFuncs.join(', ')} {\n${section.code}\n}` :
|
||||
section.code;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
/* exported trimCommentLabel */
|
||||
function trimCommentLabel(str, limit = 1000) {
|
||||
// stripping /*** foo ***/ to foo
|
||||
return clipString(str.replace(/^[!-/:;=\s]*|[-#$&(+,./:;<=>\s*]*$/g, ''), limit);
|
||||
}
|
||||
|
||||
/* exported clipString */
|
||||
function clipString(str, limit = 100) {
|
||||
return str.length <= limit ? str : str.substr(0, limit) + '...';
|
||||
}
|
||||
|
||||
// this is a decorator. Cache the first call
|
||||
/* exported memoize */
|
||||
function memoize(fn) {
|
||||
let cached = false;
|
||||
let result;
|
||||
|
@ -137,6 +168,7 @@ function memoize(fn) {
|
|||
};
|
||||
}
|
||||
|
||||
/* exported createHotkeyInput */
|
||||
/**
|
||||
* @param {!string} prefId
|
||||
* @param {?function(isEnter:boolean)} onDone
|
||||
|
|
|
@ -212,8 +212,12 @@ select[disabled] + .select-arrow {
|
|||
|
||||
:focus,
|
||||
.CodeMirror-focused,
|
||||
[data-focused-via-click] input[type="text"]:focus,
|
||||
[data-focused-via-click] input[type="number"]:focus {
|
||||
/* Allowing click outline on text/search inputs and textareas */
|
||||
textarea[data-focused-via-click]:focus,
|
||||
input:not([type])[data-focused-via-click]:focus, /* same as "text" */
|
||||
input[type="text"][data-focused-via-click]:focus,
|
||||
input[type="search"][data-focused-via-click]:focus,
|
||||
input[type="number"][data-focused-via-click]:focus {
|
||||
/* Using box-shadow instead of the ugly outline in new Chrome */
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px hsl(180, 100%, 38%), 0 0 3px hsla(180, 100%, 60%, .5);
|
||||
|
|
110
js/dom.js
110
js/dom.js
|
@ -7,20 +7,17 @@ if (!/^Win\d+/.test(navigator.platform)) {
|
|||
document.documentElement.classList.add('non-windows');
|
||||
}
|
||||
|
||||
// make querySelectorAll enumeration code readable
|
||||
// FIXME: avoid extending native?
|
||||
['forEach', 'some', 'indexOf', 'map'].forEach(method => {
|
||||
NodeList.prototype[method] = Array.prototype[method];
|
||||
Object.assign(EventTarget.prototype, {
|
||||
on: addEventListener,
|
||||
off: removeEventListener,
|
||||
/** args: [el:EventTarget, type:string, fn:function, ?opts] */
|
||||
onOff(enable, ...args) {
|
||||
(enable ? addEventListener : removeEventListener).apply(this, args);
|
||||
},
|
||||
});
|
||||
|
||||
// polyfill for old browsers to enable [...results] and for-of
|
||||
for (const type of [NodeList, NamedNodeMap, HTMLCollection, HTMLAllCollection]) {
|
||||
if (!type.prototype[Symbol.iterator]) {
|
||||
type.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
|
||||
}
|
||||
}
|
||||
|
||||
$.isTextLikeInput = el =>
|
||||
$.isTextInput = (el = {}) =>
|
||||
el.localName === 'textarea' ||
|
||||
el.localName === 'input' && /^(text|search|number)$/.test(el.type);
|
||||
|
||||
$.remove = (selector, base = document) => {
|
||||
|
@ -61,7 +58,7 @@ $$.remove = (selector, base = document) => {
|
|||
setTimeout(addTooltipsToEllipsized, 500);
|
||||
// throttle on continuous resizing
|
||||
let timer;
|
||||
window.addEventListener('resize', () => {
|
||||
window.on('resize', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(addTooltipsToEllipsized, 100);
|
||||
});
|
||||
|
@ -89,13 +86,13 @@ onDOMready().then(() => {
|
|||
// set language for CSS :lang and [FF-only] hyphenation
|
||||
document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
|
||||
// avoid adding # to the page URL when clicking dummy links
|
||||
document.addEventListener('click', e => {
|
||||
document.on('click', e => {
|
||||
if (e.target.closest('a[href="#"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
// update inputs on mousewheel when focused
|
||||
document.addEventListener('wheel', event => {
|
||||
document.on('wheel', event => {
|
||||
const el = document.activeElement;
|
||||
if (!el || el !== event.target && !el.contains(event.target)) {
|
||||
return;
|
||||
|
@ -117,7 +114,7 @@ document.addEventListener('wheel', event => {
|
|||
function onDOMready() {
|
||||
return document.readyState !== 'loading'
|
||||
? Promise.resolve()
|
||||
: new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, {once: true}));
|
||||
: new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
|
||||
}
|
||||
|
||||
|
||||
|
@ -152,12 +149,12 @@ function animateElement(el, cls = 'highlight', ...removeExtraClasses) {
|
|||
if (onDone) {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.animationName === 'none' || !parseFloat(style.animationDuration)) {
|
||||
el.removeEventListener('animationend', onDone);
|
||||
el.off('animationend', onDone);
|
||||
onDone();
|
||||
}
|
||||
}
|
||||
});
|
||||
el.addEventListener('animationend', onDone, {once: true});
|
||||
el.on('animationend', onDone, {once: true});
|
||||
el.classList.add(cls);
|
||||
});
|
||||
}
|
||||
|
@ -175,8 +172,8 @@ function enforceInputRange(element) {
|
|||
doNotify();
|
||||
}
|
||||
};
|
||||
element.addEventListener('change', onChange);
|
||||
element.addEventListener('input', onChange);
|
||||
element.on('change', onChange);
|
||||
element.on('input', onChange);
|
||||
}
|
||||
|
||||
|
||||
|
@ -320,7 +317,7 @@ function initCollapsibles({bindClickOn = 'h2'} = {}) {
|
|||
const key = el.dataset.pref;
|
||||
prefMap[key] = el;
|
||||
el.open = prefs.get(key);
|
||||
(bindClickOn && $(bindClickOn, el) || el).addEventListener('click', onClick);
|
||||
(bindClickOn && $(bindClickOn, el) || el).on('click', onClick);
|
||||
}
|
||||
|
||||
prefs.subscribe(Object.keys(prefMap), (key, value) => {
|
||||
|
@ -339,7 +336,7 @@ function initCollapsibles({bindClickOn = 'h2'} = {}) {
|
|||
}
|
||||
|
||||
function saveState(el) {
|
||||
if (!el.classList.contains('ignore-pref')) {
|
||||
if (!el.matches('.compact-layout .ignore-pref-if-compact')) {
|
||||
prefs.set(el.dataset.pref, el.open);
|
||||
}
|
||||
}
|
||||
|
@ -349,58 +346,41 @@ function initCollapsibles({bindClickOn = 'h2'} = {}) {
|
|||
function focusAccessibility() {
|
||||
// last event's focusedViaClick
|
||||
focusAccessibility.lastFocusedViaClick = false;
|
||||
// tags of focusable elements;
|
||||
// to avoid a full layout recalc we modify the closest one
|
||||
focusAccessibility.ELEMENTS = [
|
||||
'a',
|
||||
'button',
|
||||
'input',
|
||||
'label',
|
||||
'select',
|
||||
'summary',
|
||||
];
|
||||
// try to find a focusable parent for this many parentElement jumps:
|
||||
const GIVE_UP_DEPTH = 4;
|
||||
// allow outline on text/search inputs in addition to textareas
|
||||
const isOutlineAllowed = el =>
|
||||
!focusAccessibility.ELEMENTS.includes(el.localName) ||
|
||||
$.isTextLikeInput(el);
|
||||
|
||||
addEventListener('mousedown', suppressOutlineOnClick, {passive: true});
|
||||
addEventListener('keydown', keepOutlineOnTab, {passive: true});
|
||||
|
||||
function suppressOutlineOnClick({target}) {
|
||||
for (let el = target, i = 0; el && i++ < GIVE_UP_DEPTH; el = el.parentElement) {
|
||||
if (!isOutlineAllowed(el)) {
|
||||
// to avoid a full layout recalc due to changes on body/root
|
||||
// we modify the closest focusable element (like input or button or anything with tabindex=0)
|
||||
focusAccessibility.closest = el => {
|
||||
let labelSeen;
|
||||
for (; el; el = el.parentElement) {
|
||||
if (el.localName === 'label' && el.control && !labelSeen) {
|
||||
el = el.control;
|
||||
labelSeen = true;
|
||||
}
|
||||
if (el.tabIndex >= 0) return el;
|
||||
}
|
||||
};
|
||||
// suppress outline on click
|
||||
window.on('mousedown', ({target}) => {
|
||||
const el = focusAccessibility.closest(target);
|
||||
if (el) {
|
||||
focusAccessibility.lastFocusedViaClick = true;
|
||||
if (el.dataset.focusedViaClick === undefined) {
|
||||
el.dataset.focusedViaClick = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keepOutlineOnTab(event) {
|
||||
if (event.key === 'Tab') {
|
||||
}, {passive: true});
|
||||
// keep outline on Tab or Shift-Tab key
|
||||
window.on('keydown', event => {
|
||||
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
focusAccessibility.lastFocusedViaClick = false;
|
||||
setTimeout(keepOutlineOnTab, 0, true);
|
||||
return;
|
||||
} else if (event !== true) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
let el = document.activeElement;
|
||||
if (!el || isOutlineAllowed(el)) {
|
||||
return;
|
||||
}
|
||||
if (el.dataset.focusedViaClick !== undefined) {
|
||||
delete el.dataset.focusedViaClick;
|
||||
}
|
||||
el = el.closest('[data-focused-via-click]');
|
||||
if (el) {
|
||||
delete el.dataset.focusedViaClick;
|
||||
el = el.closest('[data-focused-via-click]');
|
||||
if (el) delete el.dataset.focusedViaClick;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, {passive: true});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -437,7 +417,7 @@ function setupLivePrefs(
|
|||
for (const id of IDs) {
|
||||
const element = $('#' + id);
|
||||
updateElement({id, element, force: true});
|
||||
element.addEventListener('change', onChange);
|
||||
element.on('change', onChange);
|
||||
}
|
||||
prefs.subscribe(IDs, (id, value) => updateElement({id, value}));
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@ window.INJECTED !== 1 && (() => {
|
|||
'manage.newUI.sort': 'title,asc',
|
||||
|
||||
'editor.options': {}, // CodeMirror.defaults.*
|
||||
'editor.toc.expanded': true, // UI element state: expanded/collapsed
|
||||
'editor.options.expanded': true, // UI element state: expanded/collapsed
|
||||
'editor.lint.expanded': true, // UI element state: expanded/collapsed
|
||||
'editor.lineWrapping': true, // word wrap
|
||||
|
@ -180,6 +181,11 @@ window.INJECTED !== 1 && (() => {
|
|||
if (now) fn();
|
||||
}
|
||||
},
|
||||
subscribeMany(data, opts) {
|
||||
for (const [k, fn] of Object.entries(data)) {
|
||||
prefs.subscribe(k, fn, opts);
|
||||
}
|
||||
},
|
||||
unsubscribe(keys, fn) {
|
||||
if (keys) {
|
||||
for (const key of keys) {
|
||||
|
|
|
@ -79,7 +79,7 @@ function styleSectionsEqual(a, b, {ignoreCode, checkSource} = {}) {
|
|||
|
||||
function normalizeStyleSections({sections}) {
|
||||
// retain known properties in an arbitrarily predefined order
|
||||
return (sections || []).map(section => ({
|
||||
return (sections || []).map(section => /** @namespace StyleSection */({
|
||||
code: section.code || '',
|
||||
urls: section.urls || [],
|
||||
urlPrefixes: section.urlPrefixes || [],
|
||||
|
|
|
@ -84,7 +84,7 @@ onDOMready().then(() => {
|
|||
if (event.altKey || event.metaKey || $('#message-box')) {
|
||||
return;
|
||||
}
|
||||
const inTextInput = $.isTextLikeInput(event.target);
|
||||
const inTextInput = $.isTextInput(event.target);
|
||||
const {key, code, ctrlKey: ctrl} = event;
|
||||
// `code` is independent of the current keyboard language
|
||||
if ((code === 'KeyF' && ctrl && !event.shiftKey) ||
|
||||
|
@ -94,17 +94,21 @@ onDOMready().then(() => {
|
|||
$('#search').focus();
|
||||
return;
|
||||
}
|
||||
if (ctrl || inTextInput ||
|
||||
key === ' ' && !input.value /* Space or Shift-Space is for page down/up */) {
|
||||
if (ctrl || inTextInput && event.target !== input) {
|
||||
return;
|
||||
}
|
||||
const time = performance.now();
|
||||
if (key.length === 1) {
|
||||
input.focus();
|
||||
if (time - prevTime > 1000) {
|
||||
input.value = '';
|
||||
}
|
||||
// Space or Shift-Space is for page down/up
|
||||
if (key === ' ' && !input.value) {
|
||||
input.blur();
|
||||
} else {
|
||||
input.focus();
|
||||
prevTime = time;
|
||||
}
|
||||
} else
|
||||
if (key === 'Enter' && focusedLink) {
|
||||
focusedLink.dispatchEvent(new MouseEvent('click', {bubbles: true}));
|
||||
|
|
|
@ -68,7 +68,7 @@ function messageBox({
|
|||
}
|
||||
switch (key) {
|
||||
case 'Enter':
|
||||
if (target.closest(focusAccessibility.ELEMENTS.join(','))) {
|
||||
if (focusAccessibility.closest(target)) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
|
|
|
@ -4435,14 +4435,23 @@ self.parserlib = (() => {
|
|||
const prefix = start.value.split('-')[1] || '';
|
||||
do {
|
||||
this._ws();
|
||||
functions.push(this._documentFunction());
|
||||
functions.push(this._documentFunction() || stream.LT(1));
|
||||
} while (stream.match(Tokens.COMMA));
|
||||
|
||||
this._ws();
|
||||
if (this.options.emptyDocument && stream.peek() !== Tokens.LBRACE) {
|
||||
this.fire({type: 'emptydocument', functions, prefix}, start);
|
||||
return;
|
||||
}
|
||||
for (const fn of functions) {
|
||||
if ((fn.type !== 'function' || !/^(url(-prefix)?|domain|regexp)$/i.test(fn.name)) &&
|
||||
fn.type !== 'uri') {
|
||||
this.fire({
|
||||
type: 'error',
|
||||
message: 'Expected url( or url-prefix( or domain( or regexp(, instead saw ' +
|
||||
Tokens.name(fn.tokenType || fn.type) + ' ' + (fn.text || fn.value),
|
||||
}, fn);
|
||||
}
|
||||
}
|
||||
stream.mustMatch(Tokens.LBRACE);
|
||||
|
||||
this.fire({
|
||||
|
|
Loading…
Reference in New Issue
Block a user