diff --git a/edit/edit.js b/edit/edit.js index ba4f5c12..be58728c 100644 --- a/edit/edit.js +++ b/edit/edit.js @@ -182,7 +182,8 @@ function initCodeMirror() { highlightSelectionMatches: {showToken: /[#.\-\w]/, annotateScrollbar: true}, hintOptions: {}, lint: linterConfig.getForCodeMirror(), - lintReportDelay: prefs.get('editor.lintReportDelay'), + lintReportDelay: 500, + //lintReportDelay: prefs.get('editor.lintReportDelay'), styleActiveLine: true, theme: 'default', keyMap: prefs.get('editor.keyMap'), @@ -462,11 +463,15 @@ function setupCodeMirror(textarea, index) { return cm; } -function indicateCodeChange(cm) { +function indicateCodeChange(cm, change) { const section = cm.getSection(); setCleanItem(section, cm.isClean(section.savedValue)); updateTitle(); - updateLintReportIfEnabled(cm); + if (change) { + cm.stylusChanges = cm.stylusChanges || []; + cm.stylusChanges.push(change); + } + updateLintReport(cm); } function getSectionForChild(e) { @@ -593,7 +598,7 @@ window.onbeforeunload = () => { if (isCleanGlobal()) { return; } - updateLintReportIfEnabled(null, 0); + updateLintReport(null, 0); // neither confirm() nor custom messages work in modern browsers but just in case return t('styleChangesNotSaved'); }; @@ -1242,14 +1247,13 @@ function init() { section[CssToProperty[i]] = [params[i]]; } } - window.onload = () => { - window.onload = null; - addSection(null, section); - editors[0].setOption('lint', CodeMirror.defaults.lint); - // default to enabled - $('#enabled').checked = true; - initHooks(); - }; + addSection(null, section); + editors[0].setOption('lint', CodeMirror.defaults.lint); + // default to enabled + $('#enabled').checked = true; + initHooks(); + setCleanGlobal(); + updateTitle(); return; } // This is an edit @@ -1287,11 +1291,12 @@ function initWithStyle({style, codeIsUpdated}) { updateTitle(); return; } - // if this was done in response to an update, we need to clear existing sections - getSections().forEach(div => { div.remove(); }); + editors.length = 0; + getSections().forEach(div => div.remove()); const queue = style.sections.length ? style.sections.slice() : [{code: ''}]; const queueStart = new Date().getTime(); + maximizeCodeHeight.stats = null; // after 100ms the sections will be added asynchronously while (new Date().getTime() - queueStart <= 100 && queue.length) { add(); @@ -1303,21 +1308,20 @@ function initWithStyle({style, codeIsUpdated}) { } })(); initHooks(); + setCleanGlobal(); + updateTitle(); function add() { const sectionDiv = addSection(null, queue.shift()); maximizeCodeHeight(sectionDiv, !queue.length); - const cm = sectionDiv.CodeMirror; - if (CodeMirror.lint) { - setTimeout(() => { - cm.setOption('lint', CodeMirror.defaults.lint); - updateLintReport(cm, 0); - }, prefs.get('editor.lintDelay')); - } } } function initHooks() { + if (initHooks.alreadyDone) { + return; + } + initHooks.alreadyDone = true; $$('#header .style-contributor').forEach(node => { node.addEventListener('change', onChange); node.addEventListener('input', onChange); @@ -1349,8 +1353,6 @@ function initHooks() { }); setupGlobalSearch(); - setCleanGlobal(); - updateTitle(); } @@ -1451,14 +1453,8 @@ function validate() { return null; } -function updateLintReportIfEnabled(cm, time) { - if (CodeMirror.lint) { - updateLintReport(cm, time); - } -} - function save() { - updateLintReportIfEnabled(null, 0); + updateLintReport(null, 0); // save the contents of the CodeMirror editors back into the textareas for (let i = 0; i < editors.length; i++) { diff --git a/edit/lint-codemirror-helper.js b/edit/lint-codemirror-helper.js index 588a202c..086a8243 100644 --- a/edit/lint-codemirror-helper.js +++ b/edit/lint-codemirror-helper.js @@ -1,31 +1,288 @@ /* global CodeMirror CSSLint stylelint linterConfig */ 'use strict'; -CodeMirror.registerHelper('lint', 'csslint', code => - CSSLint.verify(code, deepCopy(linterConfig.getCurrent('csslint'))) - .messages.map(message => ({ - from: CodeMirror.Pos(message.line - 1, message.col - 1), - to: CodeMirror.Pos(message.line - 1, message.col), - message: message.message + ` (${message.rule.id})`, - severity : message.type - })) -); +(() => { + let config; + const cmpPos = CodeMirror.cmpPos; -CodeMirror.registerHelper('lint', 'stylelint', code => - stylelint.lint({ - code, - config: deepCopy(linterConfig.getCurrent('stylelint')), - }).then(({results}) => { - if (!results[0]) { - return []; + CodeMirror.registerHelper('lint', 'csslint', (code, options, cm) => + copyOldIssues(cm, lintChangedRanges(cm, csslintOnRange)) + ); + + CodeMirror.registerHelper('lint', 'stylelint', (code, options, cm) => + Promise.all(lintChangedRanges(cm, stylelintOnRange)) + .then(results => copyOldIssues(cm, results)) + ); + + function csslintOnRange(range) { + return CSSLint.verify(range.code, config).messages + .map(item => + cookResult( + range, + item.line, + item.col, + item.message.replace(/ at line \d+, col \d+/, '') + ` (${item.rule.id})`, + item.type + ) + ); + } + + function stylelintOnRange(range) { + return stylelint.lint({code: range.code, config}) + .then(({results}) => ((results[0] || {}).warnings || []) + .map(item => + cookResult( + range, + item.line, + item.column, + item.text + .replace('Unexpected ', '') + .replace(/^./, firstLetter => firstLetter.toUpperCase()), + item.severity + ) + ) + ); + } + + function cookResult(range, line, col, message, severity) { + line--; + col--; + const realL = line + range.from.line; + const realC = col + (line === 0 ? range.from.ch : 0); + return { + from: CodeMirror.Pos(realL, realC), + to: CodeMirror.Pos(realL, realC + 1), + message, + severity, + }; + } + + function lintChangedRanges(cm, lintFunction) { + const EOF = CodeMirror.Pos(cm.doc.size - 1, cm.getLine(cm.doc.size - 1).length); + // cache the config for subsequent *lintOnRange + config = deepCopy(linterConfig.getCurrent()); + let ranges; + if ( + !cm.stylusChanges || + !cm.stylusChanges.length || + cm.stylusChanges.some(change => change.origin === 'setValue') + ) { + // first run: lint everything + cm.state.lint.marked = []; + // the temp monkeypatch in updateLintReport() is there + // only to allow sep=false that returns a line array + ranges = [{ + code: cm.getValue(false).join('\n'), + from: {line: 0, ch: 0}, + to: EOF, + }]; + } else { + // sort by 'from' position in ascending order + const changes = cm.stylusChanges.sort((a, b) => cmpPos(a.from, b.from)); + // extend ranges with pasted text + for (const change of changes) { + const addedLines = Math.max(0, change.text.length - 1); + const removedLines = Math.max(0, change.removed.length - 1); + const delta = addedLines - removedLines; + change.to = CodeMirror.Pos( + Math.max(0, change.to.line + delta), + Math.max(0, change.to.ch + change.text.last.length - change.removed.last.length + 1) + ); + } + // merge pass 1 + ranges = mergeRanges(changes); + // extend up to previous } and down to next } + for (const range of ranges) { + range.from = findBlockEndBefore(range.from, 2); + range.to = findBlockEndAfter(range.from, 4); + } + // merge pass 2 on the extended ranges + ranges = mergeRanges(ranges); } - return results[0].warnings.map(warning => ({ - from: CodeMirror.Pos(warning.line - 1, warning.column - 1), - to: CodeMirror.Pos(warning.line - 1, warning.column), - message: warning.text - .replace('Unexpected ', '') - .replace(/^./, firstLetter => firstLetter.toUpperCase()), - severity : warning.severity - })); - }) -); + // fill the code and run lintFunction + const results = []; + for (const range of ranges) { + range.code = cm.getRange(range.from, range.to); + results.push(lintFunction(range)); + } + // reset the changes queue and pass the ranges to updateLintReport + (cm.stylusChanges || []).length = 0; + cm.state.lint.changedRanges = ranges; + return results; + + function findBlockEndBefore(pos, repetitions = 1) { + const PREV_CMT_END = find('*/', pos, -1); + const PREV_CMT_START = (prev => cmp(prev, pos) < 0 && prev)(find('/*', PREV_CMT_END, +1)); + const NEXT_CMT_END = PREV_CMT_START && (find('*/', PREV_CMT_START, +1) || EOF); + const cursor = cm.getSearchCursor(/\/\*|\*\/|[{}]/, pos, {caseFold: false}); + let cmtStart = PREV_CMT_START; + let cmtEnd = cmtStart && cmp(NEXT_CMT_END, pos) > 0 && NEXT_CMT_END; + let blockStart; + let blockEnd; + while (cursor.findPrevious()) { + switch (cursor.pos.match[0]) { + case '{': + if (!cmtStart || cmp(cmtStart, cursor.pos.to) > 0) { + blockStart = cursor.pos.from; + } + break; + case '}': + if (!cmtStart || cmp(cmtStart, cursor.pos.to) > 0) { + blockEnd = cursor.pos.to; + if (--repetitions <= 0 || !blockStart) { + return blockEnd; + } + blockStart = null; + } + break; + case '/*': + cmtStart = cursor.pos.to; + if (cmp(cmtEnd, blockEnd) > 0) { + blockEnd = null; + } + if (cmp(cmtEnd, blockStart) > 0) { + blockStart = null; + } + break; + case '*/': + cmtEnd = cursor.pos.to; + if (blockEnd && --repetitions <= 0) { + return blockEnd; + } + break; + } + } + return blockEnd || {line: 0, ch: 0}; + } + + function findBlockEndAfter(pos, repetitions = 1) { + const PREV_CMT_END = find('*/', pos, -1); + const PREV_CMT_START = (prev => cmp(prev, pos) < 0 && prev)(find('/*', PREV_CMT_END, +1)); + const cursor = cm.getSearchCursor(/\/\*|\*\/|[{}]/, pos, {caseFold: false}); + let cmtStart = PREV_CMT_START; + let depth = 0; + while (cursor.findNext()) { + switch (cursor.pos.match[0]) { + case '{': + if (!cmtStart) { + depth++; + } + break; + case '}': + if (!cmtStart && (--depth <= 0 && --repetitions <= 0)) { + return depth < 0 ? cursor.pos.from : cursor.pos.to; + } + break; + case '/*': + cmtStart = cmtStart || cursor.pos.from; + break; + case '*/': + cmtStart = null; + break; + } + } + return EOF; + } + + function find(query, pos, direction) { + const cursor = cm.getSearchCursor(query, pos, {caseFold: false}); + return direction > 0 + ? cursor.findNext() && cursor.from() + : cursor.findPrevious() && cursor.to(); + } + + function cmp(a, b) { + if (!a && !b) { + return 0; + } + if (!a) { + return -1; + } + if (!b) { + return 1; + } + return cmpPos(a, b); + } + } + + function mergeRanges(sorted) { + const ranges = []; + let lastChange = {from: {}, to: {line: -1, ch: -1}}; + for (const change of sorted) { + if (cmpPos(change.from, change.to) > 0) { + // straighten the inverted range + const from = change.from; + change.from = change.to; + change.to = from; + } + if (cmpPos(change.from, lastChange.to) > 0) { + ranges.push({ + from: change.from, + to: change.to, + code: '', + }); + } else if (cmpPos(change.to, lastChange.to) > 0) { + ranges[ranges.length - 1].to = change.to; + } + lastChange = change; + } + return ranges; + } + + function copyOldIssues(cm, newAnns) { + const EOF = CodeMirror.Pos(cm.doc.size - 1, cm.getLine(cm.doc.size - 1).length); + + const oldMarkers = cm.state.lint.marked; + let oldIndex = 0; + let oldAnn = (oldMarkers[0] || {}).__annotation; + + const newRanges = cm.state.lint.changedRanges || []; + let newIndex = 0; + let newRange = newRanges[0]; + + const finalAnns = []; + const unique = new Set(); + const pushUnique = item => { + const key = item.from.line + ' ' + item.from.ch + ' ' + item.message; + if (!unique.has(key)) { + unique.add(key); + finalAnns.push(item); + } + }; + + const t0 = performance.now(); + while (oldAnn && cmpPos(oldAnn.from, EOF) < 0 || newRange) { + if (performance.now() - t0 > 500) { + console.error('infinite loop canceled', + JSON.stringify([ + newAnns, + oldMarkers[0] && oldMarkers.map(m => ({from: m.__annotation.from, to: m.__annotation.to})), + newRanges.map(r => Object.assign(r, {code: undefined})) + ]) + ); + break; + } + // copy old issues prior to current newRange + // eslint-disable-next-line no-unmodified-loop-condition + while (oldAnn && (!newRange || cmpPos(oldAnn.to, newRange.from) < 0)) { + pushUnique(oldAnn); + oldIndex++; + oldAnn = (oldMarkers[oldIndex] || {}).__annotation; + } + // skip all old issues within newRange + if (newRange) { + while (oldAnn && cmpPos(oldAnn.to, newRange.to) <= 0) { + oldAnn = (oldMarkers[oldIndex++] || {}).__annotation; + } + } + // copy all newRange prior to current oldAnn + // eslint-disable-next-line no-unmodified-loop-condition + while (newRange && (!oldAnn || cmpPos(newRange.to, oldAnn.from) <= 0)) { + newAnns[newIndex].forEach(pushUnique); + newIndex++; + newRange = newRanges[newIndex]; + } + } + return finalAnns; + } +})(); diff --git a/edit/lint.js b/edit/lint.js index e8cc1fc9..e3419cc0 100644 --- a/edit/lint.js +++ b/edit/lint.js @@ -191,29 +191,49 @@ function updateLinter({immediately} = {}) { } function updateLintReport(cm, delay) { + if (!CodeMirror.defaults.lint) { + return; + } + if (cm && !cm.options.lint) { + setTimeout(() => { + if (cm.options.lint) { + return; + } + cm.setOption('lint', linterConfig.getForCodeMirror()); + if (!delay) { + setTimeout(() => { + clearTimeout((cm.state.lint || {}).renderTimeout); + renderLintReport(); + }, 100); + } + }); + } + const state = cm && cm.state && cm.state.lint || {}; if (delay === 0) { // immediately show pending csslint/stylelint messages in onbeforeunload and save + clearTimeout(state.lintTimeout); update(cm); return; } if (delay > 0) { - setTimeout(cm => { + clearTimeout(state.lintTimeout); + state.lintTimeout = setTimeout(cm => { + // the temp monkeypatch only allows sep=false that returns a line array + // because during editing this is what we need, not the combined text + const _getValue = cm.getValue; + cm.getValue = sep => (sep === false ? _getValue.call(cm, sep) : ''); if (cm.performLint) { cm.performLint(); update(cm); } + cm.getValue = _getValue; }, delay, cm); return; } - // eslint-disable-next-line no-var - var state = cm.state.lint; - if (!state) { - return; - } // user is editing right now: postpone updating the report for the new issues (default: 500ms lint + 4500ms) // or update it as soon as possible (default: 500ms lint + 100ms) in case an existing issue was just fixed clearTimeout(state.reportTimeout); - state.reportTimeout = setTimeout(update, state.options.delay + 100, cm); + state.reportTimeout = setTimeout(update, (state.options || {}).delay + 100, cm); state.postponeNewIssues = delay === undefined || delay === null; function update(cm) {