Files
Learn_System/frontend/js/svg-draw.js
T
Maxim Dolgolyov d3a64ac682 feat(materials): Фаза 4 — аннотации и рисунки
- svg-draw.js: opts.bgImage (рисунок-подложка) + exportFlatBlob() — растеризация подложки и
  вектора в плоский PNG.
- /my-materials: кнопка «Рисунок» (создать с нуля) и «Аннотировать» на карточках доски/изображения
  (рисовать поверх). Модалка с SVG-рисовалкой → сохранение в «Мои материалы» как image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:20:56 +03:00

534 lines
26 KiB
JavaScript

'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; user-select:none; -webkit-user-select:none; -webkit-user-drag:none; }
.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);
// optional background image to draw over (annotation mode)
const bgUrl = opts.bgImage || null;
if (bgUrl) {
const im = document.createElementNS(SVGNS, 'image');
im.setAttribute('href', bgUrl);
try { im.setAttributeNS('http://www.w3.org/1999/xlink', 'href', bgUrl); } catch (e) {}
im.setAttribute('x', '0'); im.setAttribute('y', '0');
im.setAttribute('width', String(W)); im.setAttribute('height', String(H));
im.setAttribute('preserveAspectRatio', 'xMidYMid meet');
im.style.pointerEvents = 'none';
svg.insertBefore(im, 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;
/* The host lesson block-card is draggable=true and hijacks mouse-drag on the
canvas — and its dragstart fires on the CARD (not the svg), so it can't be
cancelled here. Fix: disable the nearest draggable ancestor while a pointer
is down on the canvas, restore on release. */
let _dragAnc = null;
function suppressAncestorDrag() {
const a = svg.closest && svg.closest('[draggable="true"]');
if (a) { _dragAnc = a; a.setAttribute('draggable', 'false'); }
}
function restoreAncestorDrag() {
if (_dragAnc) { _dragAnc.setAttribute('draggable', 'true'); _dragAnc = null; }
}
document.addEventListener('pointerup', restoreAncestorDrag);
svg.addEventListener('pointerdown', function (evt) {
if (evt.button !== 0) return;
suppressAncestorDrag();
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) {
restoreAncestorDrag();
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);
// Block native HTML5 drag (the lesson block-card is draggable=true; without
// this, pressing on the canvas drags the whole block instead of drawing).
svg.addEventListener('dragstart', function (e) { e.preventDefault(); });
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('');
}
/* Rasterize to a flattened PNG: optional background image + the vector
drawing on top. Used to save an annotated image into «Мои материалы». */
function exportFlatBlob(cb) {
const out = document.createElement('canvas');
out.width = W; out.height = H;
const ctx = out.getContext('2d');
function drawVector() {
const v = new Image();
v.onload = function () { ctx.drawImage(v, 0, 0, W, H); out.toBlob(cb, 'image/png'); };
v.onerror = function () { out.toBlob(cb, 'image/png'); };
v.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(getSVG());
}
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, W, H);
if (bgUrl) {
const bg = new Image();
bg.onload = function () {
const s = Math.min(W / bg.naturalWidth, H / bg.naturalHeight) || 1;
const dw = bg.naturalWidth * s, dh = bg.naturalHeight * s;
ctx.drawImage(bg, (W - dw) / 2, (H - dh) / 2, dw, dh);
drawVector();
};
bg.onerror = drawVector;
bg.src = bgUrl;
} else {
drawVector();
}
}
function destroy() {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerup', restoreAncestorDrag);
if (root.parentNode) root.parentNode.removeChild(root);
}
return { getSVG: getSVG, exportFlatBlob: exportFlatBlob, destroy: destroy, el: root };
}
window.SvgDraw = { mount: mount };
})();