feat(lessons): SVG-рисовалка как блок урока (svg-draw)

Лёгкий векторный редактор frontend/js/svg-draw.js (перо со сглаживанием, линия,
прямоугольник, эллипс, стрелка, текст, цвет/толщина/заливка, выбор/перемещение/удаление,
undo/redo, очистка) → выдаёт чистый <svg>. Хранится inline в данных блока, переоткрывается
для дорисовки.

- Новый тип блока svg-draw: палитра «Рисунок», редактор (монтирование виджета + подпись),
  превью и студенческий рендер (lesson.html) — санитизированный inline-SVG, адаптивный.
- Санитайзер frontend/js/svg-sanitize.js (UMD, общий клиент/сервер): whitelist тегов/атрибутов,
  вырезает script/foreignObject/style/image/a, on*=, href, javascript:. Без зависимостей.
- Сервер (lessonController): svg-draw в VALID_TYPES + очистка data.svg при сохранении.
- Переиспользуемо: тот же виджет пригоден для флешкарт и фигур генератора задач.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 20:11:04 +03:00
parent 71d94f45f1
commit ef59023546
5 changed files with 642 additions and 3 deletions
+51 -1
View File
@@ -902,6 +902,9 @@
<button class="palette-btn" onclick="addBlock('image')">
<div class="palette-btn-icon pbi-image"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></div> Изображение
</button>
<button class="palette-btn" onclick="addBlock('svg-draw')">
<div class="palette-btn-icon" style="background:rgba(139,92,246,0.1);color:#8b5cf6"><svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg></div> Рисунок
</button>
<button class="palette-btn" onclick="addBlock('video')">
<div class="palette-btn-icon pbi-video"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><rect x="2" y="5" width="14" height="14" rx="2"/><path d="M22 7l-6 4 6 4V7z"/></svg></div> Видео
</button>
@@ -1002,6 +1005,8 @@
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/svg-sanitize.js"></script>
<script src="/js/svg-draw.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
const user = LS.getUser();
@@ -1166,6 +1171,7 @@
text: { html: '' },
formula: { tex: '', label: '' },
image: { url: '', alt: '', caption: '' },
'svg-draw': { svg: '', caption: '' },
code: { code: '', lang: 'js' },
quiz: { question: '', options: ['', ''], correctIndex: 0 },
divider: {},
@@ -1190,7 +1196,7 @@
};
const BLOCK_LABELS = {
heading: 'Заголовок', text: 'Параграф', formula: 'Формула',
image: 'Изображение', code: 'Код', quiz: 'Вопрос', divider: 'Разделитель',
image: 'Изображение', 'svg-draw': 'Рисунок', code: 'Код', quiz: 'Вопрос', divider: 'Разделитель',
callout: 'Выноска', video: 'Видео', table: 'Таблица',
flashcard: 'Карточка', sim: 'Симуляция',
matching: 'Сопоставление', 'fill-blank': 'Пропуски', ordering: 'Порядок',
@@ -1263,6 +1269,35 @@
}
/* ── render all blocks ── */
/* ── SVG-draw widgets: mount/re-mount after each render ───────────── */
const _svgDrawInst = {};
function mountSvgDrawEditors() {
if (!window.SvgDraw) return;
// tear down instances whose host left the DOM (e.g. after a full re-render)
Object.keys(_svgDrawInst).forEach(function (bid) {
const inst = _svgDrawInst[bid];
if (!inst || !inst.el || !inst.el.isConnected) {
try { inst && inst.destroy(); } catch (e) {}
delete _svgDrawInst[bid];
}
});
document.querySelectorAll('.svgdraw-host').forEach(function (host) {
if (host._svgdMounted) return;
host._svgdMounted = true;
const bid = host.dataset.bid;
const b = blocks.find(function (x) { return x._id === bid; });
_svgDrawInst[bid] = SvgDraw.mount(host, {
svg: (b && b.data && b.data.svg) || '',
width: 800, height: 500,
onChange: function (svg) {
updateBlockData(bid, 'svg', svg);
markDirty();
if (typeof scheduleAutoSave === 'function') scheduleAutoSave();
},
});
});
}
function renderBlocks() {
updateWordCount();
if (document.getElementById('outline-panel')?.classList.contains('open')) updateOutline();
@@ -1277,6 +1312,7 @@
}
container.innerHTML = blocks.map((b, i) => renderBlockCard(b, i)).join('');
mountSvgDrawEditors();
// wire events
container.querySelectorAll('.block-card').forEach(card => {
@@ -1582,6 +1618,16 @@
</div>`;
}
case 'svg-draw':
return `<div>
<div class="svgdraw-host" data-bid="${bid}"></div>
<div class="block-field" style="margin-top:8px">
<div class="block-row-label">Подпись</div>
<input class="block-input" type="text" placeholder="Рис. 1" value="${escAttr(d.caption||'')}"
oninput="updateBlockData('${bid}','caption',this.value);markDirty()" />
</div>
</div>`;
case 'code':
return `<div>
<div class="block-field">
@@ -3072,6 +3118,10 @@
const ia = d.align || 'center';
return `<div class="preview-block"><div class="pv-img-wrap align-${escAttr(ia)}">${d.url ? `<img class="pv-image" src="${escAttr(d.url)}" alt="${escAttr(d.alt||'')}" />` : ''}</div>${d.caption ? `<div class="pv-image-caption">${esc(d.caption)}</div>` : ''}</div>`;
}
case 'svg-draw': {
const safeSvg = (window.SvgSanitize ? SvgSanitize.clean(d.svg || '') : '').replace('<svg ', '<svg style="max-width:100%;height:auto;display:block;margin:0 auto" ');
return `<div class="preview-block"><div class="pv-svg">${safeSvg}</div>${d.caption ? `<div class="pv-image-caption">${esc(d.caption)}</div>` : ''}</div>`;
}
case 'divider':
return `<div class="preview-block"><div class="pv-divider"></div></div>`;
case 'code':