import { canvasClear } from '../diagram/canvas-clear.js'; import { fileSaveSvg } from '../diagram/dgrm-png.js'; import { deserialize, serialize } from '../diagram/dgrm-serialization.js'; import { generateKey, srvSave } from '../diagram/dgrm-srv.js'; import { fileOpen, fileSave } from '../infrastructure/file.js'; import { tipShow, uiDisable } from './ui.js'; export class Menu extends HTMLElement { connectedCallback() { const shadow = this.attachShadow({ mode: 'closed' }); shadow.innerHTML = ` `; const options = shadow.getElementById('options'); function toggle() { options.style.visibility = options.style.visibility === 'visible' ? 'hidden' : 'visible'; } /** @param {string} id, @param {()=>void} handler */ function click(id, handler) { shadow.getElementById(id).onclick = _ => { uiDisable(true); handler(); toggle(); uiDisable(false); }; } shadow.getElementById('menu').onclick = toggle; shadow.getElementById('menu2').onclick = toggle; click('new', () => { canvasClear(this._canvas); }); click('save', () => { const serialized = serialize(this._canvas); if (serialized.s.length === 0) { alertEmpty(); return; } fileSaveSvg(this._canvas, serialized, blob => { fileSave(blob, 'dgrm.svg'); }); }); click('open', () => fileOpen('.svg', async svgFile => await loadData(this._canvas, svgFile)) ); click('link', async () => { const serialized = serialize(this._canvas); if (serialized.s.length === 0) { alertEmpty(); return; } const key = generateKey(); const url = new URL(window.location.href); url.searchParams.set('k', key); // use clipboard before server call - to fix 'Document is not focused' await navigator.clipboard.writeText(url.toString()); await srvSave(key, serialized); alert('Link to diagram copied to clipboard'); }); } /** @param {CanvasElement} canvas */ init(canvas) { /** @private */ this._canvas = canvas; // file drag to window document.body.addEventListener('dragover', evt => { evt.preventDefault(); }); document.body.addEventListener('drop', async evt => { evt.preventDefault(); if (evt.dataTransfer?.items?.length !== 1 || evt.dataTransfer.items[0].kind !== 'file' || evt.dataTransfer.items[0].type !== 'image/png') { alertCantOpen(); return; } await loadData(this._canvas, evt.dataTransfer.items[0].getAsFile()); }); } }; customElements.define('ap-menu', Menu); /** @param {CanvasElement} canvas, @param {File|Blob} svgFile */ import { CanvasSmbl } from '../infrastructure/canvas-smbl.js'; function parseTranslateFromString(str) { if (!str) return { x: 0, y: 0 }; const m = /translate\(\s*([-\d.]+)(?:px)?\s*(?:,|\s)\s*([-\d.]+)(?:px)?\s*\)/.exec(str); if (m) return { x: parseFloat(m[1]), y: parseFloat(m[2]) }; const mm = /matrix\(\s*([-\d.e]+)[,\s]+([-\d.e]+)[,\s]+([-\d.e]+)[,\s]+([-\d.e]+)[,\s]+([-\d.e]+)[,\s]+([-\d.e]+)\s*\)/.exec(str); if (mm && mm.length >= 7) return { x: parseFloat(mm[5]), y: parseFloat(mm[6]) }; return { x: 0, y: 0 }; } function getEffectiveTranslate(el) { // priority: style attribute "transform: translate(...)" -> attribute transform -> HTMLElement.style.transform if (!el || !el.getAttribute) return { x: 0, y: 0 }; const styleAttr = el.getAttribute('style'); if (styleAttr) { const m = /transform\s*:\s*([^;]+)/.exec(styleAttr); if (m) { const p = parseTranslateFromString(m[1]); if (Number.isFinite(p.x) || Number.isFinite(p.y)) return p; } } const trAttr = el.getAttribute('transform'); if (trAttr) { const p = parseTranslateFromString(trAttr); if (Number.isFinite(p.x) || Number.isFinite(p.y)) return p; } if (el instanceof HTMLElement && el.style && el.style.transform) { const p = parseTranslateFromString(el.style.transform); if (Number.isFinite(p.x) || Number.isFinite(p.y)) return p; } return { x: 0, y: 0 }; } function extractCoordsFromPathD(d) { if (!d) return { start: { x: 0, y: 0 }, end: { x: 0, y: 0 } }; const nums = (d.match(/-?\d+(\.\d+)?/g) || []).map(Number); if (nums.length >= 4) { return { start: { x: nums[0], y: nums[1] }, end: { x: nums[nums.length - 2], y: nums[nums.length - 1] } }; } return { start: { x: 0, y: 0 }, end: { x: 0, y: 0 } }; } function detectMainTag(el) { if (!el || !el.querySelector) return null; const main = el.querySelector('[data-key="main"]'); if (main) return main.tagName?.toLowerCase() || null; // fallback: check for presence of common tags if (el.querySelector('circle')) return 'circle'; if (el.querySelector('rect')) return 'rect'; if (el.querySelector('path')) return 'path'; return null; } export async function loadData(canvas, svgFile) { try { const text = await svgFile.text(); const parser = new DOMParser(); const svgDoc = parser.parseFromString(text, 'image/svg+xml'); const svgElement = svgDoc.documentElement; const srcCanvasContainer = svgElement.querySelector('#canvas') || svgElement; const canvasContainerOffset = getEffectiveTranslate(srcCanvasContainer); const sourceElements = Array.from(srcCanvasContainer.children || []); const shapeMap = canvas[CanvasSmbl] && canvas[CanvasSmbl].shapeMap; if (!shapeMap) { console.error('canvas[CanvasSmbl].shapeMap not found'); return; } const mapKeys = Object.keys(shapeMap); // Найдём ключ для стрелок (проверкой create с s/e данными) let arrowKey = null; for (const k of mapKeys) { try { const testData = { s: { data: { dir: 'right', position: { x: 0, y: 0 } } }, e: { data: { dir: 'right', position: { x: 10, y: 0 } } } }; const proto = shapeMap[k].create(testData); // если create вернул элемент и в нём есть path или g[data-key="start"] if (proto && (proto.querySelector && (proto.querySelector('path') || proto.querySelector('[data-key="start"]')))) { arrowKey = k; proto.remove && proto.remove(); break; } proto.remove && proto.remove(); } catch (err) { // ignore } } // fallback обычно '0' if (arrowKey == null) arrowKey = mapKeys.find(mk => String(mk) === '0') || null; // Сопоставление kind(tag) -> key: создаём один proto на ключ и смотрим main tag const keyByTag = {}; for (const k of mapKeys) { try { const proto = shapeMap[k].create({ type: k, position: { x: 0, y: 0 }, title: 'T' }); const tag = detectMainTag(proto); if (tag) { if (!keyByTag[tag]) keyByTag[tag] = k; } proto.remove && proto.remove(); } catch (e) { // ignore } } const createdShapes = []; // 1) создаём фигуры (non-path) for (const node of sourceElements) { if (!(node instanceof Element)) continue; const looksLikePath = node.classList.contains('shpath') || !!node.querySelector('path.path') || !!node.querySelector('path[data-key="path"]'); if (looksLikePath) continue; const groupPos = getEffectiveTranslate(node); // visible transform of group const absPos = { x: (groupPos.x || 0) + (canvasContainerOffset.x || 0), y: (groupPos.y || 0) + (canvasContainerOffset.y || 0) }; const tag = detectMainTag(node) || 'unknown'; const key = keyByTag[tag] || null; if (!key) { // fallback: просто клонируем — визуал будет, но интерактивность может отсутствовать try { const clone = node.cloneNode(true); canvas.append(clone); } catch (e) { console.error('clone failed', e); } continue; } const title = node.querySelector('text')?.textContent || 'Title'; const shapeData = { type: key, position: { x: absPos.x, y: absPos.y }, title }; try { const shapeEl = shapeMap[key].create(shapeData); // apply visible transform (absolute) shapeEl.setAttribute && shapeEl.setAttribute('transform', `translate(${absPos.x},${absPos.y})`); canvas.append(shapeEl); shapeEl.dispatchEvent && shapeEl.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); // collect connectors absolute positions from source node const connectors = {}; const connNodes = Array.from(node.querySelectorAll('circle[data-connect]') || []); connNodes.forEach(cn => { const connName = cn.getAttribute('data-connect') || cn.getAttribute('data-key') || 'conn'; const connTr = getEffectiveTranslate(cn); // connector absolute = canvasContainerOffset + groupPos + connTr const cpos = { x: (canvasContainerOffset.x || 0) + (groupPos.x || 0) + (connTr.x || 0), y: (canvasContainerOffset.y || 0) + (groupPos.y || 0) + (connTr.y || 0) }; connectors[connName] = cpos; }); createdShapes.push({ key, el: shapeEl, tag, absPos, connectors }); } catch (e) { console.error('create shape failed', e); } } // 2) создаём стрелки точно как в _shapeCreate: формируем s/e с data.position и вызываем create for (const node of sourceElements) { if (!(node instanceof Element)) continue; const looksLikePath = node.classList.contains('shpath') || !!node.querySelector('path.path') || !!node.querySelector('path[data-key="path"]'); if (!looksLikePath) continue; let sPos = { x: 0, y: 0 }, ePos = { x: 0, y: 0 }; const startG = node.querySelector('[data-key="start"]'); const endG = node.querySelector('[data-key="end"]'); if (startG) { const p = getEffectiveTranslate(startG); sPos.x = (p.x || 0) + (canvasContainerOffset.x || 0); sPos.y = (p.y || 0) + (canvasContainerOffset.y || 0); } if (endG) { const p = getEffectiveTranslate(endG); ePos.x = (p.x || 0) + (canvasContainerOffset.x || 0); ePos.y = (p.y || 0) + (canvasContainerOffset.y || 0); } if ((sPos.x === 0 && sPos.y === 0) || (ePos.x === 0 && ePos.y === 0)) { const pathEl = node.querySelector('path[data-key="path"]') || node.querySelector('path'); if (pathEl) { const coords = extractCoordsFromPathD(pathEl.getAttribute('d')); sPos.x = coords.start?.x || sPos.x; sPos.y = coords.start?.y || sPos.y; ePos.x = coords.end?.x || ePos.x; ePos.y = coords.end?.y || ePos.y; } } // Найти реальные shape и connector для s/e const findConnector = (pos) => { let best = null; let minDist = Infinity; for (const sh of createdShapes) { for (const key in sh.connectors) { const c = sh.connectors[key]; const dx = c.x - pos.x; const dy = c.y - pos.y; const dist = dx*dx + dy*dy; if (dist < minDist) { minDist = dist; best = { shapeEl: sh.el, connectorKey: key }; } } } return best; } const sShape = findConnector(sPos); const eShape = findConnector(ePos); const shapeData = { s: sShape ? { shape: sShape, data: { dir:'right', position: {...sPos} } } : { data: { dir:'right', position: {...sPos} } }, e: eShape ? { shape: eShape, data: { dir:'right', position: {...ePos} } } : { data: { dir:'right', position: {...ePos} } } }; const shapeEl = arrowKey != null ? shapeMap[arrowKey].create(shapeData) : node.cloneNode(true); canvas.append(shapeEl); shapeEl.dispatchEvent && shapeEl.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); } console.log('loadData finished. shapes created:', createdShapes.length); } catch (e) { console.error('Ошибка при загрузке SVG', e); } } const alertCantOpen = () => alert('File cannot be read. Use the exact image file you got from the application.'); const alertEmpty = () => alert('Diagram is empty'); /** @typedef { {x:number, y:number} } Point */ /** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */