@inweb/markup
Version:
JavaScript 2D markups
1,164 lines (986 loc) • 37.6 kB
text/typescript
///////////////////////////////////////////////////////////////////////////////
// Copyright (C) 2002-2025, Open Design Alliance (the "Alliance").
// All rights reserved.
//
// This software and its documentation and related materials are owned by
// the Alliance. The software may only be incorporated into application
// programs owned by members of the Alliance, subject to a signed
// Membership Agreement and Supplemental Software License Agreement with the
// Alliance. The structure and organization of this software are the valuable
// trade secrets of the Alliance and its suppliers. The software is also
// protected by copyright law and international treaty provisions. Application
// programs incorporating this software must include the following statement
// with their copyright notices:
//
// This application incorporates Open Design Alliance software pursuant to a
// license agreement with Open Design Alliance.
// Open Design Alliance Copyright (C) 2002-2025 by Open Design Alliance.
// All rights reserved.
//
// By use of this software, its documentation or related materials, you
// acknowledge and accept the above terms.
///////////////////////////////////////////////////////////////////////////////
import Konva from "konva";
import { IEventEmitter } from "@inweb/eventemitter2";
import {
ChangeActiveDraggerEvent,
IArrow,
ICloud,
IEllipse,
IImage,
ILine,
IRectangle,
IText,
IViewpoint,
PanEvent,
ZoomAtEvent,
} from "@inweb/viewer-core";
import { IMarkup, MarkupMode } from "../IMarkup";
import { IWorldTransform } from "../IWorldTransform";
import { WorldTransform } from "../WorldTransform";
import { IMarkupObject } from "../IMarkupObject";
import { MarkupLineType } from "../IMarkupLine";
import { MarkupColor } from "./MarkupColor";
import { KonvaLine } from "./KonvaLine";
import { KonvaText } from "./KonvaText";
import { KonvaRectangle } from "./KonvaRectangle";
import { KonvaEllipse } from "./KonvaEllipse";
import { KonvaArrow } from "./KonvaArrow";
import { KonvaImage } from "./KonvaImage";
import { KonvaCloud } from "./KonvaCloud";
const MarkupMode2Konva = {
SelectMarkup: {
name: "SelectMarkup",
initializer: null,
},
Line: {
name: "Line",
initializer: (ref: any, params = null) => new KonvaLine(params, ref),
},
Text: {
name: "Text",
initializer: (ref: any, params = null) => new KonvaText(params, ref),
},
Rectangle: {
name: "Rect",
initializer: (ref: any, params = null) => new KonvaRectangle(params, ref),
},
Ellipse: {
name: "Ellipse",
initializer: (ref: any, params = null) => new KonvaEllipse(params, ref),
},
Arrow: {
name: "Arrow",
initializer: (ref: any, params = null) => new KonvaArrow(params, ref),
},
Image: {
name: "Image",
initializer: (ref: any, params = null) => new KonvaImage(params, ref),
},
Cloud: {
name: "Cloud",
initializer: (ref: any, params = null) => new KonvaCloud(params, ref),
},
};
/**
* 2D markup core.
*/
export class KonvaMarkup implements IMarkup {
private _viewer: IEventEmitter;
private _worldTransformer: IWorldTransform;
private _container: HTMLElement;
private _containerEvents: string[] = [];
private _markupIsActive = false;
private _markupMode: MarkupMode;
private _markupColor = new MarkupColor(255, 0, 0);
private _konvaStage: Konva.Stage;
private _konvaLayer: Konva.Layer;
private _konvaTransformer: Konva.Transformer;
private _textInputRef: HTMLTextAreaElement;
private _textInputPos: Konva.Vector2d;
private _textInputAngle: number;
private _imageInputRef: HTMLInputElement;
private _imageInputPos: Konva.Vector2d;
private _markupContainer: HTMLDivElement;
private _resizeObserver: ResizeObserver;
private _groupImages: Konva.Group;
private _groupGeometry: Konva.Group;
private _groupTexts: Konva.Group;
public lineWidth = 4;
public lineType: MarkupLineType = "solid";
public fontSize = 34;
initialize(
container: HTMLElement,
containerEvents?: string[],
viewer?: IEventEmitter,
worldTransformer?: IWorldTransform
): void {
if (!Konva)
throw new Error(
'Markup error: Konva is not initialized. Forgot to add <script src="https://unpkg.com/konva@9/konva.min.js"></script> to your page?'
);
this._viewer = viewer;
this._worldTransformer = worldTransformer ?? new WorldTransform();
this._container = container;
this._containerEvents = containerEvents ?? [];
this._markupContainer = document.createElement("div");
this._markupContainer.id = "markup-container";
this._markupContainer.style.position = "absolute";
this._markupContainer.style.top = "0px";
this._markupContainer.style.left = "0px";
this._markupContainer.style.outline = "0px"; // <- to eliminate grey box during delete elements
this._markupContainer.style.pointerEvents = "none";
const parentDiv = this._container.parentElement;
parentDiv.appendChild(this._markupContainer);
this._resizeObserver = new ResizeObserver(this.resizeContainer);
this._resizeObserver.observe(parentDiv);
this._markupColor.setColor(255, 0, 0);
this.initializeKonva();
if (this._viewer) {
// this._containerEvents.forEach((x) => this._markupContainer.addEventListener(x, this.redirectToViewer));
this._viewer.addEventListener("changeactivedragger", this.changeActiveDragger);
this._viewer.addEventListener("pan", this.pan);
this._viewer.addEventListener("zoomat", this.zoomAt);
}
}
dispose(): void {
if (this._viewer) {
this._viewer.removeEventListener("zoomat", this.zoomAt);
this._viewer.removeEventListener("pan", this.pan);
this._viewer.removeEventListener("changeactivedragger", this.changeActiveDragger);
// this._containerEvents.forEach((x) => this._markupContainer.removeEventListener(x, this.redirectToViewer));
}
this.destroyKonva();
this._resizeObserver?.disconnect();
this._resizeObserver = undefined;
this._markupContainer?.remove();
this._markupContainer = undefined;
this._container = undefined;
this._viewer = undefined;
this._worldTransformer = undefined;
this._markupIsActive = false;
}
changeActiveDragger = (event: ChangeActiveDraggerEvent) => {
const draggerName = event.data;
this._markupContainer.className = this._container.className
.split(" ")
.filter((x) => !x.startsWith("oda-cursor-"))
.filter((x) => x)
.concat(`oda-cursor-${draggerName.toLowerCase()}`)
.join(" ");
this.removeTextInput();
this.removeImageInput();
this.enableEditMode(draggerName as MarkupMode);
};
resizeContainer = (entries: ResizeObserverEntry[]) => {
const { width, height } = entries[0].contentRect;
if (!width || !height) return; // <- invisible container, or container with parent removed
if (!this._konvaStage) return;
this._konvaStage.width(width);
this._konvaStage.height(height);
};
pan = (event: PanEvent) => {
const newPos = {
x: this._konvaStage.x() + event.dX,
y: this._konvaStage.y() + event.dY,
};
this._konvaStage.position(newPos);
};
zoomAt = (event: ZoomAtEvent) => {
const newScale = this._konvaStage.scaleX() * event.data;
this._konvaStage.scale({ x: newScale, y: newScale });
const newPos = {
x: event.x - (event.x - this._konvaStage.x()) * event.data,
y: event.y - (event.y - this._konvaStage.y()) * event.data,
};
this._konvaStage.position(newPos);
};
redirectToViewer = (event: any) => {
if (this._viewer) this._viewer.emit(event);
};
syncOverlay(): void {}
clearOverlay(): void {
this.removeTextInput();
this.removeImageInput();
this.clearSelected();
this.getObjects().forEach((obj) => obj.delete());
}
getMarkupColor(): { r: number; g: number; b: number } {
return this._markupColor.asRGB();
}
setMarkupColor(r: number, g: number, b: number): void {
this._markupColor.setColor(r, g, b);
this.redirectToViewer({ type: "changemarkupcolor", data: { r, g, b } });
}
colorizeAllMarkup(r: number, g: number, b: number): void {
const hexColor = new MarkupColor(r, g, b).asHex();
this.getObjects().filter((obj: any) => obj.setColor?.(hexColor));
}
colorizeSelectedMarkups(r: number, g: number, b: number): void {
const hexColor = new MarkupColor(r, g, b).asHex();
this.getSelectedObjects().filter((obj: any) => obj.setColor?.(hexColor));
}
setViewpoint(viewpoint: IViewpoint): void {
this.clearSelected();
this.removeTextInput();
this.removeImageInput();
this._konvaStage.scale({ x: 1, y: 1 });
this._konvaStage.position({ x: 0, y: 0 });
const markupColor = viewpoint.custom_fields?.markup_color || { r: 255, g: 0, b: 0 };
this.setMarkupColor(markupColor.r, markupColor.g, markupColor.b);
viewpoint.lines?.forEach((line: ILine) => {
const linePoints = [];
line.points.forEach((point) => {
const screenPoint = this._worldTransformer.worldToScreen(point);
linePoints.push(screenPoint.x);
linePoints.push(screenPoint.y);
});
this.addLine(linePoints, line.color, line.type as MarkupLineType, line.width, line.id);
});
viewpoint.texts?.forEach((text: IText) => {
const screenPoint = this._worldTransformer.worldToScreen(text.position);
this.addText(text.text, screenPoint, text.angle, text.color, text.text_size, text.font_size, text.id);
});
viewpoint.rectangles?.forEach((rect: IRectangle) => {
const screenPoint = this._worldTransformer.worldToScreen(rect.position);
this.addRectangle(screenPoint, rect.width, rect.height, rect.line_width, rect.color, rect.id);
});
viewpoint.ellipses?.forEach((ellipse: IEllipse) => {
const screenPoint = this._worldTransformer.worldToScreen(ellipse.position);
this.addEllipse(screenPoint, ellipse.radius, ellipse.line_width, ellipse.color, ellipse.id);
});
viewpoint.arrows?.forEach((arrow: IArrow) => {
const startPoint = this._worldTransformer.worldToScreen(arrow.start);
const endPoint = this._worldTransformer.worldToScreen(arrow.end);
this.addArrow(startPoint, endPoint, arrow.color, arrow.id);
});
viewpoint.clouds?.forEach((cloud: ICloud) => {
const screenPoint = this._worldTransformer.worldToScreen(cloud.position);
this.addCloud(screenPoint, cloud.width, cloud.height, cloud.line_width, cloud.color, cloud.id);
});
viewpoint.images?.forEach((image: IImage) => {
const screenPoint = this._worldTransformer.worldToScreen(image.position);
this.addImage(screenPoint, image.src, image.width, image.height, image.id);
});
}
getViewpoint(viewpoint: IViewpoint): IViewpoint {
if (!viewpoint) viewpoint = {};
viewpoint.lines = this.getMarkupLines();
viewpoint.texts = this.getMarkupTexts();
viewpoint.arrows = this.getMarkupArrows();
viewpoint.clouds = this.getMarkupClouds();
viewpoint.ellipses = this.getMarkupEllipses();
viewpoint.images = this.getMarkupImages();
viewpoint.rectangles = this.getMarkupRectangles();
viewpoint.custom_fields = { markup_color: this.getMarkupColor() };
viewpoint.snapshot = { data: this.combineMarkupWithDrawing() };
return viewpoint;
}
enableEditMode(mode: MarkupMode | false): this {
if (!mode || !MarkupMode2Konva[mode]) {
this.clearSelected();
this.removeTextInput();
this.removeImageInput();
this._markupContainer.style.pointerEvents = "none";
this._markupIsActive = false;
} else {
this._markupMode = mode;
this._markupContainer.style.pointerEvents = "all";
this._markupIsActive = true;
}
return this;
}
createObject(type: string, params: any): IMarkupObject {
const konvaShape = MarkupMode2Konva[type];
if (!konvaShape || !konvaShape.initializer)
throw new Error(`Markup CreateObject - unsupported markup type ${type}`);
const object = konvaShape.initializer(null, params);
this.addObject(object);
return object;
}
getObjects(): IMarkupObject[] {
const objects = [];
Object.keys(MarkupMode2Konva).forEach((type) => {
const konvaShape = MarkupMode2Konva[type];
this.konvaLayerFind(type).forEach((ref) => objects.push(konvaShape.initializer(ref)));
});
return objects;
}
getSelectedObjects(): IMarkupObject[] {
if (!this._konvaTransformer) return [];
return this._konvaTransformer
.nodes()
.map((ref) => {
const name = ref.className;
const konvaShape = Object.values(MarkupMode2Konva).find((shape) => shape.name === name);
return konvaShape ? konvaShape.initializer(ref) : null;
})
.filter((x) => x);
}
selectObjects(objects: IMarkupObject[]) {
if (!this._konvaTransformer) return;
const selectedObjs = this._konvaTransformer.nodes().concat(objects.map((x) => x.ref()));
this._konvaTransformer.nodes(selectedObjs);
}
clearSelected(): void {
if (this._konvaTransformer) this._konvaTransformer.nodes([]);
}
private addObject(object: IMarkupObject): void {
if (object.type() === "Image") this._groupImages.add(object.ref());
else if (object.type() === "Text") this._groupTexts.add(object.ref());
else this._groupGeometry.add(object.ref());
}
private konvaLayerFind(type: string): any {
if (!this._konvaLayer) return [];
const konvaShape = MarkupMode2Konva[type];
if (!konvaShape || !konvaShape.initializer) return [];
// for "draggable" Konva uses Rectangles in Transformer. We need only Shapes from layer.
return this._konvaLayer
.find(konvaShape.name)
.filter(
(ref) =>
ref.parent === this._konvaLayer ||
ref.parent === this._groupImages ||
ref.parent === this._groupGeometry ||
ref.parent === this._groupTexts
);
}
private getRelativePointPosition = (point, node) => {
// the function will return pointer position relative to the passed node
const transform = node.getAbsoluteTransform().copy();
// to detect relative position we need to invert transform
transform.invert();
// get pointer (say mouse or touch) position
// now we find relative point
return transform.point(point);
};
private getRelativePointerPosition = (node) => {
return this.getRelativePointPosition(node.getStage().getPointerPosition(), node);
};
private initializeKonva(): any {
// first we need Konva core things: stage and layer
const stage = new Konva.Stage({
container: this._markupContainer,
width: this._container.clientWidth,
height: this._container.clientHeight,
});
this._konvaStage = stage;
const layer = new Konva.Layer({ pixelRation: window.devicePixelRatio });
stage.add(layer);
this._groupImages = new Konva.Group();
layer.add(this._groupImages);
this._groupGeometry = new Konva.Group();
layer.add(this._groupGeometry);
this._groupTexts = new Konva.Group();
layer.add(this._groupTexts);
this._konvaLayer = layer;
const transformer = new Konva.Transformer({
shouldOverdrawWholeArea: false,
keepRatio: false,
flipEnabled: false,
});
layer.add(transformer);
this._konvaTransformer = transformer;
let isPaint = false;
let lastLine;
let mouseDownPos;
let lastObj;
stage.on("mousedown touchstart", (e) => {
// do nothing if we mousedown on any shape
if (!this._markupIsActive || e.target !== stage || this._markupMode === "Text" || this._markupMode === "Image")
return;
if (e.target === stage && transformer.nodes().length > 0) {
transformer.nodes([]);
return;
}
const pos = this.getRelativePointerPosition(stage);
mouseDownPos = pos;
isPaint = ["Arrow", "Cloud", "Ellipse", "Line", "Rectangle"].some((m) => m === this._markupMode);
if (this._markupMode === "Line") {
// add point twice, so we have some drawings even on a simple click
lastLine = this.addLine([pos.x, pos.y, pos.x, pos.y]);
}
});
stage.on("mouseup touchend", (e) => {
if (!this._markupIsActive) return;
if (isPaint) {
const pos = this.getRelativePointerPosition(stage);
const defParams = mouseDownPos && pos.x === mouseDownPos.x && pos.y === mouseDownPos.y;
const startX = defParams ? mouseDownPos.x : Math.min(mouseDownPos.x, pos.x);
const startY = defParams ? mouseDownPos.y : Math.min(mouseDownPos.y, pos.y);
const dX = defParams ? 200 : Math.abs(mouseDownPos.x - pos.x);
const dY = defParams ? 200 : Math.abs(mouseDownPos.y - pos.y);
if (defParams) {
if (this._markupMode === "Rectangle") {
this.addRectangle({ x: startX, y: startY }, dX, dY);
} else if (this._markupMode === "Ellipse") {
this.addEllipse({ x: startX, y: startY }, { x: dX / 2, y: dY / 2 });
} else if (this._markupMode === "Arrow") {
this.addArrow(
{ x: mouseDownPos.x, y: mouseDownPos.y },
{ x: defParams ? mouseDownPos.x + 200 : pos.x, y: defParams ? startY : pos.y }
);
} else if (this._markupMode === "Cloud") {
this.addCloud({ x: startX, y: startY }, Math.max(100, dX), Math.max(100, dY));
}
}
}
lastObj = undefined;
isPaint = false;
});
stage.on("mousemove touchmove", (e) => {
if (!this._markupIsActive) return;
if (!isPaint) {
return;
}
// prevent scrolling on touch devices
//e.evt.preventDefault();
const pos = this.getRelativePointerPosition(stage);
const defParams = mouseDownPos && pos.x === mouseDownPos.x && pos.y === mouseDownPos.y;
const startX = defParams ? mouseDownPos.x : Math.min(mouseDownPos.x, pos.x);
const startY = defParams ? mouseDownPos.y : Math.min(mouseDownPos.y, pos.y);
const dX = defParams ? 200 : Math.abs(mouseDownPos.x - pos.x);
const dY = defParams ? 200 : Math.abs(mouseDownPos.y - pos.y);
if (this._markupMode === "Line") {
lastLine.addPoints([{ x: pos.x, y: pos.y }]);
} else if (this._markupMode === "Arrow") {
if (lastObj) lastObj.setEndPoint(pos.x, pos.y);
else lastObj = this.addArrow({ x: mouseDownPos.x, y: mouseDownPos.y }, { x: pos.x, y: pos.y });
} else if (this._markupMode === "Rectangle") {
if (lastObj) {
lastObj.setPosition(startX, startY);
lastObj.setWidth(dX);
lastObj.setHeight(dY);
} else lastObj = this.addRectangle({ x: startX, y: startY }, dX, dY);
} else if (this._markupMode === "Ellipse") {
if (lastObj) {
lastObj.setPosition(startX, startY);
lastObj.setRadiusX(dX);
lastObj.setRadiusY(dY);
} else lastObj = this.addEllipse({ x: startX, y: startY }, { x: dX, y: dY });
} else if (this._markupMode === "Cloud") {
if (lastObj) {
lastObj.setPosition(startX, startY);
lastObj.setWidth(Math.max(100, dX));
lastObj.setHeight(Math.max(100, dY));
} else lastObj = this.addCloud({ x: startX, y: startY }, dX, dY);
}
});
// clicks should select/deselect shapes
stage.on("click tap", (e) => {
if (!this._markupIsActive) return;
// if click on empty area - remove all selections
if (e.target === stage) {
if (this._markupMode === "Text") {
if (this._textInputRef && this._textInputRef.value)
this.addText(this._textInputRef.value, this._textInputPos, this._textInputAngle);
else if (transformer.nodes().length === 0) {
const pos = this.getRelativePointerPosition(stage);
this.createTextInput(pos, e.evt.pageX, e.evt.pageY, 0, null);
}
} else if (this._markupMode === "Image") {
if (this._imageInputRef && this._imageInputRef.value)
this.addImage(
{ x: this._imageInputPos.x, y: this._imageInputPos.y },
this._imageInputRef.value,
0,
0,
this._imageInputRef.value
);
else if (transformer.nodes().length === 0) {
const pos = this.getRelativePointerPosition(stage);
this.createImageInput(pos);
}
}
transformer.nodes([]);
return;
}
if (this._markupMode === "Text" || this._markupMode === "SelectMarkup") {
if (e.target.className === "Text" && transformer.nodes().length === 1 && transformer.nodes()[0] === e.target) {
if (this._textInputRef && this._textInputRef.value)
this.addText(this._textInputRef.value, this._textInputPos, this._textInputAngle);
else
this.createTextInput(
{ x: e.target.attrs.x, y: e.target.attrs.y },
e.evt.pageX,
e.evt.pageY,
e.target.attrs.rotation,
e.target.attrs.text
);
return;
} else {
this.removeTextInput();
}
}
if (this._markupMode === "Image" || this._markupMode === "SelectMarkup") {
if (e.target.className === "Image" && transformer.nodes().length === 1 && transformer.nodes()[0] === e.target) {
if (this._imageInputRef && this._imageInputRef.value)
this.addImage(this._imageInputPos, this._imageInputRef.value, 0, 0);
else this.createImageInput({ x: e.target.attrs.x, y: e.target.attrs.y });
return;
} else {
this.removeImageInput();
}
}
if (
transformer.nodes().filter((x) => x.className === "Cloud" || x.className === "Image").length > 0 ||
e.target.className === "Cloud" ||
e.target.className === "Image"
) {
transformer.rotateEnabled(false);
} else {
transformer.rotateEnabled(true);
}
// do we pressed shift or ctrl?
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
if (!metaPressed && !isSelected) {
// if no key pressed and the node is not selected
// select just one
transformer.nodes([e.target]);
} else if (metaPressed && isSelected) {
// if we pressed keys and node was selected
// we need to remove it from selection:
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
// remove node from array
nodes.splice(nodes.indexOf(e.target), 1);
transformer.nodes(nodes);
} else if (metaPressed && !isSelected) {
// add the node into selection
const nodes = transformer.nodes().concat([e.target]);
transformer.nodes(nodes);
}
});
const container = stage.container();
container.tabIndex = 1;
// focus it
// also stage will be in focus on its click
container.focus();
container.addEventListener("keydown", (e) => {
if (!this._markupIsActive) return;
if (e.code === "Delete") {
this.getSelectedObjects().forEach((obj: IMarkupObject) => obj.delete());
this.clearSelected();
return;
}
e.preventDefault();
});
}
private destroyKonva() {
this.removeTextInput();
this.removeImageInput();
this.clearOverlay();
this._konvaStage?.destroy();
this._groupImages = undefined;
this._groupGeometry = undefined;
this._groupTexts = undefined;
this._konvaLayer = undefined;
this._konvaTransformer = undefined;
this._konvaStage = undefined;
}
private getMarkupLines(): Array<ILine> {
const lines = [];
this.konvaLayerFind("Line").forEach((ref) => {
const linePoints = ref.points();
if (!linePoints) return;
const worldPoints = [];
const absoluteTransform = ref.getAbsoluteTransform();
for (let i = 0; i < linePoints.length; i += 2) {
// we need getAbsoluteTransform because inside Konva position starts from {0, 0}
// https://stackoverflow.com/a/57641487 - check answer's comments
const atPoint = absoluteTransform.point({
x: linePoints[i],
y: linePoints[i + 1],
});
const worldPoint = this._worldTransformer.screenToWorld(atPoint);
worldPoints.push(worldPoint);
}
const konvaLine = new KonvaLine(null, ref);
const line: ILine = {
id: konvaLine.id(),
points: worldPoints,
color: konvaLine.getColor() || "#ff0000",
type: konvaLine.getLineType() || this.lineType,
width: konvaLine.getLineWidth() || this.lineWidth,
};
lines.push(line);
});
return lines;
}
private getMarkupTexts(): Array<IText> {
const texts = [];
this.konvaLayerFind("Text").forEach((ref) => {
const textSize = 0.02;
const textScale = this._worldTransformer.getScale();
const position = ref.position();
const stageAbsoluteTransform = this._konvaStage.getAbsoluteTransform();
const atPoint = stageAbsoluteTransform.point({ x: position.x, y: position.y });
const worldPoint = this._worldTransformer.screenToWorld(atPoint);
const shape = new KonvaText(null, ref);
const text: IText = {
id: shape.id(),
position: worldPoint,
text: shape.getText(),
text_size: textSize * textScale.y,
angle: shape.getRotation(),
color: shape.getColor(),
font_size: shape.getFontSize() * stageAbsoluteTransform.getMatrix()[0],
};
texts.push(text);
});
return texts;
}
private getMarkupRectangles(): Array<IRectangle> {
const rectangles = [];
this.konvaLayerFind("Rectangle").forEach((ref) => {
const position = ref.position();
const stageAbsoluteTransform = this._konvaStage.getAbsoluteTransform();
const atPoint = stageAbsoluteTransform.point({ x: position.x, y: position.y });
const worldPoint = this._worldTransformer.screenToWorld(atPoint);
const scale = stageAbsoluteTransform.getMatrix()[0];
const shape = new KonvaRectangle(null, ref);
const rectangle: IRectangle = {
id: shape.id(),
position: worldPoint,
width: shape.getWidth() * scale,
height: shape.getHeigth() * scale,
line_width: shape.getLineWidth(),
color: shape.getColor(),
};
rectangles.push(rectangle);
});
return rectangles;
}
private getMarkupEllipses(): Array<IEllipse> {
const ellipses = [];
this.konvaLayerFind("Ellipse").forEach((ref) => {
const position = ref.position();
const stageAbsoluteTransform = this._konvaStage.getAbsoluteTransform();
const atPoint = stageAbsoluteTransform.point({ x: position.x, y: position.y });
const worldPoint = this._worldTransformer.screenToWorld(atPoint);
const scale = stageAbsoluteTransform.getMatrix()[0];
const shape = new KonvaEllipse(null, ref);
const ellipse: IEllipse = {
id: shape.id(),
position: worldPoint,
radius: {
x: ref.getRadiusX() * scale,
y: ref.getRadiusY() * scale,
},
line_width: shape.getLineWidth(),
color: shape.getColor(),
};
ellipses.push(ellipse);
});
return ellipses;
}
private getMarkupArrows(): Array<IArrow> {
const arrows = [];
this.konvaLayerFind("Arrow").forEach((ref) => {
// we need getAbsoluteTransform because inside Konva position starts from {0, 0}
const absoluteTransform = ref.getAbsoluteTransform();
const atStartPoint = absoluteTransform.point({
x: ref.points()[0],
y: ref.points()[1],
});
const worldStartPoint = this._worldTransformer.screenToWorld(atStartPoint);
const atEndPoint = absoluteTransform.point({
x: ref.points()[2],
y: ref.points()[3],
});
const worldEndPoint = this._worldTransformer.screenToWorld(atEndPoint);
const shape = new KonvaArrow(null, ref);
const arrow: IArrow = {
id: shape.id(),
start: worldStartPoint,
end: worldEndPoint,
color: shape.getColor(),
};
arrows.push(arrow);
});
return arrows;
}
private getMarkupImages(): Array<IImage> {
const images = [];
this.konvaLayerFind("Image").forEach((ref) => {
const position = ref.position();
const stageAbsoluteTransform = this._konvaStage.getAbsoluteTransform();
const atPoint = stageAbsoluteTransform.point({ x: position.x, y: position.y });
const worldPoint = this._worldTransformer.screenToWorld(atPoint);
const scale = stageAbsoluteTransform.getMatrix()[0];
const shape = new KonvaImage(null, ref);
const image: IImage = {
id: shape.id(),
position: worldPoint,
src: shape.getSrc(),
width: shape.getWidth() * scale,
height: shape.getHeight() * scale,
};
images.push(image);
});
return images;
}
private getMarkupClouds(): Array<ICloud> {
const clouds = [];
this.konvaLayerFind("Cloud").forEach((ref) => {
const position = ref.position();
const stageAbsoluteTransform = this._konvaStage.getAbsoluteTransform();
const atPoint = stageAbsoluteTransform.point({ x: position.x, y: position.y });
const worldPoint = this._worldTransformer.screenToWorld(atPoint);
const scale = stageAbsoluteTransform.getMatrix()[0];
const shape = new KonvaCloud(null, ref);
const cloud: ICloud = {
id: shape.id(),
position: worldPoint,
width: shape.getWidth() * scale,
height: shape.getHeigth() * scale,
line_width: shape.getLineWidth(),
color: shape.getColor(),
};
clouds.push(cloud);
});
return clouds;
}
private combineMarkupWithDrawing() {
this.clearSelected();
const tempCanvas = document.createElement("canvas");
if (this._konvaStage) {
tempCanvas.width = this._konvaStage.width();
tempCanvas.height = this._konvaStage.height();
const ctx = tempCanvas.getContext("2d");
if (this._container instanceof HTMLCanvasElement) ctx.drawImage(this._container, 0, 0);
ctx.drawImage(this._konvaStage.toCanvas({ pixelRatio: window.devicePixelRatio }), 0, 0);
}
return tempCanvas.toDataURL("image/jpeg", 0.25);
}
private addLine(
linePoints: number[],
color?: string,
type?: MarkupLineType,
width?: number,
id?: string
): KonvaLine | void {
if (!linePoints || linePoints.length === 0) return;
const points: { x: number; y: number }[] = [];
for (let i = 0; i < linePoints.length; i += 2) {
points.push({ x: linePoints[i], y: linePoints[i + 1] });
}
const konvaLine = new KonvaLine({
points,
type: type || this.lineType,
width: width || this.lineWidth,
color: color || this._markupColor.asHex(),
id,
});
this.addObject(konvaLine);
return konvaLine;
}
private createTextInput(pos: Konva.Vector2d, inputX: number, inputY: number, angle: number, text: string): void {
if (!this._textInputRef) {
this._textInputPos = pos;
this._textInputAngle = angle;
this._textInputRef = document.createElement("textarea");
this._textInputRef.style.zIndex = "9999";
this._textInputRef.style.position = "absolute";
this._textInputRef.style.display = "block";
this._textInputRef.style.top = inputY + "px";
this._textInputRef.style.left = inputX + "px";
this._textInputRef.style.fontSize = `${this.fontSize}px`;
this._textInputRef.style.color = `${this._markupColor.asHex()}`;
this._textInputRef.style.fontFamily = "Calibri";
this._textInputRef.onkeydown = (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
this.addText(this._textInputRef.value, this._textInputPos, this._textInputAngle);
}
if (event.key === "Escape") {
event.preventDefault();
this.removeTextInput();
}
};
if (text) this._textInputRef.value = text;
document.body.appendChild(this._textInputRef);
setTimeout(() => {
this._textInputRef.focus();
}, 50);
} else {
this.removeTextInput();
}
}
private removeTextInput(): void {
this._textInputRef?.remove();
this._textInputRef = null;
this._textInputPos = null;
this._textInputAngle = 0;
}
private createImageInput(pos: Konva.Vector2d): void {
if (!this._imageInputRef) {
const convertBase64 = (file) => {
return new Promise<string | ArrayBuffer>((resolve, reject) => {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = () => {
resolve(fileReader.result);
};
fileReader.onerror = (error) => {
reject(error);
};
});
};
this._imageInputPos = pos;
this._imageInputRef = document.createElement("input");
this._imageInputRef.style.display = "none";
this._imageInputRef.type = "file";
this._imageInputRef.accept = "image/png, image/jpeg";
this._imageInputRef.onchange = async (event) => {
const file = (event.target as HTMLInputElement).files[0];
const base64 = await convertBase64(file);
this.addImage({ x: this._imageInputPos.x, y: this._imageInputPos.y }, base64.toString(), 0, 0);
};
this._imageInputRef.oncancel = (event) => {
this.removeImageInput();
};
document.body.appendChild(this._imageInputRef);
setTimeout(() => {
this._imageInputRef.click();
}, 50);
} else {
this.removeImageInput();
}
}
private removeImageInput(): void {
this._imageInputRef?.remove();
this._imageInputRef = null;
this._imageInputPos = null;
}
private addText(
text: string,
position: Konva.Vector2d,
angle?: number,
color?: string,
textSize?: number,
fontSize?: number,
id?: string
): KonvaText | void {
if (!text) return;
// in case of edit - remove old Konva.Text object
this.getSelectedObjects().at(0)?.delete();
this.clearSelected();
this.removeTextInput();
// in case we have old viewpoint without font_size
const tolerance = 1.0e-6;
if (textSize && textSize > tolerance && (!fontSize || fontSize < tolerance)) {
const size = 0.02;
const scale = this._worldTransformer.getScale();
fontSize = textSize / (scale.y / size) / 34;
}
const konvaText = new KonvaText({
position: { x: position.x, y: position.y },
text,
rotation: angle,
fontSize: fontSize || this.fontSize,
color: color || this._markupColor.asHex(),
id,
});
this.addObject(konvaText);
return konvaText;
}
private addRectangle(
position: Konva.Vector2d,
width: number,
height: number,
lineWidth?: number,
color?: string,
id?: string
): KonvaRectangle | void {
if (!position) return;
const konvaRectangle = new KonvaRectangle({
position,
width,
height,
lineWidth: lineWidth || this.lineWidth,
color: color || this._markupColor.asHex(),
id,
});
this.addObject(konvaRectangle);
return konvaRectangle;
}
private addEllipse(
position: { x: number; y: number },
radius: { x: number; y: number },
lineWidth?: number,
color?: string,
id?: string
): KonvaEllipse | void {
if (!position) return;
const konvaEllipse = new KonvaEllipse({
position,
radius,
lineWidth,
color: color || this._markupColor.asHex(),
id,
});
this.addObject(konvaEllipse);
return konvaEllipse;
}
private addArrow(
start: { x: number; y: number },
end: { x: number; y: number },
color?: string,
id?: string
): KonvaArrow | void {
if (!start || !end) return;
const konvaArrow = new KonvaArrow({
start,
end,
color: color || this._markupColor.asHex(),
id,
});
this.addObject(konvaArrow);
return konvaArrow;
}
private addCloud(
position: { x: number; y: number },
width: number,
height: number,
lineWidth?: number,
color?: string,
id?: string
): KonvaCloud | void {
if (!position || !width || !height) return;
const konvaCloud = new KonvaCloud({
position,
width,
height,
color: color || this._markupColor.asHex(),
lineWidth: lineWidth || this.lineWidth,
id,
});
this.addObject(konvaCloud);
return konvaCloud;
}
private addImage(
position: { x: number; y: number },
src: string,
width?: number,
height?: number,
id?: string
): KonvaImage | void {
if (!position || !src) return;
// in case of edit - remove old Image placeholder object
this.getSelectedObjects().at(0)?.delete();
this.clearSelected();
this.removeImageInput();
const konvaImage = new KonvaImage({
position,
src,
width,
height,
maxWidth: this._konvaStage.width() - position.x,
maxHeight: this._konvaStage.height() - position.y,
id,
});
this.addObject(konvaImage);
return konvaImage;
}
}