/** * @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(); });