- Lab page is now read-only with a settings screen for levels/variants; variant issuance moved to a large modal on the group page (assign-fragment). - Header: bordeaux logo and 2px accent underline, bordeaux login/logout/theme buttons; smaller radii across the UI; filled secondary/danger buttons. - Includes prior agent work (login backoff, no-cache pages, API timeouts, content tweaks) and fixes (variant usage Map.values, import order). - Bump static asset version to v=9.
137 lines
5.9 KiB
JavaScript
137 lines
5.9 KiB
JavaScript
/* Редактор markdown: панель кнопок применяется к textarea по data-md-target.
|
||
Плюс переключение статьи в режим правки прямо на странице (без модального окна). */
|
||
(function () {
|
||
var dirty = false;
|
||
|
||
function target(toolbar) {
|
||
return document.querySelector(toolbar.getAttribute('data-md-target'));
|
||
}
|
||
|
||
function replaceSelection(area, before, after, placeholder) {
|
||
var start = area.selectionStart, end = area.selectionEnd;
|
||
var selected = area.value.slice(start, end) || placeholder || '';
|
||
var text = before + selected + after;
|
||
area.setRangeText(text, start, end, 'end');
|
||
if (!area.selectionEnd || selected === placeholder) {
|
||
// оставляем курсор внутри вставленного фрагмента
|
||
area.selectionStart = start + before.length;
|
||
area.selectionEnd = start + before.length + selected.length;
|
||
}
|
||
area.focus();
|
||
area.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
function prefixLines(area, prefix, numbered) {
|
||
var start = area.selectionStart, end = area.selectionEnd;
|
||
var value = area.value;
|
||
var lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||
var lineEnd = value.indexOf('\n', end);
|
||
if (lineEnd === -1) lineEnd = value.length;
|
||
var block = value.slice(lineStart, lineEnd);
|
||
var lines = block.split('\n');
|
||
var changed = lines.map(function (line, index) {
|
||
if (line.trim() === '') return line;
|
||
return (numbered ? (index + 1) + '. ' : prefix) + line;
|
||
}).join('\n');
|
||
area.setRangeText(changed, lineStart, lineEnd, 'end');
|
||
area.focus();
|
||
area.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
function insertBlock(area, text, cursorOffset) {
|
||
var start = area.selectionStart;
|
||
var value = area.value;
|
||
var atLineStart = start === 0 || value[start - 1] === '\n';
|
||
var prefix = atLineStart ? '' : '\n';
|
||
var insert = prefix + text + '\n';
|
||
area.setRangeText(insert, start, area.selectionEnd, 'end');
|
||
var pos = area.selectionStart - 1 - (text.length - (cursorOffset || 0));
|
||
area.selectionStart = area.selectionEnd = start + prefix.length + (cursorOffset || 0);
|
||
area.focus();
|
||
area.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
|
||
function apply(action, area) {
|
||
switch (action) {
|
||
case 'bold': replaceSelection(area, '**', '**', 'текст'); break;
|
||
case 'italic': replaceSelection(area, '*', '*', 'текст'); break;
|
||
case 'code': replaceSelection(area, '`', '`', 'код'); break;
|
||
case 'h2': prefixLines(area, '## '); break;
|
||
case 'ul': prefixLines(area, '- '); break;
|
||
case 'ol': prefixLines(area, '', true); break;
|
||
case 'quote': prefixLines(area, '> '); break;
|
||
case 'link': replaceSelection(area, '[', '](https://)', 'ссылка'); break;
|
||
case 'codeblock':
|
||
replaceSelection(area, '```c\n', '\n```', 'int main(void)\n{\n return 0;\n}');
|
||
break;
|
||
case 'table':
|
||
insertBlock(area, '| Столбец | Столбец |\n|---|---|\n| значение | значение |', 2);
|
||
break;
|
||
case 'figure':
|
||
replaceSelection(area, ':::figure assets/', ' | Рис. 1. Подпись', 'имя.svg');
|
||
break;
|
||
case 'video':
|
||
replaceSelection(area, ':::video assets/', ' | Подпись к видео', 'имя.mp4');
|
||
break;
|
||
case 'spoiler':
|
||
replaceSelection(area, ':::spoiler Решение\n', '\n:::', 'решение');
|
||
break;
|
||
}
|
||
}
|
||
|
||
document.addEventListener('click', function (event) {
|
||
var button = event.target.closest('.md-btn');
|
||
if (!button) return;
|
||
var toolbar = button.closest('.md-toolbar');
|
||
if (!toolbar) return;
|
||
var area = target(toolbar);
|
||
if (!area) return;
|
||
event.preventDefault();
|
||
apply(button.getAttribute('data-md'), area);
|
||
});
|
||
|
||
/* --- режим правки статьи на странице --- */
|
||
window.startArticleEdit = function () {
|
||
var view = document.getElementById('article-view');
|
||
var form = document.getElementById('article-edit');
|
||
if (!view || !form) return;
|
||
view.hidden = true;
|
||
form.hidden = false;
|
||
dirty = false;
|
||
var area = document.getElementById('article-body');
|
||
if (area) area.focus();
|
||
};
|
||
|
||
window.cancelArticleEdit = function () {
|
||
if (dirty && !confirm('Выйти из редактирования без сохранения?')) return;
|
||
var view = document.getElementById('article-view');
|
||
var form = document.getElementById('article-edit');
|
||
if (!view || !form) return;
|
||
form.hidden = true;
|
||
view.hidden = false;
|
||
dirty = false;
|
||
};
|
||
|
||
document.addEventListener('input', function (event) {
|
||
var id = event.target && event.target.id;
|
||
if (id === 'article-body' || id === 'article-title') dirty = true;
|
||
});
|
||
|
||
/* Сохранение — это осознанный уход со страницы, подтверждение не нужно */
|
||
document.addEventListener('submit', function (event) {
|
||
if (event.target && event.target.id === 'article-edit') dirty = false;
|
||
}, true);
|
||
|
||
window.addEventListener('beforeunload', function (event) {
|
||
var form = document.getElementById('article-edit');
|
||
if (dirty && form && !form.hidden) {
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
}
|
||
});
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
if (window.location.search.indexOf('edit=1') >= 0) window.startArticleEdit();
|
||
});
|
||
})();
|