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

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

149
main_plugin/dgrm/dgrm.css Executable file
View File

@@ -0,0 +1,149 @@
#diagram {
height: 100%;
width: 100%;
margin: 0;
user-select: none;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 16px;
color: rgb(73, 80, 87);
outline: none;
height: calc(95% + 2px);
}
a {
color: #0d6efd;
text-decoration: underline;
}
#dgrmDiv {
display: inline-block;
user-select: none;
width: -webkit-fill-available;
border-radius: 5px;
height: 600px;
font-size: 1em;
max-width: calc(100% - 20px);
overflow-y: hidden;
transform: translate(0%, 0%);
}
#dgrmTop {
text-align: center;
border-bottom: 1px #40464d solid;
padding: 5px;
background-color: rgba(255, 255, 255, 0.6);
}
#dgrmTopTitle {
text-align: center;
}
#options {
position: absolute !important;
}
.menu {
position: absolute !important;
}
#menu {
position: absolute !important;
}
text {
white-space: pre-wrap;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 16px;
color: rgb(73, 80, 87);
}
textarea {
text-align: center;
border: none;;
padding: 10px;
padding-top: 0.8em;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 16px;
background-color: transparent;
color: transparent;
outline: none;
overflow: hidden;
resize: none;
line-height: 1em;
caret-color: #fff;
}
[data-connect] { display: none; }
.select path[data-key="selected"],
.select .path-end,
.select [data-connect],
.highlight-e [data-key="end"] .path-end,
.highlight-s [data-key="start"] .path-end,
.hover [data-connect] {
display: unset;
opacity: 0.51;
stroke: rgb(108 187 247);
fill: rgb(108 187 247);
}
[data-connect].hover { stroke-width: 25px; }
.select path[data-key="selected"] { fill:none; }
.highlight [data-key="main"]{
paint-order: stroke;
stroke-width: 10px;
stroke: rgb(108 187 247 / 51%);
}
.shpath [data-key="end"] .path,
.shpath [data-key="start"] .path { display: none;}
.shpath.arw-e [data-key="end"] .path,
.shpath.arw-s [data-key="start"] .path { display: unset;}
.shpath.dash [data-key="path"] { stroke-dasharray:5; }
@media (pointer: coarse) {
circle.path-end { r: 20px; }
.ative-elem {
stroke: rgb(108 187 247 / 51%);
stroke-width: 70px;
}
[data-connect] { stroke-width: 15px; }
[data-connect].hover { stroke-width: 70px; }
}
.shrect.ta-1 text, .shtxt.ta-1 text { text-anchor: start; }
.shrect.ta-2 text, .shtxt.ta-2 text { text-anchor: middle; }
.shrect.ta-3 text, .shtxt.ta-3 text { text-anchor: end; }
.shrect.ta-1 textarea, .shtxt.ta-1 textarea { text-align: left; }
.shrect.ta-2 textarea, .shtxt.ta-2 textarea { text-align: center; }
.shrect.ta-3 textarea, .shtxt.ta-3 textarea { text-align: right; }
.shtxt textarea { caret-color: rgb(73, 80, 87); }
.shtxt text { fill:rgb(73, 80, 87); }
.shtxt [data-key="main"] { fill: transparent; stroke: transparent; }
.shtxt.select [data-key="main"], .shtxt.highlight [data-key="main"] { stroke: rgb(108 187 247 / 51%); stroke-width: 2px; }
.shrhomb.highlight [data-key="border"] { stroke-width: 28px; stroke: rgb(108 187 247 / 51%); }
.shrhomb.highlight [data-key="main"] { stroke-width:18px; stroke:#1D809F; }
.cl-red [data-key="main"] { fill: #E74C3C; } .cl-red .path { stroke: #E74C3C;}
.cl-orange [data-key="main"] { fill: #ff6600;} .cl-orange .path { stroke: #ff6600;}
.cl-green [data-key="main"] { fill: #19bc9b;} .cl-green .path { stroke: #19bc9b;}
.cl-blue [data-key="main"] { fill: #1aaee5;} .cl-blue .path { stroke: #1aaee5;}
.cl-dblue [data-key="main"] { fill: #1D809F;} .cl-dblue .path { stroke: #1D809F;}
.cl-dgray [data-key="main"] { fill: #495057;} .cl-dgray .path { stroke: #495057;}
.shtxt.cl-red [data-key="main"] { fill: transparent; } .shtxt.cl-red text { fill: #E74C3C; }
.shtxt.cl-orange [data-key="main"] { fill: transparent; } .shtxt.cl-orange text { fill: #ff6600; }
.shtxt.cl-green [data-key="main"] { fill: transparent; } .shtxt.cl-green text { fill: #19bc9b; }
.shtxt.cl-blue [data-key="main"] { fill: transparent; } .shtxt.cl-blue text { fill: #1aaee5; }
.shtxt.cl-dblue [data-key="main"] { fill: transparent; } .shtxt.cl-dblue text { fill: #1D809F; }
.shtxt.cl-dgray [data-key="main"] { fill: transparent; } .shtxt.cl-dgray text { fill: #495057; }
.shrhomb.cl-red [data-key="main"] { stroke-width:18px; stroke:#E74C3C; }
.shrhomb.cl-orange [data-key="main"] { stroke-width:18px; stroke:#ff6600; }
.shrhomb.cl-green [data-key="main"] { stroke-width:18px; stroke:#19bc9b; }
.shrhomb.cl-blue [data-key="main"] { stroke-width:18px; stroke:#1aaee5; }
.shrhomb.cl-dblue [data-key="main"] { stroke-width:18px; stroke:#1D809F; }
.shrhomb.cl-dgray [data-key="main"] { stroke-width:18px; stroke:#495057; }

View File

@@ -0,0 +1,26 @@
import { CanvasSmbl } from '../infrastructure/canvas-smbl.js';
import { PathSmbl } from '../shapes/path-smbl.js';
import { ShapeSmbl } from '../shapes/shape-smbl.js';
/** @param {CanvasElement} canvas */
export function canvasClear(canvas) {
while (canvas.firstChild) {
(canvas.firstChild[ShapeSmbl] || canvas.firstChild[PathSmbl]).del();
}
canvas[CanvasSmbl].move(0, 0, 1);
}
//
// selection clear function
/** @param {CanvasElement} canvas */
export function canvasSelectionClear(canvas) {
if (canvas[CanvasSmbl].selectClear) { canvas[CanvasSmbl].selectClear(); };
}
/** @param {CanvasElement} canvas, @param {()=>void} clearFn */
export function canvasSelectionClearSet(canvas, clearFn) {
canvas[CanvasSmbl].selectClear = clearFn;
}
/** @typedef { import('../infrastructure/move-scale-applay.js').CanvasElement } CanvasElement */

View File

@@ -0,0 +1,30 @@
import { CanvasSmbl } from '../infrastructure/canvas-smbl.js';
/**
* @param {CanvasElement} canvas
* @param {any} serializedData
* @param {function(Blob):void} callBack
*/
export function fileSaveSvg(canvas, serializedData, callBack) {
const svgVirtual = /** @type {SVGSVGElement} */(canvas.ownerSVGElement.cloneNode(true));
svgVirtual.style.backgroundImage = null;
svgVirtual.querySelectorAll('.select, .highlight').forEach(el => el.classList.remove('select', 'highlight'));
const nonSvgElems = svgVirtual.getElementsByTagName('foreignObject');
while (nonSvgElems[0]) { nonSvgElems[0].parentNode.removeChild(nonSvgElems[0]); }
/* svgVirtual.querySelectorAll('g.hovertrack.shtxt.ta-1').forEach(group => {
group.querySelectorAll('text, tspan').forEach(el => console.log(el));
}); */
const svgStr = new XMLSerializer().serializeToString(svgVirtual);
const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
callBack(blob);
}
/** @typedef { {x:number, y:number} } Point */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */

View File

@@ -0,0 +1,108 @@
import { CanvasSmbl } from '../infrastructure/canvas-smbl.js';
import { PathSmbl } from '../shapes/path-smbl.js';
import { ShapeSmbl } from '../shapes/shape-smbl.js';
import { canvasClear } from './canvas-clear.js';
const v = '1.1';
/** @param {Element} canvas */
export const serialize = (canvas) => serializeShapes(/** @type {Array<ShapeElement & PathElement>} */([...canvas.children]));
/** @param {Array<ShapeElement & PathElement>} shapes */
export function serializeShapes(shapes) {
/** @type {DiagramSerialized} */
const diagramSerialized = { v, s: [] };
for (const shape of shapes) {
if (shape[ShapeSmbl]) {
// shape
diagramSerialized.s.push(shape[ShapeSmbl].data);
} else {
// path
/** @param {PathEnd} pathEnd */
function pathSerialize(pathEnd) {
const shapeIndex = shapes.indexOf(pathEnd.shape?.shapeEl);
return (shapeIndex !== -1)
? { s: shapeIndex, k: pathEnd.shape.connectorKey }
: { p: pathEnd.data };
}
const pathData = shape[PathSmbl].data;
const pathJson = { type: 0, s: pathSerialize(pathData.s), e: pathSerialize(pathData.e) };
if (pathData.styles) { pathJson.c = pathData.styles; }
diagramSerialized.s.push(pathJson);
}
}
return diagramSerialized;
}
/**
* @param {CanvasElement} canvas
* @param {DiagramSerialized} data
* @param {Boolean=} dontClear
*/
export function deserialize(canvas, data, dontClear) {
if (data.v !== v) { alert('Wrong format'); return null; }
if (!dontClear) { canvasClear(canvas); }
/** @type {Map<ShapeData, ShapeElement>} */
const shapeDataToElem = new Map();
/** @param {ShapeData} shapeData */
function shapeEnsure(shapeData) {
let shapeEl = shapeDataToElem.get(shapeData);
if (!shapeEl) {
shapeEl = canvas[CanvasSmbl].shapeMap[shapeData.type].create(shapeData);
canvas.append(shapeEl);
shapeDataToElem.set(shapeData, shapeEl);
}
return shapeEl;
}
/** @param {number?} index */
const shapeByIndex = index => shapeEnsure(/** @type {ShapeData} */(data.s[index]));
/** @type {PathElement[]} */
const paths = [];
for (const shape of data.s) {
switch (shape.type) {
// path
case 0: {
/** @param {PathEndSerialized} pathEnd */
const pathDeserialize = pathEnd => pathEnd.p
? { data: pathEnd.p }
: { shape: { shapeEl: shapeByIndex(pathEnd.s), connectorKey: pathEnd.k } };
const path = canvas[CanvasSmbl].shapeMap[0].create({
styles: /** @type {PathSerialized} */(shape).c,
s: pathDeserialize(/** @type {PathSerialized} */(shape).s),
e: pathDeserialize(/** @type {PathSerialized} */(shape).e)
});
paths.push(path);
canvas.append(path);
break;
}
default: shapeEnsure(/** @type {ShapeData} */(shape)); break;
}
}
return [...shapeDataToElem.values(), ...paths];
}
/** @typedef {{v:string, s: Array<ShapeData | PathSerialized>}} DiagramSerialized */
/** @typedef { import("../shapes/shape-smbl").ShapeElement } ShapeElement */
/** @typedef { import('../shapes/shape-evt-proc').ShapeData } ShapeData */
/** @typedef { import("../shapes/path-smbl").PathElement } PathElement */
/** @typedef { import('../shapes/path').PathEndData } PathEndData */
/** @typedef { import('../shapes/path').PathEnd } PathEnd */
/** @typedef { import('../shapes/path').PathData } PathData */
/** @typedef { {s?:number, k?:string, p?:PathEndData} } PathEndSerialized */
/** @typedef { {type:number, c?:string, s:PathEndSerialized, e:PathEndSerialized} } PathSerialized */
/** @typedef { import('../shapes/shape-evt-proc').CanvasData } CanvasData */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */

View File

@@ -0,0 +1,32 @@
const svrApi = 'https://localhost:7156/api';
/**
* @param {string} key
* @param {DiagramSerialized} serialized
* @returns {Promise}
*/
export async function srvSave(key, serialized) {
return await fetch(`${svrApi}/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json;charset=utf-8' },
body: JSON.stringify(serialized)
});
}
/**
* get diagram json by key
* @param {string} key
* @returns {Promise<DiagramSerialized>}
*/
export async function srvGet(key) {
return (await fetch(`${svrApi}/${key}`)).json();
}
export function generateKey() {
const arr = new Uint8Array((8 / 2));
window.crypto.getRandomValues(arr);
const date = new Date();
return `${date.getUTCFullYear()}${(date.getUTCMonth() + 1).toString().padStart(2, '0')}${Array.from(arr, dec => dec.toString(16).padStart(2, '0')).join('')}`;
}
/** @typedef { import("./dgrm-serialization").DiagramSerialized } DiagramSerialized */

View File

@@ -0,0 +1,61 @@
import { CanvasSmbl } from '../infrastructure/canvas-smbl.js';
import { placeToCell, pointInCanvas } from '../infrastructure/move-scale-applay.js';
import { pointShift } from '../infrastructure/util.js';
/** @param {CanvasElement} canvas, @param {DiagramSerialized} data */
export function groupMoveToCenter(canvas, data) {
const screenCenter = pointInCanvas(canvas[CanvasSmbl].data, window.innerWidth / 2, window.innerHeight / 2);
placeToCell(screenCenter, canvas[CanvasSmbl].data.cell);
const shift = pointShift(screenCenter, centerCalc(data), -1);
iteratePoints(data, point => { if (point) { pointShift(point, shift); } });
}
/** @param {DiagramSerialized} data */
function centerCalc(data) {
const minMax = maxAndMinPoint(data);
return {
x: minMax.min.x + (minMax.max.x - minMax.min.x) / 2,
y: minMax.min.y + (minMax.max.y - minMax.min.y) / 2
};
}
/** @param {DiagramSerialized} data */
function maxAndMinPoint(data) {
/** @type {Point} */
const min = { x: Infinity, y: Infinity };
/** @type {Point} */
const max = { x: -Infinity, y: -Infinity };
iteratePoints(data, point => {
if (!point) { return; }
if (min.x > point.x) { min.x = point.x; }
if (min.y > point.y) { min.y = point.y; }
if (max.x < point.x) { max.x = point.x; }
if (max.y < point.y) { max.y = point.y; }
});
return { min, max };
}
/** @param {DiagramSerialized} data, @param {(point:Point)=>void} callbackfn */
function iteratePoints(data, callbackfn) {
data.s.forEach(shapeOrPath => {
if (shapeOrPath.type === 0) {
// path
callbackfn(/** @type {PathSerialized} */(shapeOrPath).s.p?.position);
callbackfn(/** @type {PathSerialized} */(shapeOrPath).e.p?.position);
} else {
// shape
callbackfn(/** @type {ShapeData} */(shapeOrPath).position);
}
});
}
/** @typedef { {x:number, y:number} } Point */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */
/** @typedef { import('./dgrm-serialization.js').DiagramSerialized } DiagramSerialized */
/** @typedef { import('./dgrm-serialization.js').PathSerialized } PathSerialized */
/** @typedef { import('../shapes/shape-evt-proc.js').ShapeData } ShapeData */

View File

@@ -0,0 +1,396 @@
import { CanvasSmbl } from '../infrastructure/canvas-smbl.js';
import { movementApplay, ProcessedSmbl, shapeSelect } from '../infrastructure/move-evt-proc.js';
import { placeToCell, pointInCanvas } from '../infrastructure/move-scale-applay.js';
import { arrPop, classAdd, classDel, deepCopy, listen, listenDel, positionSet, svgEl } from '../infrastructure/util.js';
import { PathSmbl } from '../shapes/path-smbl.js';
import { ShapeSmbl } from '../shapes/shape-smbl.js';
import { GroupSettings } from './group-settings.js';
import { modalCreate } from '../shapes/modal-create.js';
import { groupMoveToCenter } from './group-move.js';
import { deserialize, serializeShapes } from './dgrm-serialization.js';
import { canvasSelectionClear, canvasSelectionClearSet } from './canvas-clear.js';
import { tipShow } from '../ui/ui.js';
//
// copy past
const clipboardDataKey = 'dgrm';
/** @param {() => Array<ShapeElement & PathElement>} shapesToClipboardGetter */
export function listenCopy(shapesToClipboardGetter) {
/** @param {ClipboardEvent & {target:HTMLElement | SVGElement}} evt */
function onCopy(evt) {
const shapes = shapesToClipboardGetter();
if (document.activeElement === shapes[0].ownerSVGElement) {
evt.preventDefault();
evt.clipboardData.setData(
clipboardDataKey,
JSON.stringify(copyDataCreate(shapes)));
}
}
document.addEventListener('copy', onCopy);
// dispose fn
return function() {
listenDel(document, 'copy', onCopy);
};
}
/** @param {CanvasElement} canvas */
export function copyPastApplay(canvas) {
listen(document, 'paste', /** @param {ClipboardEvent & {target:HTMLElement | SVGElement}} evt */ evt => {
if (evt.target.tagName.toUpperCase() === 'TEXTAREA') { return; }
// if (document.activeElement !== canvas.ownerSVGElement) { return; }
const dataStr = evt.clipboardData.getData(clipboardDataKey);
if (!dataStr) { return; }
tipShow(false);
canvasSelectionClear(canvas);
past(canvas, JSON.parse(dataStr));
});
}
/** @param {CanvasElement} canvas, @param {Array<ShapeElement & PathElement>} shapes */
export const copyAndPast = (canvas, shapes) => past(canvas, copyDataCreate(shapes));
/** @param {Array<ShapeElement & PathElement>} shapes */
const copyDataCreate = shapes => deepCopy(serializeShapes(shapes));
/** @param {CanvasElement} canvas, @param {DiagramSerialized} data */
function past(canvas, data) {
canvasSelectionClear(canvas);
groupMoveToCenter(canvas, data);
groupSelect(canvas, deserialize(canvas, data, true));
}
//
// group select
const highlightSClass = 'highlight-s';
const highlightEClass = 'highlight-e';
const highlightClass = 'highlight';
/** wait long press and draw selected rectangle
* @param {CanvasElement} canvas
*/
export function groupSelectApplay(canvas) {
const svg = canvas.ownerSVGElement;
let timer;
/** @type {Point} */ let selectStart;
/** @type {SVGCircleElement} */ let startCircle;
/** @type {SVGRectElement} */ let selectRect;
/** @type {Point} */ let selectRectPos;
/** @param {PointerEvent} evt */
function onMove(evt) {
if (evt[ProcessedSmbl] || !selectRect) { reset(); return; }
evt[ProcessedSmbl] = true;
if (startCircle) { startCircle.remove(); startCircle = null; }
// draw rect
const x = evt.clientX - selectStart.x;
const y = evt.clientY - selectStart.y;
selectRect.width.baseVal.value = Math.abs(x);
selectRect.height.baseVal.value = Math.abs(y);
if (x < 0) { selectRectPos.x = evt.clientX; }
if (y < 0) { selectRectPos.y = evt.clientY; }
selectRect.style.transform = `translate(${selectRectPos.x}px, ${selectRectPos.y}px)`;
}
function onUp() {
if (selectRect) {
/** @param {Point} point */
const inRect = point => pointInRect(
pointInCanvas(canvas[CanvasSmbl].data, selectRectPos.x, selectRectPos.y),
selectRect.width.baseVal.value / canvas[CanvasSmbl].data.scale,
selectRect.height.baseVal.value / canvas[CanvasSmbl].data.scale,
point.x, point.y);
// select shapes in rect
groupSelect(
canvas,
/** @type {Iterable<ShapeOrPathElement>} */(canvas.children),
inRect);
}
reset();
}
function reset() {
clearTimeout(timer); timer = null;
startCircle?.remove(); startCircle = null;
selectRect?.remove(); selectRect = null;
listenDel(svg, 'pointermove', onMove);
listenDel(svg, 'wheel', reset);
listenDel(svg, 'pointerup', onUp);
}
listen(svg, 'pointerdown', /** @param {PointerEvent} evt */ evt => {
if (evt[ProcessedSmbl] || !evt.isPrimary) { reset(); return; }
listen(svg, 'pointermove', onMove);
listen(svg, 'wheel', reset, true);
listen(svg, 'pointerup', onUp, true);
timer = setTimeout(_ => {
canvasSelectionClear(canvas);
startCircle = svgEl('circle');
classAdd(startCircle, 'ative-elem');
startCircle.style.cssText = 'r:10px; fill: rgb(108 187 247 / 51%)';
positionSet(startCircle, { x: evt.clientX, y: evt.clientY });
svg.append(startCircle);
selectStart = { x: evt.clientX, y: evt.clientY };
selectRectPos = { x: evt.clientX, y: evt.clientY };
selectRect = svgEl('rect');
selectRect.style.cssText = 'rx:10px; fill: rgb(108 187 247 / 51%)';
positionSet(selectRect, selectRectPos);
svg.append(selectRect);
}, 500);
});
}
/**
* Highlight selected shapes and procces group operations (move, del, copy)
* @param {CanvasElement} canvas
* @param {Iterable<ShapeOrPathElement>} elems
* @param {{(position:Point):boolean}=} inRect
*/
export function groupSelect(canvas, elems, inRect) {
/** @param {{position:Point}} data */
const shapeInRect = data => inRect ? inRect(data.position) : true;
/** @type {Selected} */
const selected = {
shapes: [],
shapesPaths: [],
pathEnds: [],
pathEndsPaths: []
};
/**
* @param {ShapeOrPathElement} pathEl, @param {PathEnd} pathEnd, @param {string} highlightClass
* @returns {1|2|0}
*/
function pathEndInRect(pathEl, pathEnd, highlightClass) {
if (!pathEnd.shape && shapeInRect(pathEnd.data)) {
selected.pathEnds.push(pathEnd);
classAdd(pathEl, highlightClass);
return 1; // connect to end in rect
} else if (pathEnd.shape && shapeInRect(pathEnd.shape.shapeEl[ShapeSmbl].data)) {
return 2; // connect to shape in rect
}
return 0; // not in rect
}
for (const shapeEl of elems) {
if (shapeEl[ShapeSmbl]) {
if (shapeInRect(shapeEl[ShapeSmbl].data)) {
classAdd(shapeEl, highlightClass);
selected.shapes.push(shapeEl);
}
} else if (shapeEl[PathSmbl]) {
const isStartIn = pathEndInRect(shapeEl, shapeEl[PathSmbl].data.s, highlightSClass);
const isEndIn = pathEndInRect(shapeEl, shapeEl[PathSmbl].data.e, highlightEClass);
if (isStartIn === 1 || isEndIn === 1) {
selected.pathEndsPaths.push(shapeEl);
}
if (isStartIn === 2 || isEndIn === 2) {
selected.shapesPaths.push(shapeEl);
}
}
}
groupEvtProc(canvas, selected);
}
/**
* @param {CanvasElement} canvas
* @param {Selected} selected
*/
function groupEvtProc(canvas, selected) {
// only one shape selected
if (selected.shapes?.length === 1 && !selected.pathEnds?.length) {
classDel(selected.shapes[0], 'highlight');
shapeSelect(selected.shapes[0]);
return;
}
// only one pathEnd selected
if (!selected.shapes?.length && selected.pathEnds?.length === 1) {
pathUnhighlight(selected.pathEndsPaths[0]);
return;
}
// only one path selected
if (!selected.shapes?.length && selected.pathEnds?.length === 2 && selected.pathEndsPaths?.length === 1) {
pathUnhighlight(selected.pathEndsPaths[0]);
shapeSelect(selected.pathEndsPaths[0]);
return;
}
const svg = canvas.ownerSVGElement;
let isMove = false;
let isDownOnSelectedShape = false;
/** @type {{del():void}} */
let settingsPnl;
const pnlDel = () => { settingsPnl?.del(); settingsPnl = null; };
/** @param {PointerEvent & {target:Node}} evt */
function down(evt) {
pnlDel();
isDownOnSelectedShape =
selected.shapes?.some(shapeEl => shapeEl.contains(evt.target)) ||
selected.pathEnds?.some(pathEnd => pathEnd.el.contains(evt.target));
// down on not selected shape
if (!isDownOnSelectedShape && evt.target !== svg) {
dispose();
return;
}
if (isDownOnSelectedShape) {
evt.stopImmediatePropagation();
}
svg.setPointerCapture(evt.pointerId);
listen(svg, 'pointerup', up, true);
listen(svg, 'pointermove', move);
}
/** @param { {(point:Point):void} } pointMoveFn */
function drawSelection(pointMoveFn) {
selected.shapes?.forEach(shapeEl => {
pointMoveFn(shapeEl[ShapeSmbl].data.position);
shapeEl[ShapeSmbl].drawPosition();
});
selected.pathEnds?.forEach(pathEnd => pointMoveFn(pathEnd.data.position));
selected.pathEndsPaths?.forEach(path => path[PathSmbl].draw());
}
/** @param {PointerEvent} evt */
function up(evt) {
if (!isMove) {
// click on canvas
if (!isDownOnSelectedShape) { dispose(); return; }
// click on selected shape - show settings panel
settingsPnl = modalCreate(evt.clientX - 10, evt.clientY - 10, new GroupSettings(cmd => {
switch (cmd) {
case 'del':
arrPop(selected.shapes, shapeEl => shapeEl[ShapeSmbl].del());
arrPop(selected.pathEndsPaths, pathEl => pathEl[PathSmbl].del());
dispose();
break;
case 'copy': {
copyAndPast(canvas, elemsToCopyGet(selected)); // will call dispose
break;
}
}
}));
} else {
// move end
drawSelection(point => placeToCell(point, canvas[CanvasSmbl].data.cell));
}
dispose(true);
}
/** @param {PointerEventFixMovement} evt */
function move(evt) {
// move canvas
if (!isDownOnSelectedShape) { dispose(true); return; }
// move selected shapes
isMove = true;
drawSelection(point => movementApplay(point, canvas[CanvasSmbl].data.scale, evt));
}
/** @param {boolean=} saveOnDown */
function dispose(saveOnDown) {
listenDel(svg, 'pointerup', up);
listenDel(svg, 'pointermove', move);
isMove = false;
isDownOnSelectedShape = false;
if (!saveOnDown) {
canvasSelectionClearSet(canvas, null);
if (listenCopyDispose) { listenCopyDispose(); listenCopyDispose = null; }
listenDel(svg, 'pointerdown', down, true);
pnlDel();
arrPop(selected.shapes, shapeEl => classDel(shapeEl, highlightClass));
arrPop(selected.pathEndsPaths, pathEl => pathUnhighlight(pathEl));
selected.pathEnds = null;
selected.shapesPaths = null;
}
}
svg.addEventListener('pointerdown', down, { passive: true, capture: true });
canvasSelectionClearSet(canvas, dispose);
let listenCopyDispose = listenCopy(() => elemsToCopyGet(selected));
}
/** @param {Selected} selected */
function elemsToCopyGet(selected) {
/** @type {Set<PathElement>} */
const fullSelectedPaths = new Set();
/** @param {PathEnd} pathEnd */
const pathEndSelected = pathEnd =>
selected.shapes.includes(pathEnd.shape?.shapeEl) || selected.pathEnds.includes(pathEnd);
/** @param {PathElement} pathEl */
function fullSelectedPathAdd(pathEl) {
if (pathEndSelected(pathEl[PathSmbl].data.s) && pathEndSelected(pathEl[PathSmbl].data.e)) {
fullSelectedPaths.add(pathEl);
}
}
selected.shapesPaths?.forEach(fullSelectedPathAdd);
selected.pathEndsPaths?.forEach(fullSelectedPathAdd);
return [...selected.shapes, ...fullSelectedPaths];
}
/** @param {PathElement} pathEl`` */
function pathUnhighlight(pathEl) {
classDel(pathEl, highlightSClass);
classDel(pathEl, highlightEClass);
}
/**
* @param {Point} rectPosition
* @param {number} rectWidth, @param {number} rectHeight
* @param {number} x, @param {number} y
*/
const pointInRect = (rectPosition, rectWidth, rectHeight, x, y) =>
rectPosition.x <= x && x <= rectPosition.x + rectWidth &&
rectPosition.y <= y && y <= rectPosition.y + rectHeight;
/**
* @typedef { {
* shapes:ShapeElement[]
* shapesPaths:PathElement[]
* pathEnds: PathEnd[]
* pathEndsPaths: PathElement[]
* } } Selected
*/
/** @typedef { {x:number, y:number} } Point */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */
/** @typedef { import('../shapes/shape-smbl').ShapeElement } ShapeElement */
/** @typedef { import('../shapes/shape-evt-proc').Shape } Shape */
/** @typedef { import('../shapes/path').Path } Path */
/** @typedef { import('../shapes/path').PathEnd } PathEnd */
/** @typedef { import('../shapes/path-smbl').PathElement } PathElement */
/** @typedef { SVGGraphicsElement & { [ShapeSmbl]?: Shape, [PathSmbl]?:Path }} ShapeOrPathElement */
/** @typedef { import('../infrastructure/move-evt-mobile-fix.js').PointerEventFixMovement} PointerEventFixMovement */
/** @typedef { import('./dgrm-serialization.js').DiagramSerialized } DiagramSerialized */

View File

@@ -0,0 +1,32 @@
import { copySvg, delSvg } from '../infrastructure/assets.js';
import { clickForAll, evtTargetAttr } from '../infrastructure/util.js';
export class GroupSettings extends HTMLElement {
/** @param {(cms:string)=>void} cmdHandler */
constructor(cmdHandler) {
super();
/** @private */
this._cmdHandler = cmdHandler;
}
connectedCallback() {
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
.ln { display: flex; }
.ln > * {
height: 24px;
padding: 10px;
}
[data-cmd] { cursor: pointer; }
</style>
<div class="ln">
${copySvg}
${delSvg}
</div>`;
clickForAll(shadow, '[data-cmd]',
evt => this._cmdHandler(evtTargetAttr(evt, 'data-cmd')));
}
}
customElements.define('ap-grp-settings', GroupSettings);

52
main_plugin/dgrm/index.js Executable file
View File

@@ -0,0 +1,52 @@
/**
* @file index.js
* @brief Основной файл диаграммного редактора, содержит инициализацию канваса, загрузку диаграмм и подключение модулей UI
*/
import { moveEvtMobileFix } from './infrastructure/move-evt-mobile-fix.js';
import { CanvasSmbl } from './infrastructure/canvas-smbl.js';
import { moveScaleApplay } from './infrastructure/move-scale-applay.js';
import { evtRouteApplay } from './infrastructure/evt-route-applay.js';
import { tipShow, uiDisable } from './ui/ui.js';
import { srvGet } from './diagram/dgrm-srv.js';
import { deserialize } from './diagram/dgrm-serialization.js';
import { copyPastApplay, groupSelectApplay } from './diagram/group-select-applay.js';
import { shapeTypeMap } from './shapes/shape-type-map.js';
import './ui/menu.js';
import './ui/shape-menu.js';
// @ts-ignore
/** @type {import('./infrastructure/canvas-smbl.js').CanvasElement} */
/** @brief Элемент канваса */
const canvas = document.getElementById('canvas');
/** @brief Данные канваса и отображение фигур */
canvas[CanvasSmbl] = {
data: {
position: { x: 0, y: 0 },
scale: 1,
cell: 24
},
shapeMap: shapeTypeMap(canvas)
};
moveEvtMobileFix(canvas.ownerSVGElement);
evtRouteApplay(canvas.ownerSVGElement);
copyPastApplay(canvas);
groupSelectApplay(canvas); // groupSelectApplay must go before moveScaleApplay
moveScaleApplay(canvas);
/** @type { import('./ui/menu').Menu } */(document.getElementById('menu')).init(canvas);
/** @type { import('./ui/shape-menu').ShapeMenu } */(document.getElementById('menu-shape')).init(canvas);
/** @brief Загружает диаграмму по ссылке, если указан параметр k */
let url = new URL(window.location.href);
if (url.searchParams.get('k')) {
uiDisable(true);
srvGet(url.searchParams.get('k')).then(appData => {
url.searchParams.delete('k');
if (deserialize(canvas, appData)) { tipShow(false); }
history.replaceState(null, null, url);
uiDisable(false);
url = null;
});
} else { url = null; }

17
main_plugin/dgrm/index.php Executable file
View File

@@ -0,0 +1,17 @@
<?php
/**
* @file index.php
* @brief Контейнер для блока схемы
*/
?>
<?php /** @brief Основной контейнер диаграммы */ $dgrmDiv; ?>
<div id="dgrmDiv" class="bfloat">
<div class="btitle" style="background-color: transparent;">Блок схема</div>
<ap-menu id="menu"></ap-menu>
<ap-menu-shape id="menu-shape"></ap-menu-shape>
<div id="tip"></div>
<svg id="diagram" tabindex="0" style="background-position: 0px 0px; touch-action: none; background-color: #fff; display:block; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; pointer-events: none;">
<g id="canvas" style="transform: matrix(1, 0, 0, 1, 0, 0);"></g>
</svg>
</div>

View File

@@ -0,0 +1,2 @@
export const delSvg = '<svg data-cmd="del" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M17 6h5v2h-2v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8H2V6h5V3a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v3zm1 2H6v12h12V8zm-9 3h2v6H9v-6zm4 0h2v6h-2v-6zM9 4v2h6V4H9z" fill="rgb(52,71,103)"/></svg>';
export const copySvg = '<svg data-cmd="copy" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M7 6V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-3v3c0 .552-.45 1-1.007 1H4.007A1.001 1.001 0 0 1 3 21l.003-14c0-.552.45-1 1.007-1H7zM5.003 8L5 20h10V8H5.003zM9 6h8v10h2V4H9v2z" fill="rgb(52,71,103)"/></svg>';

View File

@@ -0,0 +1,15 @@
export const CanvasSmbl = Symbol('Canvas');
/** @typedef { {x:number, y:number} } Point */
/** @typedef {{position:Point, scale:number, cell: number}} CanvasData */
/** @typedef {SVGGElement & { [CanvasSmbl]?: Canvas }} CanvasElement */
/**
@typedef {{
move?(x:number, y:number, scale:number): void
data: CanvasData
// TODO: it is not infrastructure methods -> shouldn't be here
selectClear?(): void
shapeMap: Record<number, import("../shapes/shape-type-map").ShapeType>
}} Canvas
*/

View File

@@ -0,0 +1,35 @@
/** @param {Element} elem */
export function evtRouteApplay(elem) {
elem.addEventListener('pointerdown', /** @param {RouteEvent} evt */ evt => {
if (!evt.isPrimary || evt[RouteedSmbl] || !evt.isTrusted) { return; }
evt.stopImmediatePropagation();
const newEvt = new PointerEvent('pointerdown', evt);
newEvt[RouteedSmbl] = true;
activeElemFromPoint(evt).dispatchEvent(newEvt);
}, { capture: true, passive: true });
}
/** @param { {clientX:number, clientY:number} } evt */
function activeElemFromPoint(evt) {
return elemFromPointByPrioity(evt).find(el => !el.hasAttribute('data-evt-no'));
}
/** @param { {clientX:number, clientY:number} } evt */
export function priorityElemFromPoint(evt) {
return elemFromPointByPrioity(evt)[0];
}
/** @param { {clientX:number, clientY:number} } evt */
function elemFromPointByPrioity(evt) {
return document.elementsFromPoint(evt.clientX, evt.clientY)
.sort((a, b) => {
const ai = a.getAttribute('data-evt-index');
const bi = b.getAttribute('data-evt-index');
return (ai === bi) ? 0 : ai > bi ? -1 : 1;
});
}
const RouteedSmbl = Symbol('routeed');
/** @typedef {PointerEvent & { [RouteedSmbl]?: boolean }} RouteEvent */

View File

@@ -0,0 +1,60 @@
/**
* save file to user
* @param {Blob} blob
* @param {string} name
*/
export function fileSave(blob, name) { ('showSaveFilePicker' in window) ? fileSaveAs(blob) : fileDownload(blob, name); }
/**
* save file with "File save as" dialog
* @param {Blob} blob
*/
async function fileSaveAs(blob) {
try {
// @ts-ignore
const writable = await (await window.showSaveFilePicker({
types: [
{
description: 'PNG Image',
accept: {
'image/png': ['.png']
}
}
]
})).createWritable();
await writable.write(blob);
await writable.close();
} catch {
alert('File not saved');
}
}
/**
* save file with default download process
* @param {Blob} blob
* @param {string} name
*/
function fileDownload(blob, name) {
const link = document.createElement('a');
link.download = name;
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(link.href);
link.remove();
}
/**
* @param {string} accept
* @param {BlobCallback} callBack
*/
export function fileOpen(accept, callBack) {
const input = document.createElement('input');
input.type = 'file';
input.multiple = false;
input.accept = accept;
input.onchange = async function() {
callBack((!input.files?.length) ? null : input.files[0]);
};
input.click();
input.remove();
}

View File

@@ -0,0 +1,50 @@
import { listenDel } from './util.js';
/** @param {Element} elem */
export function moveEvtMobileFix(elem) {
/** @type {Point} */ let pointDown;
/** @type {number} */ let prevX;
/** @type {number} */ let prevY;
/** @param {PointerEventFixMovement} evt */
function move(evt) {
if (!evt.isPrimary || !evt.isTrusted) { return; }
// fix old Android
if (pointDown &&
Math.abs(pointDown.x - evt.clientX) < 3 &&
Math.abs(pointDown.y - evt.clientY) < 3) {
evt.stopImmediatePropagation();
return;
}
pointDown = null;
// fix iOS
if (evt.movementX === undefined) {
evt[MovementXSmbl] = (prevX ? evt.clientX - prevX : 0);
evt[MovementYSmbl] = (prevY ? evt.clientY - prevY : 0);
prevX = evt.clientX;
prevY = evt.clientY;
} else {
evt[MovementXSmbl] = evt.movementX;
evt[MovementYSmbl] = evt.movementY;
}
}
elem.addEventListener('pointerdown', /** @param {PointerEvent} evt */ evt => {
pointDown = { x: evt.clientX, y: evt.clientY };
prevX = null;
prevY = null;
elem.addEventListener('pointermove', move, { capture: true, passive: true });
elem.addEventListener('pointerup', _ => {
listenDel(elem, 'pointermove', move, true);
}, { capture: true, once: true, passive: true });
}, { capture: true, passive: true });
}
export const MovementXSmbl = Symbol('movementX');
export const MovementYSmbl = Symbol('movementY');
/** @typedef {PointerEvent & { [MovementXSmbl]: number, [MovementYSmbl]: number }} PointerEventFixMovement */
/** @typedef { {x:number, y:number} } Point */

View File

@@ -0,0 +1,117 @@
import { MovementXSmbl, MovementYSmbl } from './move-evt-mobile-fix.js';
import { listenDel, listen } from './util.js';
/**
* @param { Element } elemTrackOutdown poitdows in this element will be tracking to fire {onOutdown} callback
* @param { Element } elem
* @param { {scale:number} } canvasScale
* @param { Point } shapePosition
* @param { {(evt:PointerEvent):void} } onMoveStart
* @param { {(evt:PointerEvent):void} } onMove
* @param { {(evt:PointerEvent):void} } onMoveEnd
* @param { {(evt:PointerEvent):void} } onClick
* @param { {():void} } onOutdown
*/
export function moveEvtProc(elemTrackOutdown, elem, canvasScale, shapePosition, onMoveStart, onMove, onMoveEnd, onClick, onOutdown) {
let isMoved = false;
let isInit = false;
/** @type {Element} */ let target;
/** @param {PointerEventFixMovement} evt */
function move(evt) {
if (!isInit) { return; }
if (!isMoved) {
onMoveStart(evt);
// if reset
if (!isInit) { return; }
}
movementApplay(shapePosition, canvasScale.scale, evt);
isMoved = true;
onMove(evt);
}
/** @param {PointerEvent} evt */
function cancel(evt) {
if (isMoved) {
onMoveEnd(evt);
} else {
onClick(evt);
}
reset(true);
}
/** @param {PointerEvent & { target:Node}} docEvt */
function docDown(docEvt) {
if (!elem.contains(docEvt.target)) {
reset();
onOutdown();
}
}
function wheel() {
reset();
onOutdown();
}
/**
* @param {ProcEvent} evt
*/
function init(evt) {
if (evt[ProcessedSmbl] || !evt.isPrimary) {
return;
}
evt[ProcessedSmbl] = true;
target = /** @type {Element} */(evt.target);
if (evt.pointerId !== fakePointerId) { target.setPointerCapture(evt.pointerId); }
listen(target, 'pointercancel', cancel, true);
listen(target, 'pointerup', cancel, true);
listen(target, 'pointermove', move);
listen(elemTrackOutdown, 'wheel', wheel, true);
listen(elemTrackOutdown, 'pointerdown', docDown);
isInit = true;
}
listen(elem, 'pointerdown', init);
/** @param {boolean=} saveOutTrack */
function reset(saveOutTrack) {
listenDel(target, 'pointercancel', cancel);
listenDel(target, 'pointerup', cancel);
listenDel(target, 'pointermove', move);
if (!saveOutTrack) {
listenDel(elemTrackOutdown, 'pointerdown', docDown);
listenDel(elemTrackOutdown, 'wheel', wheel);
}
target = null;
isMoved = false;
isInit = false;
}
return reset;
}
/** @param {Point} point, @param {number} scale, @param {PointerEventFixMovement} evt */
export function movementApplay(point, scale, evt) {
point.x += evt[MovementXSmbl] / scale;
point.y += evt[MovementYSmbl] / scale;
}
const fakePointerId = 42; // random number
/** @param {SVGGraphicsElement} shapeOrPathEl */
export function shapeSelect(shapeOrPathEl) {
shapeOrPathEl.ownerSVGElement.focus();
shapeOrPathEl.dispatchEvent(new PointerEvent('pointerdown', { isPrimary: true, pointerId: fakePointerId }));
shapeOrPathEl.dispatchEvent(new PointerEvent('pointerup', { isPrimary: true }));
}
export const ProcessedSmbl = Symbol('processed');
/** @typedef {PointerEvent & { [ProcessedSmbl]?: boolean }} ProcEvent */
/** @typedef {import('./move-evt-mobile-fix.js').PointerEventFixMovement} PointerEventFixMovement */
/** @typedef { {x:number, y:number} } Point */

View File

@@ -0,0 +1,223 @@
import { CanvasSmbl } from './canvas-smbl.js';
import { ProcessedSmbl } from './move-evt-proc.js';
import { listen, listenDel } from './util.js';
/**
* Get point in canvas given the scale and position of the canvas
* @param {{position:{x:number, y:number}, scale:number}} canvasData
* @param {number} x, @param {number} y
*/
export const pointInCanvas = (canvasData, x, y) => ({
x: (x - canvasData.position.x) / canvasData.scale,
y: (y - canvasData.position.y) / canvasData.scale
});
/**
* @param {Point} point
* @param {number} cell
*/
export function placeToCell(point, cell) {
const cellSizeHalf = cell / 2;
function placeToCell(coordinate) {
const coor = (Math.round(coordinate / cell) * cell);
return (coordinate - coor > 0) ? coor + cellSizeHalf : coor - cellSizeHalf;
}
point.x = placeToCell(point.x);
point.y = placeToCell(point.y);
}
/** @param { CanvasElement } canvas */
export function moveScaleApplay(canvas) {
const canvasData = canvas[CanvasSmbl].data;
const gripUpdate = applayGrid(canvas.ownerSVGElement, canvasData);
function transform() {
canvas.style.transform = `matrix(${canvasData.scale}, 0, 0, ${canvasData.scale}, ${canvasData.position.x}, ${canvasData.position.y})`;
gripUpdate();
}
/**
* @param {number} nextScale
* @param {Point} originPoint
*/
function scale(nextScale, originPoint) {
if (nextScale < 0.25 || nextScale > 4) { return; }
const divis = nextScale / canvasData.scale;
canvasData.scale = nextScale;
canvasData.position.x = divis * (canvasData.position.x - originPoint.x) + originPoint.x;
canvasData.position.y = divis * (canvasData.position.y - originPoint.y) + originPoint.y;
transform();
}
// move, scale with fingers
applayFingers(canvas.ownerSVGElement, canvasData, scale, transform);
// scale with mouse wheel
canvas.ownerSVGElement.addEventListener('wheel', /** @param {WheelEvent} evt */ evt => {
evt.preventDefault();
const delta = evt.deltaY || evt.deltaX;
const scaleStep = Math.abs(delta) < 50
? 0.05 // trackpad pitch
: 0.25; // mouse wheel
scale(
canvasData.scale + (delta < 0 ? scaleStep : -scaleStep),
evtPoint(evt));
});
canvas[CanvasSmbl].move = function (x, y, scale) {
canvasData.position.x = x;
canvasData.position.y = y;
canvasData.scale = scale;
transform();
};
}
/**
* @param { SVGSVGElement } svg
* @param { {position:Point, scale:number} } canvasData
* @param { {(nextScale:number, originPoint:Point):void} } scaleFn
* @param { {():void} } transformFn
* @return
*/
function applayFingers(svg, canvasData, scaleFn, transformFn) {
/** @type { Pointer } */
let firstPointer;
/** @type { Pointer} */
let secondPointer;
/** @type {number} */
let distance;
/** @type {Point} */
let center;
/** @param {PointerEvent} evt */
function cancel(evt) {
distance = null;
center = null;
if (firstPointer?.id === evt.pointerId) { firstPointer = null; }
if (secondPointer?.id === evt.pointerId) { secondPointer = null; }
if (!firstPointer && !secondPointer) {
listenDel(svg, 'pointermove', move);
listenDel(svg, 'pointercancel', cancel);
listenDel(svg, 'pointerup', cancel);
}
};
/** @param {PointerEvent} evt */
function move(evt) {
if (evt[ProcessedSmbl]) { return; }
if ((firstPointer && !secondPointer) || (!firstPointer && secondPointer)) {
// move with one pointer
canvasData.position.x = evt.clientX + (firstPointer || secondPointer).shift.x;
canvasData.position.y = evt.clientY + (firstPointer || secondPointer).shift.y;
transformFn();
return;
}
if (!secondPointer || !firstPointer || (secondPointer?.id !== evt.pointerId && firstPointer?.id !== evt.pointerId)) { return; }
const distanceNew = Math.hypot(firstPointer.pos.x - secondPointer.pos.x, firstPointer.pos.y - secondPointer.pos.y);
const centerNew = {
x: (firstPointer.pos.x + secondPointer.pos.x) / 2,
y: (firstPointer.pos.y + secondPointer.pos.y) / 2
};
// not first move
if (distance) {
canvasData.position.x = canvasData.position.x + centerNew.x - center.x;
canvasData.position.y = canvasData.position.y + centerNew.y - center.y;
scaleFn(
canvasData.scale / distance * distanceNew,
centerNew);
}
distance = distanceNew;
center = centerNew;
if (firstPointer.id === evt.pointerId) { firstPointer = evtPointer(evt, canvasData); }
if (secondPointer.id === evt.pointerId) { secondPointer = evtPointer(evt, canvasData); }
}
listen(svg, 'pointerdown', /** @param {PointerEvent} evt */ evt => {
if (evt[ProcessedSmbl] || (!firstPointer && !evt.isPrimary) || (firstPointer && secondPointer)) {
return;
}
svg.setPointerCapture(evt.pointerId);
if (!firstPointer) {
listen(svg, 'pointermove', move);
listen(svg, 'pointercancel', cancel);
listen(svg, 'pointerup', cancel);
}
if (!firstPointer) { firstPointer = evtPointer(evt, canvasData); return; }
if (!secondPointer) { secondPointer = evtPointer(evt, canvasData); }
});
}
/**
* @param { SVGSVGElement } svg
* @param { import('./canvas-smbl.js').CanvasData } canvasData
*/
function applayGrid(svg, canvasData) {
let curOpacity;
/** @param {number} opacity */
function backImg(opacity) {
if (curOpacity !== opacity) {
curOpacity = opacity;
svg.style.backgroundImage = `radial-gradient(rgb(73 80 87 / ${opacity}) 1px, transparent 0)`;
}
}
backImg(0.7);
svg.style.backgroundSize = `${canvasData.cell}px ${canvasData.cell}px`;
return function() {
const size = canvasData.cell * canvasData.scale;
if (canvasData.scale < 0.5) { backImg(0); } else
if (canvasData.scale <= 0.9) { backImg(0.3); } else { backImg(0.7); }
svg.style.backgroundSize = `${size}px ${size}px`;
svg.style.backgroundPosition = `${canvasData.position.x}px ${canvasData.position.y}px`;
};
}
/**
* @param {PointerEvent | MouseEvent} evt
* @return {Point}
*/
function evtPoint(evt) { return { x: evt.clientX, y: evt.clientY }; }
/**
* @param { PointerEvent } evt
* @param { {position:Point, scale:number} } canvasData
* @return { Pointer }
*/
function evtPointer(evt, canvasData) {
return {
id: evt.pointerId,
pos: evtPoint(evt),
shift: {
x: canvasData.position.x - evt.clientX,
y: canvasData.position.y - evt.clientY
}
};
}
/** @typedef { {x:number, y:number} } Point */
/** @typedef { {id:number, pos:Point, shift:Point} } Pointer */
/** @typedef { import("./move-evt-proc").ProcEvent } DgrmEvent */
/** @typedef { import('./canvas-smbl.js').CanvasData } CanvasData */
/** @typedef { import('./canvas-smbl.js').CanvasElement } CanvasElement */

View File

@@ -0,0 +1,93 @@
/**
* @param {Blob} png
* @param {string} chunkName 4 symbol string
* @returns {Promise<DataView | null>} chunk data
*/
export async function pngChunkGet(png, chunkName) {
return chunkGet(
await png.arrayBuffer(),
toUit32(chunkName));
}
/**
* @param {Blob} png
* @param {string} chunkName 4 symbol string
* @param {Uint8Array} data
* @returns {Promise<Blob>} new png
*/
export async function pngChunkSet(png, chunkName, data) {
return chunkSet(
await png.arrayBuffer(),
toUit32(chunkName),
data
);
}
/**
* @param {ArrayBuffer} pngData
* @param {number} chunkNameUint32 chunk name as Uint32
* @param {Uint8Array} data
* @returns {Blob} new png
*/
function chunkSet(pngData, chunkNameUint32, data) {
/** @type {DataView} */
let startPart;
/** @type {DataView} */
let endPart;
const existingChunk = chunkGet(pngData, chunkNameUint32);
if (existingChunk) {
startPart = new DataView(pngData, 0, existingChunk.byteOffset - 8);
endPart = new DataView(pngData, existingChunk.byteOffset + existingChunk.byteLength + 4);
} else {
const endChunkStart = pngData.byteLength - 12; // 12 - end chunk length
startPart = new DataView(pngData, 0, endChunkStart);
endPart = new DataView(pngData, endChunkStart);
}
const chunkHeader = new DataView(new ArrayBuffer(8));
chunkHeader.setUint32(0, data.length);
chunkHeader.setUint32(4, chunkNameUint32);
return new Blob([
startPart,
// new chunk
chunkHeader,
data,
new Uint32Array([0]), // CRC fake - not calculated
endPart
],
{ type: 'image/png' });
}
/**
* @param {ArrayBuffer} pngData
* @param {number} chunkNameUint32 chunk name as Uint32
* @returns {DataView | null} chunk data
*/
function chunkGet(pngData, chunkNameUint32) {
const dataView = new DataView(pngData, 8); // 8 byte - png signature
let chunkPosition = 0;
let chunkUint = dataView.getUint32(4);
let chunkLenght;
while (chunkUint !== 1229278788) { // last chunk 'IEND'
chunkLenght = dataView.getUint32(chunkPosition);
if (chunkUint === chunkNameUint32) {
return new DataView(pngData, chunkPosition + 16, chunkLenght);
}
chunkPosition = chunkPosition + 12 + chunkLenght;
chunkUint = dataView.getUint32(chunkPosition + 4);
}
return null;
}
/**
* @param {string} chunkName 4 symbol string
* @return {number} uit32
*/
function toUit32(chunkName) {
return new DataView((new TextEncoder()).encode(chunkName).buffer).getUint32(0);
}

View File

@@ -0,0 +1,70 @@
import { svgTextDraw } from './svg-text-draw.js';
import { svgEl } from './util.js';
/**
* Create teaxtArea above SVGTextElement 'textEl'
* update 'textEl' with text from teaxtArea
* resize teaxtArea - so teaxtArea always cover all 'textEl'
* @param {SVGTextElement} textEl
* @param {number} verticalMiddle em
* @param {string} val
* @param {{(val:string):void}} onchange
* @param {{(val:string):void}} onblur
*/
export function textareaCreate(textEl, verticalMiddle, val, onchange, onblur) {
let foreign = svgEl('foreignObject');
const textarea = document.createElement('textarea');
const draw = () => foreignWidthSet(textEl, foreign, textarea, textareaPaddingAndBorder, textareaStyle.textAlign);
textarea.value = val || '';
textarea.oninput = function() {
svgTextDraw(textEl, verticalMiddle, textarea.value);
onchange(textarea.value);
draw();
};
textarea.onblur = function() {
onblur(textarea.value);
};
textarea.onpointerdown = function(evt) {
evt.stopImmediatePropagation();
};
foreign.appendChild(textarea);
textEl.parentElement.appendChild(foreign);
const textareaStyle = getComputedStyle(textarea);
// must be in px
const textareaPaddingAndBorder = parseInt(textareaStyle.paddingLeft) + parseInt(textareaStyle.borderWidth);
draw();
textarea.focus();
return {
dispose: () => { foreign.remove(); foreign = null; },
draw
};
}
/**
* @param {SVGTextElement} textEl
* @param {SVGForeignObjectElement} foreign
* @param {HTMLTextAreaElement} textarea
* @param {number} textareaPaddingAndBorder
* @param {string} textAlign
*/
function foreignWidthSet(textEl, foreign, textarea, textareaPaddingAndBorder, textAlign) {
const textBbox = textEl.getBBox();
const width = textBbox.width + 20; // +20 paddings for iPhone
foreign.width.baseVal.value = width + 2 * textareaPaddingAndBorder + 2; // +2 magic number for FireFox
foreign.x.baseVal.value = textBbox.x - textareaPaddingAndBorder - (
textAlign === 'center'
? 10
: textAlign === 'right' ? 20 : 0);
foreign.height.baseVal.value = textBbox.height + 2 * textareaPaddingAndBorder + 3; // +3 magic number for FireFox
foreign.y.baseVal.value = textBbox.y - textareaPaddingAndBorder;
textarea.style.width = `${width}px`;
textarea.style.height = `${textBbox.height}px`;
}

View File

@@ -0,0 +1,43 @@
/**
* @param {SVGTextElement} textEl target text element
* @param {number} verticalMiddle
* @param {string} str
* @returns {void}
*/
export function svgTextDraw(textEl, verticalMiddle, str) {
const strData = svgStrToTspan(
(str || ''),
textEl.x?.baseVal[0]?.value ?? 0);
textEl.innerHTML = strData.s;
textEl.y.baseVal[0].newValueSpecifiedUnits(
textEl.y.baseVal[0].SVG_LENGTHTYPE_EMS, // em
strData.c > 0 ? verticalMiddle - (strData.c) / 2 : verticalMiddle);
}
/**
* create multiline tspan markup
* @param {string} str
* @param {number} x
* @returns { {s:string, c:number} }
*/
function svgStrToTspan(str, x) {
let c = 0;
return {
s: str.split('\n')
.map((t, i) => {
c = i;
return `<tspan x="${x}" dy="${i === 0 ? 0.41 : 1}em" ${t.length === 0 ? 'visibility="hidden"' : ''}>${t.length === 0 ? '.' : escapeHtml(t).replaceAll(' ', '&nbsp;')}</tspan>`;
}).join(''),
c
};
}
/**
* @param {string} str
* @returns {string}
*/
function escapeHtml(str) {
return str.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}

View File

@@ -0,0 +1,93 @@
// src/infrastructure/svg-to-png.js
/**
* @param {SVGElement} svg - виртуальный SVG (готовый для рендеринга)
* @param {{x:number,y:number,width:number,height:number}} rect - область в единицах SVG user units
* @param {number} scale - множитель (вызов передаёт, например, 3)
* @param {(blob:Blob|null)=>void} callBack
*/
export function svgToPng(svg, rect, scale, callBack) {
if (!svg || !rect || rect.width <= 0 || rect.height <= 0) {
callBack(null);
return;
}
// output размеры с учётом devicePixelRatio
const dpr = window.devicePixelRatio || 1;
const outputWidth = Math.round(rect.width * scale * dpr);
const outputHeight = Math.round(rect.height * scale * dpr);
// Сериализуем svg в строку и делаем blob/url
let svgString;
try {
svgString = new XMLSerializer().serializeToString(svg);
} catch (e) {
callBack(null);
return;
}
let url;
try {
url = URL.createObjectURL(new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }));
} catch (e) {
callBack(null);
return;
}
const img = new Image();
// crossOrigin можно добавить, если нужно: img.crossOrigin = 'anonymous';
img.onload = function () {
try {
const canvas = document.createElement('canvas');
canvas.width = outputWidth;
canvas.height = outputHeight;
canvas.style.width = `${outputWidth}px`;
canvas.style.height = `${outputHeight}px`;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
callBack(null);
return;
}
ctx.imageSmoothingEnabled = false;
// Если rect.x/rect.y отрицательные => смещение исходного изображения внутри canvas
const sx = Math.max(0, rect.x * scale * dpr);
const sy = Math.max(0, rect.y * scale * dpr);
const sWidth = rect.width * scale * dpr;
const sHeight = rect.height * scale * dpr;
// dx/dy: смещение на canvas (если rect.x < 0, то мы сдвигаем вправо)
const dx = rect.x < 0 ? -rect.x * scale * dpr : 0;
const dy = rect.y < 0 ? -rect.y * scale * dpr : 0;
// drawImage с указанием исходной области и целевой области
ctx.drawImage(
img,
sx, // sx
sy, // sy
sWidth, // sWidth
sHeight, // sHeight
dx, // dx (на canvas)
dy, // dy
outputWidth, // dWidth
outputHeight // dHeight
);
URL.revokeObjectURL(url);
canvas.toBlob(blob => {
callBack(blob);
}, 'image/png');
} catch (err) {
URL.revokeObjectURL(url);
callBack(null);
}
};
img.onerror = function () {
try { URL.revokeObjectURL(url); } catch (e) {}
callBack(null);
};
img.src = url;
}

View File

@@ -0,0 +1,146 @@
//
// dom utils
/**
* @template T
* @param {Element} parent
* @param {string} key
* @returns T
*/
export const child = (parent, key) => /** @type {T} */(parent.querySelector(`[data-key="${key}"]`));
/** @param {HTMLElement|SVGElement} crcl, @param {Point} pos */
export function positionSet(crcl, pos) { crcl.style.transform = `translate(${pos.x}px, ${pos.y}px)`; }
/** @param {Element} el, @param {string[]} cl */
export const classAdd = (el, ...cl) => el?.classList.add(...cl);
/** @param {Element} el, @param {string} cl */
export const classDel = (el, cl) => el?.classList.remove(cl);
/** @param {Element} el, @param {string} cl */
export const classHas = (el, cl) => el?.classList.contains(cl);
/** @param {Element} shapeEl, @param {{styles?:string[]}} shapeData, @param {string} classPrefix, @param {string} classToAdd */
export function classSingleAdd(shapeEl, shapeData, classPrefix, classToAdd) {
if (!shapeData.styles) { shapeData.styles = []; }
const currentClass = shapeData.styles.findIndex(ss => ss.startsWith(classPrefix));
if (currentClass > -1) {
classDel(shapeEl, shapeData.styles[currentClass]);
shapeData.styles.splice(currentClass, 1);
}
shapeData.styles.push(classToAdd);
classAdd(shapeEl, classToAdd);
}
/** @param {Element | GlobalEventHandlers} el, @param {string} type, @param {EventListenerOrEventListenerObject} listener, @param {boolean?=} once */
export const listen = (el, type, listener, once) => {
if (el) el.addEventListener(type, listener, { passive: true, once });
};
/** @param {Element | GlobalEventHandlers} el, @param {string} type, @param {EventListenerOrEventListenerObject} listener, @param {boolean?=} capture */
export const listenDel = (el, type, listener, capture) => el?.removeEventListener(type, listener, { capture });
/** @param {ParentNode} el, @param {string} selector, @param {(this: GlobalEventHandlers, ev: PointerEvent & { currentTarget: Element }) => any} handler */
export function clickForAll(el, selector, handler) { el.querySelectorAll(selector).forEach(/** @param {HTMLElement} el */ el => { el.onclick = handler; }); }
/** @param {PointerEvent & { currentTarget: Element }} evt, @param {string} attr */
export const evtTargetAttr = (evt, attr) => evt.currentTarget.getAttribute(attr);
/**
* @template {keyof SVGElementTagNameMap} T
* @param {T} qualifiedName
* @param {string?=} innerHTML
* @returns {SVGElementTagNameMap[T]}
*/
export function svgEl(qualifiedName, innerHTML) {
const svgGrp = document.createElementNS('http://www.w3.org/2000/svg', qualifiedName);
if (innerHTML) { svgGrp.innerHTML = innerHTML; }
return svgGrp;
}
/**
* calc farthest point of <tspan>s bbox in {textEl}
* origin is in the center
* @param {SVGTextElement} textEl
*/
export function svgTxtFarthestPoint(textEl) {
/** @type {Point} */
let maxPoint;
let maxAbsSum = 0;
for (const span of textEl.getElementsByTagName('tspan')) {
for (const point of boxPoints(span.getBBox())) {
const pointAbsSum = Math.abs(point.x) + Math.abs(point.y);
if (maxAbsSum < pointAbsSum) {
maxPoint = point;
maxAbsSum = pointAbsSum;
}
}
}
return maxPoint;
}
/** @param {DOMRect} box */
const boxPoints = (box) => [
{ x: box.x, y: box.y },
{ x: box.right, y: box.y },
{ x: box.x, y: box.bottom },
{ x: box.right, y: box.bottom }
];
//
// math, arr utils
/**
* Get the ceiling for a number {val} with a given floor height {step}
* @param {number} min
* @param {number} step
* @param {number} val
*/
export function ceil(min, step, val) {
if (val <= min) { return min; }
return min + Math.ceil((val - min) / step) * step;
}
/**
* @template T
* @param {Array<T>} arr
* @param {{(el:T):void}} action
*/
export function arrPop(arr, action) {
let itm = arr.pop();
while (itm) { action(itm); itm = arr.pop(); };
}
/**
* @template T
* @param {Array<T>} arr
* @param {T} el
*/
export function arrDel(arr, el) {
const index = arr.indexOf(el);
if (index > -1) {
arr.splice(index, 1);
}
}
/** @param {Point} point, @param {Point} shift, @param {number=} coef */
export function pointShift(point, shift, coef) {
const _coef = coef ?? 1;
point.x += _coef * shift.x;
point.y += _coef * shift.y;
return point;
}
//
// object utils
/**
* @template T
* @param {T} obj
* @returns {T}
*/
export const deepCopy = obj => JSON.parse(JSON.stringify(obj));
/** @typedef { {x:number, y:number} } Point */

25
main_plugin/dgrm/plug.php Executable file
View File

@@ -0,0 +1,25 @@
<?php
/**
* @file plug.php
* @brief Подключает плагин dgrm для администраторов и выводит соответствующий HTML и JS
*/
global $path, $_SESSION, $configAdmins;
if (in_array($_SESSION['username'], $configAdmins, true)) {
echo file_get_contents($path . 'main_plugin/dgrm/index.php');
echo "<script type='module'>
document.addEventListener('DOMContentLoaded', () => {
const c = document.querySelector('.center-float');
const d = document.getElementById('dgrmDiv');
if (c && d) {
c.appendChild(document.createElement('br'));
c.appendChild(d);
import('/main_plugin/dgrm/index.js');
} else if (d) {
d.remove();
}
});
</script>";
echo '<link rel="stylesheet" type="text/css" href="/main_plugin/dgrm/dgrm.css">';
}
?>

View File

@@ -0,0 +1,69 @@
import { ceil, child, positionSet, svgTxtFarthestPoint } from '../infrastructure/util.js';
import { shapeCreate } from './shape-evt-proc.js';
/**
* @param {CanvasElement} canvas
* @param {CircleData} circleData
*/
export function circle(canvas, circleData) {
const templ = `
<circle data-key="outer" data-evt-no data-evt-index="2" r="72" fill="transparent" stroke-width="0" />
<circle data-key="main" r="48" fill="#ff6600" stroke="#fff" stroke-width="1" />
<text data-key="text" x="0" y="0" text-anchor="middle" style="pointer-events: none;" fill="#fff">&nbsp;</text>`;
const shape = shapeCreate(canvas, circleData, templ,
{
right: { dir: 'right', position: { x: 48, y: 0 } },
left: { dir: 'left', position: { x: -48, y: 0 } },
bottom: { dir: 'bottom', position: { x: 0, y: 48 } },
top: { dir: 'top', position: { x: 0, y: -48 } }
},
// onTextChange
txtEl => {
const newRadius = textElRadius(txtEl, 48, 24);
if (newRadius !== circleData.r) {
circleData.r = newRadius;
resize();
}
});
function resize() {
shape.cons.right.position.x = circleData.r;
shape.cons.left.position.x = -circleData.r;
shape.cons.bottom.position.y = circleData.r;
shape.cons.top.position.y = -circleData.r;
for (const connectorKey in shape.cons) {
positionSet(child(shape.el, connectorKey), shape.cons[connectorKey].position);
}
radiusSet(shape.el, 'outer', circleData.r + 24);
radiusSet(shape.el, 'main', circleData.r);
shape.draw();
}
if (!!circleData.r && circleData.r !== 48) { resize(); } else { shape.draw(); }
return shape.el;
}
/** @param {Element} svgGrp, @param {string} key, @param {number} r */
function radiusSet(svgGrp, key, r) { /** @type {SVGCircleElement} */(child(svgGrp, key)).r.baseVal.value = r; }
/**
* calc radius that cover all <tspan> in SVGTextElement
* origin is in the center of the circle
* @param {SVGTextElement} textEl
* @param {*} minR
* @param {*} step
*/
function textElRadius(textEl, minR, step) {
const farthestPoint = svgTxtFarthestPoint(textEl);
return ceil(minR, step, Math.sqrt(farthestPoint.x ** 2 + farthestPoint.y ** 2));
}
/** @typedef { {x:number, y:number} } Point */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */
/** @typedef { import('./shape-evt-proc').CanvasData } CanvasData */
/** @typedef { import('./shape-evt-proc').ConnectorsData } ConnectorsData */
/** @typedef { {type:number, position: Point, title?: string, styles?: string[], r?:number} } CircleData */

View File

@@ -0,0 +1,27 @@
/** @type {HTMLDivElement} */
let editModalDiv;
/** @param {number} bottomX, @param {number} bottomY, @param {HTMLElement} elem */
export function modalCreate(bottomX, bottomY, elem) {
editModalDiv = document.createElement('div');
editModalDiv.style.cssText = 'position: fixed; box-shadow: 0px 0px 58px 2px rgb(34 60 80 / 20%); border-radius: 16px; background-color: rgba(255,255,255, .9);';
editModalDiv.append(elem);
document.body.append(editModalDiv);
function position(btmX, btmY) {
editModalDiv.style.left = `${btmX}px`;
editModalDiv.style.top = `${btmY - 35}px`;
}
position(bottomX, bottomY);
return {
position,
del: () => { editModalDiv.remove(); editModalDiv = null; }
};
}
/** @param {number} dif */
export function modalChangeTop(dif) {
editModalDiv.style.top = `${editModalDiv.getBoundingClientRect().top - 90}px`;
}

View File

@@ -0,0 +1,75 @@
import { copyAndPast } from '../diagram/group-select-applay.js';
import { classAdd, classDel, clickForAll, listen, classSingleAdd, evtTargetAttr } from '../infrastructure/util.js';
import { PathSmbl } from './path-smbl.js';
export class PathSettings extends HTMLElement {
/**
* @param {CanvasElement} canvas
* @param {PathElement} pathElement
*/
constructor(canvas, pathElement) {
super();
/** @private */
this._pathElement = pathElement;
/** @private */
this._canvas = canvas;
}
connectedCallback() {
const pathStyles = this._pathElement[PathSmbl].data.styles;
const actStyle = style => this._pathElement[PathSmbl].data.styles?.includes(style) ? 'class="actv"' : '';
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
.ln { display: flex; }
.ln > * {
height: 24px;
padding: 10px;
fill-opacity: 0.3;
stroke-opacity: 0.3;
}
[data-cmd] { cursor: pointer; }
.actv {
fill-opacity: 1;
stroke-opacity: 1;
}
</style>
<ap-shape-edit id="edit" edit-btn="true">
<div class="ln">
<svg data-cmd data-cmd-arg="arw-s" ${actStyle('arw-s')} viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M7.828 11H20v2H7.828l5.364 5.364-1.414 1.414L4 12l7.778-7.778 1.414 1.414z" fill="rgb(52,71,103)"/></svg>
<svg data-cmd data-cmd-arg="arw-e" ${actStyle('arw-e')} viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M16.172 11l-5.364-5.364 1.414-1.414L20 12l-7.778 7.778-1.414-1.414L16.172 13H4v-2z" fill="rgb(52,71,103)"/></svg>
<svg data-cmd data-cmd-arg="dash" ${actStyle('dash')} viewBox="0 0 24 24" width="24" height="24"><path d="M 2,11 L 20,11" stroke="rgb(52,71,103)" style="stroke-dasharray: 4,3; stroke-width: 3;"></path></svg>
</div>
</ap-shape-edit>`;
// colors, del
listen(shadow.getElementById('edit'), 'cmd', /** @param {CustomEvent<{cmd:string, arg:string}>} evt */ evt => {
switch (evt.detail.cmd) {
case 'style': classSingleAdd(this._pathElement, this._pathElement[PathSmbl].data, 'cl-', evt.detail.arg); break;
case 'del': this._pathElement[PathSmbl].del(); break;
case 'copy': copyAndPast(this._canvas, [this._pathElement]); break;
}
});
// arrows, dotted
clickForAll(shadow, '[data-cmd]', evt => {
const argStyle = evtTargetAttr(evt, 'data-cmd-arg');
const currentArr = pathStyles.indexOf(argStyle);
if (currentArr > -1) {
classDel(this._pathElement, argStyle);
pathStyles.splice(currentArr, 1);
classDel(evt.currentTarget, 'actv');
} else {
classAdd(this._pathElement, argStyle);
pathStyles.push(argStyle);
classAdd(evt.currentTarget, 'actv');
}
});
}
}
customElements.define('ap-path-settings', PathSettings);
/** @typedef { import('./path-smbl').PathElement } PathElement */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */

View File

@@ -0,0 +1,2 @@
export const PathSmbl = Symbol('path');
/** @typedef {SVGGraphicsElement & { [PathSmbl]?: import("./path").Path }} PathElement */

402
main_plugin/dgrm/shapes/path.js Executable file
View File

@@ -0,0 +1,402 @@
import { child, classAdd, classDel, classHas, listen, listenDel, svgEl } from '../infrastructure/util.js';
import { moveEvtProc, movementApplay } from '../infrastructure/move-evt-proc.js';
import { placeToCell, pointInCanvas } from '../infrastructure/move-scale-applay.js';
import { priorityElemFromPoint } from '../infrastructure/evt-route-applay.js';
import { ShapeSmbl } from './shape-smbl.js';
import { PathSettings } from './path-settings.js';
import { PathSmbl } from './path-smbl.js';
import { CanvasSmbl } from '../infrastructure/canvas-smbl.js';
import { modalCreate } from './modal-create.js';
import { canvasSelectionClearSet } from '../diagram/canvas-clear.js';
import { listenCopy } from '../diagram/group-select-applay.js';
/**
* @param {CanvasElement} canvas
* @param {PathData} pathData
*/
export function path(canvas, pathData) {
/** @type {PathElement} */
const svgGrp = svgEl('g', `
<path data-key="outer" d="M0 0" stroke="transparent" stroke-width="20" fill="none" />
<path data-key="path" class="path" d="M0 0" stroke="#495057" stroke-width="1.8" fill="none" style="pointer-events: none;" />
<path data-key="selected" d="M0 0" stroke="transparent" stroke-width="10" fill="none" style="pointer-events: none;" />
<g data-key="start">
<circle data-evt-index="1" class="path-end" r="10" stroke-width="0" fill="transparent" />
<path class="path" d="M-7 7 l 7 -7 l -7 -7" stroke="#495057" stroke-width="1.8" fill="none" style="pointer-events: none;"></path>
</g>
<g data-key="end">
<circle data-evt-index="1" class="path-end" r="10" stroke-width="0" fill="transparent" />
<path class="path" d="M-7 7 l 7 -7 l -7 -7" stroke="#495057" stroke-width="1.8" fill="none" style="pointer-events: none;"></path>
</g>`);
classAdd(svgGrp, 'shpath');
pathData.s.el = child(svgGrp, 'start');
pathData.e.el = child(svgGrp, 'end');
pathData.styles = pathData.styles ?? ['arw-e'];
const paths = childs(svgGrp, 'path', 'outer', 'selected');
function draw() {
const endDir = dirByAngle(pathData.s.data.position, pathData.e.data.position);
pathData.e.data.dir = endDir;
pathData.s.data.dir = dirReverse(endDir);
const dAttr = pathCalc(pathData);
paths.forEach(pp => pp.setAttribute('d', dAttr));
endDraw(pathData.s);
endDraw(pathData.e);
}
/** @param {PathEnd} pathEnd */
function pathDelFromShape(pathEnd) { shapeObj(pathEnd.shape)?.pathDel(svgGrp); }
/** @param {PathEnd} pathEnd */
function pathAddToShape(pathEnd) {
if (pathEnd.shape) {
pathEnd.data = shapeObj(pathEnd.shape).pathAdd(pathEnd.shape.connectorKey, svgGrp);
}
};
/** @type { {position:(bottomX:number, bottomY:number)=>void, del:()=>void} } */
let settingsPnl;
function del() {
unSelect();
reset();
pathDelFromShape(pathData.s);
pathDelFromShape(pathData.e);
svgGrp.remove();
}
/**
* @type {0|1|2}
* 0 - init, 1 - selected, 2 - edit
*/
let state = 0;
/** @type {()=>void} */
let listenCopyDispose;
/** @param {PointerEvent} evt */
function select(evt) {
// in edit mode
if (state === 2) { return; }
// to edit mode
if (state === 1) {
state = 2;
settingsPnl = modalCreate(evt.clientX - 10, evt.clientY - 10, new PathSettings(canvas, svgGrp));
return;
}
// to select mode
state = 1;
classAdd(svgGrp, 'select');
endSetEvtIndex(pathData.s, 2);
endSetEvtIndex(pathData.e, 2);
canvasSelectionClearSet(canvas, unSelect);
listenCopyDispose = listenCopy(() => [svgGrp]);
};
/** @type { {():void} } */
let hoverEmulateDispose;
function unSelect() {
state = 0;
classDel(svgGrp, 'select');
endSetEvtIndex(pathData.s, 1);
endSetEvtIndex(pathData.e, 1);
settingsPnl?.del(); settingsPnl = null;
if (hoverEmulateDispose) {
hoverEmulateDispose();
hoverEmulateDispose = null;
svgGrp.style.pointerEvents = 'unset';
}
canvasSelectionClearSet(canvas, null);
if (listenCopyDispose) { listenCopyDispose(); listenCopyDispose = null; }
};
/** @type {'s'|'e'} */
let movedEnd;
const reset = moveEvtProc(
canvas.ownerSVGElement,
svgGrp,
canvas[CanvasSmbl].data,
// data.end.position,
{
get x() { return pathData[movedEnd]?.data.position.x; },
set x(val) { if (movedEnd) { pathData[movedEnd].data.position.x = val; } },
get y() { return pathData[movedEnd]?.data.position.y; },
set y(val) { if (movedEnd) { pathData[movedEnd].data.position.y = val; } }
},
// onMoveStart
/** @param {PointerEvent & { target: Element} } evt */ evt => {
unSelect();
movedEnd = pathData.e.el.contains(evt.target) ? 'e' : pathData.s.el.contains(evt.target) ? 's' : null;
//
// move whole path
if (!movedEnd) {
return;
}
//
// move path end
// disconnect from shape
if (pathData[movedEnd].shape) {
if (pathData[movedEnd].shape.shapeEl !== pathData[movedEnd === 's' ? 'e' : 's'].shape?.shapeEl) {
pathDelFromShape(pathData[movedEnd]);
}
pathData[movedEnd].shape = null;
pathData[movedEnd].data = {
dir: pathData[movedEnd].data.dir,
position: pointInCanvas(canvas[CanvasSmbl].data, evt.clientX, evt.clientY)
};
}
// hover emulation - start
svgGrp.style.pointerEvents = 'none';
hoverEmulateDispose = hoverEmulate(svgGrp.parentElement);
},
// onMove
/** @param {PointerEventFixMovement} evt */
evt => {
if (!movedEnd) {
moveWholePath(canvas[CanvasSmbl].data, pathData, draw, evt);
} else {
const diagram = document.getElementById('diagram');
const rect = diagram.getBoundingClientRect();
pathData[movedEnd].data.position = {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
};
draw();
}
},
// onMoveEnd
evt => {
if (!movedEnd) {
moveWholePathFinish(canvas[CanvasSmbl].data, pathData, draw);
} else {
// connect to shape
const elemFromPoint = priorityElemFromPoint(evt);
const connectorKey = elemFromPoint?.getAttribute('data-connect');
if (connectorKey) {
// @ts-ignore
pathData[movedEnd].shape = { shapeEl: elemFromPoint.parentElement, connectorKey };
pathAddToShape(pathData[movedEnd]);
} else {
placeToCell(pathData[movedEnd].data.position, canvas[CanvasSmbl].data.cell);
}
draw();
}
// hover emulation - end
unSelect();
},
// onClick
select,
// onOutdown
unSelect
);
svgGrp[PathSmbl] = {
draw,
/** @param {PointerEventInit} evt */
pointerCapture: evt => pathData.e.el.dispatchEvent(new PointerEvent('pointerdown', evt)),
del,
data: pathData
};
if (pathData.styles) { classAdd(svgGrp, ...pathData.styles); }
pathAddToShape(pathData.s);
pathAddToShape(pathData.e);
draw();
return svgGrp;
}
/**
* @param {{scale:number}} canvasData
* @param {PathData} pathData
* @param {{():void}} draw
* @param {PointerEventFixMovement} evt
*/
function moveWholePath(canvasData, pathData, draw, evt) {
/** @param {Point} point */
const move = point => movementApplay(point, canvasData.scale, evt);
moveShapeOrEnd(pathData.s, move);
moveShapeOrEnd(pathData.e, move);
// if any shape connected - shape will draw connected path
if (!pathData.s.shape && !pathData.e.shape) { draw(); }
}
/**
* @param {{cell:number}} canvasData
* @param {PathData} pathData
* @param {{():void}} draw
*/
function moveWholePathFinish(canvasData, pathData, draw) {
/** @param {Point} point */
const toCell = point => placeToCell(point, canvasData.cell);
moveShapeOrEnd(pathData.s, toCell);
moveShapeOrEnd(pathData.e, toCell);
if (!pathData.s.shape || !pathData.e.shape) { draw(); }
}
/**
* applay moveFn to connected shape or to path end point
* @param {PathEnd} pathEnd, @param {{(point:Point):void}} moveFn */
function moveShapeOrEnd(pathEnd, moveFn) {
if (pathEnd.shape) {
moveFn(shapeObj(pathEnd.shape).data.position);
shapeObj(pathEnd.shape).drawPosition();
} else {
moveFn(pathEnd.data.position);
}
}
/** @param {PathConnectedShape} pathConnectedShape */
const shapeObj = pathConnectedShape => pathConnectedShape?.shapeEl[ShapeSmbl];
/** @param {PathEnd} pathEnd */
function endDraw(pathEnd) {
pathEnd.el.style.transform = `translate(${pathEnd.data.position.x}px, ${pathEnd.data.position.y}px) rotate(${arrowAngle(pathEnd.data.dir)}deg)`;
}
/** @param {PathEnd} pathEnd, @param {number} index */
function endSetEvtIndex(pathEnd, index) { pathEnd.el.firstElementChild.setAttribute('data-evt-index', index.toString()); }
/** @param {Dir} dir */
const arrowAngle = dir => dir === 'right'
? 180
: dir === 'left'
? 0
: dir === 'bottom'
? 270
: 90;
/** @param {Dir} dir, @return {Dir} */
export const dirReverse = dir => dir === 'left'
? 'right'
: dir === 'right'
? 'left'
: dir === 'top' ? 'bottom' : 'top';
/** @param {Point} s, @param {Point} e, @return {Dir} */
function dirByAngle(s, e) {
const rad = Math.atan2(e.y - s.y, e.x - s.x);
return numInRangeIncludeEnds(rad, -0.8, 0.8)
? 'left'
: numInRangeIncludeEnds(rad, 0.8, 2.4)
? 'top'
: numInRangeIncludeEnds(rad, 2.4, 3.2) || numInRangeIncludeEnds(rad, -3.2, -2.4) ? 'right' : 'bottom';
}
/** @param {PathData} data */
function pathCalc(data) {
let coef = Math.hypot(
data.s.data.position.x - data.e.data.position.x,
data.s.data.position.y - data.e.data.position.y) * 0.5;
coef = coef > 70
? 70
: coef < 15 ? 15 : coef;
/** @param {PathEndData} pathEnd */
function cx(pathEnd) {
return (pathEnd.dir === 'right' || pathEnd.dir === 'left')
? pathEnd.dir === 'right' ? pathEnd.position.x + coef : pathEnd.position.x - coef
: pathEnd.position.x;
}
/** @param {PathEndData} pathEnd */
function cy(pathEnd) {
return (pathEnd.dir === 'right' || pathEnd.dir === 'left')
? pathEnd.position.y
: pathEnd.dir === 'bottom' ? pathEnd.position.y + coef : pathEnd.position.y - coef;
}
return `M ${data.s.data.position.x} ${data.s.data.position.y} C ${cx(data.s.data)} ${cy(data.s.data)}, ` +
`${cx(data.e.data)} ${cy(data.e.data)}, ${data.e.data.position.x} ${data.e.data.position.y}`;
}
/** @param {Element} element */
function hoverEmulate(element) {
/** @type {Element} */
let elemFromPoint = null;
/** @param {PointerEvent} evt */
function move(evt) {
const elemFromPointNew = priorityElemFromPoint(evt);
if (elemFromPoint !== elemFromPointNew) {
if (classHas(elemFromPointNew, 'hovertrack')) {
classAdd(elemFromPointNew, 'hover');
}
let parentHover = false;
if (classHas(elemFromPointNew?.parentElement, 'hovertrack')) {
classAdd(elemFromPointNew.parentElement, 'hover');
parentHover = true;
}
classDel(elemFromPoint, 'hover');
if (elemFromPoint?.parentElement !== elemFromPointNew?.parentElement || !parentHover) {
classDel(elemFromPoint?.parentElement, 'hover');
}
elemFromPoint = elemFromPointNew;
}
}
listen(element, 'pointermove', move);
// dispose fn
return function() {
listenDel(element, 'pointermove', move);
classDel(elemFromPoint, 'hover');
classDel(elemFromPoint?.parentElement, 'hover');
elemFromPoint = null;
};
}
/** @param {Element} el, @param {...string} keys */
const childs = (el, ...keys) => keys.map(kk => child(el, kk));
/** @param {number} num, @param {number} a, @param {number} b */
const numInRangeIncludeEnds = (num, a, b) => a <= num && num <= b;
/** @typedef { {x:number, y:number} } Point */
/** @typedef { 'left' | 'right' | 'top' | 'bottom' } Dir */
/** @typedef { {shapeEl: ShapeElement, connectorKey: string} } PathConnectedShape */
/** @typedef { {position: Point, dir: Dir }} PathEndData */
/** @typedef { {shape?:PathConnectedShape, data?:PathEndData, el?:SVGElement} } PathEnd */
/**
@typedef {{
s: PathEnd,
e: PathEnd,
styles?: string[],
}} PathData
*/
/** @typedef { {shape?:PathConnectedShape, data?:PathEndData, oppositeShape?:PathConnectedShape, type:number} } MovedEnd */
/**
@typedef {{
draw(): void
pointerCapture: (evt:PointerEventInit)=>void
del(): void
data: PathData
}} Path
*/
/** @typedef { import('./path-smbl.js').PathElement } PathElement */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */
/** @typedef { import('./shape-smbl').ShapeElement } ShapeElement */
/** @typedef { import('./shape-evt-proc').Shape } Shape */
/** @typedef { import('../infrastructure/move-evt-mobile-fix.js').PointerEventFixMovement } PointerEventFixMovement */

View File

@@ -0,0 +1,86 @@
import { copyAndPast } from '../diagram/group-select-applay.js';
import { classAdd, classDel, clickForAll, listen, classSingleAdd, evtTargetAttr } from '../infrastructure/util.js';
import { modalCreate } from './modal-create.js';
import { ShapeSmbl } from './shape-smbl.js';
/**
* @param {import('../infrastructure/canvas-smbl.js').CanvasElement} canvas
* @param {import('./shape-smbl').ShapeElement} shapeElement
* @param {number} bottomX positon of the bottom left corner of the panel
* @param {number} bottomY positon of the bottom left corner of the panel
*/
export const rectTxtSettingsPnlCreate = (canvas, shapeElement, bottomX, bottomY) =>
modalCreate(bottomX, bottomY, new RectTxtSettings(canvas, shapeElement));
class RectTxtSettings extends HTMLElement {
/**
* @param {import('../infrastructure/canvas-smbl.js').CanvasElement} canvas
* @param {import('./shape-smbl').ShapeElement} rectElement
*/
constructor(canvas, rectElement) {
super();
/** @private */
this._rectElement = rectElement;
/** @private */
this._canvas = canvas;
}
connectedCallback() {
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
.ln { display: flex; }
.ln > * {
height: 24px;
padding: 10px;
fill-opacity: 0.3;
stroke-opacity: 0.3;
}
[data-cmd] { cursor: pointer; }
.ta-1 [data-cmd-arg="1"],
.ta-2 [data-cmd-arg="2"],
.ta-3 [data-cmd-arg="3"]
{ fill-opacity: 1; stroke-opacity: 1; }
</style>
<ap-shape-edit id="F" edit-btn="true">
<div class="ln">
<svg data-cmd data-cmd-arg="1" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 4h18v2H3V4zm0 15h14v2H3v-2zm0-5h18v2H3v-2zm0-5h14v2H3V9z" fill="rgb(52,71,103)"/></svg>
<svg data-cmd data-cmd-arg="2" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 4h18v2H3V4zm2 15h14v2H5v-2zm-2-5h18v2H3v-2zm2-5h14v2H5V9z" fill="rgb(52,71,103)"/></svg>
<svg data-cmd data-cmd-arg="3" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M3 4h18v2H3V4zm4 15h14v2H7v-2zm-4-5h18v2H3v-2zm4-5h14v2H7V9z" fill="rgb(52,71,103)"/></svg>
</div>
</ap-shape-edit>`;
const rectData = /** @type {import('./rect.js').RectData} */(this._rectElement[ShapeSmbl].data);
const editEl = shadow.getElementById('edit');
classAdd(editEl, `ta-${rectData.a}`);
// colors, del
listen(editEl, 'cmd', /** @param {CustomEvent<{cmd:string, arg:string}>} evt */ evt => {
switch (evt.detail.cmd) {
case 'style': classSingleAdd(this._rectElement, rectData, 'cl-', evt.detail.arg); break;
case 'del': this._rectElement[ShapeSmbl].del(); break;
case 'copy': copyAndPast(this._canvas, [this._rectElement]); break;
}
});
// text align
clickForAll(shadow, '[data-cmd]', evt => {
const alignNew = /** @type {1|2|3} */(Number.parseInt(evtTargetAttr(evt, 'data-cmd-arg')));
if (alignNew === rectData.a) { return; }
const alignOld = rectData.a;
// applay text align to shape
rectData.a = alignNew;
this._rectElement[ShapeSmbl].draw();
// highlight text align btn in settings panel
classDel(editEl, `ta-${alignOld}`);
classAdd(editEl, `ta-${rectData.a}`);
});
}
}
customElements.define('ap-rect-txt-settings', RectTxtSettings);

142
main_plugin/dgrm/shapes/rect.js Executable file
View File

@@ -0,0 +1,142 @@
import { ceil, child, classAdd, classDel, positionSet } from '../infrastructure/util.js';
import { rectTxtSettingsPnlCreate } from './rect-txt-settings.js';
import { shapeCreate } from './shape-evt-proc.js';
import { settingsPnlCreate } from './shape-settings.js';
import { ShapeSmbl } from './shape-smbl.js';
/**
* @param {CanvasElement} canvas
* @param {RectData} rectData
*/
export function rect(canvas, rectData) {
rectData.w = rectData.w ?? 96;
rectData.h = rectData.h ?? 48;
rectData.a = rectData.a ?? (rectData.t ? 1 : 2);
const templ = `
<rect data-key="outer" data-evt-no data-evt-index="2" width="144" height="96" x="-72" y="-48" fill="transparent" stroke="transparent" stroke-width="0" />
<rect data-key="main" width="96" height="48" x="-48" y="-24" rx="15" ry="15" fill="#1aaee5" stroke="#fff" stroke-width="1" />
<text data-key="text" y="0" x="${rectTxtXByAlign(rectData)}" style="pointer-events: none;" fill="#fff">&nbsp;</text>`;
const shape = shapeCreate(canvas, rectData, templ,
{
right: { dir: 'right', position: { x: 48, y: 0 } },
left: { dir: 'left', position: { x: -48, y: 0 } },
bottom: { dir: 'bottom', position: { x: 0, y: 24 } },
top: { dir: 'top', position: { x: 0, y: -24 } }
},
// onTextChange
txtEl => {
const textBox = txtEl.getBBox();
const newWidth = ceil(96, 48, textBox.width + (rectData.t ? 6 : 0)); // 6 px right padding for text shape
const newHeight = ceil(48, 48, textBox.height);
if (rectData.w !== newWidth || rectData.h !== newHeight) {
rectData.w = newWidth;
rectData.h = newHeight;
resize();
}
},
// settingsPnlCreateFn
rectData.t ? rectTxtSettingsPnlCreate : settingsPnlCreate);
classAdd(shape.el, rectData.t ? 'shtxt' : 'shrect');
let currentW = rectData.w;
let currentTxtAlign = rectData.a;
/** @param {boolean?=} fixTxtAlign */
function resize(fixTxtAlign) {
const mainX = rectData.w / -2;
const mainY = rectData.h / -2;
const middleX = 0;
shape.cons.right.position.x = -mainX;
shape.cons.left.position.x = mainX;
shape.cons.bottom.position.y = -mainY;
shape.cons.bottom.position.x = middleX;
shape.cons.top.position.y = mainY;
shape.cons.top.position.x = middleX;
for (const connectorKey in shape.cons) {
positionSet(child(shape.el, connectorKey), shape.cons[connectorKey].position);
}
rectSet(shape.el, 'main', rectData.w, rectData.h, mainX, mainY);
rectSet(shape.el, 'outer', rectData.w + 48, rectData.h + 48, mainX - 24, mainY - 24);
// if text align or width changed
// fix text align
if (fixTxtAlign || currentTxtAlign !== rectData.a || currentW !== rectData.w) {
let txtX;
let posXDelta;
switch (rectData.a) {
// text align left
case 1:
txtX = mainX + 8;
posXDelta = (rectData.w - currentW) / 2;
break;
case 2:
txtX = 0;
posXDelta = 0;
break;
// text align right
case 3:
txtX = -mainX - 8;
posXDelta = (rectData.w - currentW) / -2;
break;
}
const txtEl = child(shape.el, 'text');
txtEl.x.baseVal[0].value = txtX;
txtEl.querySelectorAll('tspan').forEach(ss => { ss.x.baseVal[0].value = txtX; });
rectData.position.x += posXDelta;
classDel(shape.el, `ta-${currentTxtAlign}`);
classAdd(shape.el, `ta-${rectData.a}`);
currentTxtAlign = rectData.a;
currentW = rectData.w;
}
shape.draw();
}
classAdd(shape.el, `ta-${rectData.a}`);
if (rectData.w !== 96 || rectData.h !== 48) { resize(true); } else { shape.draw(); }
shape.el[ShapeSmbl].draw = resize;
return shape.el;
}
/**
* @param {Element} svgGrp, @param {string} key,
* @param {number} w, @param {number} h
* @param {number} x, @param {number} y
*/
function rectSet(svgGrp, key, w, h, x, y) {
/** @type {SVGRectElement} */ const rect = child(svgGrp, key);
rect.width.baseVal.value = w;
rect.height.baseVal.value = h;
rect.x.baseVal.value = x;
rect.y.baseVal.value = y;
}
/** @param {RectData} rectData */
const rectTxtXByAlign = rectData => rectData.a === 1
? -40 // text align keft
: rectData.a === 2
? 0 // text align middle
: 40; // text align right
/** @typedef { {x:number, y:number} } Point */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */
/** @typedef { import('./shape-evt-proc').CanvasData } CanvasData */
/** @typedef { import('./shape-evt-proc').ConnectorsData } ConnectorsData */
/**
@typedef {{
type:number, position: Point, title?: string, styles?: string[],
w?:number, h?:number
t?:boolean,
a?: 1|2|3
}} RectData */

101
main_plugin/dgrm/shapes/rhomb.js Executable file
View File

@@ -0,0 +1,101 @@
import { ceil, child, classAdd, positionSet, svgTxtFarthestPoint } from '../infrastructure/util.js';
import { shapeCreate } from './shape-evt-proc.js';
/**
* @param {CanvasElement} canvas
* @param {RhombData} rhombData
*/
export function rhomb(canvas, rhombData) {
const templ = `
<path data-key="outer" data-evt-no data-evt-index="2" d="M-72 0 L0 -72 L72 0 L0 72 Z" stroke-width="0" fill="transparent" />
<path data-key="border" d="M-39 0 L0 -39 L39 0 L0 39 Z" stroke-width="20" stroke="#fff" fill="transparent" stroke-linejoin="round" />
<path data-key="main" d="M-39 0 L0 -39 L39 0 L0 39 Z" stroke-width="18" stroke-linejoin="round" stroke="#1D809F" fill="#1D809F" />
<text data-key="text" x="0" y="0" text-anchor="middle" style="pointer-events: none;" fill="#fff">&nbsp;</text>`;
const shape = shapeCreate(canvas, rhombData, templ,
{
right: { dir: 'right', position: { x: 48, y: 0 } },
left: { dir: 'left', position: { x: -48, y: 0 } },
bottom: { dir: 'bottom', position: { x: 0, y: 48 } },
top: { dir: 'top', position: { x: 0, y: -48 } }
},
// onTextChange
txtEl => {
const newWidth = ceil(96, 48, textElRhombWidth(txtEl) - 20); // -20 experemental val
if (newWidth !== rhombData.w) {
rhombData.w = newWidth;
resize();
}
});
classAdd(shape.el, 'shrhomb');
function resize() {
const connectors = rhombCalc(rhombData.w, 0);
shape.cons.right.position.x = connectors.r.x;
shape.cons.left.position.x = connectors.l.x;
shape.cons.bottom.position.y = connectors.b.y;
shape.cons.top.position.y = connectors.t.y;
for (const connectorKey in shape.cons) {
positionSet(child(shape.el, connectorKey), shape.cons[connectorKey].position);
}
const mainRhomb = rhombCalc(rhombData.w, 9);
rhombSet(shape.el, 'main', mainRhomb);
rhombSet(shape.el, 'border', mainRhomb);
rhombSet(shape.el, 'outer', rhombCalc(rhombData.w, -24));
shape.draw();
}
if (!!rhombData.w && rhombData.w !== 96) { resize(); } else { shape.draw(); }
return shape.el;
}
/**
* @param {Element} svgGrp, @param {string} key,
* @param {RhombPoints} rhomb
*/
function rhombSet(svgGrp, key, rhomb) {
/** @type {SVGPathElement} */(child(svgGrp, key)).setAttribute('d', `M${rhomb.l.x} ${rhomb.l.y} L${rhomb.t.x} ${rhomb.t.y} L${rhomb.r.x} ${rhomb.r.y} L${rhomb.b.x} ${rhomb.b.y} Z`);
}
/**
* calc square rhomb points by width
* origin is in the center of the rhomb
* @param {number} width, @param {number} margin
* @returns {RhombPoints}
*/
function rhombCalc(width, margin) {
const half = width / 2;
const mrgnMinHalf = margin - half;
const halfMinMrgn = half - margin;
return {
l: { x: mrgnMinHalf, y: 0 },
t: { x: 0, y: mrgnMinHalf },
r: { x: halfMinMrgn, y: 0 },
b: { x: 0, y: halfMinMrgn }
};
}
/**
* calc width of the square rhomb that cover all tspan in {textEl}
* origin is in the center of the rhomb
* @param {SVGTextElement} textEl
*/
function textElRhombWidth(textEl) {
const farthestPoint = svgTxtFarthestPoint(textEl);
return 2 * (Math.abs(farthestPoint.x) + Math.abs(farthestPoint.y));
}
/** @typedef { {x:number, y:number} } Point */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */
/** @typedef { import('./shape-evt-proc').CanvasData } CanvasData */
/** @typedef { import('./shape-evt-proc').ConnectorsData } ConnectorsData */
/**
@typedef {{
type:number, position: Point, title?: string, styles?: string[]
w?:number
}} RhombData
*/
/** @typedef { { l:Point, t:Point, r:Point, b:Point } } RhombPoints */

View File

@@ -0,0 +1,306 @@
import { child, classAdd, classDel, deepCopy, svgEl } from '../infrastructure/util.js';
import { moveEvtProc } from '../infrastructure/move-evt-proc.js';
import { path, dirReverse } from './path.js';
import { textareaCreate } from '../infrastructure/svg-text-area.js';
import { settingsPnlCreate } from './shape-settings.js';
import { placeToCell, pointInCanvas } from '../infrastructure/move-scale-applay.js';
import { ShapeSmbl } from './shape-smbl.js';
import { svgTextDraw } from '../infrastructure/svg-text-draw.js';
import { PathSmbl } from './path-smbl.js';
import { CanvasSmbl } from '../infrastructure/canvas-smbl.js';
import { canvasSelectionClearSet } from '../diagram/canvas-clear.js';
import { listenCopy } from '../diagram/group-select-applay.js';
/**
* provides:
* - shape move
* - connectors
*
* - text editor
* - standard edit panel
* - onTextChange callback
* @param {CanvasElement} canvas
* @param {string} shapeHtml must have '<text data-key="text">'
* @param {ShapeData & { title?: string, styles?: string[]}} shapeData
* @param {ConnectorsData} cons
* @param {SettingsPnlCreateFn=} settingsPnlCreateFn
* @param {{(txtEl:SVGTextElement):void}} onTextChange
*/
export function shapeCreate(canvas, shapeData, shapeHtml, cons, onTextChange, settingsPnlCreateFn) {
/** @type {ShapeElement} */
const el = svgEl('g', `${shapeHtml}
${Object.entries(cons)
.map(cc => `<circle data-key="${cc[0]}" data-connect="${cc[1].dir}" class="hovertrack" data-evt-index="2" r="10" cx="0" cy="0" style="transform: translate(${cc[1].position.x}px, ${cc[1].position.y}px);" />`)
.join()}`);
const textSettings = {
/** @type {SVGTextElement} */
el: child(el, 'text'),
/** vericale middle, em */
vMid: 0
};
svgTextDraw(textSettings.el, textSettings.vMid, shapeData.title);
const shapeProc = shapeEditEvtProc(canvas, el, shapeData, cons, textSettings,
settingsPnlCreateFn,
// onTextChange
() => onTextChange(textSettings.el));
return {
el,
cons,
draw: shapeProc.draw
};
}
/**
* provides:
* - shape move
* - connectors
* - copy fn
*
* - text editor
* - standard edit panel
* - onTextChange callback
* @param {CanvasElement} canvas
* @param {ShapeElement} svgGrp
* @param {ShapeData & { title?: string, styles?: string[]}} shapeData
* @param {ConnectorsData} connectorsInnerPosition
* @param { {el:SVGTextElement, vMid: number} } textSettings vMid in em
* @param {{():void}} onTextChange
* @param {SettingsPnlCreateFn} settingsPnlCreateFn
*/
function shapeEditEvtProc(canvas, svgGrp, shapeData, connectorsInnerPosition, textSettings, settingsPnlCreateFn, onTextChange) {
/** @type {{dispose():void, draw():void}} */
let textEditor;
/** @type { {position:(bottomX:number, bottomY:number)=>void, del:()=>void} } */
let settingsPnl;
function unSelect() {
textEditor?.dispose(); textEditor = null;
settingsPnl?.del(); settingsPnl = null;
}
/** @param {string} txt */
function onTxtChange(txt) {
shapeData.title = txt;
onTextChange();
}
const settingPnlCreate = settingsPnlCreateFn ?? settingsPnlCreate;
const shapeProc = shapeEvtProc(canvas, svgGrp, shapeData, connectorsInnerPosition,
// onEdit
() => {
textEditor = textareaCreate(textSettings.el, textSettings.vMid, shapeData.title, onTxtChange, onTxtChange);
const position = svgGrp.getBoundingClientRect();
settingsPnl = settingPnlCreate(canvas, svgGrp, position.left + 10, position.top + 10);
},
// onUnselect
unSelect
);
if (shapeData.styles) { classAdd(svgGrp, ...shapeData.styles); }
svgGrp[ShapeSmbl].del = function() {
shapeProc.del();
svgGrp.remove();
};
return {
draw: () => {
shapeProc.drawPosition();
if (settingsPnl) {
const position = svgGrp.getBoundingClientRect();
settingsPnl.position(position.left + 10, position.top + 10);
}
if (textEditor) { textEditor.draw(); }
}
};
}
/**
* provides:
* - shape move
* - connectors
* - copy fn
* - onEdit, onEditStop callbacks
* @param {CanvasElement} canvas
* @param {ShapeElement} svgGrp
* @param {ShapeData} shapeData
* @param {ConnectorsData} connectorsInnerPosition
* @param {{():void}} onEdit
* @param {{():void}} onUnselect
*/
function shapeEvtProc(canvas, svgGrp, shapeData, connectorsInnerPosition, onEdit, onUnselect) {
classAdd(svgGrp, 'hovertrack');
/** @type {ConnectorsData} */
const connectorsData = deepCopy(connectorsInnerPosition);
/** @type { Set<PathElement> } */
const paths = new Set();
function drawPosition() {
svgGrp.style.transform = `translate(${shapeData.position.x}px, ${shapeData.position.y}px)`;
// paths
for (const connectorKey in connectorsInnerPosition) {
connectorsData[connectorKey].position = {
x: connectorsInnerPosition[connectorKey].position.x + shapeData.position.x,
y: connectorsInnerPosition[connectorKey].position.y + shapeData.position.y
};
}
for (const path of paths) {
path[PathSmbl].draw();
}
};
/**
* @type {0|1|2}
* 0 - init, 1 - selected, 2 - edit
*/
let state = 0;
/** @type {()=>void} */
let listenCopyDispose;
function unSelect() {
onUnselect();
state = 0;
classDel(svgGrp, 'select');
classDel(svgGrp, 'highlight');
canvasSelectionClearSet(canvas, null);
if (listenCopyDispose) { listenCopyDispose(); listenCopyDispose = null; }
}
const moveProcReset = moveEvtProc(
canvas.ownerSVGElement,
svgGrp,
canvas[CanvasSmbl].data,
shapeData.position,
// onMoveStart
/** @param {PointerEvent & { target: Element} } evt */
evt => {
unSelect();
const connectorKey = evt.target.getAttribute('data-connect');
if (connectorKey) {
moveProcReset();
const diagramEl = document.getElementById('diagram');
const rect = diagramEl.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
const pathEl = path(canvas, {
s: { shape: { shapeEl: svgGrp, connectorKey } },
e: {
data: {
dir: dirReverse(connectorsData[connectorKey].dir),
position: pointInCanvas(canvas[CanvasSmbl].data, x, y)
}
}
});
svgGrp.parentNode.append(pathEl);
pathEl[PathSmbl].pointerCapture(evt);
paths.add(pathEl);
}
},
// onMove
drawPosition,
// onMoveEnd
_ => {
placeToCell(shapeData.position, canvas[CanvasSmbl].data.cell);
drawPosition();
},
// onClick
_ => {
// in edit mode
if (state === 2) { return; }
// to edit mode
if (state === 1) {
state = 2;
classDel(svgGrp, 'select');
classAdd(svgGrp, 'highlight');
// edit mode
onEdit();
return;
}
// to select mode
state = 1;
classAdd(svgGrp, 'select');
canvasSelectionClearSet(canvas, unSelect);
listenCopyDispose = listenCopy(() => [svgGrp]);
},
// onOutdown
unSelect);
svgGrp[ShapeSmbl] = {
/**
* @param {string} connectorKey
* @param {PathElement} pathEl
*/
pathAdd: function(connectorKey, pathEl) {
paths.add(pathEl);
return connectorsData[connectorKey];
},
/** @param {PathElement} pathEl */
pathDel: function(pathEl) {
paths.delete(pathEl);
},
drawPosition,
data: shapeData
};
return {
drawPosition,
del: () => {
unSelect();
moveProcReset();
for (const path of paths) {
path[PathSmbl].del();
}
}
};
}
/** @typedef { {x:number, y:number} } Point */
/** @typedef { {position:Point, scale:number, cell:number} } CanvasData */
/** @typedef { 'left' | 'right' | 'top' | 'bottom' } PathDir */
/** @typedef { {position: Point, dir: PathDir} } PathEnd */
/** @typedef { Object.<string, PathEnd> } ConnectorsData */
/** @typedef { {type: number, position: Point, styles?:string[]} } ShapeData */
/**
@typedef {{
pathAdd(connectorKey:string, pathEl:PathElement): PathEnd
pathDel(pathEl:PathElement): void
drawPosition: ()=>void
data: ShapeData
del?: ()=>void
draw?: ()=>void
}} Shape
*/
/** @typedef { {(canvas:CanvasElement, shapeElement:ShapeElement, bottomX:number, bottomY:number):{position(btmX:number, btmY:number):void, del():void} } } SettingsPnlCreateFn */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */
/** @typedef {import('./shape-smbl').ShapeElement} ShapeElement */
/** @typedef {import('./path').Path} Path */
/** @typedef {import('./path-smbl').PathElement} PathElement */

View File

@@ -0,0 +1,121 @@
import { copyAndPast } from '../diagram/group-select-applay.js';
import { copySvg, delSvg } from '../infrastructure/assets.js';
import { clickForAll, listen, classSingleAdd, evtTargetAttr } from '../infrastructure/util.js';
import { modalChangeTop, modalCreate } from './modal-create.js';
import { ShapeSmbl } from './shape-smbl.js';
/**
* @param {import('../infrastructure/canvas-smbl').CanvasElement} canvas
* @param {import('./shape-smbl').ShapeElement} shapeElement
* @param {number} bottomX positon of the bottom left corner of the panel
* @param {number} bottomY positon of the bottom left corner of the panel
*/
export function settingsPnlCreate(canvas, shapeElement, bottomX, bottomY) {
const shapeSettings = new ShapeEdit();
listen(shapeSettings, 'cmd', /** @param {CustomEvent<{cmd:string, arg:string}>} evt */ evt => {
switch (evt.detail.cmd) {
case 'style': classSingleAdd(shapeElement, shapeElement[ShapeSmbl].data, 'cl-', evt.detail.arg); break;
case 'del': shapeElement[ShapeSmbl].del(); break;
case 'copy': copyAndPast(canvas, [shapeElement]); break;
}
});
return modalCreate(bottomX, bottomY, shapeSettings);
}
class ShapeEdit extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML =
`<style>
.ln { display: flex; }
.ln > * {
height: 24px;
padding: 10px;
cursor: pointer;
}
#prop { padding-bottom: 10px; }
.crcl { width: 25px; height: 25px; border-radius: 50%; }
</style>
<div id="pnl">
<div id="clr" style="display: none;">
<div class="ln">
<div data-cmd="style" data-cmd-arg="cl-red">
<div class="crcl" style="background: #E74C3C"></div>
</div>
<div data-cmd="style" data-cmd-arg="cl-orange">
<div class="crcl" style="background: #ff6600"></div>
</div>
<div data-cmd="style" data-cmd-arg="cl-green">
<div class="crcl" style="background: #19bc9b"></div>
</div>
</div>
<div class="ln">
<div data-cmd="style" data-cmd-arg="cl-blue">
<div class="crcl" style="background: #1aaee5"></div>
</div>
<div data-cmd="style" data-cmd-arg="cl-dblue">
<div class="crcl" style="background: #1D809F"></div>
</div>
<div data-cmd="style" data-cmd-arg="cl-dgray">
<div class="crcl" style="background: #495057"></div>
</div>
</div>
</div>
<div id="prop" style="display: none;"><slot id="slot"></slot></div>
</div>
<div class="ln">
<svg data-toggle="clr" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M19.228 18.732l1.768-1.768 1.767 1.768a2.5 2.5 0 1 1-3.535 0zM8.878 1.08l11.314 11.313a1 1 0 0 1 0 1.415l-8.485 8.485a1 1 0 0 1-1.414 0l-8.485-8.485a1 1 0 0 1 0-1.415l7.778-7.778-2.122-2.121L8.88 1.08zM11 6.03L3.929 13.1 11 20.173l7.071-7.071L11 6.029z" fill="rgb(52,71,103)"/></svg>
<svg data-toggle="prop" ${this.getAttribute('edit-btn') ? '' : 'style="display: none;"'} viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12.9 6.858l4.242 4.243L7.242 21H3v-4.243l9.9-9.9zm1.414-1.414l2.121-2.122a1 1 0 0 1 1.414 0l2.829 2.829a1 1 0 0 1 0 1.414l-2.122 2.121-4.242-4.242z" fill="rgb(52,71,103)"/></svg>
${copySvg}
${delSvg}
</div>`;
//
// tabs
{
const pnl = shadow.getElementById('pnl');
/** @param {1|-1} coef */
function modalSetTop(coef) {
modalChangeTop(window.scrollY + coef * pnl.getBoundingClientRect().height); // window.scrollY fix IPhone keyboard
}
/** @type {HTMLElement} */
let currentTab;
clickForAll(shadow, '[data-toggle]', evt => {
if (currentTab) {
modalSetTop(1);
display(currentTab, false);
}
const tab = shadow.getElementById(evtTargetAttr(evt, 'data-toggle'));
if (currentTab !== tab) {
display(tab, true);
modalSetTop(-1);
currentTab = tab;
} else {
currentTab = null;
}
});
}
//
// commands
clickForAll(shadow, '[data-cmd]', evt => {
this.dispatchEvent(new CustomEvent('cmd', {
detail: {
cmd: evtTargetAttr(evt, 'data-cmd'),
arg: evtTargetAttr(evt, 'data-cmd-arg')
}
}));
});
}
}
customElements.define('ap-shape-edit', ShapeEdit);
/** @param {ElementCSSInlineStyle} el, @param {boolean} isDisp */
function display(el, isDisp) { el.style.display = isDisp ? 'unset' : 'none'; }

View File

@@ -0,0 +1,3 @@
export const ShapeSmbl = Symbol('shape');
/** @typedef {SVGGraphicsElement & { [ShapeSmbl]?: import('./shape-evt-proc').Shape }} ShapeElement */

View File

@@ -0,0 +1,27 @@
import { circle } from './circle.js';
import { path } from './path.js';
import { rect } from './rect.js';
import { rhomb } from './rhomb.js';
/**
* @param {CanvasElement} canvas
* @returns {Record<number, ShapeType>}
*/
export function shapeTypeMap(canvas) {
return {
0: { create: shapeData => path(canvas, shapeData) },
1: { create: shapeData => circle(canvas, shapeData) },
2: { create: shapeData => rect(canvas, shapeData) },
3: { create: shapeData => { /** @type {RectData} */(shapeData).t = true; return rect(canvas, shapeData); } },
4: { create: shapeData => rhomb(canvas, shapeData) }
};
}
/** @typedef { {x:number, y:number} } Point */
/** @typedef { import('./rect.js').RectData } RectData */
/** @typedef { import('../infrastructure/canvas-smbl.js').CanvasElement } CanvasElement */
/**
@typedef {{
create: (shapeData)=>SVGGraphicsElement
}} ShapeType
*/

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';
}