18265b94c6
fixes #1254
417 lines
13 KiB
JavaScript
417 lines
13 KiB
JavaScript
/* global $ $$ $create setupLivePrefs waitForSelector */// dom.js
|
|
/* global API */// msg.js
|
|
/* global CODEMIRROR_THEMES */
|
|
/* global CodeMirror */
|
|
/* global MozDocMapper */// sections-util.js
|
|
/* global initBeautifyButton */// beautify.js
|
|
/* global prefs */
|
|
/* global t */// localization.js
|
|
/* global
|
|
FIREFOX
|
|
debounce
|
|
getOwnTab
|
|
sessionStore
|
|
tryJSONparse
|
|
tryURL
|
|
*/// toolbox.js
|
|
'use strict';
|
|
|
|
/**
|
|
* @type Editor
|
|
* @namespace Editor
|
|
*/
|
|
const editor = {
|
|
dirty: DirtyReporter(),
|
|
isUsercss: false,
|
|
isWindowed: false,
|
|
lazyKeymaps: {
|
|
emacs: '/vendor/codemirror/keymap/emacs',
|
|
vim: '/vendor/codemirror/keymap/vim',
|
|
},
|
|
livePreview: null,
|
|
/** @type {'customName'|'name'} */
|
|
nameTarget: 'name',
|
|
previewDelay: 200, // Chrome devtools uses 200
|
|
scrollInfo: null,
|
|
|
|
updateTitle(isDirty = editor.dirty.isDirty()) {
|
|
const {customName, name} = editor.style;
|
|
document.title = `${
|
|
isDirty ? '* ' : ''
|
|
}${
|
|
customName || name || t('styleMissingName')
|
|
} - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
|
|
},
|
|
};
|
|
|
|
//#region pre-init
|
|
|
|
const baseInit = (() => {
|
|
const domReady = waitForSelector('#sections');
|
|
|
|
return {
|
|
domReady,
|
|
ready: Promise.all([
|
|
domReady,
|
|
loadStyle(),
|
|
prefs.ready.then(() =>
|
|
Promise.all([
|
|
loadTheme(),
|
|
loadKeymaps(),
|
|
])),
|
|
]),
|
|
};
|
|
|
|
/** Preloads vim/emacs keymap only if it's the active one, otherwise will load later */
|
|
function loadKeymaps() {
|
|
const km = prefs.get('editor.keyMap');
|
|
return /emacs/i.test(km) && require([editor.lazyKeymaps.emacs]) ||
|
|
/vim/i.test(km) && require([editor.lazyKeymaps.vim]);
|
|
}
|
|
|
|
async function loadStyle() {
|
|
const params = new URLSearchParams(location.search);
|
|
const id = Number(params.get('id'));
|
|
const style = id && await API.styles.get(id) || {
|
|
name: params.get('domain') ||
|
|
tryURL(params.get('url-prefix')).hostname ||
|
|
'',
|
|
enabled: true,
|
|
sections: [
|
|
MozDocMapper.toSection([...params], {code: ''}),
|
|
],
|
|
};
|
|
// switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
|
|
editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
|
|
editor.style = style;
|
|
editor.updateTitle(false);
|
|
document.documentElement.classList.toggle('usercss', editor.isUsercss);
|
|
sessionStore.justEditedStyleId = style.id || '';
|
|
// no such style so let's clear the invalid URL parameters
|
|
if (!style.id) history.replaceState({}, '', location.pathname);
|
|
}
|
|
|
|
/** Preloads the theme so CodeMirror can use the correct metrics in its first render */
|
|
async function loadTheme() {
|
|
const theme = prefs.get('editor.theme');
|
|
if (!CODEMIRROR_THEMES.includes(theme)) {
|
|
prefs.set('editor.theme', 'default');
|
|
return;
|
|
}
|
|
if (theme !== 'default') {
|
|
const el = $('#cm-theme');
|
|
const el2 = await require([`/vendor/codemirror/theme/${theme}.css`]);
|
|
el2.id = el.id;
|
|
el.remove();
|
|
// FF containers take more time to load CSS
|
|
for (let retry = 0; !el2.sheet && ++retry <= 10;) {
|
|
await new Promise(requestAnimationFrame);
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
|
|
//#endregion
|
|
//#region init layout/resize
|
|
|
|
baseInit.domReady.then(() => {
|
|
let headerHeight;
|
|
detectLayout(true);
|
|
window.on('resize', () => detectLayout());
|
|
|
|
function detectLayout(now) {
|
|
const compact = window.innerWidth <= 850;
|
|
if (compact) {
|
|
document.body.classList.add('compact-layout');
|
|
if (!editor.isUsercss) {
|
|
if (now) fixedHeader();
|
|
else debounce(fixedHeader, 250);
|
|
window.on('scroll', fixedHeader, {passive: true});
|
|
}
|
|
} else {
|
|
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);
|
|
}
|
|
}
|
|
|
|
function fixedHeader() {
|
|
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 < scrollPoint && headerFixed) {
|
|
$('body').classList.remove('fixed-header');
|
|
}
|
|
}
|
|
});
|
|
|
|
//#endregion
|
|
//#region init header
|
|
|
|
baseInit.ready.then(() => {
|
|
initBeautifyButton($('#beautify'));
|
|
initKeymapElement();
|
|
initNameArea();
|
|
initThemeElement();
|
|
setupLivePrefs();
|
|
|
|
$('#heading').textContent = t(editor.style.id ? 'editStyleHeading' : 'addStyleTitle');
|
|
$('#preview-label').classList.toggle('hidden', !editor.style.id);
|
|
|
|
require(Object.values(editor.lazyKeymaps), () => {
|
|
initKeymapElement();
|
|
prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true});
|
|
window.on('showHotkeyInTooltip', showHotkeyInTooltip);
|
|
});
|
|
|
|
function findKeyForCommand(command, map) {
|
|
if (typeof map === 'string') map = CodeMirror.keyMap[map];
|
|
let key = Object.keys(map).find(k => map[k] === command);
|
|
if (key) {
|
|
return key;
|
|
}
|
|
for (const ft of Array.isArray(map.fallthrough) ? map.fallthrough : [map.fallthrough]) {
|
|
key = ft && findKeyForCommand(command, ft);
|
|
if (key) {
|
|
return key;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function initNameArea() {
|
|
const nameEl = $('#name');
|
|
const resetEl = $('#reset-name');
|
|
const isCustomName = editor.style.updateUrl || editor.isUsercss;
|
|
editor.nameTarget = isCustomName ? 'customName' : 'name';
|
|
nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
|
|
nameEl.title = isCustomName ? t('customNameHint') : '';
|
|
nameEl.on('input', () => {
|
|
editor.updateName(true);
|
|
resetEl.hidden = false;
|
|
});
|
|
resetEl.hidden = !editor.style.customName;
|
|
resetEl.onclick = () => {
|
|
const {style} = editor;
|
|
nameEl.focus();
|
|
nameEl.select();
|
|
// trying to make it undoable via Ctrl-Z
|
|
if (!document.execCommand('insertText', false, style.name)) {
|
|
nameEl.value = style.name;
|
|
editor.updateName(true);
|
|
}
|
|
style.customName = null; // to delete it from db
|
|
resetEl.hidden = true;
|
|
};
|
|
const enabledEl = $('#enabled');
|
|
enabledEl.onchange = () => editor.updateEnabledness(enabledEl.checked);
|
|
}
|
|
|
|
function initThemeElement() {
|
|
$('#editor.theme').append(...[
|
|
$create('option', {value: 'default'}, t('defaultTheme')),
|
|
...CODEMIRROR_THEMES.map(s => $create('option', s)),
|
|
]);
|
|
// move the theme after built-in CSS so that its same-specificity selectors win
|
|
document.head.appendChild($('#cm-theme'));
|
|
}
|
|
|
|
function initKeymapElement() {
|
|
// move 'pc' or 'mac' prefix to the end of the displayed label
|
|
const maps = Object.keys(CodeMirror.keyMap)
|
|
.map(name => ({
|
|
value: name,
|
|
name: name.replace(/^(pc|mac)(.+)/, (s, arch, baseName) =>
|
|
baseName.toLowerCase() + '-' + (arch === 'mac' ? 'Mac' : 'PC')),
|
|
}))
|
|
.sort((a, b) => a.name < b.name && -1 || a.name > b.name && 1);
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
let bin = fragment;
|
|
let groupName;
|
|
// group suffixed maps in <optgroup>
|
|
maps.forEach(({value, name}, i) => {
|
|
groupName = !name.includes('-') ? name : groupName;
|
|
const groupWithNext = maps[i + 1] && maps[i + 1].name.startsWith(groupName);
|
|
if (groupWithNext) {
|
|
if (bin === fragment) {
|
|
bin = fragment.appendChild($create('optgroup', {label: name.split('-')[0]}));
|
|
}
|
|
}
|
|
const el = bin.appendChild($create('option', {value}, name));
|
|
if (value === prefs.defaults['editor.keyMap']) {
|
|
el.dataset.default = '';
|
|
el.title = t('defaultTheme');
|
|
}
|
|
if (!groupWithNext) bin = fragment;
|
|
});
|
|
const selector = $('#editor.keyMap');
|
|
selector.textContent = '';
|
|
selector.appendChild(fragment);
|
|
selector.value = prefs.get('editor.keyMap');
|
|
}
|
|
|
|
function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) {
|
|
const extraKeys = CodeMirror.defaults.extraKeys;
|
|
for (const el of $$('[data-hotkey-tooltip]')) {
|
|
if (el._hotkeyTooltipKeyMap !== mapName) {
|
|
el._hotkeyTooltipKeyMap = mapName;
|
|
const title = el._hotkeyTooltipTitle = el._hotkeyTooltipTitle || el.title;
|
|
const cmd = el.dataset.hotkeyTooltip;
|
|
const key = cmd[0] === '=' ? cmd.slice(1) :
|
|
findKeyForCommand(cmd, mapName) ||
|
|
extraKeys && findKeyForCommand(cmd, extraKeys);
|
|
const newTitle = title + (title && key ? '\n' : '') + (key || '');
|
|
if (el.title !== newTitle) el.title = newTitle;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
//#endregion
|
|
//#region init windowed mode
|
|
|
|
(() => {
|
|
let ownTabId;
|
|
if (chrome.windows) {
|
|
initWindowedMode();
|
|
const pos = tryJSONparse(sessionStore.windowPos);
|
|
delete sessionStore.windowPos;
|
|
// resize the window on 'undo close'
|
|
if (pos && pos.left != null) {
|
|
chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
|
|
}
|
|
}
|
|
|
|
getOwnTab().then(async tab => {
|
|
ownTabId = tab.id;
|
|
// use browser history back when 'back to manage' is clicked
|
|
if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
|
|
await baseInit.domReady;
|
|
$('#cancel-button').onclick = event => {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
history.back();
|
|
};
|
|
}
|
|
});
|
|
|
|
async function initWindowedMode() {
|
|
chrome.tabs.onAttached.addListener(onTabAttached);
|
|
const isSimple = (await browser.windows.getCurrent()).type === 'popup';
|
|
if (isSimple) require(['/edit/embedded-popup']);
|
|
editor.isWindowed = isSimple || (
|
|
history.length === 1 &&
|
|
await prefs.ready && prefs.get('openEditInWindow') &&
|
|
(await browser.windows.getAll()).length > 1 &&
|
|
(await browser.tabs.query({currentWindow: true})).length === 1
|
|
);
|
|
}
|
|
|
|
async function onTabAttached(tabId, info) {
|
|
if (tabId !== ownTabId) {
|
|
return;
|
|
}
|
|
if (info.newPosition !== 0) {
|
|
prefs.set('openEditInWindow', false);
|
|
return;
|
|
}
|
|
const win = await browser.windows.get(info.newWindowId, {populate: true});
|
|
// If there's only one tab in this window, it's been dragged to new window
|
|
const openEditInWindow = win.tabs.length === 1;
|
|
// FF-only because Chrome retardedly resets the size during dragging
|
|
if (openEditInWindow && FIREFOX) {
|
|
chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
|
|
}
|
|
prefs.set('openEditInWindow', openEditInWindow);
|
|
}
|
|
})();
|
|
|
|
//#endregion
|
|
//#region internals
|
|
|
|
/** @returns DirtyReporter */
|
|
function DirtyReporter() {
|
|
const data = new Map();
|
|
const listeners = new Set();
|
|
const notifyChange = wasDirty => {
|
|
if (wasDirty !== (data.size > 0)) {
|
|
listeners.forEach(cb => cb());
|
|
}
|
|
};
|
|
/** @namespace DirtyReporter */
|
|
return {
|
|
add(obj, value) {
|
|
const wasDirty = data.size > 0;
|
|
const saved = data.get(obj);
|
|
if (!saved) {
|
|
data.set(obj, {type: 'add', newValue: value});
|
|
} else if (saved.type === 'remove') {
|
|
if (saved.savedValue === value) {
|
|
data.delete(obj);
|
|
} else {
|
|
saved.newValue = value;
|
|
saved.type = 'modify';
|
|
}
|
|
}
|
|
notifyChange(wasDirty);
|
|
},
|
|
clear(obj) {
|
|
const wasDirty = data.size > 0;
|
|
if (obj === undefined) {
|
|
data.clear();
|
|
} else {
|
|
data.delete(obj);
|
|
}
|
|
notifyChange(wasDirty);
|
|
},
|
|
has(key) {
|
|
return data.has(key);
|
|
},
|
|
isDirty() {
|
|
return data.size > 0;
|
|
},
|
|
modify(obj, oldValue, newValue) {
|
|
const wasDirty = data.size > 0;
|
|
const saved = data.get(obj);
|
|
if (!saved) {
|
|
if (oldValue !== newValue) {
|
|
data.set(obj, {type: 'modify', savedValue: oldValue, newValue});
|
|
}
|
|
} else if (saved.type === 'modify') {
|
|
if (saved.savedValue === newValue) {
|
|
data.delete(obj);
|
|
} else {
|
|
saved.newValue = newValue;
|
|
}
|
|
} else if (saved.type === 'add') {
|
|
saved.newValue = newValue;
|
|
}
|
|
notifyChange(wasDirty);
|
|
},
|
|
onChange(cb, add = true) {
|
|
listeners[add ? 'add' : 'delete'](cb);
|
|
},
|
|
remove(obj, value) {
|
|
const wasDirty = data.size > 0;
|
|
const saved = data.get(obj);
|
|
if (!saved) {
|
|
data.set(obj, {type: 'remove', savedValue: value});
|
|
} else if (saved.type === 'add') {
|
|
data.delete(obj);
|
|
} else if (saved.type === 'modify') {
|
|
saved.type = 'remove';
|
|
}
|
|
notifyChange(wasDirty);
|
|
},
|
|
};
|
|
}
|
|
|
|
//#endregion
|