Learn: inline markdown editor with formatting toolbar; drop edit modals

This commit is contained in:
2026-09-17 18:31:43 +03:00
parent 8753d3658e
commit c111dce6da
7 changed files with 210 additions and 55 deletions

View File

@@ -385,3 +385,24 @@ th.th-filterable { padding-bottom: 0.35rem; }
.learn-main { order: 1; }
.learn-side { position: static; max-height: none; order: 2; }
}
/* Панель форматирования markdown и встроенный редактор */
.md-toolbar { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.4rem; }
.md-btn {
font: inherit; font-size: 0.78rem; line-height: 1.4; cursor: pointer;
padding: 0.2rem 0.5rem; border: 1px solid var(--c-border); border-radius: 0.35rem;
background: var(--c-surface); color: var(--c-text);
}
.md-btn:hover { background: var(--c-bg); }
.md-btn:active { transform: translateY(1px); }
.md-editor-body {
width: 100%; min-height: 20rem; font-family: "DejaVu Sans Mono", Consolas, monospace;
font-size: 0.9rem; line-height: 1.5; white-space: pre; overflow-wrap: normal;
}
.md-figure { margin: 1rem 0; text-align: center; }
.md-figure img { width: 100%; max-width: 100%; height: auto; }
.md-figure figcaption, .md-video-figure figcaption {
margin-top: 0.4rem; font-size: 0.875rem; color: var(--c-text-muted);
}
.md-video-figure { margin: 1rem 0; text-align: center; }
.md-video { width: 100%; max-width: 100%; border-radius: 0.5rem; background: #000; }

View File

@@ -0,0 +1,130 @@
/* Редактор 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) {
if (event.target && event.target.id === 'article-body') dirty = 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();
});
})();