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
+473
View File
@@ -0,0 +1,473 @@
'use strict';
/* svg-draw.js — lightweight vanilla SVG drawing widget.
*
* Outputs clean, re-editable <svg> markup (vector). Used by the lesson editor
* (block type "svg-draw") and reusable elsewhere (flashcards, exam figures).
*
* API:
* const ed = SvgDraw.mount(container, {
* svg: '<svg>…</svg>' | null, // existing drawing to continue editing
* width: 800, height: 500, // viewBox size (logical units)
* onChange: (svgString) => {}, // called after every committed change
* });
* ed.getSVG(); // current drawing as a standalone <svg> string
* ed.destroy();
*
* Security: the widget only ever emits a fixed safe set of elements
* (path, line, rect, ellipse, circle, polyline, polygon, text, g) with
* geometric/style attributes — no script, foreignObject, href or event
* handlers. Always pass stored SVG through SvgSanitize.clean() before render.
*/
(function () {
const SVGNS = 'http://www.w3.org/2000/svg';
const TOOLS = ['select', 'pen', 'line', 'rect', 'ellipse', 'arrow', 'text'];
const SWATCHES = ['#0f172a', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#ffffff'];
function injectStyles() {
if (document.getElementById('svgd-style')) return;
const s = document.createElement('style');
s.id = 'svgd-style';
s.textContent = `
.svgd { border:1px solid var(--border,#e2e8f0); border-radius:12px; overflow:hidden; background:#fff; font-family:inherit; }
.svgd-toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:6px; padding:8px 10px; background:var(--surface,#f8fafc); border-bottom:1px solid var(--border,#e2e8f0); }
.svgd-tool, .svgd-act { width:34px; height:34px; border:1px solid var(--border,#e2e8f0); background:#fff; border-radius:9px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; color:#475569; padding:0; transition:background .12s,border-color .12s,color .12s; }
.svgd-tool:hover, .svgd-act:hover { border-color:#8b5cf6; color:#8b5cf6; }
.svgd-tool.active { background:#ede9fe; border-color:#8b5cf6; color:#6d28d9; }
.svgd-tool svg, .svgd-act svg { width:17px; height:17px; pointer-events:none; }
.svgd-sep { width:1px; height:24px; background:var(--border,#e2e8f0); margin:0 3px; }
.svgd-sw { width:20px; height:20px; border-radius:50%; border:2px solid #fff; box-shadow:0 0 0 1px rgba(0,0,0,.15); cursor:pointer; padding:0; }
.svgd-sw.active { box-shadow:0 0 0 2px #8b5cf6; }
.svgd-color { width:28px; height:28px; border:1px solid var(--border,#e2e8f0); border-radius:7px; padding:0; cursor:pointer; background:none; }
.svgd-range { width:90px; }
.svgd-lbl { font-size:.72rem; color:#64748b; font-weight:600; }
.svgd-fillbtn { font-size:.72rem; font-weight:700; padding:0 9px; height:34px; border:1px solid var(--border,#e2e8f0); border-radius:9px; background:#fff; cursor:pointer; color:#475569; }
.svgd-fillbtn.on { background:#ede9fe; border-color:#8b5cf6; color:#6d28d9; }
.svgd-canvas-wrap { position:relative; background:
linear-gradient(45deg,#f8fafc 25%,transparent 25%),linear-gradient(-45deg,#f8fafc 25%,transparent 25%),
linear-gradient(45deg,transparent 75%,#f8fafc 75%),linear-gradient(-45deg,transparent 75%,#f8fafc 75%);
background-size:18px 18px; background-position:0 0,0 9px,9px -9px,-9px 0; }
.svgd-canvas { display:block; width:100%; height:auto; touch-action:none; cursor:crosshair; }
.svgd-canvas.tool-select { cursor:default; }
.svgd-sel { outline:none; }
.svgd-selbox { fill:none; stroke:#8b5cf6; stroke-width:1.5; stroke-dasharray:5 4; vector-effect:non-scaling-stroke; pointer-events:none; }
.svgd-textinput { position:absolute; border:1px solid #8b5cf6; border-radius:4px; padding:1px 4px; font-family:inherit; outline:none; background:#fff; z-index:5; }
`;
document.head.appendChild(s);
}
const ICONS = {
select: '<path d="M3 3l7 17 2-7 7-2z"/>',
pen: '<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"/>',
line: '<line x1="5" y1="19" x2="19" y2="5"/>',
rect: '<rect x="4" y="6" width="16" height="12" rx="1"/>',
ellipse: '<ellipse cx="12" cy="12" rx="9" ry="6"/>',
arrow: '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="13 6 19 12 13 18"/>',
text: '<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>',
undo: '<path d="M9 14L4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3"/>',
redo: '<path d="M15 14l5-5-5-5"/><path d="M20 9H9a5 5 0 0 0-5 5 5 5 0 0 0 5 5h3"/>',
trash: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/>',
};
function icon(name) {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
}
function mount(container, opts) {
opts = opts || {};
injectStyles();
const W = opts.width || 800;
const H = opts.height || 500;
const onChange = typeof opts.onChange === 'function' ? opts.onChange : function () {};
const state = {
tool: 'pen',
color: '#0f172a',
width: 3,
fill: false,
sel: null, // selected element (select tool)
undo: [],
redo: [],
};
/* ── DOM scaffold ─────────────────────────────────────────────── */
const root = document.createElement('div');
root.className = 'svgd';
const toolbar = document.createElement('div');
toolbar.className = 'svgd-toolbar';
const TOOL_TITLE = { select: 'Выбор / перемещение', pen: 'Карандаш', line: 'Линия', rect: 'Прямоугольник', ellipse: 'Эллипс', arrow: 'Стрелка', text: 'Текст' };
const toolBtns = {};
TOOLS.forEach(function (t) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'svgd-tool' + (t === state.tool ? ' active' : '');
b.title = TOOL_TITLE[t]; b.innerHTML = icon(t);
b.addEventListener('click', function () { setTool(t); });
toolBtns[t] = b; toolbar.appendChild(b);
});
sep(toolbar);
// swatches
SWATCHES.forEach(function (c) {
const sw = document.createElement('button');
sw.type = 'button'; sw.className = 'svgd-sw' + (c === state.color ? ' active' : '');
sw.style.background = c; sw.title = c;
sw.addEventListener('click', function () { setColor(c); });
toolbar.appendChild(sw);
});
const colorInp = document.createElement('input');
colorInp.type = 'color'; colorInp.className = 'svgd-color'; colorInp.value = state.color; colorInp.title = 'Свой цвет';
colorInp.addEventListener('input', function () { setColor(colorInp.value); });
toolbar.appendChild(colorInp);
sep(toolbar);
const wLbl = document.createElement('span'); wLbl.className = 'svgd-lbl'; wLbl.textContent = 'толщина';
const wRange = document.createElement('input');
wRange.type = 'range'; wRange.min = '1'; wRange.max = '20'; wRange.value = String(state.width); wRange.className = 'svgd-range';
wRange.addEventListener('input', function () { state.width = Number(wRange.value); });
toolbar.appendChild(wLbl); toolbar.appendChild(wRange);
const fillBtn = document.createElement('button');
fillBtn.type = 'button'; fillBtn.className = 'svgd-fillbtn'; fillBtn.textContent = 'Заливка';
fillBtn.addEventListener('click', function () { state.fill = !state.fill; fillBtn.classList.toggle('on', state.fill); });
toolbar.appendChild(fillBtn);
sep(toolbar);
const undoBtn = actBtn('undo', 'Отменить', undo);
const redoBtn = actBtn('redo', 'Повторить', redo);
const clearBtn = actBtn('trash', 'Очистить всё', clearAll);
toolbar.appendChild(undoBtn); toolbar.appendChild(redoBtn); toolbar.appendChild(clearBtn);
function actBtn(ic, title, fn) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'svgd-act'; b.title = title; b.innerHTML = icon(ic);
b.addEventListener('click', fn); return b;
}
function sep(bar) { const d = document.createElement('div'); d.className = 'svgd-sep'; bar.appendChild(d); }
const wrap = document.createElement('div');
wrap.className = 'svgd-canvas-wrap';
const svg = document.createElementNS(SVGNS, 'svg');
svg.setAttribute('class', 'svgd-canvas tool-' + state.tool);
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
svg.setAttribute('xmlns', SVGNS);
const content = document.createElementNS(SVGNS, 'g'); // user drawing lives here
content.setAttribute('class', 'svgd-content');
svg.appendChild(content);
wrap.appendChild(svg);
root.appendChild(toolbar);
root.appendChild(wrap);
container.appendChild(root);
// Load existing drawing (sanitized) into content
if (opts.svg) loadSvg(opts.svg);
pushUndo(true); // baseline snapshot (no onChange)
/* ── helpers ──────────────────────────────────────────────────── */
function setTool(t) {
state.tool = t;
Object.keys(toolBtns).forEach(function (k) { toolBtns[k].classList.toggle('active', k === t); });
svg.setAttribute('class', 'svgd-canvas tool-' + t);
if (t !== 'select') deselect();
}
function setColor(c) {
state.color = c; colorInp.value = c;
toolbar.querySelectorAll('.svgd-sw').forEach(function (sw) { sw.classList.toggle('active', sw.style.background === c || rgbToHex(sw.style.background) === c); });
if (state.sel && state.sel.tagName !== 'text') {
state.sel.setAttribute(state.sel.getAttribute('fill') !== 'none' && isFillShape(state.sel) && state.fill ? 'fill' : 'stroke', c);
} else if (state.sel && state.sel.tagName === 'text') {
state.sel.setAttribute('fill', c);
}
if (state.sel) commit();
}
function svgPoint(evt) {
const pt = svg.createSVGPoint();
pt.x = evt.clientX; pt.y = evt.clientY;
const ctm = svg.getScreenCTM();
const p = pt.matrixTransform(ctm.inverse());
return { x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 };
}
function isFillShape(el) { return ['rect', 'ellipse', 'circle', 'polygon', 'path'].indexOf(el.tagName) !== -1; }
function applyStyle(el, closed) {
el.setAttribute('stroke', state.color);
el.setAttribute('stroke-width', String(state.width));
el.setAttribute('stroke-linecap', 'round');
el.setAttribute('stroke-linejoin', 'round');
el.setAttribute('fill', (state.fill && closed) ? state.color : 'none');
}
/* ── drawing interaction ──────────────────────────────────────── */
let drawing = false, startP = null, curEl = null, penPts = null, dragMode = null, dragStart = null, selStartXY = null;
svg.addEventListener('pointerdown', function (evt) {
if (evt.button !== 0) return;
const p = svgPoint(evt);
if (state.tool === 'select') {
const target = (evt.target !== svg && evt.target.closest) ? evt.target.closest('.svgd-content > *') : null;
if (target && content.contains(target) && !target.classList.contains('svgd-selbox')) {
select(target);
dragMode = 'move'; dragStart = p; selStartXY = currentTranslate(target);
svg.setPointerCapture(evt.pointerId);
} else { deselect(); }
return;
}
if (state.tool === 'text') {
spawnTextInput(evt, p);
return;
}
drawing = true; startP = p;
svg.setPointerCapture(evt.pointerId);
if (state.tool === 'pen') {
penPts = [p];
curEl = document.createElementNS(SVGNS, 'path');
applyStyle(curEl, false);
curEl.setAttribute('d', 'M ' + p.x + ' ' + p.y);
content.appendChild(curEl);
} else if (state.tool === 'line' || state.tool === 'arrow') {
curEl = document.createElementNS(SVGNS, state.tool === 'arrow' ? 'g' : 'line');
if (state.tool === 'line') {
applyStyle(curEl, false);
setLine(curEl, p, p);
} else {
buildArrow(curEl, p, p);
}
content.appendChild(curEl);
} else if (state.tool === 'rect') {
curEl = document.createElementNS(SVGNS, 'rect');
applyStyle(curEl, true);
setRect(curEl, p, p);
content.appendChild(curEl);
} else if (state.tool === 'ellipse') {
curEl = document.createElementNS(SVGNS, 'ellipse');
applyStyle(curEl, true);
setEllipse(curEl, p, p);
content.appendChild(curEl);
}
});
svg.addEventListener('pointermove', function (evt) {
const p = svgPoint(evt);
if (dragMode === 'move' && state.sel) {
const dx = p.x - dragStart.x, dy = p.y - dragStart.y;
state.sel.setAttribute('transform', 'translate(' + (selStartXY.x + dx) + ' ' + (selStartXY.y + dy) + ')');
positionSelBox();
return;
}
if (!drawing || !curEl) return;
if (state.tool === 'pen') {
penPts.push(p);
curEl.setAttribute('d', penPath(penPts));
} else if (state.tool === 'line') {
setLine(curEl, startP, p);
} else if (state.tool === 'arrow') {
buildArrow(curEl, startP, p);
} else if (state.tool === 'rect') {
setRect(curEl, startP, p);
} else if (state.tool === 'ellipse') {
setEllipse(curEl, startP, p);
}
});
function endDraw(evt) {
if (dragMode === 'move') { dragMode = null; commit(); return; }
if (!drawing) return;
drawing = false;
try { svg.releasePointerCapture(evt.pointerId); } catch (e) {}
if (curEl) {
// discard zero-size shapes / dot-only strokes
if (state.tool !== 'pen' && isTiny(curEl)) { curEl.remove(); curEl = null; return; }
if (state.tool === 'pen' && penPts.length < 2) {
// single tap → tiny dot circle
curEl.remove();
const c = document.createElementNS(SVGNS, 'circle');
c.setAttribute('cx', startP.x); c.setAttribute('cy', startP.y); c.setAttribute('r', String(Math.max(1, state.width / 2)));
c.setAttribute('fill', state.color); c.setAttribute('stroke', 'none');
content.appendChild(c);
}
curEl = null; commit();
}
}
svg.addEventListener('pointerup', endDraw);
svg.addEventListener('pointercancel', endDraw);
function isTiny(el) {
const b = el.getBBox();
return (b.width < 3 && b.height < 3);
}
/* ── shape setters ────────────────────────────────────────────── */
function setLine(el, a, b) { el.setAttribute('x1', a.x); el.setAttribute('y1', a.y); el.setAttribute('x2', b.x); el.setAttribute('y2', b.y); }
function setRect(el, a, b) {
el.setAttribute('x', Math.min(a.x, b.x)); el.setAttribute('y', Math.min(a.y, b.y));
el.setAttribute('width', Math.abs(b.x - a.x)); el.setAttribute('height', Math.abs(b.y - a.y));
}
function setEllipse(el, a, b) {
el.setAttribute('cx', (a.x + b.x) / 2); el.setAttribute('cy', (a.y + b.y) / 2);
el.setAttribute('rx', Math.abs(b.x - a.x) / 2); el.setAttribute('ry', Math.abs(b.y - a.y) / 2);
}
function buildArrow(g, a, b) {
while (g.firstChild) g.removeChild(g.firstChild);
const line = document.createElementNS(SVGNS, 'line');
applyStyle(line, false); setLine(line, a, b);
g.appendChild(line);
const ang = Math.atan2(b.y - a.y, b.x - a.x);
const len = Math.max(8, state.width * 3.2);
const head = document.createElementNS(SVGNS, 'polyline');
const p1 = { x: b.x - len * Math.cos(ang - Math.PI / 7), y: b.y - len * Math.sin(ang - Math.PI / 7) };
const p2 = { x: b.x - len * Math.cos(ang + Math.PI / 7), y: b.y - len * Math.sin(ang + Math.PI / 7) };
head.setAttribute('points', round1(p1.x) + ',' + round1(p1.y) + ' ' + round1(b.x) + ',' + round1(b.y) + ' ' + round1(p2.x) + ',' + round1(p2.y));
head.setAttribute('fill', 'none'); head.setAttribute('stroke', state.color);
head.setAttribute('stroke-width', String(state.width)); head.setAttribute('stroke-linecap', 'round'); head.setAttribute('stroke-linejoin', 'round');
g.appendChild(head);
}
function round1(n) { return Math.round(n * 10) / 10; }
// Catmull-Rom → cubic bezier smoothing
function penPath(pts) {
if (pts.length < 2) return 'M ' + pts[0].x + ' ' + pts[0].y;
let d = 'M ' + pts[0].x + ' ' + pts[0].y;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
d += ' C ' + round1(c1x) + ' ' + round1(c1y) + ' ' + round1(c2x) + ' ' + round1(c2y) + ' ' + round1(p2.x) + ' ' + round1(p2.y);
}
return d;
}
/* ── text tool ────────────────────────────────────────────────── */
function spawnTextInput(evt, p) {
const rect = wrap.getBoundingClientRect();
const inp = document.createElement('input');
inp.type = 'text'; inp.className = 'svgd-textinput'; inp.placeholder = 'текст…';
inp.style.left = (evt.clientX - rect.left) + 'px';
inp.style.top = (evt.clientY - rect.top) + 'px';
inp.style.color = state.color;
inp.style.fontSize = Math.max(12, state.width * 5) + 'px';
wrap.appendChild(inp); inp.focus();
function finish(keep) {
const val = inp.value.trim();
inp.remove();
if (keep && val) {
const t = document.createElementNS(SVGNS, 'text');
t.setAttribute('x', p.x); t.setAttribute('y', p.y);
t.setAttribute('fill', state.color);
t.setAttribute('font-size', String(Math.max(12, state.width * 5)));
t.setAttribute('font-family', 'Inter, system-ui, sans-serif');
t.textContent = val;
content.appendChild(t); commit();
}
}
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') finish(true); else if (e.key === 'Escape') finish(false); });
inp.addEventListener('blur', function () { finish(true); });
}
/* ── selection ────────────────────────────────────────────────── */
let selBox = null;
function select(el) {
deselect(); state.sel = el;
selBox = document.createElementNS(SVGNS, 'rect');
selBox.setAttribute('class', 'svgd-selbox');
svg.appendChild(selBox); positionSelBox();
}
function positionSelBox() {
if (!selBox || !state.sel) return;
try {
const b = state.sel.getBBox(); const tr = currentTranslate(state.sel);
selBox.setAttribute('x', b.x + tr.x - 3); selBox.setAttribute('y', b.y + tr.y - 3);
selBox.setAttribute('width', b.width + 6); selBox.setAttribute('height', b.height + 6);
} catch (e) {}
}
function deselect() { if (selBox) { selBox.remove(); selBox = null; } state.sel = null; }
function currentTranslate(el) {
const tr = el.getAttribute('transform');
const m = tr && tr.match(/translate\(\s*(-?[\d.]+)[ ,]+(-?[\d.]+)/);
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : { x: 0, y: 0 };
}
document.addEventListener('keydown', onKey);
function onKey(e) {
if (!root.isConnected) return;
if ((e.key === 'Delete' || e.key === 'Backspace') && state.sel && document.activeElement && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
e.preventDefault(); state.sel.remove(); deselect(); commit();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && root.querySelector(':hover')) { e.preventDefault(); e.shiftKey ? redo() : undo(); }
}
/* ── undo / redo / commit ─────────────────────────────────────── */
function snapshot() { return content.innerHTML; }
function pushUndo(silent) {
state.undo.push(snapshot());
if (state.undo.length > 60) state.undo.shift();
state.redo.length = 0;
if (!silent) onChange(getSVG());
}
function commit() { deselectKeepRef(); pushUndo(false); }
function deselectKeepRef() { /* selBox excluded from snapshot since it's on svg, not content */ }
function undo() {
if (state.undo.length <= 1) return;
deselect();
state.redo.push(state.undo.pop());
content.innerHTML = state.undo[state.undo.length - 1];
onChange(getSVG());
}
function redo() {
if (!state.redo.length) return;
deselect();
const html = state.redo.pop(); state.undo.push(html);
content.innerHTML = html; onChange(getSVG());
}
function clearAll() {
if (!content.firstChild) return;
if (!confirm('Очистить весь рисунок?')) return;
deselect(); content.innerHTML = ''; pushUndo(false);
}
/* ── load / export ────────────────────────────────────────────── */
function loadSvg(str) {
const clean = (window.SvgSanitize ? window.SvgSanitize.clean(str) : str) || '';
const doc = new DOMParser().parseFromString(clean, 'image/svg+xml');
const root2 = doc.querySelector('svg');
if (!root2) return;
content.innerHTML = '';
// inner content may be wrapped in a <g class="svgd-content"> or be loose children
const inner = root2.querySelector('g.svgd-content') || root2;
Array.prototype.slice.call(inner.childNodes).forEach(function (n) {
if (n.nodeType === 1) content.appendChild(document.importNode(n, true));
});
}
function getSVG() {
deselect();
const out = document.createElementNS(SVGNS, 'svg');
out.setAttribute('xmlns', SVGNS);
out.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
out.setAttribute('width', String(W));
out.setAttribute('height', String(H));
const g = content.cloneNode(true);
out.appendChild(g);
const str = new XMLSerializer().serializeToString(out);
return window.SvgSanitize ? window.SvgSanitize.clean(str) : str;
}
function rgbToHex(rgb) {
const m = (rgb || '').match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (!m) return rgb;
return '#' + [m[1], m[2], m[3]].map(function (x) { return ('0' + parseInt(x, 10).toString(16)).slice(-2); }).join('');
}
function destroy() {
document.removeEventListener('keydown', onKey);
if (root.parentNode) root.parentNode.removeChild(root);
}
return { getSVG: getSVG, destroy: destroy, el: root };
}
window.SvgDraw = { mount: mount };
})();
+99
View File
@@ -0,0 +1,99 @@
'use strict';
/* svg-sanitize.js — whitelist sanitizer for inline <svg> drawings.
*
* Shared by the browser (DOM-based whitelist, robust) and Node (regex-based
* conservative strip, no DOM dependency). Defense-in-depth: the SvgDraw editor
* only emits a safe subset, but stored block data could be tampered with, so
* BOTH the server (on save) and the client (on render) clean it.
*
* Allowed: a fixed set of geometric/text elements + geometric & style attrs.
* Removed: <script>, <foreignObject>, <style>, <image>, <a>, <use>, <iframe>,
* any on*= handler, href/xlink:href, and url()/javascript: in styles.
*/
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof window !== 'undefined') window.SvgSanitize = api;
})(this, function () {
const ALLOWED_TAGS = new Set([
'svg', 'g', 'path', 'line', 'rect', 'circle', 'ellipse',
'polyline', 'polygon', 'text', 'tspan', 'title', 'desc', 'defs',
'linearGradient', 'radialGradient', 'stop',
]);
const ALLOWED_ATTR = new Set([
'viewbox', 'width', 'height', 'xmlns', 'class', 'transform', 'fill',
'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity',
'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'opacity',
'd', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry',
'points', 'width', 'height', 'dx', 'dy', 'text-anchor', 'dominant-baseline',
'font-size', 'font-family', 'font-weight', 'font-style', 'letter-spacing',
'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform',
'id', 'vector-effect', 'preserveaspectratio',
]);
function badAttrValue(v) {
const s = String(v).replace(/\s+/g, '').toLowerCase();
return s.indexOf('javascript:') !== -1 || s.indexOf('expression(') !== -1 ||
s.indexOf('data:text/html') !== -1 || /url\(/.test(s) && s.indexOf('url(#') !== 0;
}
/* ── Browser path: parse → walk → whitelist → serialize ───────────── */
function cleanDom(str) {
const doc = new DOMParser().parseFromString(str, 'image/svg+xml');
const svg = doc.querySelector('svg');
if (!svg || doc.querySelector('parsererror')) return '';
(function walk(node) {
// iterate over a static copy — we mutate during traversal
Array.prototype.slice.call(node.childNodes).forEach(function (child) {
if (child.nodeType === 1) { // element
const tag = child.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) { child.remove(); return; }
// scrub attributes
Array.prototype.slice.call(child.attributes).forEach(function (a) {
const name = a.name.toLowerCase();
if (name.indexOf('on') === 0 || name === 'href' || name === 'xlink:href' || name === 'style') {
child.removeAttribute(a.name); return;
}
if (!ALLOWED_ATTR.has(name)) { child.removeAttribute(a.name); return; }
if (badAttrValue(a.value)) child.removeAttribute(a.name);
});
walk(child);
} else if (child.nodeType === 8) { // comment
child.remove();
}
});
})(svg);
return new XMLSerializer().serializeToString(svg);
}
/* ── Node path: conservative regex strip (no DOM) ─────────────────── */
function cleanRegex(str) {
let s = String(str || '');
// drop dangerous element blocks entirely
s = s.replace(/<\s*(script|foreignObject|style|iframe|image|use|a)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '');
// drop self-closing / unclosed dangerous tags
s = s.replace(/<\s*(script|foreignObject|style|iframe|image|use|a)\b[^>]*\/?\s*>/gi, '');
// strip on*= handlers (single/double/unquoted)
s = s.replace(/\son[a-z-]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
// strip href / xlink:href
s = s.replace(/\s(?:xlink:)?href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
// neutralise javascript:/expression in any remaining attribute
s = s.replace(/javascript:/gi, '').replace(/expression\(/gi, '');
if (!/<svg[\s>]/i.test(s)) return '';
return s.trim();
}
function clean(str) {
if (!str) return '';
try {
if (typeof DOMParser !== 'undefined' && typeof XMLSerializer !== 'undefined') return cleanDom(str);
} catch (e) { /* fall through */ }
return cleanRegex(str);
}
return { clean: clean, ALLOWED_TAGS: ALLOWED_TAGS, ALLOWED_ATTR: ALLOWED_ATTR };
});
+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':
+9
View File
@@ -833,6 +833,7 @@
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/svg-sanitize.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
@@ -1142,6 +1143,14 @@
${d.caption ? `<div class="block-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="lesson-block block-image">
${safeSvg}
${d.caption ? `<div class="block-image-caption">${esc(d.caption)}</div>` : ''}
</div>`;
}
case 'divider':
return `<div class="lesson-block block-divider"><hr /></div>`;