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 */