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:
tophf 2020-11-08 11:12:42 +03:00 committed by GitHub
parent 71cabc2029
commit 5e5fecbcfe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 2008 additions and 1633 deletions

View File

@ -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"

View File

@ -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>

View File

@ -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)));
}
}

View File

@ -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

View File

@ -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;

View File

@ -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);
}
}

View File

@ -79,7 +79,7 @@ onDOMready().then(() => {
doReplace();
return;
}
return !event.target.closest(focusAccessibility.ELEMENTS.join(','));
return !focusAccessibility.closest(event.target);
},
'Esc': () => {
destroyDialog({restoreFocus: true});

View File

@ -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)
};
}

View File

@ -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
View 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
View 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});
}
}

View File

@ -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);
}
}
}
}

View File

@ -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});
}
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();
/** @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));
}
}
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);
}
}
}

View File

@ -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,
});
}

View File

@ -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;
}
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();
has(key) {
return this._dirty.has(key);
}
}
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

View File

@ -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
View File

@ -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}));

View File

@ -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) {

View File

@ -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 || [],

View File

@ -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}));

View File

@ -68,7 +68,7 @@ function messageBox({
}
switch (key) {
case 'Enter':
if (target.closest(focusAccessibility.ELEMENTS.join(','))) {
if (focusAccessibility.closest(target)) {
return;
}
break;

View File

@ -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({