UNPKG

@inweb/markup

Version:
1,164 lines (986 loc) 37.6 kB
/////////////////////////////////////////////////////////////////////////////// // 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; } }