Ветка 07_11_25
This commit is contained in:
762
main_plugin/SvgEditorM/SvgEditorM.js
Normal file
762
main_plugin/SvgEditorM/SvgEditorM.js
Normal file
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* @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(); });
|
||||
Reference in New Issue
Block a user