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
+10 -2
View File
@@ -1,6 +1,9 @@
const db = require('../db/db');
const { onLessonComplete } = require('./gamificationController');
const { stripTags } = require('../utils/sanitize');
// Shared whitelist SVG sanitizer (UMD module, node-safe) — single source of
// truth with the client. In node it uses the conservative regex path.
const { clean: cleanSvg } = require('../../../frontend/js/svg-sanitize.js');
/* ── helpers ──────────────────────────────────────────────────────────── */
function parseBlock(b) {
@@ -151,7 +154,7 @@ function saveBlocks(req, res) {
if (!Array.isArray(blocks))
return res.status(400).json({ error: 'blocks must be an array' });
const VALID_TYPES = ['heading','text','formula','image','quiz','sim','table','code','divider','callout','video','flashcard','matching','fill-blank','ordering','accordion','timeline','diagram','geogebra','audio','columns','alert'];
const VALID_TYPES = ['heading','text','formula','image','svg-draw','quiz','sim','table','code','divider','callout','video','flashcard','matching','fill-blank','ordering','accordion','timeline','diagram','geogebra','audio','columns','alert'];
db.transaction(() => {
db.prepare('DELETE FROM lesson_blocks WHERE lesson_id = ?').run(lesson.id);
@@ -160,7 +163,12 @@ function saveBlocks(req, res) {
);
blocks.forEach((b, i) => {
const type = VALID_TYPES.includes(b.type) ? b.type : 'text';
ins.run(lesson.id, type, b.orderIndex ?? i, JSON.stringify(b.data || {}));
let data = b.data || {};
// Sanitize inline SVG drawings server-side (defense-in-depth).
if (type === 'svg-draw' && data && typeof data.svg === 'string') {
data = Object.assign({}, data, { svg: cleanSvg(data.svg) });
}
ins.run(lesson.id, type, b.orderIndex ?? i, JSON.stringify(data));
});
// recalculate read time — pass already-parsed data objects, no double stringify/parse