stylus/edit/moz-section-widget.js
tophf dd38856eda
scrollable details + sticky header (#1400)
* shorten section labels in lint report
* `sectioned` class on html for sectioned editor
* fix scrollElementIntoView
2022-02-14 22:19:20 +03:00

452 lines
13 KiB
JavaScript

/* global $ $create messageBoxProxy */// dom.js
/* global CodeMirror */
/* global MozSectionFinder */
/* global colorMimicry */
/* global editor */
/* global msg */
/* global prefs */
/* global t */// localization.js
/* global tryCatch */// toolbox.js
'use strict';
/* exported MozSectionWidget */
function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
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;
/** @type {HTMLStyleElement} */
let actualStyle;
return {
toggle(enable) {
if (Boolean(enable) !== enabled) {
(enable ? init : destroy)();
}
},
};
function init() {
const hint = {title: t('appliesHelp')};
enabled = true;
TPL = {
container:
$create('div' + C_CONTAINER, [
$create(C_LABEL, hint, t('appliesLabel')),
$create('ul' + C_LIST),
]),
listItem:
t.template.appliesTo.cloneNode(true),
appliesToEverything:
$create('li.applies-to-everything', t('appliesToEverything')),
};
Object.assign($(C_TYPE, TPL.listItem), hint);
$(C_VALUE, TPL.listItem).after(
$create('button.test-regexp', t('genericTest')));
CLICK_ROUTE = {
'.test-regexp': showRegExpTester,
/**
* @param {HTMLElement} elItem
* @param {MarkedFunc} func
*/
'.remove-applies-to'(elItem, func) {
const funcs = getFuncsFor(elItem);
if (funcs.length < 2) {
messageBoxProxy.show({
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) editor.updateToc([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);
updateWidgetStyle(); // updating in this paint frame to avoid FOUC for dark themes
cm.display.wrapper.style.setProperty('--cm-bar-width', cm.display.barWidth + 'px');
}
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(cm.display.wrapper),
gutter: colorMimicry(cm.display.gutters, {
bg: 'backgroundColor',
border: 'borderRightColor',
}),
line: colorMimicry('.CodeMirror-linenumber', null, cm.display.lineDiv),
comment: colorMimicry('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} 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;
}
`;
$.root.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 = Math.round(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,
});
widget.on('redraw', () => {
const value = cm.display.barWidth + 'px';
if (widget[KEY] !== value) {
widget[KEY] = value;
node.style.setProperty('--cm-bar-width', value);
}
});
}
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();
}
async function showRegExpTester(el) {
/* global regexpTester */
await require(['/edit/regexp-tester']);
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});
}
}