Files
slava.home/main_plugin/SvgEditorM/SvgEditorM.js

763 lines
26 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file SvgEditorM.js
* @brief Основной файл SvgEditorM, реализует функционал редактирования SVG-графики на сайте
*/
/**
* @brief Базовый SVG-элемент
* @param width Ширина
* @param height Высота
* @param posX Позиция X
* @param posY Позиция Y
* @param id ID элемента
* @param type Тип SVG-элемента
*/
class SVGElement {
constructor({ width, height, posX, posY, id, type } = {}) {
this.width = width || 100;
this.height = height || 100;
this.posX = posX || 100;
this.posY = posY || 100;
this.id = id || `svgElement_${Date.now()}`;
this.type = type || 'svg';
this.domNode = this.createSVGDOMNode(this.type);
}
createSVGDOMNode(type) {
let { height, width, posY, posX, id } = this;
let svg = document.createElementNS('http://www.w3.org/2000/svg', type);
svg.setAttribute('style', 'border: 1px solid black; box-sizing: border-box;');
svg.setAttribute('id', id);
if (type !== 'svg') {
svg.setAttribute('x', posX);
svg.setAttribute('y', posY);
svg.setAttribute('width', width);
svg.setAttribute('height', height);
} else {
svg.setAttribute('width', width);
svg.setAttribute('height', height);
}
svg.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xlink', 'http://www.w3.org/1999/xlink');
return svg;
}
}
/**
* @brief Класс для модификации SVG-элементов
*/
class SVGElementModifier {
static moveSelectedElement({ handler, event }) {
const { selected, selector, editor, offset } = handler;
const { clientX, clientY } = event;
if (selected) {
if (selected.tagName.toLowerCase() === 'circle') {
selected.setAttribute('cx', clientX + offset.x);
selected.setAttribute('cy', clientY + offset.y);
} else {
selected.setAttribute('x', clientX + offset.x);
selected.setAttribute('y', clientY + offset.y);
selector.updateSelection(selected, editor);
}
selector.updateSelection(selected);
}
}
static deselectElement({ handler }) {
handler.selected = null;
}
static toggelHoveredElement({ handler, target }) {
return handler.selector.updateSelection(target, handler.editor);
}
static selectElement({ handler, event }) {
let { target, clientX, clientY } = event;
const { selector, editor } = handler;
if (selector.isSelectable(target, editor)) {
handler.offset.x = parseFloat(target.getAttribute('x')) - clientX || 0;
handler.offset.y = parseFloat(target.getAttribute('y')) - clientY || 0;
handler.selected = target;
}
}
}
/**
* @brief Прямоугольник
* @param posX Позиция X
* @param posY Позиция Y
* @param width Ширина
* @param height Высота
* @param id ID элемента
*/
class Rect extends SVGElement {
constructor({ posX, posY, width, height, id } = {}) {
super({ type: 'rect', posX, posY, width, height, id });
this.domNode.setAttribute('width', width || 100);
this.domNode.setAttribute('height', height || 100);
this.domNode.setAttribute('x', posX || 100);
this.domNode.setAttribute('y', posY || 100);
this.domNode.setAttribute('fill', 'rgba(0,120,200,0.2)');
this.domNode.setAttribute('stroke', '#0078c8');
}
}
/**
* @brief Класс выбора фигур
* @param id ID селектора
*/
class ShapeSelector {
constructor(id) {
this.id = id;
this.selectorElem = document.getElementById(this.id);
}
isSelectable(element, area) {
if (!element) return false;
if (element.isSameNode(area)) return false;
const tag = element.tagName ? element.tagName.toLowerCase() : '';
return (tag === 'rect' || tag === 'circle' || tag === 'g' || tag === 'path' || tag === 'ellipse');
}
toggleSelectionOverElement(element) {
let bound = element.getBoundingClientRect();
const { style } = this.selectorElem;
style.left = bound.left + 'px';
style.top = bound.top + 'px';
style.width = bound.width + 'px';
style.height = bound.height + 'px';
style.display = 'block';
}
updateSelection(element, area) {
if (this.isSelectable(element, area)) {
return this.toggleSelectionOverElement(element);
}
return this.hideSelection();
}
hideSelection() {
if (this.selectorElem) this.selectorElem.style.display = 'none';
}
}
/**
* @brief Обработчик событий редактора
*/
class EditorEventHandler {
constructor() {
this.editor = null;
this.selector = null;
this.selected = null;
this.offset = { x: 0, y: 0 };
}
startListening({ editor, selector }) {
this.editor = editor;
this.selector = selector;
this.handleEditorEvents();
}
handleEditorEvents() {
this.handleElementsHover();
this.handleElementSelect();
this.handleMoveElement();
this.handleElementDeselect();
}
handleElementsHover() {
this.editor.addEventListener('mouseover', ({ target }) => {
SVGElementModifier.toggelHoveredElement({ handler: this, target });
});
}
handleElementSelect() {
this.editor.addEventListener('mousedown', event => {
SVGElementModifier.selectElement({ handler: this, event });
});
}
handleElementDeselect() {
this.editor.addEventListener('mouseup', event => {
SVGElementModifier.deselectElement({ handler: this });
});
}
handleMoveElement() {
this.editor.addEventListener('mousemove', event => {
SVGElementModifier.moveSelectedElement({ handler: this, event });
});
}
}
/**
* @brief Область для рисования
* @param options Параметры области
*/
class Area extends SVGElement {
constructor(options = {}) {
super({ type: 'svg', width: options.width || 800, height: options.height || 400, id: options.id || 'mySVG' });
this.domNode.style.border = '3px solid black';
}
appendElement(element) {
this.domNode.appendChild(element);
}
setDimentions() { }
}
/**
* @brief Редактор SVG
* @param targetNode Узел-цель
* @param playGround Игровая область
* @param eventHandler Обработчик событий
* @param selector Селектор фигур
*/
class Editor {
constructor({ targetNode, playGround, eventHandler, selector }) {
this.targetNode = targetNode;
this.playGround = playGround;
this.domNode = this.createEditor();
this.selector = selector;
this.svgElements = [];
this.EditorEventHandler = eventHandler;
this.EditorEventHandler.startListening({
editor: this.domNode,
selector: this.selector
});
}
setDimentions(node, width, height) {
node.style.width = '-webkit-fill-available';
node.style.height = '-webkit-fill-available';
return node;
}
createEditor() {
let editor = this.playGround.domNode;
const { width, height } = this.getEditorParams();
this.setDimentions(editor, width, height);
return editor;
}
getEditorParams() {
let width = Math.max(this.targetNode.offsetWidth - 10, 0);
let height = Math.max(this.targetNode.offsetHeight - 10, 0);
return { width, height };
}
addElement(element) {
if (!element || !element.domNode) return;
this.playGround.appendElement(element.domNode);
this.svgElements.push(element);
}
}
/* -----------------------------
Инициализация (выполняется после загрузки DOM)
----------------------------- */
window.addEventListener('DOMContentLoaded', () => {
let main = document.getElementById('main') || document.body;
let selection = document.getElementById('selector');
if (!selection) {
selection = document.createElement('div');
selection.id = 'selector';
Object.assign(selection.style, {
position: 'absolute',
display: 'none',
border: '1px dashed #333',
pointerEvents: 'none',
zIndex: 9999
});
document.body.appendChild(selection);
}
const area = new Area({ id: 'mySVG', width: 800, height: 400 });
main.appendChild(area.domNode);
const selector = new ShapeSelector('selector');
const editorEventHandler = new EditorEventHandler();
const editor = new Editor({
targetNode: main,
playGround: area,
selector,
eventHandler: editorEventHandler
});
const testRect = new Rect({ posX: 20, posY: 20, width: 120, height: 80, id: 'r1' });
const testRect2 = new Rect({ posX: 200, posY: 150, width: 140, height: 90, id: 'r2' });
editor.addElement(testRect);
editor.addElement(testRect2);
area.domNode.addEventListener('mouseover', (e) => {
const tgt = e.target;
selector.updateSelection(tgt, area.domNode);
});
window.addEventListener('resize', () => {
const { width, height } = editor.getEditorParams();
editor.setDimentions(area.domNode, width, height);
});
console.log('SVG Editor initialized', { area, editor, selector });
});
/* -----------------------------
edit
----------------------------- */
/** @brief Селектор основного контейнера */
const MAIN_SEL = "#main";
/** @brief ID элемента outline */
const OUTLINE_ID = "outline";
/** @brief Селектор боковой панели с фигурами */
const SIDEBAR_SHAPES = ".sidebar__shapes";
/** @brief Селектор боковой панели с режимами */
const SIDEBAR_SELECTORS = ".sidebar__selectors";
/** @brief Ключ для сохранения количества изменений */
const saveCountKey = 'SvgEditorM_saveCount';
/** @brief Основной контейнер DOM */
const main = document.querySelector(MAIN_SEL);
if (!main) throw new Error("Не найден #main");
/** @brief Массив svg элементов внутри main */
let svgsInMain = Array.from(main.querySelectorAll("svg"));
/** @brief Рабочий svg элемент */
let svg;
if (svgsInMain.length === 0) {
svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("style", "border: 1px solid #ccc; box-sizing: border-box; width: -webkit-fill-available; height: -webkit-fill-available;");
svg.setAttribute("width", "1000");
svg.setAttribute("height", "600");
main.appendChild(svg);
} else {
// keep first svg, remove extras to avoid duplicates
svg = svgsInMain[0];
for (let i = 1; i < svgsInMain.length; i++) svgsInMain[i].remove();
}
/** @brief Создает и добавляет маркер стрелки к SVG */
(function ensureArrowMarker() {
let defs = svg.querySelector("defs");
if (!defs) {
defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
svg.appendChild(defs);
}
if (!svg.querySelector("#arrowhead")) {
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
marker.setAttribute("id", "arrowhead");
marker.setAttribute("markerWidth", "6");
marker.setAttribute("markerHeight", "4");
marker.setAttribute("refX", "4.8");
marker.setAttribute("refY", "2");
marker.setAttribute("orient", "auto");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M0,0 L6,2 L0,4 z");
path.setAttribute("fill", "#495057");
marker.appendChild(path);
defs.appendChild(marker);
}
})();
/** @brief Получает элемент outline из DOM */
const outline = document.getElementById(OUTLINE_ID);
/** @brief Счетчик для генерации уникальных ID */
let uid = 1;
/**
* @brief Генерирует уникальный идентификатор для элемента
* @param prefix префикс для ID
*/
function makeId(prefix = "el") { return `${prefix}_${Date.now().toString(36)}_${uid++}`; }
/**
* @brief Преобразует координаты из клиентских в координаты SVG
* @param clientX координата X в пикселях
* @param clientY координата Y в пикселях
*/
function clientToSvgPoint(clientX, clientY) {
const pt = svg.createSVGPoint();
pt.x = clientX; pt.y = clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return { x: clientX, y: clientY };
const inv = ctm.inverse();
const p = pt.matrixTransform(inv);
return { x: p.x, y: p.y };
}
/**
* @brief Возвращает размеры и позицию элемента в координатах экрана
* @param el элемент SVG
*/
function screenBBoxOf(el) {
try {
const bbox = el.getBBox();
const p1 = svg.createSVGPoint(); p1.x = bbox.x; p1.y = bbox.y;
const p2 = svg.createSVGPoint(); p2.x = bbox.x + bbox.width; p2.y = bbox.y + bbox.height;
const s1 = p1.matrixTransform(svg.getScreenCTM());
const s2 = p2.matrixTransform(svg.getScreenCTM());
return {
left: Math.min(s1.x, s2.x),
top: Math.min(s1.y, s2.y),
width: Math.abs(s2.x - s1.x),
height: Math.abs(s2.y - s1.y)
};
} catch (e) {
const r = el.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
}
}
/**
* @brief Показывает или скрывает outline вокруг элемента
* @param el элемент SVG или null
*/
function showOutlineFor(el) {
if (!outline) return;
if (!el) { outline.style.display = "none"; return; }
const b = screenBBoxOf(el);
outline.style.display = "block";
outline.style.position = "fixed";
outline.style.left = `${b.left-2}px`;
outline.style.top = `${b.top-2}px`;
outline.style.width = `${b.width}px`;
outline.style.height = `${b.height}px`;
outline.style.border = "2px solid rgba(0,120,255,0.6)";
outline.style.pointerEvents = "none";
}
/** @brief Режим работы редактора */
let mode = "Move";
/** @brief Текущий выбранный элемент */
let currentSelected = null;
/** @brief Состояние перетаскивания элемента */
let dragState = null;
document.querySelectorAll(`${SIDEBAR_SHAPES} .sidebar__shape`).forEach(node => {
node.addEventListener("click", () => {
const shapeName = node.textContent.trim().toLowerCase();
createShape(shapeName);
});
});
document.querySelectorAll(`${SIDEBAR_SELECTORS} .sidebar__selector`).forEach(node => {
node.addEventListener("click", () => {
document.querySelectorAll(`${SIDEBAR_SELECTORS} .sidebar__selector`).forEach(n => n.classList.remove("active"));
node.classList.add("active");
mode = node.textContent.trim();
});
});
const defaultSel = document.querySelector(`${SIDEBAR_SELECTORS} .sidebar__selector`);
if (defaultSel) {
defaultSel.classList.add("active");
mode = defaultSel.textContent.trim();
}
/**
* @brief Создает фигуру указанного типа в центре SVG
* @param kind тип фигуры: rect, circle, line, arrow
*/
function createShape(kind) {
const center = { x: parseFloat(svg.getAttribute("width"))/2 || 200, y: parseFloat(svg.getAttribute("height"))/2 || 150 };
const id = makeId(kind);
let el = null;
if (kind === "rect") {
el = document.createElementNS("http://www.w3.org/2000/svg", "rect");
el.setAttribute("x", center.x - 50);
el.setAttribute("y", center.y - 25);
el.setAttribute("width", 100);
el.setAttribute("height", 50);
el.setAttribute("fill", "#1aaee5");
} else if (kind === "circle") {
el = document.createElementNS("http://www.w3.org/2000/svg", "circle");
el.setAttribute("cx", center.x);
el.setAttribute("cy", center.y);
el.setAttribute("r", 30);
el.setAttribute("fill", "#ff6600");
} else if (kind === "line") {
el = document.createElementNS("http://www.w3.org/2000/svg", "line");
el.setAttribute("x1", center.x - 60);
el.setAttribute("y1", center.y);
el.setAttribute("x2", center.x + 60);
el.setAttribute("y2", center.y);
el.setAttribute("stroke", "#495057");
el.setAttribute("stroke-width", 4);
} else if (kind === "arrow") {
el = document.createElementNS("http://www.w3.org/2000/svg", "line");
el.setAttribute("x1", center.x - 60);
el.setAttribute("y1", center.y - 10);
el.setAttribute("x2", center.x + 60);
el.setAttribute("y2", center.y + 10);
el.setAttribute("stroke", "#495057");
el.setAttribute("stroke-width", 4);
el.setAttribute("marker-end", "url(#arrowhead)");
} else {
alert("Unknown shape: " + kind);
return;
}
el.setAttribute("id", id);
el.classList.add("draggable");
svg.appendChild(el);
attachShapeListeners(el);
selectElement(el);
}
/**
* @brief Подключает обработчики событий для фигуры
* @param el элемент SVG
*/
function attachShapeListeners(el) {
el.addEventListener("mousedown", (ev) => {
ev.stopPropagation();
const startClient = { x: ev.clientX, y: ev.clientY };
const startSvg = clientToSvgPoint(ev.clientX, ev.clientY);
const startAttrs = readElementAttrs(el);
const action = mode === "Resize" ? "resize" : "move";
dragState = { type: action, el, startClient, startSvg, startAttrs };
selectElement(el);
window.addEventListener("mousemove", onWindowMouseMove);
window.addEventListener("mouseup", onWindowMouseUp);
});
el.addEventListener("click", (e) => { e.stopPropagation(); selectElement(el); });
}
/**
* @brief Считывает атрибуты элемента SVG
* @param el элемент SVG
*/
function readElementAttrs(el) {
const tag = el.tagName.toLowerCase();
if (tag === "rect") {
return {
x: parseFloat(el.getAttribute("x")||0),
y: parseFloat(el.getAttribute("y")||0),
width: parseFloat(el.getAttribute("width")||0),
height: parseFloat(el.getAttribute("height")||0)
};
} else if (tag === "circle") {
return {
cx: parseFloat(el.getAttribute("cx")||0),
cy: parseFloat(el.getAttribute("cy")||0),
r: parseFloat(el.getAttribute("r")||0)
};
} else if (tag === "line") {
return {
x1: parseFloat(el.getAttribute("x1")||0),
y1: parseFloat(el.getAttribute("y1")||0),
x2: parseFloat(el.getAttribute("x2")||0),
y2: parseFloat(el.getAttribute("y2")||0)
};
}
return {};
}
/**
* @brief Обрабатывает движение мыши при перетаскивании/изменении размера
* @param ev событие мыши
*/
function onWindowMouseMove(ev) {
if (!dragState) return;
const { type, el, startSvg, startAttrs } = dragState;
const curSvg = clientToSvgPoint(ev.clientX, ev.clientY);
const dx = curSvg.x - startSvg.x;
const dy = curSvg.y - startSvg.y;
const tag = el.tagName.toLowerCase();
if (type === "move") {
if (tag === "rect") {
el.setAttribute("x", startAttrs.x + dx);
el.setAttribute("y", startAttrs.y + dy);
} else if (tag === "circle") {
el.setAttribute("cx", startAttrs.cx + dx);
el.setAttribute("cy", startAttrs.cy + dy);
} else if (tag === "line") {
el.setAttribute("x1", startAttrs.x1 + dx);
el.setAttribute("y1", startAttrs.y1 + dy);
el.setAttribute("x2", startAttrs.x2 + dx);
el.setAttribute("y2", startAttrs.y2 + dy);
}
} else {
if (tag === "rect") {
const newW = Math.max(6, startAttrs.width + dx);
const newH = Math.max(6, startAttrs.height + dy);
el.setAttribute("width", newW);
el.setAttribute("height", newH);
} else if (tag === "circle") {
const center = { x: startAttrs.cx, y: startAttrs.cy };
const newR = Math.max(4, Math.hypot(curSvg.x - center.x, curSvg.y - center.y));
el.setAttribute("r", newR);
} else if (tag === "line") {
el.setAttribute("x2", startAttrs.x2 + dx);
el.setAttribute("y2", startAttrs.y2 + dy);
}
}
showOutlineFor(el);
}
/**
* @brief Обрабатывает отпускание кнопки мыши при перетаскивании/изменении размера
*/
function onWindowMouseUp() {
if (dragState) {
dragState = null;
window.removeEventListener("mousemove", onWindowMouseMove);
window.removeEventListener("mouseup", onWindowMouseUp);
}
}
svg.addEventListener("mousedown", (ev) => {
if (ev.target === svg) selectElement(null);
});
/**
* @brief Выбирает элемент или снимает выбор
* @param el элемент SVG или null
*/
function selectElement(el) {
if (currentSelected === el) return;
if (currentSelected) currentSelected.classList.remove("selected");
currentSelected = el;
if (el) {
el.classList.add("selected");
showOutlineFor(el);
} else {
showOutlineFor(null);
}
}
Array.from(svg.querySelectorAll(".draggable, rect, circle, line")).forEach(attachShapeListeners);
window.addEventListener("resize", () => showOutlineFor(currentSelected));
window.addEventListener("scroll", () => showOutlineFor(currentSelected));
window.addEventListener("keydown", (ev) => {
if ((ev.key === "Delete" || ev.key === "Backspace") && currentSelected) {
currentSelected.remove();
selectElement(null);
}
});
/** @brief Кнопки панели сайдбара */
const buttons = Array.from(document.querySelectorAll('.sidebar__button'));
/** @brief Кнопка сохранения */
const saveBtn = buttons.find(b => b.textContent.trim().toLowerCase() === 'save');
/** @brief Кнопка загрузки */
const loadBtn = buttons.find(b => b.textContent.trim().toLowerCase() === 'load');
/** @brief Генерирует следующее имя файла для сохранения */
function nextFilename() {
localStorage.setItem(saveCountKey, String((parseInt(localStorage.getItem(saveCountKey) || '0', 10) || 0) + 1));
const n = parseInt(localStorage.getItem(saveCountKey), 10) || 1;
return n === 1 ? 'SvgEditorM.svg' : `SvgEditorM-${n}.svg`;
}
/** @brief Возвращает SVG внутри main */
function getMainSvg() {
return main.querySelector('svg');
}
/**
* @brief Устанавливает атрибуты xmlns для элемента SVG, если их нет
* @param el элемент SVG
*/
function ensureXmlns(el) {
if (!el.getAttribute('xmlns')) el.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (!el.getAttribute('xmlns:xlink')) el.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
return el;
}
/** @brief Сохраняет SVG из main в файл */
function saveSvg() {
const svg = getMainSvg();
if (!svg) return;
const clone = svg.cloneNode(true);
ensureXmlns(clone);
const xml = new XMLSerializer().serializeToString(clone);
const blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = nextFilename();
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
/** @brief Элемент input для загрузки файлов */
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.svg,image/svg+xml';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
/**
* @brief Делает узел и его потомков интерактивными
* @param node элемент SVG
*/
function makeInteractiveForNode(node) {
if (!(node instanceof Element)) return;
const tag = node.tagName.toLowerCase();
if (['rect','circle','line','path','polyline','polygon','g'].includes(tag)) {
if (!node.classList.contains('draggable')) node.classList.add('draggable');
if (typeof attachShapeListeners === 'function') {
attachShapeListeners(node);
}
}
Array.from(node.children || []).forEach(ch => makeInteractiveForNode(ch));
}
/**
* @brief Загружает SVG из файла в main
* @param file объект File
*/
async function loadSvgFile(file) {
if (!file) return;
const text = await file.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'image/svg+xml');
const parsedSvg = doc.querySelector('svg');
if (!parsedSvg) {
alert('Выбранный файл не содержит SVG');
return;
}
const targetSvg = getMainSvg();
if (!targetSvg) {
const imported = document.importNode(parsedSvg, true);
ensureXmlns(imported);
main.appendChild(imported);
Array.from(imported.querySelectorAll('*')).forEach(n => makeInteractiveForNode(n));
} else {
const preservedOutline = document.getElementById('outline');
while (targetSvg.firstChild) targetSvg.removeChild(targetSvg.firstChild);
Array.from(parsedSvg.childNodes).forEach(n => {
const imp = document.importNode(n, true);
targetSvg.appendChild(imp);
});
Array.from(parsedSvg.attributes).forEach(attr => {
if (attr.name === 'xmlns' || attr.name === 'xmlns:xlink') return;
targetSvg.setAttribute(attr.name, attr.value);
});
ensureXmlns(targetSvg);
Array.from(targetSvg.querySelectorAll('*')).forEach(n => makeInteractiveForNode(n));
if (preservedOutline && preservedOutline.parentElement !== document.body) {
}
}
main.dispatchEvent(new CustomEvent('svg-loaded', { detail: { fileName: file.name } }));
}
fileInput.addEventListener('change', () => {
const f = fileInput.files && fileInput.files[0];
loadSvgFile(f).finally(() => { fileInput.value = ''; });
});
if (saveBtn) saveBtn.addEventListener('click', (e) => { e.stopPropagation(); saveSvg(); });
if (loadBtn) loadBtn.addEventListener('click', (e) => { e.stopPropagation(); fileInput.click(); });