372 lines
16 KiB
JavaScript
Executable File
372 lines
16 KiB
JavaScript
Executable File
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 */
|