stylus/edit/source-editor.js

323 lines
10 KiB
JavaScript

/* global $ $$remove $create $isTextInput messageBoxProxy */// dom.js
/* global API */// msg.js
/* global CodeMirror */
/* global MozDocMapper */// util.js
/* global MozSectionFinder */
/* global MozSectionWidget */
/* global RX_META debounce */// toolbox.js
/* global chromeSync */// storage-util.js
/* global cmFactory */
/* global editor */
/* global linterMan */
/* global prefs */
/* global t */// localization.js
'use strict';
/* exported SourceEditor */
function SourceEditor() {
const {style, /** @type DirtyReporter */dirty} = editor;
let savedGeneration;
let prevMode = NaN;
$$remove('.sectioned-only');
$('#header').on('wheel', headerOnScroll);
$('#sections').textContent = '';
$('#sections').appendChild($create('.single-editor'));
$('#save-button').onauxclick = e => e.detail === 'tpl' && saveTemplate();
const cm = cmFactory.create($('.single-editor'));
const sectionFinder = MozSectionFinder(cm);
const sectionWidget = MozSectionWidget(cm, sectionFinder);
editor.livePreview.init(preprocess);
if (!style.id) setupNewStyle();
createMetaCompiler(meta => {
style.usercssData = meta;
style.name = meta.name;
style.url = meta.homepageURL || style.installationUrl;
updateMeta();
});
updateMeta();
cm.setValue(style.sourceCode);
/** @namespace Editor */
Object.assign(editor, {
sections: sectionFinder.sections,
replaceStyle,
updateLivePreview,
updateMeta,
closestVisible: () => cm,
getEditors: () => [cm],
getEditorTitle: () => '',
getValue: () => cm.getValue(),
getSearchableInputs: () => [],
prevEditor: nextPrevSection.bind(null, -1),
nextEditor: nextPrevSection.bind(null, 1),
jumpToEditor(i) {
const sec = sectionFinder.sections[i];
if (sec) {
sectionFinder.updatePositions(sec);
cm.jumpToPos(sec.start);
cm.focus();
}
},
async saveImpl() {
const sourceCode = cm.getValue();
try {
const {customName, enabled, id} = style;
let res = !id && await API.usercss.build({sourceCode, checkDup: true, metaOnly: true});
if (res && res.dup) {
messageBoxProxy.alert(t('usercssAvoidOverwriting'), 'danger', t('genericError'));
} else {
res = await API.usercss.editSave({customName, enabled, id, sourceCode});
// Awaiting inside `try` so that exceptions go to our `catch`
await replaceStyle(res.style);
}
showLog(res);
} catch (err) {
showSaveError(err);
}
},
scrollToEditor: () => {},
});
prefs.subscribeMany({
'editor.linter': updateLinterSwitch,
'editor.appliesToLineWidget': (k, val) => sectionWidget.toggle(val),
'editor.toc.expanded': (k, val) => sectionFinder.onOff(editor.updateToc, val),
}, {runNow: true});
editor.applyScrollInfo(cm);
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;
linterMan.run();
updateLinterSwitch();
});
setTimeout(linterMan.enableForEditor, 0, cm);
if (!$isTextInput(document.activeElement)) {
cm.focus();
}
async function preprocess(style) {
const res = await API.usercss.build({
styleId: style.id,
sourceCode: style.sourceCode,
assignVars: true,
});
showLog(res);
delete res.style.enabled;
return Object.assign(style, res.style);
}
/** Shows the console.log output from the background worker stored in `log` property */
function showLog(data) {
if (data.log) data.log.forEach(args => console.log(...args));
return data;
}
function updateLivePreview() {
if (!style.id) {
return;
}
editor.livePreview.update(Object.assign({}, style, {sourceCode: cm.getValue()}));
}
function updateLinterSwitch() {
const el = $('#editor.linter');
el.value = getCurrentLinter();
const cssLintOption = $('[value="csslint"]', el);
const mode = getModeName();
if (mode !== 'css') {
cssLintOption.disabled = true;
cssLintOption.title = t('linterCSSLintIncompatible', mode);
} else {
cssLintOption.disabled = false;
cssLintOption.title = '';
}
}
function getCurrentLinter() {
const name = prefs.get('editor.linter');
if (cm.getOption('mode') !== 'css' && name === 'csslint') {
return 'stylelint';
}
return name;
}
function setupNewStyle() {
const comment = `/* ${t('usercssReplaceTemplateSectionBody')} */`;
const sec0 = style.sections[0];
sec0.code = ' '.repeat(prefs.get('editor.tabSize')) + comment;
if (Object.keys(sec0).length === 1) { // the only key is 'code'
sec0.domains = ['example.com'];
}
const DEFAULT_CODE = `
/* ==UserStyle==
@name ${''/* a trick to preserve the trailing spaces */}
@namespace github.com/openstyles/stylus
@version 1.0.0
@description A new userstyle
@author Me
==/UserStyle== */
`.replace(/^\s+/gm, '');
style.name = [style.name, new Date().toLocaleString()].filter(Boolean).join(' - ');
style.sourceCode = (editor.template || DEFAULT_CODE)
.replace(/(@name)(?:([\t\x20]+).*|\n)/, (_, k, space) => `${k}${space || ' '}${style.name}`)
.replace(/\s*@-moz-document[^{]*{([^}]*)}\s*$/g, // stripping dummy sections
(s, body) => body.trim() === comment ? '\n\n' : s)
.trim() +
'\n\n' +
MozDocMapper.styleToCss(style);
cm.startOperation();
cm.setValue(style.sourceCode);
cm.clearHistory();
cm.markClean();
cm.endOperation();
dirty.clear('sourceGeneration');
savedGeneration = cm.changeGeneration();
}
function updateMeta() {
const name = style.customName || style.name;
$('#name').value = name;
$('#enabled').checked = style.enabled;
$('#url').href = style.url;
editor.updateName();
cm.setPreprocessor((style.usercssData || {}).preprocessor);
}
async function replaceStyle(newStyle) {
dirty.clear('name');
const sameCode = newStyle.sourceCode === cm.getValue();
if (sameCode) {
savedGeneration = cm.changeGeneration();
dirty.clear('sourceGeneration');
editor.useSavedStyle(newStyle);
dirty.clear('enabled');
updateLivePreview();
return;
}
if (await messageBoxProxy.confirm(t('styleUpdateDiscardChanges'))) {
editor.useSavedStyle(newStyle);
if (!sameCode) {
const cursor = cm.getCursor();
cm.setValue(style.sourceCode);
cm.setCursor(cursor);
savedGeneration = cm.changeGeneration();
}
if (sameCode) {
// the code is same but the environment is changed
updateLivePreview();
}
dirty.clear();
}
}
async function saveTemplate() {
if (await messageBoxProxy.confirm(t('usercssReplaceTemplateConfirmation'))) {
const key = chromeSync.LZ_KEY.usercssTemplate;
const code = cm.getValue();
await chromeSync.setLZValue(key, code);
if (await chromeSync.getLZValue(key) !== code) {
messageBoxProxy.alert(t('syncStorageErrorSaving'));
}
}
}
function showSaveError(err) {
err = Array.isArray(err) ? err : [err];
const text = err.map(e => e.message || e).join('\n');
const points = err.map(e =>
e.index >= 0 && cm.posFromIndex(e.index) || // usercss meta parser
e.offset >= 0 && {line: e.line - 1, ch: e.col - 1} // csslint code parser
).filter(Boolean);
cm.setSelections(points.map(p => ({anchor: p, head: p})));
messageBoxProxy.alert($create('pre', text), 'pre');
}
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;
}
cm.jumpToPos(sections[(i + dir + num) % num].start);
}
function headerOnScroll({target, deltaY, deltaMode, shiftKey}) {
while ((target = target.parentElement)) {
if (deltaY < 0 && target.scrollTop ||
deltaY > 0 && target.scrollTop + target.clientHeight < target.scrollHeight) {
return;
}
}
cm.display.scroller.scrollTop +=
// WheelEvent.DOM_DELTA_LINE
deltaMode === 1 ? deltaY * cm.defaultTextHeight() :
// WheelEvent.DOM_DELTA_PAGE
deltaMode === 2 || shiftKey ? Math.sign(deltaY) * cm.display.scroller.clientHeight :
// WheelEvent.DOM_DELTA_PIXEL
deltaY;
}
function getModeName() {
const mode = cm.doc.mode;
if (!mode) return '';
return (mode.name || mode || '') +
(mode.helperType || '');
}
function createMetaCompiler(onUpdated) {
let meta = null;
let metaIndex = null;
let cache = [];
linterMan.register(async (text, options, _cm) => {
if (_cm !== cm) {
return;
}
const match = text.match(RX_META);
if (!match) {
return [];
}
if (match[0] === meta && match.index === metaIndex) {
return cache;
}
const {metadata, errors} = await linterMan.worker.metalint(match[0]);
if (errors.every(err => err.code === 'unknownMeta')) {
onUpdated(metadata);
}
cache = errors.map(({code, index, args, message}) => {
const isUnknownMeta = code === 'unknownMeta';
const typo = isUnknownMeta && args[1] ? 'Typo' : ''; // args[1] may be present but undefined
return ({
from: cm.posFromIndex((index || 0) + match.index),
to: cm.posFromIndex((index || 0) + match.index),
message: code && t(`meta_${code}${typo}`, args, false) || message,
severity: isUnknownMeta ? 'warning' : 'error',
rule: code,
});
});
meta = match[0];
metaIndex = match.index;
return cache;
});
}
}