Добавляем все файлы
This commit is contained in:
2
main_plugin/dgrm/infrastructure/assets.js
Executable file
2
main_plugin/dgrm/infrastructure/assets.js
Executable 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>';
|
||||
15
main_plugin/dgrm/infrastructure/canvas-smbl.js
Executable file
15
main_plugin/dgrm/infrastructure/canvas-smbl.js
Executable 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
|
||||
*/
|
||||
35
main_plugin/dgrm/infrastructure/evt-route-applay.js
Executable file
35
main_plugin/dgrm/infrastructure/evt-route-applay.js
Executable 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 */
|
||||
60
main_plugin/dgrm/infrastructure/file.js
Executable file
60
main_plugin/dgrm/infrastructure/file.js
Executable 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();
|
||||
}
|
||||
50
main_plugin/dgrm/infrastructure/move-evt-mobile-fix.js
Executable file
50
main_plugin/dgrm/infrastructure/move-evt-mobile-fix.js
Executable 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 */
|
||||
117
main_plugin/dgrm/infrastructure/move-evt-proc.js
Executable file
117
main_plugin/dgrm/infrastructure/move-evt-proc.js
Executable 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 */
|
||||
223
main_plugin/dgrm/infrastructure/move-scale-applay.js
Executable file
223
main_plugin/dgrm/infrastructure/move-scale-applay.js
Executable 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 */
|
||||
93
main_plugin/dgrm/infrastructure/png-chunk.js
Executable file
93
main_plugin/dgrm/infrastructure/png-chunk.js
Executable 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);
|
||||
}
|
||||
70
main_plugin/dgrm/infrastructure/svg-text-area.js
Executable file
70
main_plugin/dgrm/infrastructure/svg-text-area.js
Executable 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`;
|
||||
}
|
||||
43
main_plugin/dgrm/infrastructure/svg-text-draw.js
Executable file
43
main_plugin/dgrm/infrastructure/svg-text-draw.js
Executable 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(' ', ' ')}</tspan>`;
|
||||
}).join(''),
|
||||
c
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return str.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
93
main_plugin/dgrm/infrastructure/svg-to-png.js
Executable file
93
main_plugin/dgrm/infrastructure/svg-to-png.js
Executable 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;
|
||||
}
|
||||
146
main_plugin/dgrm/infrastructure/util.js
Executable file
146
main_plugin/dgrm/infrastructure/util.js
Executable 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 */
|
||||
Reference in New Issue
Block a user