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

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

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);