'use strict'; /* svg-draw.js — lightweight vanilla SVG drawing widget. * * Outputs clean, re-editable markup (vector). Used by the lesson editor * (block type "svg-draw") and reusable elsewhere (flashcards, exam figures). * * API: * const ed = SvgDraw.mount(container, { * 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 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: '', pen: '', line: '', rect: '', ellipse: '', arrow: '', text: '', undo: '', redo: '', trash: '', }; function icon(name) { return '' + (ICONS[name] || '') + ''; } 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 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 }; })();