diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 638e5b3b..d99e03e5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/edit.html b/edit.html index 5d7d0c00..e5bc1fdb 100644 --- a/edit.html +++ b/edit.html @@ -32,12 +32,14 @@ + + @@ -84,10 +86,10 @@ - - + + @@ -311,154 +313,147 @@ -
-

- - - -

-
- - -
+
+ + + + +
-
-

-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- - - - -
-
- -
-
-
-
- - -
-
- -
- - +
+
+

+
+
+
+
- - - -
-
- -
- - +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + +
+
+
-
- -
- - +
+
+ +
-
-
- +
+
- + +
+ + + +
+
+ +
+ + +
+
+
+ +
+
- - - +
+
+ +
+ + +
+ + + +
-
-
-
- -

: - - - -

-
-
-
-
-
+
+
+

+
    +
    +
    + +

    : + + + +

    +
    +
    +
    +
    +
    +
    -
    - - -
    +
    diff --git a/edit/applies-to-line-widget.js b/edit/applies-to-line-widget.js deleted file mode 100644 index f3d227cc..00000000 --- a/edit/applies-to-line-widget.js +++ /dev/null @@ -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))); - } -} diff --git a/edit/codemirror-default.js b/edit/codemirror-default.js index dc987a6a..ff2f51a8 100644 --- a/edit/codemirror-default.js +++ b/edit/codemirror-default.js @@ -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', - }, - 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(); - } - 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; + 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); } - }); - return isBlank; + }, + /** 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; + } }); // editor commands diff --git a/edit/edit.css b/edit/edit.css index 8301a737..dcfe72db 100644 --- a/edit/edit.css +++ b/edit/edit.css @@ -1,5 +1,6 @@ :root { --header-narrow-min-height: 12em; + --fixed-padding: unset; } body { @@ -18,6 +19,7 @@ body { z-index: 2147483647; opacity: 0; transition: opacity 2s; + contain: strict; } #global-progress[title] { opacity: 1; @@ -146,9 +148,6 @@ label { display: inline-block; vertical-align: middle; } -#mozilla-format-heading .svg-inline-wrapper { - margin-left: 0; -} #colorpicker-settings.svg-inline-wrapper { margin: -2px 0 0 .1rem; } @@ -190,8 +189,6 @@ input:invalid { align-items: center; margin-left: -13px; cursor: pointer; - margin-top: .5rem; - margin-bottom: .5rem; } #header summary h2 { @@ -203,6 +200,9 @@ input:invalid { padding-left: 13px; /* clicking directly on details-marker doesn't set pref so we cover it with h2 */ } +#options-wrapper { + padding: .5rem 0; +} #header summary:hover h2 { border-color: #bbb; } @@ -211,18 +211,25 @@ input:invalid { margin-top: -3px; } +#details-wrapper { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +#header details { + margin-top: .5rem; +} + #actions > * { display: inline-flex; flex-wrap: wrap; } -#mozilla-format-container { - flex-direction: column; -} - #mozilla-format-buttons { display: flex; flex-wrap: wrap; + align-items: center; } #actions > div > a { @@ -272,6 +279,7 @@ input:invalid { /************ section editor ***********/ .CodeMirror-vscrollbar, .CodeMirror-hscrollbar { + box-shadow: none !important; pointer-events: auto !important; /* FF bug */ } .section-editor .section { @@ -305,6 +313,9 @@ input:invalid { counter-reset: codebox; } #sections > .section > label { + padding: 0 0 4px 0; + display: inline-block; + font-size: 13px; animation: 2s highlight; animation-play-state: paused; animation-direction: reverse; @@ -312,9 +323,41 @@ input:invalid { } #sections > .section > label::after { counter-increment: codebox; - content: counter(codebox); + content: counter(codebox) ": " attr(data-text); margin-left: 0.25rem; } +.single-editor .applies-to > label::before { + content: attr(data-index) ":"; + margin-right: 0.25rem; +} +.code-label[data-text] { + font-weight: bold; +} +#toc { + counter-reset: codelabel; + margin: 0; + padding: .5rem 0; +} +#toc li { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} +#toc li.current:not(:only-child) { + font-weight: bold; +} +#toc li[tabindex="-1"] { + opacity: .25; + pointer-events: none; +} +#toc li:hover { + background-color: hsla(180, 50%, 36%, .2); +} +#toc li[tabindex="0"]::before { + counter-increment: codelabel; + content: counter(codelabel) ": "; +} .section:only-of-type .move-section-up, .section:only-of-type .move-section-down { display: none; @@ -438,6 +481,10 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high min-height: 30px; flex-wrap: wrap; } +.applies-to.error { + background-color: #f002; + border-color: #f008; +} .applies-to label { display: flex; padding: 0; @@ -617,9 +664,12 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high #help-popup .CodeMirror { margin: 3px; } - +#help-popup .keymap-list input[type="search"] { + margin: 0 0 2px; +} .keymap-list { font-size: 12px; + padding: 0 3px 0 0; border-spacing: 0; word-break: break-all; } @@ -677,13 +727,13 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high padding-left: 4px; } #lint[open]:not(.hidden-unless-compact) { - min-height: 130px; + min-height: 102px; } #lint summary h2 { - margin-left: -16px; + text-indent: -2px; } #lint > .lint-scroll-container { - margin: 42px 1rem 0; + margin: 34px 10px 0; position: absolute; top: 0; bottom: 0; @@ -721,7 +771,7 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high cursor: pointer; } #lint tr:hover { - background-color: rgba(0, 0, 0, 0.1); + background-color: hsla(180, 50%, 36%, .2); } #lint td[role="severity"] { font-size: 0; @@ -799,8 +849,7 @@ body:not(.find-open) [data-match-highlight-count="1"] .CodeMirror-selection-high } html:not(.usercss) .usercss-only, -.usercss #mozilla-format-container, -.usercss #sections > h2 { +.usercss .sectioned-only { display: none !important; /* hide during page init */ } @@ -877,7 +926,7 @@ body.linter-disabled .hidden-unless-compact { padding: 0; } .fixed-header { - padding-top: 40px; + padding-top: var(--fixed-padding); } .fixed-header #header { min-height: 40px; @@ -885,10 +934,11 @@ body.linter-disabled .hidden-unless-compact { top: 0; left: 0; right: 0; - padding: 8px 0 0; + padding: 0; background-color: #fff; } - .fixed-header #header > *:not(#lint) { + .fixed-header #header > *:not(#details-wrapper), + .fixed-header #options { display: none !important; } #actions { @@ -925,9 +975,31 @@ body.linter-disabled .hidden-unless-compact { #options-wrapper { display: flex; flex-wrap: wrap; - padding: 0 1rem .5rem; + padding: .5rem 1rem 0; box-sizing: border-box; } + #toc { + padding: .5rem 1rem; + } + #details-wrapper { + flex-direction: row; + flex-wrap: wrap; + padding-bottom: .25rem; + } + #options { + width: 100%; + } + #sections-list[open] { + height: 102px; + } + #sections-list[open] #toc { + max-height: 60px; + overflow-y: auto; + } + #sections-list, + #lint { + width: 50%; + } .options-column { flex-grow: 1; padding-right: .5rem; @@ -947,7 +1019,7 @@ body.linter-disabled .hidden-unless-compact { position: static; margin-bottom: 0; } - #options summary { + #header summary { margin-left: 0; padding-left: 4px; } @@ -966,15 +1038,11 @@ body.linter-disabled .hidden-unless-compact { top: 0.2rem; } #lint > .lint-scroll-container { - margin: 32px 1rem 0; - bottom: 6px; + margin: 26px 1rem 0; } #lint { padding: 0; - margin: 1rem 0 0; - } - #lint > summary { - margin-top: 0; + margin: .5rem 0 0; } #lint:not([open]) + #footer { margin: .25em 0 -1em .25em; diff --git a/edit/edit.js b/edit/edit.js index 5f38cd89..1a5c4326 100644 --- a/edit/edit.js +++ b/edit/edit.js @@ -1,52 +1,72 @@ -/* global CodeMirror onDOMready prefs setupLivePrefs $ $$ $create t tHTML - createSourceEditor sessionStorageHash getOwnTab FIREFOX API tryCatch - closeCurrentTab messageBox debounce tryJSONparse - initBeautifyButton ignoreChromeError dirtyReporter linter - moveFocus msg createSectionsEditor rerouteHotkeys CODEMIRROR_THEMES */ -/* exported showCodeMirrorPopup editorWorker toggleContextMenuDelete */ +/* global + $ + $$ + $create + API + clipString + closeCurrentTab + CodeMirror + CODEMIRROR_THEMES + debounce + deepEqual + DirtyReporter + DocFuncMapper + FIREFOX + getOwnTab + initBeautifyButton + linter + messageBox + moveFocus + msg + onDOMready + prefs + rerouteHotkeys + SectionsEditor + sessionStorageHash + setupLivePrefs + SourceEditor + t + tHTML + tryCatch + tryJSONparse +*/ 'use strict'; -// direct & reverse mapping of @-moz-document keywords and internal property names -const propertyToCss = {urls: 'url', urlPrefixes: 'url-prefix', domains: 'domain', regexps: 'regexp'}; -const CssToProperty = Object.entries(propertyToCss) - .reduce((o, v) => { - o[v[1]] = v[0]; - return o; - }, {}); - -let editor; +/** @type {EditorBase|SourceEditor|SectionsEditor} */ +const editor = { + isUsercss: false, + previewDelay: 200, // Chrome devtools uses 200 +}; let isWindowed; -let scrollPointTimer; +let headerHeight; -window.addEventListener('beforeunload', beforeUnload); +window.on('beforeunload', beforeUnload); msg.onExtension(onRuntimeMessage); lazyInit(); (async function init() { - const [style] = await Promise.all([ - initStyleData(), - onDOMready(), - prefs.initializing.then(() => new Promise(resolve => { - const theme = prefs.get('editor.theme'); - const el = $('#cm-theme'); - if (theme === 'default') { - resolve(); - } else { - // preload the theme so CodeMirror can use the correct metrics - el.href = `vendor/codemirror/theme/${theme}.css`; - el.addEventListener('load', resolve, {once: true}); - } - })), - ]); - const usercss = isUsercss(style); - const dirty = dirtyReporter(); - let wasDirty = false; + let style; let nameTarget; - - prefs.subscribe(['editor.linter'], updateLinter); - prefs.subscribe(['editor.keyMap'], showHotkeyInTooltip); - addEventListener('showHotkeyInTooltip', showHotkeyInTooltip); + let wasDirty = false; + const dirty = new DirtyReporter(); + await Promise.all([ + initStyle(), + prefs.initializing + .then(initTheme), + onDOMready(), + ]); + /** @namespace EditorBase */ + Object.assign(editor, { + style, + dirty, + updateName, + updateToc, + toggleStyle, + }); + prefs.subscribe('editor.linter', updateLinter); + prefs.subscribe('editor.keyMap', showHotkeyInTooltip); + window.on('showHotkeyInTooltip', showHotkeyInTooltip); showHotkeyInTooltip(); buildThemeElement(); buildKeymapElement(); @@ -55,32 +75,57 @@ lazyInit(); initBeautifyButton($('#beautify'), () => editor.getEditors()); initResizeListener(); detectLayout(); - updateTitle(); $('#heading').textContent = t(style.id ? 'editStyleHeading' : 'addStyleTitle'); $('#preview-label').classList.toggle('hidden', !style.id); - editor = (usercss ? createSourceEditor : createSectionsEditor)({ - style, - dirty, - updateName, - toggleStyle, - }); + const toc = []; + const elToc = $('#toc'); + elToc.onclick = e => editor.jumpToEditor([...elToc.children].indexOf(e.target)); + + (editor.isUsercss ? SourceEditor : SectionsEditor)(); + + prefs.subscribe('editor.toc.expanded', (k, val) => val && editor.updateToc(), {now: true}); dirty.onChange(updateDirty); await editor.ready; // enabling after init to prevent flash of validation failure on an empty name - $('#name').required = !usercss; + $('#name').required = !editor.isUsercss; $('#save-button').onclick = editor.save; + async function initStyle() { + const params = new URLSearchParams(location.search); + const id = Number(params.get('id')); + style = id ? await API.getStyle(id) : initEmptyStyle(params); + // switching the mode here to show the correct page ASAP, usually before DOMContentLoaded + editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss')); + document.documentElement.classList.toggle('usercss', editor.isUsercss); + sessionStorage.justEditedStyleId = style.id || ''; + // no such style so let's clear the invalid URL parameters + if (!style.id) history.replaceState({}, '', location.pathname); + updateTitle(false); + } + + function initEmptyStyle(params) { + return { + name: params.get('domain') || + tryCatch(() => new URL(params.get('url-prefix')).hostname) || + '', + enabled: true, + sections: [ + DocFuncMapper.toSection([...params], {code: ''}), + ], + }; + } + function initNameArea() { const nameEl = $('#name'); const resetEl = $('#reset-name'); - const isCustomName = style.updateUrl || usercss; + const isCustomName = style.updateUrl || editor.isUsercss; nameTarget = isCustomName ? 'customName' : 'name'; - nameEl.placeholder = t(usercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName'); + nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName'); nameEl.title = isCustomName ? t('customNameHint') : ''; - nameEl.addEventListener('input', () => { + nameEl.on('input', () => { updateName(true); resetEl.hidden = false; }); @@ -101,6 +146,38 @@ lazyInit(); enabledEl.onchange = () => updateEnabledness(enabledEl.checked); } + function initResizeListener() { + const {onBoundsChanged} = chrome.windows || {}; + if (onBoundsChanged) { + // * movement is reported even if the window wasn't resized + // * fired just once when done so debounce is not needed + onBoundsChanged.addListener(wnd => { + // getting the current window id as it may change if the user attached/detached the tab + chrome.windows.getCurrent(ownWnd => { + if (wnd.id === ownWnd.id) saveWindowPos(); + }); + }); + } + window.on('resize', () => { + if (!onBoundsChanged) debounce(saveWindowPos, 100); + detectLayout(); + }); + } + + function initTheme() { + return new Promise(resolve => { + const theme = prefs.get('editor.theme'); + const el = $('#cm-theme'); + if (theme === 'default') { + resolve(); + } else { + // preload the theme so CodeMirror can use the correct metrics + el.href = `vendor/codemirror/theme/${theme}.css`; + el.on('load', resolve, {once: true}); + } + }); + } + function findKeyForCommand(command, map) { if (typeof map === 'string') map = CodeMirror.keyMap[map]; let key = Object.keys(map).find(k => map[k] === command); @@ -171,24 +248,6 @@ lazyInit(); } } - function initResizeListener() { - const {onBoundsChanged} = chrome.windows || {}; - if (onBoundsChanged) { - // * movement is reported even if the window wasn't resized - // * fired just once when done so debounce is not needed - onBoundsChanged.addListener(wnd => { - // getting the current window id as it may change if the user attached/detached the tab - chrome.windows.getCurrent(ownWnd => { - if (wnd.id === ownWnd.id) saveWindowPos(); - }); - }); - } - window.addEventListener('resize', () => { - if (!onBoundsChanged) debounce(saveWindowPos, 100); - detectLayout(); - }); - } - function toggleStyle() { $('#enabled').checked = !style.enabled; updateEnabledness(!style.enabled); @@ -217,17 +276,50 @@ lazyInit(); dirty.modify('name', style[nameTarget] || style.name, value); style[nameTarget] = value; } - updateTitle({}); + updateTitle(); } - function updateTitle() { - document.title = `${dirty.isDirty() ? '* ' : ''}${style.customName || style.name}`; + function updateTitle(isDirty = dirty.isDirty()) { + document.title = `${isDirty ? '* ' : ''}${style.customName || style.name}`; } function updateLinter(key, value) { $('body').classList.toggle('linter-disabled', value === ''); linter.run(); } + + function updateToc(added = editor.sections) { + const {sections} = editor; + const first = sections.indexOf(added[0]); + let el = elToc.children[first]; + if (added.focus) { + const cls = 'current'; + const old = $('.' + cls, elToc); + if (old && old !== el) old.classList.remove(cls); + el.classList.add(cls); + return; + } + if (first >= 0) { + for (let i = first; i < sections.length; i++) { + const entry = sections[i].tocEntry; + if (!deepEqual(entry, toc[i])) { + if (!el) el = elToc.appendChild($create('li', {tabIndex: 0})); + el.tabIndex = entry.removed ? -1 : 0; + toc[i] = Object.assign({}, entry); + const s = el.textContent = clipString(entry.label) || ( + entry.target == null + ? t('appliesToEverything') + : clipString(entry.target) + (entry.numTargets > 1 ? ', ...' : '')); + if (s.length > 30) el.title = s; + } + el = el.nextElementSibling; + } + } + while (toc.length > sections.length) { + elToc.lastElementChild.remove(); + toc.length--; + } + } })(); /* Stuff not needed for the main init so we can let it run at its own tempo */ @@ -330,53 +422,6 @@ function beforeUnload(e) { } } -function isUsercss(style) { - return ( - style.usercssData || - !style.id && prefs.get('newStyleAsUsercss') - ); -} - -function initStyleData() { - const params = new URLSearchParams(location.search); - const id = Number(params.get('id')); - const createEmptyStyle = () => ({ - name: params.get('domain') || - tryCatch(() => new URL(params.get('url-prefix')).hostname) || - '', - enabled: true, - sections: [ - Object.assign({code: ''}, - ...Object.keys(CssToProperty) - .map(name => ({ - [CssToProperty[name]]: params.get(name) && [params.get(name)] || [] - })) - ) - ], - }); - return fetchStyle() - .then(style => { - if (style.id) sessionStorage.justEditedStyleId = style.id; - // we set "usercss" class on when 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); - } -} diff --git a/edit/global-search.js b/edit/global-search.js index 3df729c2..546805c5 100644 --- a/edit/global-search.js +++ b/edit/global-search.js @@ -79,7 +79,7 @@ onDOMready().then(() => { doReplace(); return; } - return !event.target.closest(focusAccessibility.ELEMENTS.join(',')); + return !focusAccessibility.closest(event.target); }, 'Esc': () => { destroyDialog({restoreFocus: true}); diff --git a/edit/linter-meta.js b/edit/linter-meta.js index 0307f0e3..0068771a 100644 --- a/edit/linter-meta.js +++ b/edit/linter-meta.js @@ -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) - }; } diff --git a/edit/live-preview.js b/edit/live-preview.js index 4372bbd0..34bf13be 100644 --- a/edit/live-preview.js +++ b/edit/live-preview.js @@ -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) { diff --git a/edit/moz-section-finder.js b/edit/moz-section-finder.js new file mode 100644 index 00000000..e80fbb12 --- /dev/null +++ b/edit/moz-section-finder.js @@ -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} */ + 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 + */ diff --git a/edit/moz-section-widget.js b/edit/moz-section-widget.js new file mode 100644 index 00000000..e8f4cfe3 --- /dev/null +++ b/edit/moz-section-widget.js @@ -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}); + } +} diff --git a/edit/sections-editor-section.js b/edit/sections-editor-section.js index dce07e93..d1446881 100644 --- a/edit/sections-editor-section.js +++ b/edit/sections-editor-section.js @@ -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,89 +49,74 @@ 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 + 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); + }, + off(fn) { + changeListeners.delete(fn); + }, + get removed() { + return removed; + }, + tocEntry: { + label: '', + get removed() { + return removed; + }, + }, }; + + prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {now: true}); + return section; - function onChange(fn) { - changeListeners.add(fn); - } - - function off(fn) { - changeListeners.delete(fn); - } - - function emitSectionChange() { + function emitSectionChange(origin) { for (const fn of changeListeners) { - fn(); + fn(origin); } } - function getModel() { - const section = { - code: cm.getValue() - }; - for (const apply of appliesTo) { - if (apply.all) { - continue; - } - const key = CssToProperty[apply.getType()]; - if (!section[key]) { - section[key] = []; - } - section[key].push(apply.getValue()); - } - return section; - } - function registerEvents() { cm.on('changes', () => { 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); + } + } + } +} diff --git a/edit/sections-editor.js b/edit/sections-editor.js index 567a5bc4..a2c07773 100644 --- a/edit/sections-editor.js +++ b/edit/sections-editor.js @@ -1,75 +1,145 @@ -/* global showHelp toggleContextMenuDelete createSection - CodeMirror linter createLivePreview showCodeMirrorPopup - sectionsToMozFormat messageBox clipString - $ $$ $create t FIREFOX API - debounce */ -/* exported createSectionsEditor */ +/* global + $ + $$ + $create + API + clipString + CodeMirror + createLivePreview + createSection + debounce + editor + FIREFOX + ignoreChromeError + linter + messageBox + prefs + sectionsToMozFormat + showCodeMirrorPopup + showHelp + t +*/ 'use strict'; -function createSectionsEditor(editorBase) { - const {style, dirty} = editorBase; +/* exported SectionsEditor */ + +function SectionsEditor() { + const {style, dirty} = editor; + const container = $('#sections'); + /** @type {EditorSection[]} */ + const sections = []; + const xo = window.IntersectionObserver && + new IntersectionObserver(refreshOnViewListener, {rootMargin: '100%'}); + const livePreview = createLivePreview(null, style.id); let INC_ID = 0; // an increment id that is used by various object to track the order - - const container = $('#sections'); - const sections = []; + let sectionOrder = ''; + let headerOffset; // in compact mode the header is at the top so it reduces the available height container.classList.add('section-editor'); updateHeader(); - $('#to-mozilla').addEventListener('click', showMozillaFormat); - $('#to-mozilla-help').addEventListener('click', showToMozillaHelp); - $('#from-mozilla').addEventListener('click', () => showMozillaFormatImport()); - - document.addEventListener('wheel', scrollEntirePageOnCtrlShift, {passive: false}); + $('#to-mozilla').on('click', showMozillaFormat); + $('#to-mozilla-help').on('click', showToMozillaHelp); + $('#from-mozilla').on('click', () => showMozillaFormatImport()); + document.on('wheel', scrollEntirePageOnCtrlShift, {passive: false}); CodeMirror.defaults.extraKeys['Shift-Ctrl-Wheel'] = 'scrollWindow'; - if (!FIREFOX) { - $$([ - 'input:not([type])', - 'input[type="text"]', - 'input[type="search"]', - 'input[type="number"]', - ].join(',')) - .forEach(e => e.addEventListener('mousedown', toggleContextMenuDelete)); + $$('input:not([type]), input[type=text], input[type=search], input[type=number]') + .forEach(e => e.on('mousedown', toggleContextMenuDelete)); } - const xo = window.IntersectionObserver && new IntersectionObserver(entries => { - for (const {isIntersecting, target} of entries) { - if (isIntersecting) { - target.CodeMirror.refresh(); - xo.unobserve(target); - } - } - }, {rootMargin: '100%'}); - const refreshOnView = (cm, force) => - force || !xo ? - cm.refresh() : - xo.observe(cm.display.wrapper); + /** @namespace SectionsEditor */ + Object.assign(editor, { - let sectionOrder = ''; - let headerOffset; // in compact mode the header is at the top so it reduces the available height - const ready = initSections(style.sections, {pristine: true}); + sections, - const livePreview = createLivePreview(); - livePreview.show(Boolean(style.id)); + closestVisible, + updateLivePreview, - return Object.assign({}, editorBase, { - ready, - replaceStyle, - getEditors, - scrollToEditor, - getEditorTitle: cm => { - const index = sections.filter(s => !s.isRemoved()).findIndex(s => s.cm === cm); + getEditors() { + return sections.filter(s => !s.removed).map(s => s.cm); + }, + + getEditorTitle(cm) { + const index = editor.getEditors().indexOf(cm); return `${t('sectionCode')} ${index + 1}`; }, - save, - nextEditor, - prevEditor, - closestVisible, - getSearchableInputs, - updateLivePreview, + + getSearchableInputs(cm) { + return sections.find(s => s.cm === cm).appliesTo.map(a => a.valueEl).filter(Boolean); + }, + + jumpToEditor(i) { + const {cm} = sections[i] || {}; + if (cm) { + editor.scrollToEditor(cm); + cm.focus(); + } + }, + + nextEditor(cm, cycle = true) { + return cycle || cm !== findLast(sections, s => !s.removed).cm + ? nextPrevEditor(cm, 1) + : null; + }, + + prevEditor(cm, cycle = true) { + return cycle || cm !== sections.find(s => !s.removed).cm + ? nextPrevEditor(cm, -1) + : null; + }, + + async replaceStyle(newStyle, codeIsUpdated) { + dirty.clear('name'); + // FIXME: avoid recreating all editors? + if (codeIsUpdated !== false) { + await initSections(newStyle.sections, {replace: true, pristine: true}); + } + Object.assign(style, newStyle); + updateHeader(); + dirty.clear(); + // Go from new style URL to edit style URL + if (location.href.indexOf('id=') === -1 && style.id) { + history.replaceState({}, document.title, 'edit.html?id=' + style.id); + $('#heading').textContent = t('editStyleHeading'); + } + livePreview.show(Boolean(style.id)); + updateLivePreview(); + }, + + async save() { + if (!dirty.isDirty()) { + return; + } + let newStyle = getModel(); + if (!validate(newStyle)) { + return; + } + newStyle = await API.editSave(newStyle); + destroyRemovedSections(); + sessionStorage.justEditedStyleId = newStyle.id; + editor.replaceStyle(newStyle, false); + }, + + scrollToEditor(cm) { + const section = sections.find(s => s.cm === cm).el; + const bounds = section.getBoundingClientRect(); + if ( + (bounds.bottom > window.innerHeight && bounds.top > 0) || + (bounds.top < 0 && bounds.bottom < window.innerHeight) + ) { + if (bounds.top < 0) { + window.scrollBy(0, bounds.top - 1); + } else { + window.scrollBy(0, bounds.bottom - window.innerHeight + 1); + } + } + }, }); + editor.ready = initSections(style.sections, {pristine: true}); + + /** @param {EditorSection} section */ function fitToContent(section) { const {el, cm, cm: {display: {wrapper, sizer}}} = section; if (cm.display.renderedView) { @@ -90,18 +160,19 @@ function createSectionsEditor(editorBase) { cm.off('update', resize); const cmHeight = wrapper.offsetHeight; const maxHeight = (window.innerHeight - headerOffset) - (section.el.offsetHeight - cmHeight); - cm.setSize(null, Math.min(contentHeight, maxHeight)); + const fit = Math.min(contentHeight, maxHeight); + if (Math.abs(fit - cmHeight) > 1) { + cm.setSize(null, fit); + } } } function fitToAvailableSpace() { - const ch = container.offsetHeight; - let available = ch - sections[sections.length - 1].el.getBoundingClientRect().bottom + headerOffset; - if (available <= 1) available = window.innerHeight - ch - headerOffset; - const delta = Math.floor(available / sections.length); + const lastSectionBottom = sections[sections.length - 1].el.getBoundingClientRect().bottom; + const delta = Math.floor((window.innerHeight - lastSectionBottom) / sections.length); if (delta > 1) { sections.forEach(({cm}) => { - cm.setSize(null, cm.display.wrapper.offsetHeight + delta); + cm.setSize(null, cm.display.lastWrapHeight + delta); }); } } @@ -129,14 +200,12 @@ function createSectionsEditor(editorBase) { showHelp(t('styleMozillaFormatHeading'), t('styleToMozillaFormatHelp')); } - function getSearchableInputs(cm) { - return sections.find(s => s.cm === cm).appliesTo.map(a => a.valueEl).filter(Boolean); - } - - // priority: - // 1. associated CM for applies-to element - // 2. last active if visible - // 3. first visible + /** + priority: + 1. associated CM for applies-to element + 2. last active if visible + 3. first visible + */ function closestVisible(nearbyElement) { const cm = nearbyElement instanceof CodeMirror ? nearbyElement : @@ -181,7 +250,7 @@ function createSectionsEditor(editorBase) { } function findClosest() { - const editors = getEditors(); + const editors = editor.getEditors(); const last = editors.length - 1; let a = 0; let b = last; @@ -206,7 +275,7 @@ function createSectionsEditor(editorBase) { } const cm = editors[b]; if (distances[b] > 0) { - scrollToEditor(cm); + editor.scrollToEditor(cm); } return cm; } @@ -221,24 +290,6 @@ function createSectionsEditor(editorBase) { } } - function getEditors() { - return sections.filter(s => !s.isRemoved()).map(s => s.cm); - } - - function nextEditor(cm, cycle = true) { - if (!cycle && findLast(sections, s => !s.isRemoved()).cm === cm) { - return; - } - return nextPrevEditor(cm, 1); - } - - function prevEditor(cm, cycle = true) { - if (!cycle && sections.find(s => !s.isRemoved()).cm === cm) { - return; - } - return nextPrevEditor(cm, -1); - } - function findLast(arr, match) { for (let i = arr.length - 1; i >= 0; i--) { if (match(arr[i])) { @@ -248,32 +299,17 @@ function createSectionsEditor(editorBase) { } function nextPrevEditor(cm, direction) { - const editors = getEditors(); + const editors = editor.getEditors(); cm = editors[(editors.indexOf(cm) + direction + editors.length) % editors.length]; - scrollToEditor(cm); + editor.scrollToEditor(cm); cm.focus(); return cm; } - function scrollToEditor(cm) { - const section = sections.find(s => s.cm === cm).el; - const bounds = section.getBoundingClientRect(); - if ( - (bounds.bottom > window.innerHeight && bounds.top > 0) || - (bounds.top < 0 && bounds.bottom < window.innerHeight) - ) { - if (bounds.top < 0) { - window.scrollBy(0, bounds.top - 1); - } else { - window.scrollBy(0, bounds.bottom - window.innerHeight + 1); - } - } - } - function getLastActivatedEditor() { let result; for (const section of sections) { - if (section.isRemoved()) { + if (section.removed) { continue; } // .lastActive is initiated by codemirror-factory @@ -387,16 +423,18 @@ function createSectionsEditor(editorBase) { function updateSectionOrder() { const oldOrder = sectionOrder; - const validSections = sections.filter(s => !s.isRemoved()); + const validSections = sections.filter(s => !s.removed); sectionOrder = validSections.map(s => s.id).join(','); dirty.modify('sectionOrder', oldOrder, sectionOrder); container.dataset.sectionCount = validSections.length; linter.refreshReport(); + editor.updateToc(); } + /** @returns {Style} */ function getModel() { return Object.assign({}, style, { - sections: sections.filter(s => !s.isRemoved()).map(s => s.getModel()) + sections: sections.filter(s => !s.removed).map(s => s.getModel()) }); } @@ -407,7 +445,7 @@ function createSectionsEditor(editorBase) { } for (const section of sections) { for (const apply of section.appliesTo) { - if (apply.getType() !== 'regexp') { + if (apply.type !== 'regexp') { continue; } if (!apply.valueEl.reportValidity()) { @@ -419,25 +457,9 @@ function createSectionsEditor(editorBase) { return true; } - function save() { - if (!dirty.isDirty()) { - return; - } - const newStyle = getModel(); - if (!validate(newStyle)) { - return; - } - API.editSave(newStyle) - .then(newStyle => { - destroyRemovedSections(); - sessionStorage.justEditedStyleId = newStyle.id; - replaceStyle(newStyle, false); - }); - } - function destroyRemovedSections() { for (let i = 0; i < sections.length;) { - if (!sections[i].isRemoved()) { + if (!sections[i].removed) { i++; continue; } @@ -451,14 +473,14 @@ function createSectionsEditor(editorBase) { $('#name').value = style.customName || style.name || ''; $('#enabled').checked = style.enabled !== false; $('#url').href = style.url || ''; - editorBase.updateName(); + editor.updateName(); } function updateLivePreview() { - debounce(_updateLivePreview, 200); + debounce(updateLivePreviewNow, editor.previewDelay); } - function _updateLivePreview() { + function updateLivePreviewNow() { livePreview.update(getModel()); } @@ -492,7 +514,8 @@ function createSectionsEditor(editorBase) { setGlobalProgress(total - originalSections.length, total); if (!originalSections.length) { setGlobalProgress(); - fitToAvailableSpace(); + requestAnimationFrame(fitToAvailableSpace); + sections.forEach(({cm}) => setTimeout(linter.enableForEditor, 0, cm)); done(); } else { setTimeout(chunk); @@ -500,8 +523,9 @@ function createSectionsEditor(editorBase) { } } + /** @param {EditorSection} section */ function removeSection(section) { - if (sections.every(s => s.isRemoved() || s === section)) { + if (sections.every(s => s.removed || s === section)) { // TODO: hide remove button when `#sections[data-section-count=1]` throw new Error('Cannot remove last section'); } @@ -528,6 +552,7 @@ function createSectionsEditor(editorBase) { updateLivePreview(); } + /** @param {EditorSection} section */ function restoreSection(section) { section.restore(); updateSectionOrder(); @@ -535,40 +560,36 @@ function createSectionsEditor(editorBase) { updateLivePreview(); } + /** + * @param {StyleSection} [init] + * @param {EditorSection} [base] + * @param {boolean} [forceRefresh] + */ function insertSectionAfter(init, base, forceRefresh) { if (!init) { init = {code: '', urlPrefixes: ['http://example.com']}; } - const section = createSection({ - originalSection: init, - genId, - dirty, - showMozillaFormatImport, - removeSection, - restoreSection, - insertSectionAfter, - moveSectionUp, - moveSectionDown, - prevEditor, - nextEditor - }); + const section = createSection(init, genId); const {cm} = section; sections.splice(base ? sections.indexOf(base) + 1 : sections.length, 0, section); container.insertBefore(section.el, base ? base.el.nextSibling : null); refreshOnView(cm, forceRefresh); + registerEvents(section); if (!base || init.code) { // Fit a) during startup or b) when the clone button is clicked on a section with some code fitToContent(section); } if (base) { cm.focus(); - setTimeout(scrollToEditor, 0, cm); + setTimeout(editor.scrollToEditor, 0, cm); + linter.enableForEditor(cm); } updateSectionOrder(); section.onChange(updateLivePreview); updateLivePreview(); } + /** @param {EditorSection} section */ function moveSectionUp(section) { const index = sections.indexOf(section); if (index === 0) { @@ -580,6 +601,7 @@ function createSectionsEditor(editorBase) { updateSectionOrder(); } + /** @param {EditorSection} section */ function moveSectionDown(section) { const index = sections.indexOf(section); if (index === sections.length - 1) { @@ -591,21 +613,56 @@ function createSectionsEditor(editorBase) { updateSectionOrder(); } - async function replaceStyle(newStyle, codeIsUpdated) { - dirty.clear('name'); - // FIXME: avoid recreating all editors? - if (codeIsUpdated !== false) { - await initSections(newStyle.sections, {replace: true, pristine: true}); + /** @param {EditorSection} section */ + function registerEvents(section) { + const {el, cm} = section; + $('.applies-to-help', el).onclick = () => showHelp(t('appliesLabel'), t('appliesHelp')); + $('.remove-section', el).onclick = () => removeSection(section); + $('.add-section', el).onclick = () => insertSectionAfter(undefined, section); + $('.clone-section', el).onclick = () => insertSectionAfter(section.getModel(), section); + $('.move-section-up', el).onclick = () => moveSectionUp(section); + $('.move-section-down', el).onclick = () => moveSectionDown(section); + $('.restore-section', el).onclick = () => restoreSection(section); + cm.on('paste', maybeImportOnPaste); + if (!FIREFOX) { + cm.on('mousedown', (cm, event) => toggleContextMenuDelete.call(cm, event)); } - Object.assign(style, newStyle); - updateHeader(); - dirty.clear(); - // Go from new style URL to edit style URL - if (location.href.indexOf('id=') === -1 && style.id) { - history.replaceState({}, document.title, 'edit.html?id=' + style.id); - $('#heading').textContent = t('editStyleHeading'); + } + + 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); } - livePreview.show(Boolean(style.id)); - updateLivePreview(); } } diff --git a/edit/source-editor.js b/edit/source-editor.js index a409a480..2e2d5be3 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -1,70 +1,98 @@ /* 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; - cm.on('optionChange', (cm, option) => { - if (option !== 'mode') return; - const mode = getModeName(); - if (mode === prevMode) return; - prevMode = mode; - linter.run(); - updateLinterSwitch(); - }); - - $('#editor.linter').addEventListener('change', updateLinterSwitch); - updateLinterSwitch(); - - setTimeout(() => { - if ((document.activeElement || {}).localName !== 'input') { - cm.focus(); - } - }); + 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(); + if (mode === prevMode) return; + prevMode = mode; + linter.run(); + updateLinterSwitch(); + }); + 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); - } - } 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]; - } - if (goFind(handle)) { - return true; - } - } - line = cm.doc.size - 1; - stopAtLine = cursor.line; - } + 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; } - 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; + jumpToPos(sections[(i + dir + num) % num].start); + } + + 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, - }); } diff --git a/edit/util.js b/edit/util.js index 772292fc..8af544de 100644 --- a/edit/util.js +++ b/edit/util.js @@ -1,130 +1,161 @@ -/* global CodeMirror $create prefs */ -/* exported dirtyReporter memoize clipString sectionsToMozFormat createHotkeyInput */ +/* global + $create + CodeMirror + prefs +*/ 'use strict'; -function dirtyReporter() { - const dirty = new Map(); - const onchanges = []; +/* exported DirtyReporter */ +class DirtyReporter { + constructor() { + this._dirty = new Map(); + this._onchange = new Set(); + } - function add(obj, value) { - const saved = dirty.get(obj); + add(obj, value) { + const wasDirty = this.isDirty(); + const saved = this._dirty.get(obj); if (!saved) { - dirty.set(obj, {type: 'add', newValue: value}); + this._dirty.set(obj, {type: 'add', newValue: value}); } else if (saved.type === 'remove') { if (saved.savedValue === value) { - dirty.delete(obj); + this._dirty.delete(obj); } else { saved.newValue = value; saved.type = 'modify'; } } + this.notifyChange(wasDirty); } - function remove(obj, value) { - const saved = dirty.get(obj); + remove(obj, value) { + const wasDirty = this.isDirty(); + const saved = this._dirty.get(obj); if (!saved) { - dirty.set(obj, {type: 'remove', savedValue: value}); + this._dirty.set(obj, {type: 'remove', savedValue: value}); } else if (saved.type === 'add') { - dirty.delete(obj); + this._dirty.delete(obj); } else if (saved.type === 'modify') { saved.type = 'remove'; } + this.notifyChange(wasDirty); } - function modify(obj, oldValue, newValue) { - const saved = dirty.get(obj); + modify(obj, oldValue, newValue) { + const wasDirty = this.isDirty(); + const saved = this._dirty.get(obj); if (!saved) { if (oldValue !== newValue) { - dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue}); + this._dirty.set(obj, {type: 'modify', savedValue: oldValue, newValue}); } } else if (saved.type === 'modify') { if (saved.savedValue === newValue) { - dirty.delete(obj); + this._dirty.delete(obj); } else { saved.newValue = newValue; } } else if (saved.type === 'add') { saved.newValue = newValue; } + this.notifyChange(wasDirty); } - function clear(obj) { + clear(obj) { + const wasDirty = this.isDirty(); if (obj === undefined) { - dirty.clear(); + this._dirty.clear(); } else { - dirty.delete(obj); + this._dirty.delete(obj); + } + this.notifyChange(wasDirty); + } + + isDirty() { + return this._dirty.size > 0; + } + + onChange(cb, add = true) { + this._onchange[add ? 'add' : 'delete'](cb); + } + + notifyChange(wasDirty) { + if (wasDirty !== this.isDirty()) { + this._onchange.forEach(cb => cb()); } } - function isDirty() { - return dirty.size > 0; + has(key) { + return this._dirty.has(key); } - - function onChange(cb) { - // make sure the callback doesn't throw - onchanges.push(cb); - } - - function wrap(obj) { - for (const key of ['add', 'remove', 'modify', 'clear']) { - obj[key] = trackChange(obj[key]); - } - return obj; - } - - function emitChange() { - for (const cb of onchanges) { - cb(); - } - } - - function trackChange(fn) { - return function () { - const dirty = isDirty(); - const result = fn.apply(null, arguments); - if (dirty !== isDirty()) { - emitChange(); - } - return result; - }; - } - - function has(key) { - return dirty.has(key); - } - - return wrap({add, remove, modify, clear, isDirty, onChange, has}); } - -function sectionsToMozFormat(style) { - const propertyToCss = { - urls: 'url', +/* exported DocFuncMapper */ +const DocFuncMapper = { + TO_CSS: { + urls: 'url', urlPrefixes: 'url-prefix', - domains: 'domain', - regexps: 'regexp', - }; - 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, '\\\\') + '")' - )); + 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} 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 cssMds.length ? - '@-moz-document ' + cssMds.join(', ') + ' {\n' + section.code + '\n}' : + return section; + }, +}; + +/* exported sectionsToMozFormat */ +function sectionsToMozFormat(style) { + return style.sections.map(section => { + 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 diff --git a/global.css b/global.css index fa5ff877..ad92d486 100644 --- a/global.css +++ b/global.css @@ -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); diff --git a/js/dom.js b/js/dom.js index a3db3148..2babfac6 100644 --- a/js/dom.js +++ b/js/dom.js @@ -7,21 +7,18 @@ 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 => - el.localName === 'input' && /^(text|search|number)$/.test(el.type); +$.isTextInput = (el = {}) => + el.localName === 'textarea' || + el.localName === 'input' && /^(text|search|number)$/.test(el.type); $.remove = (selector, base = document) => { const el = selector && typeof selector === 'string' ? $(selector, base) : selector; @@ -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)) { - focusAccessibility.lastFocusedViaClick = true; - if (el.dataset.focusedViaClick === undefined) { - el.dataset.focusedViaClick = ''; - } - return; + // 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 = ''; } } - } - - 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) { + el = el.closest('[data-focused-via-click]'); + if (el) delete el.dataset.focusedViaClick; + } + }); } - 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; - } - } + }, {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})); diff --git a/js/prefs.js b/js/prefs.js index 4434df6a..c3f19485 100644 --- a/js/prefs.js +++ b/js/prefs.js @@ -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) { diff --git a/js/sections-util.js b/js/sections-util.js index 48a2f9c1..68c08bbc 100644 --- a/js/sections-util.js +++ b/js/sections-util.js @@ -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 || [], diff --git a/manage/incremental-search.js b/manage/incremental-search.js index 791cdd2c..8d6deb90 100644 --- a/manage/incremental-search.js +++ b/manage/incremental-search.js @@ -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 = ''; } - prevTime = time; + // 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})); diff --git a/msgbox/msgbox.js b/msgbox/msgbox.js index 3daff5d5..69a8edcc 100644 --- a/msgbox/msgbox.js +++ b/msgbox/msgbox.js @@ -68,7 +68,7 @@ function messageBox({ } switch (key) { case 'Enter': - if (target.closest(focusAccessibility.ELEMENTS.join(','))) { + if (focusAccessibility.closest(target)) { return; } break; diff --git a/vendor-overwrites/csslint/parserlib.js b/vendor-overwrites/csslint/parserlib.js index 34a9af64..4003e3c2 100644 --- a/vendor-overwrites/csslint/parserlib.js +++ b/vendor-overwrites/csslint/parserlib.js @@ -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({