7e640e4207
Прошлый гард не работал: dragstart срабатывает на самой карточке (draggable=true), а не на svg, поэтому e.target.closest(.svgdraw-host) был null. Теперь на pointerdown снимаем draggable с ближайшего предка-карточки и возвращаем на pointerup — холст рисует, а не тащит блок. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
494 lines
24 KiB
JavaScript
494 lines
24 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);
|
|
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('');
|
|
}
|
|
|
|
function destroy() {
|
|
document.removeEventListener('keydown', onKey);
|
|
document.removeEventListener('pointerup', restoreAncestorDrag);
|
|
if (root.parentNode) root.parentNode.removeChild(root);
|
|
}
|
|
|
|
return { getSVG: getSVG, destroy: destroy, el: root };
|
|
}
|
|
|
|
window.SvgDraw = { mount: mount };
|
|
})();
|