Добавляем все файлы

This commit is contained in:
2025-11-06 19:41:55 +02:00
parent 235d6a3a18
commit 2e5aaec307
218 changed files with 79015 additions and 0 deletions

371
main_plugin/dgrm/ui/menu.js Executable file
View File

@@ -0,0 +1,371 @@
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 */

183
main_plugin/dgrm/ui/shape-menu.js Executable file
View File

@@ -0,0 +1,183 @@
import { CanvasSmbl } from '../infrastructure/canvas-smbl.js';
import { pointInCanvas } from '../infrastructure/move-scale-applay.js';
import { listen } from '../infrastructure/util.js';
import { tipShow } from './ui.js';
export class ShapeMenu extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML =
`<style>
.menu {
position: absolute !important;
overflow-x: auto;
padding: 0;
top: 50%;
left: 13px;
transform: translateY(-50%);
box-shadow: 0px 0px 58px 2px rgba(34, 60, 80, 0.2);
border-radius: 16px;
background-color: rgba(255,255,255, .9);
}
.content {
white-space: nowrap;
display: flex;
flex-direction: column;
}
[data-cmd] {
cursor: pointer;
}
.menu svg { padding: 10px; }
.stroke {
stroke: #344767;
stroke-width: 2px;
fill: transparent;
}
.menu .big {
width: 62px;
min-width: 62px;
}
@media only screen and (max-width: 700px) {
.menu {
width: 100%;
border-radius: 0;
bottom: 0;
display: flex;
flex-direction: column;
top: unset;
left: unset;
transform: unset;
}
.content {
align-self: center;
flex-direction: row;
}
}
</style>
<div id="menu" class="menu" style="touch-action: none;">
<div class="content">
<svg class="stroke" data-cmd="shapeAdd" data-cmd-arg="1" viewBox="0 0 24 24" width="24" height="24"><circle r="9" cx="12" cy="12"></circle></svg>
<svg class="stroke" data-cmd="shapeAdd" data-cmd-arg="4" viewBox="0 0 24 24" width="24" height="24"><path d="M2 12 L12 2 L22 12 L12 22 Z" stroke-linejoin="round"></path></svg>
<svg class="stroke" data-cmd="shapeAdd" data-cmd-arg="2" viewBox="0 0 24 24" width="24" height="24"><rect x="2" y="4" width="20" height="16" rx="3" ry="3"></rect></svg>
<svg data-cmd="shapeAdd" data-cmd-arg="0" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M13 8v8a3 3 0 0 1-3 3H7.83a3.001 3.001 0 1 1 0-2H10a1 1 0 0 0 1-1V8a3 3 0 0 1 3-3h3V2l5 4-5 4V7h-3a1 1 0 0 0-1 1z" fill="rgba(52,71,103,1)"/></svg>
<svg data-cmd="shapeAdd" data-cmd-arg="3" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M13 6v15h-2V6H5V4h14v2z" fill="rgba(52,71,103,1)"/></svg>
</div>
</div>`;
const menu = shadow.getElementById('menu');
menu.querySelectorAll('[data-cmd="shapeAdd"]').forEach(el => listen(el, 'pointerdown', this));
listen(menu, 'pointerleave', this);
listen(menu, 'pointerup', this);
listen(menu, 'pointermove', this);
};
/** @param {CanvasElement} canvas */
init(canvas) {
/** @private */ this._canvas = canvas;
}
/** @param {PointerEvent & { currentTarget: Element }} evt */
handleEvent(evt) {
switch (evt.type) {
case 'pointermove':
if (!this._isNativePointerleaveTriggered) {
// emulate pointerleave for mobile
const pointElem = document.elementFromPoint(evt.clientX, evt.clientY);
if (pointElem === this._pointElem) {
return;
}
// pointerleave
if (this._parentElem === this._pointElem) {
// TODO: check mobile
this._canvas.ownerSVGElement.setPointerCapture(evt.pointerId);
}
/**
* @type {Element}
* @private
*/
this._pointElem = pointElem;
}
break;
case 'pointerleave':
this._isNativePointerleaveTriggered = true;
if (this._pressedShapeTemplKey != null) {
// when shape drag out from menu panel
this._shapeCreate(evt);
}
this._clean();
break;
case 'pointerdown':
this._pressedShapeTemplKey = parseInt(evt.currentTarget.getAttribute('data-cmd-arg'));
// for emulate pointerleave
this._parentElem = document.elementFromPoint(evt.clientX, evt.clientY);
this._pointElem = this._parentElem;
this._isNativePointerleaveTriggered = null;
break;
case 'pointerup':
this._clean();
break;
}
}
/**
* @param {PointerEvent} evt
* @private
*/
_shapeCreate(evt) {
tipShow(false);
if (this._pressedShapeTemplKey == null) return;
const svg = this._canvas.ownerSVGElement;
const pt = svg.createSVGPoint();
pt.x = evt.clientX; pt.y = evt.clientY;
const loc = pt.matrixTransform(this._canvas.getScreenCTM().inverse());
const x = loc.x, y = loc.y;
let shapeData;
if (this._pressedShapeTemplKey === 0) {
shapeData = {
s: { data: { dir: 'right', position: { x: x - 24, y } } },
e: { data: { dir: 'right', position: { x: x + 24, y } } }
};
} else {
shapeData = {
type: this._pressedShapeTemplKey,
position: { x, y },
title: 'Title'
};
}
const shapeEl = this._canvas[CanvasSmbl].shapeMap[this._pressedShapeTemplKey].create(shapeData);
this._canvas.append(shapeEl);
// Для стрелки и других форм диспатчим pointerdown
shapeEl.dispatchEvent(new PointerEvent('pointerdown', evt));
// transform ставим только для фигур, которые не path
if (this._pressedShapeTemplKey !== 0) {
shapeEl.setAttribute('transform', `translate(${x},${y})`);
}
}
/** @private */
_clean() {
this._pressedShapeTemplKey = null;
this._parentElem = null;
this._pointElem = null;
}
}
customElements.define('ap-menu-shape', ShapeMenu);
/** @typedef { import('../shapes/shape-type-map.js').ShapeType } ShapeType */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */

29
main_plugin/dgrm/ui/ui.js Executable file
View File

@@ -0,0 +1,29 @@
/** @type {HTMLDivElement} */ let overlay;
/** @param {boolean} isDisable */
export function uiDisable(isDisable) {
/* if (isDisable && !overlay) {
overlay = document.createElement('div');
overlay.style.cssText = 'z-index: 2; position: fixed; left: 0; top: 0; width:100%; height:100%; background: #fff; opacity: 0';
overlay.innerHTML =
`<style>
@keyframes blnk {
0% { opacity: 0; }
50% { opacity: 0.7; }
100% {opacity: 0;}
}
.blnk { animation: blnk 1.6s linear infinite; }
</style>`;
overlay.classList.add('blnk');
document.body.append(overlay);
} else if (!isDisable) {
overlay.remove();
overlay = null;
} */
}
/** @param {boolean} show */
tipShow(false);
export function tipShow(show) {
document.getElementById('diagram').style.pointerEvents = show ? 'none' : 'unset';
document.getElementById('tip').style.display = show ? 'unset' : 'none';
}