Files
slava.home/main_plugin/dgrm/ui/menu.js

372 lines
16 KiB
JavaScript
Executable File
Raw 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.

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 = `
<style>
.menu {
top: 15px;
left: 15px;
cursor: pointer;
}
#options {
padding: 15px;
box-shadow: 0px 0px 58px 2px rgb(34 60 80 / 20%);
background-color: rgba(255,255,255, .9);
position: absolute !important;
top: 0px;
left: 0px;
width: 200px;
z-index: 1;
}
#options div, #options a {
color: rgb(13, 110, 253);
cursor: pointer; margin: 10px 0;
display: flex;
align-items: center;
line-height: 25px;
text-decoration: none;
}
#options div svg, #options a svg { margin-right: 10px; }
</style>
<svg id="menu" class="menu" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z" fill="rgb(52,71,103)"/></svg>
<div id="options" style="visibility: hidden;">
<div id="menu2" style="margin: 0 0 15px;"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z" fill="rgb(52,71,103)"/></svg></div>
<div id="new"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M9 2.003V2h10.998C20.55 2 21 2.455 21 2.992v18.016a.993.993 0 0 1-.993.992H3.993A1 1 0 0 1 3 20.993V8l6-5.997zM5.83 8H9V4.83L5.83 8zM11 4v5a1 1 0 0 1-1 1H5v10h14V4h-8z" fill="rgb(52,71,103)"/></svg>New diagram</div>
<div id="open"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 21a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h7.414l2 2H20a1 1 0 0 1 1 1v3h-2V7h-7.414l-2-2H4v11.998L5.5 11h17l-2.31 9.243a1 1 0 0 1-.97.757H3zm16.938-8H7.062l-1.5 6h12.876l1.5-6z" fill="rgb(52,71,103)"/></svg>Open diagram image</div>
<div id="save"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 19h18v2H3v-2zm10-5.828L19.071 7.1l1.414 1.414L12 17 3.515 8.515 4.929 7.1 11 13.17V2h2v11.172z" fill="rgb(52,71,103)"/></svg>Save diagram image</div>
<div id="link" style="display: none;"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M13.06 8.11l1.415 1.415a7 7 0 0 1 0 9.9l-.354.353a7 7 0 0 1-9.9-9.9l1.415 1.415a5 5 0 1 0 7.071 7.071l.354-.354a5 5 0 0 0 0-7.07l-1.415-1.415 1.415-1.414zm6.718 6.011l-1.414-1.414a5 5 0 1 0-7.071-7.071l-.354.354a5 5 0 0 0 0 7.07l1.415 1.415-1.415 1.414-1.414-1.414a7 7 0 0 1 0-9.9l.354-.353a7 7 0 0 1 9.9 9.9z" fill="rgb(52,71,103)"/></svg>Copy link to diagram</div>
<a style="display: none;" href="/donate.html" target="_blank" style="margin-bottom: 0;">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M12.001 4.529c2.349-2.109 5.979-2.039 8.242.228 2.262 2.268 2.34 5.88.236 8.236l-8.48 8.492-8.478-8.492c-2.104-2.356-2.025-5.974.236-8.236 2.265-2.264 5.888-2.34 8.244-.228zm6.826 1.641c-1.5-1.502-3.92-1.563-5.49-.153l-1.335 1.198-1.336-1.197c-1.575-1.412-3.99-1.35-5.494.154-1.49 1.49-1.565 3.875-.192 5.451L12 18.654l7.02-7.03c1.374-1.577 1.299-3.959-.193-5.454z" fill="rgb(255,66,77)"/></svg>Donate
</a>
</div>`;
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 */