├── .prettierrc.json ├── custom.d.ts ├── test └── manual │ ├── images │ ├── image1.jpg │ ├── portrait.jpg │ ├── landscape.jpg │ └── landscape_sm.jpg │ ├── TriangleMarker.ts │ └── template.html ├── .hintrc ├── src ├── editor │ ├── ResizeGrip.ts │ ├── RotateGrip.ts │ ├── ShapeMarkerEditor.ts │ ├── ArrowMarkerEditor.ts │ ├── MarkerEditorProperties.ts │ ├── CaptionFrameMarkerEditor.ts │ ├── ShapeOutlineMarkerEditor.ts │ ├── RectangularBoxMarkerGrips.ts │ ├── ImageMarkerEditor.ts │ ├── CalloutMarkerEditor.ts │ ├── UndoRedoManager.ts │ ├── Grip.ts │ ├── FreehandMarkerEditor.ts │ ├── CurveMarkerEditor.ts │ ├── TextMarkerEditor.ts │ ├── TextBlockEditor.ts │ ├── LinearMarkerEditor.ts │ └── PolygonMarkerEditor.ts ├── core │ ├── ISize.ts │ ├── IPoint.ts │ ├── ShapeMarkerBaseState.ts │ ├── CaptionFrameMarkerState.ts │ ├── ShapeOutlineMarkerBaseState.ts │ ├── FreehandMarkerState.ts │ ├── CurveMarkerState.ts │ ├── FontSize.ts │ ├── ArrowMarkerState.ts │ ├── PolygonMarkerState.ts │ ├── CalloutMarkerState.ts │ ├── CustomImageMarker.ts │ ├── LinearMarkerBaseState.ts │ ├── TextMarkerState.ts │ ├── HighlighterMarker.ts │ ├── ImageMarkerBaseState.ts │ ├── XImageMarker.ts │ ├── LineMarker.ts │ ├── FrameMarker.ts │ ├── MarkerBaseState.ts │ ├── EllipseMarker.ts │ ├── CheckImageMarker.ts │ ├── EllipseFrameMarker.ts │ ├── HighlightMarker.ts │ ├── CoverMarker.ts │ ├── TransformMatrix.ts │ ├── RectangularBoxMarkerBaseState.ts │ ├── AnnotationState.ts │ ├── CurveMarker.ts │ ├── ShapeMarkerBase.ts │ ├── SvgFilters.ts │ ├── Activator.ts │ ├── MeasurementMarker.ts │ ├── ShapeOutlineMarkerBase.ts │ ├── ArrowMarker.ts │ ├── FreehandMarker.ts │ ├── PolygonMarker.ts │ ├── CaptionFrameMarker.ts │ ├── ImageMarkerBase.ts │ ├── RectangularBoxMarkerBase.ts │ ├── LinearMarkerBase.ts │ ├── TextMarker.ts │ ├── MarkerBase.ts │ ├── CalloutMarker.ts │ └── SvgHelper.ts ├── viewer.ts ├── editor.ts ├── index.ts ├── assets │ └── markerjs-logo-m.svg └── core.ts ├── tsconfig.json ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── create-release.yml ├── .gitignore ├── CONTRIBUTING.md ├── eslint.config.mjs ├── rollup.config.dev.mjs ├── LICENSE ├── package.json ├── rollup.config.prod.mjs └── README.md /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /test/manual/images/image1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ailon/markerjs3/HEAD/test/manual/images/image1.jpg -------------------------------------------------------------------------------- /test/manual/images/portrait.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ailon/markerjs3/HEAD/test/manual/images/portrait.jpg -------------------------------------------------------------------------------- /test/manual/images/landscape.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ailon/markerjs3/HEAD/test/manual/images/landscape.jpg -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "no-inline-styles": "off" 7 | } 8 | } -------------------------------------------------------------------------------- /test/manual/images/landscape_sm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ailon/markerjs3/HEAD/test/manual/images/landscape_sm.jpg -------------------------------------------------------------------------------- /src/editor/ResizeGrip.ts: -------------------------------------------------------------------------------- 1 | import { Grip } from './Grip'; 2 | 3 | /** 4 | * Represents a resize grip. 5 | */ 6 | export class ResizeGrip extends Grip {} 7 | -------------------------------------------------------------------------------- /src/core/ISize.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes a size 3 | */ 4 | export interface ISize { 5 | /** 6 | * Width 7 | */ 8 | width: number; 9 | /** 10 | * Height 11 | */ 12 | height: number; 13 | } 14 | -------------------------------------------------------------------------------- /src/core/IPoint.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes a point objects use internally 3 | */ 4 | export interface IPoint { 5 | /** 6 | * Horizontal (X) coordinate. 7 | */ 8 | x: number; 9 | /** 10 | * Vertical (Y) coordinate. 11 | */ 12 | y: number; 13 | } 14 | -------------------------------------------------------------------------------- /src/core/ShapeMarkerBaseState.ts: -------------------------------------------------------------------------------- 1 | import { ShapeOutlineMarkerBaseState } from './ShapeOutlineMarkerBaseState'; 2 | 3 | /** 4 | * Represents filled shape's state. 5 | */ 6 | export interface ShapeMarkerBaseState extends ShapeOutlineMarkerBaseState { 7 | /** 8 | * Marker's fill color. 9 | */ 10 | fillColor: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/core/CaptionFrameMarkerState.ts: -------------------------------------------------------------------------------- 1 | import { ShapeMarkerBaseState } from './ShapeMarkerBaseState'; 2 | import { TextMarkerState } from './TextMarkerState'; 3 | 4 | /** 5 | * Represents the state of a caption frame marker. 6 | */ 7 | export interface CaptionFrameMarkerState 8 | extends TextMarkerState, 9 | ShapeMarkerBaseState {} 10 | -------------------------------------------------------------------------------- /src/core/ShapeOutlineMarkerBaseState.ts: -------------------------------------------------------------------------------- 1 | import { RectangularBoxMarkerBaseState } from './RectangularBoxMarkerBaseState'; 2 | 3 | /** 4 | * Represents outline shape's state. 5 | */ 6 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 7 | export interface ShapeOutlineMarkerBaseState 8 | extends RectangularBoxMarkerBaseState {} 9 | -------------------------------------------------------------------------------- /src/core/FreehandMarkerState.ts: -------------------------------------------------------------------------------- 1 | import { IPoint } from './IPoint'; 2 | import { MarkerBaseState } from './MarkerBaseState'; 3 | 4 | /** 5 | * Represents the state of a freehand marker. 6 | */ 7 | export interface FreehandMarkerState extends MarkerBaseState { 8 | /** 9 | * Points of the freehand line. 10 | */ 11 | points: Array; 12 | } 13 | -------------------------------------------------------------------------------- /src/core/CurveMarkerState.ts: -------------------------------------------------------------------------------- 1 | import { LinearMarkerBaseState } from './LinearMarkerBaseState'; 2 | 3 | export interface CurveMarkerState extends LinearMarkerBaseState { 4 | /** 5 | * x coordinate for the curve control point. 6 | */ 7 | curveX: number; 8 | /** 9 | * y coordinate for the curve control point. 10 | */ 11 | curveY: number; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "lib": ["es2015", "dom"], 5 | "target": "es6", 6 | "forceConsistentCasingInFileNames": true, 7 | "preserveSymlinks": true, 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true 11 | }, 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /src/editor/RotateGrip.ts: -------------------------------------------------------------------------------- 1 | import { Grip } from './Grip'; 2 | 3 | /** 4 | * Represents a rotation grip. 5 | */ 6 | export class RotateGrip extends Grip { 7 | constructor() { 8 | super(); 9 | // swap fill and stroke colors 10 | const oldFill = this.fillColor; 11 | this.fillColor = this.strokeColor; 12 | this.strokeColor = oldFill; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/core/FontSize.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Font size settings. 3 | */ 4 | export interface FontSize { 5 | /** 6 | * Number of {@link units}. 7 | */ 8 | value: number; 9 | /** 10 | * Units the {@link value} represents. 11 | */ 12 | units: string; 13 | /** 14 | * Value increment/decrement step for controls cycling through the size values. 15 | */ 16 | step: number; 17 | } 18 | -------------------------------------------------------------------------------- /src/editor/ShapeMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { ShapeMarkerBase } from '../core'; 2 | import { ShapeOutlineMarkerEditor } from './ShapeOutlineMarkerEditor'; 3 | 4 | /** 5 | * Editor for filled shape markers. 6 | * 7 | * @summary Filled shape marker editor. 8 | * @group Editors 9 | */ 10 | export class ShapeMarkerEditor< 11 | TMarkerType extends ShapeMarkerBase = ShapeMarkerBase, 12 | > extends ShapeOutlineMarkerEditor {} 13 | -------------------------------------------------------------------------------- /src/core/ArrowMarkerState.ts: -------------------------------------------------------------------------------- 1 | import { LinearMarkerBaseState } from './LinearMarkerBaseState'; 2 | 3 | /** 4 | * Arrow type. 5 | * 6 | * Specifies whether the arrow should be drawn at the start, end, both ends or none. 7 | */ 8 | export type ArrowType = 'both' | 'start' | 'end' | 'none'; 9 | 10 | /** 11 | * Represents the state of the arrow marker. 12 | */ 13 | export interface ArrowMarkerState extends LinearMarkerBaseState { 14 | arrowType: ArrowType; 15 | } 16 | -------------------------------------------------------------------------------- /src/core/PolygonMarkerState.ts: -------------------------------------------------------------------------------- 1 | import { IPoint } from './IPoint'; 2 | import { MarkerBaseState } from './MarkerBaseState'; 3 | 4 | /** 5 | * Represents polygon marker's state used to save and restore state. 6 | */ 7 | export interface PolygonMarkerState extends MarkerBaseState { 8 | /** 9 | * Polygon points. 10 | */ 11 | points: Array; 12 | /** 13 | * Marker's fill color. 14 | * 15 | * @since 3.6.2 16 | */ 17 | fillColor?: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/core/CalloutMarkerState.ts: -------------------------------------------------------------------------------- 1 | import { IPoint } from './IPoint'; 2 | import { ShapeMarkerBaseState } from './ShapeMarkerBaseState'; 3 | import { TextMarkerState } from './TextMarkerState'; 4 | 5 | /** 6 | * Represents the state of a callout marker. 7 | */ 8 | export interface CalloutMarkerState 9 | extends TextMarkerState, 10 | ShapeMarkerBaseState { 11 | /** 12 | * Coordinates of the position of the tip of the callout. 13 | */ 14 | tipPosition: IPoint; 15 | } 16 | -------------------------------------------------------------------------------- /src/viewer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Viewer 3 | * @category API Reference 4 | */ 5 | import { MarkerView } from './MarkerView'; 6 | 7 | export { 8 | MarkerView, 9 | MarkerViewEventData, 10 | MarkerEventData, 11 | MarkerViewEventMap, 12 | } from './MarkerView'; 13 | 14 | if ( 15 | window && 16 | window.customElements && 17 | window.customElements.get('mjs-marker-view') === undefined 18 | ) { 19 | window.customElements.define('mjs-marker-view', MarkerView); 20 | } 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # No Pull Requests, please 2 | 3 | marker.js 3 is not a "true" OSS project. It's a personal (private) project with a source code repository publicly available. 4 | 5 | Therefore, it would be an unnecessary burden to resolve all the legal and other issues arising from accepting third party code contributions. 6 | 7 | If you find a bug or want to make a feature request, please open an issue, and let's discuss it there. 8 | 9 | Thank you, 10 | Alan. 11 | -------------------------------------------------------------------------------- /src/core/CustomImageMarker.ts: -------------------------------------------------------------------------------- 1 | import { ImageMarkerBase } from './ImageMarkerBase'; 2 | 3 | /** 4 | * Used to represent user-set images. 5 | * 6 | * Use this marker to display custom images at runtime. 7 | * For example, you can use this type to represent emojis selected in an emoji picker. 8 | * 9 | * @summary Custom image marker. 10 | * @group Markers 11 | */ 12 | export class CustomImageMarker extends ImageMarkerBase { 13 | public static typeName = 'CustomImageMarker'; 14 | public static title = 'Custom image marker'; 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | 14 | # dev build 15 | /build-dev 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .vscode/settings.json 28 | .vscode/launch.json 29 | /dts 30 | /docs 31 | /docs-md 32 | debug.log 33 | -------------------------------------------------------------------------------- /src/core/LinearMarkerBaseState.ts: -------------------------------------------------------------------------------- 1 | import { MarkerBaseState } from '../core/MarkerBaseState'; 2 | 3 | /** 4 | * Represents base state for line-style markers. 5 | */ 6 | export interface LinearMarkerBaseState extends MarkerBaseState { 7 | /** 8 | * x coordinate for the first end-point. 9 | */ 10 | x1: number; 11 | /** 12 | * y coordinate for the first end-point. 13 | */ 14 | y1: number; 15 | /** 16 | * x coordinate for the second end-point. 17 | */ 18 | x2: number; 19 | /** 20 | * y coordinate for the second end-point. 21 | */ 22 | y2: number; 23 | } 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to marker.js 3 2 | 3 | Thank you for your interest in contributing to marker.js 3. Before you proceed, please read this short guide. 4 | 5 | ## No Pull Requests, please 6 | marker.js 3 is not a "true" OSS project. It's a personal (private) project with a source code repository publicly available. 7 | 8 | Therefore, it would be an unnecessary burden to resolve all the legal and other issues arising from accepting third party code contributions. 9 | 10 | If you find a bug or want to make a feature request, please open an issue, and let's discuss it there. 11 | 12 | Thank you, 13 | Alan. 14 | -------------------------------------------------------------------------------- /src/core/TextMarkerState.ts: -------------------------------------------------------------------------------- 1 | import { FontSize } from './FontSize'; 2 | import { RectangularBoxMarkerBaseState } from './RectangularBoxMarkerBaseState'; 3 | 4 | /** 5 | * Represents a state snapshot of a TextMarker. 6 | */ 7 | export interface TextMarkerState extends RectangularBoxMarkerBaseState { 8 | /** 9 | * Text color. 10 | */ 11 | color: string; 12 | /** 13 | * Font family. 14 | */ 15 | fontFamily: string; 16 | /** 17 | * Font size. 18 | */ 19 | fontSize: FontSize; 20 | /** 21 | * Text content. 22 | */ 23 | text: string; 24 | /** 25 | * Text padding. 26 | */ 27 | padding?: number; 28 | } 29 | -------------------------------------------------------------------------------- /src/core/HighlighterMarker.ts: -------------------------------------------------------------------------------- 1 | import { FreehandMarker } from './FreehandMarker'; 2 | 3 | /** 4 | * Highlighter marker imitates a freeform highlighter pen. 5 | * 6 | * @summary Semi-transparent freeform marker. 7 | * @group Markers 8 | * @since 3.2.0 9 | */ 10 | export class HighlighterMarker extends FreehandMarker { 11 | public static typeName = 'HighlighterMarker'; 12 | public static title = 'Highlighter marker'; 13 | public static applyDefaultFilter = false; 14 | 15 | constructor(container: SVGGElement) { 16 | super(container); 17 | 18 | this.opacity = 0.5; 19 | this.strokeColor = '#ffff00'; 20 | this.strokeWidth = 20; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/manual/TriangleMarker.ts: -------------------------------------------------------------------------------- 1 | import { ShapeOutlineMarkerBase } from '../../src/core'; 2 | 3 | export class TriangleMarker extends ShapeOutlineMarkerBase { 4 | public static typeName = 'TriangleMarker'; 5 | public static title = 'Triangle marker'; 6 | 7 | constructor(container: SVGGElement) { 8 | super(container); 9 | 10 | this.strokeColor = '#ff0000'; 11 | this.strokeWidth = 3; 12 | } 13 | 14 | protected getPath( 15 | width: number = this.width, 16 | height: number = this.height, 17 | ): string { 18 | const result = `M ${width / 2} 0 19 | L ${width} ${height} 20 | L 0 ${height} Z`; 21 | 22 | return result; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/editor/ArrowMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { ArrowMarker, ArrowType } from '../core'; 2 | import { LinearMarkerEditor } from './LinearMarkerEditor'; 3 | 4 | /** 5 | * Editor for arrow markers. 6 | * 7 | * @summary Arrow marker editor. 8 | * @group Editors 9 | */ 10 | export class ArrowMarkerEditor< 11 | TMarkerType extends ArrowMarker = ArrowMarker, 12 | > extends LinearMarkerEditor { 13 | /** 14 | * Sets the arrow type. 15 | */ 16 | public set arrowType(value: ArrowType) { 17 | this.marker.arrowType = value; 18 | } 19 | 20 | /** 21 | * Returns the arrow type. 22 | */ 23 | public get arrowType(): ArrowType { 24 | return this.marker.arrowType; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/editor/MarkerEditorProperties.ts: -------------------------------------------------------------------------------- 1 | import { MarkerBase } from '../core'; 2 | 3 | /** 4 | * Properties for marker editor. 5 | */ 6 | export interface MarkerEditorProperties< 7 | TMarkerType extends MarkerBase = MarkerBase, 8 | > { 9 | /** 10 | * SVG container for the marker and editor elements. 11 | */ 12 | container: SVGGElement; 13 | /** 14 | * HTML overlay container for editor's HTML elements (such as label text editor). 15 | */ 16 | overlayContainer: HTMLDivElement; 17 | /** 18 | * Type of marker to create. 19 | */ 20 | markerType: new (container: SVGGElement) => TMarkerType; 21 | /** 22 | * Previously created marker to edit. 23 | */ 24 | marker?: TMarkerType; 25 | } 26 | -------------------------------------------------------------------------------- /src/core/ImageMarkerBaseState.ts: -------------------------------------------------------------------------------- 1 | import { RectangularBoxMarkerBaseState } from './RectangularBoxMarkerBaseState'; 2 | 3 | /** 4 | * The type of image (svg or bitmap). 5 | * 6 | * Used in {@link Core!ImageMarkerBase | ImageMarkerBase } and its descendants. 7 | */ 8 | export type ImageType = 'svg' | 'bitmap'; 9 | 10 | /** 11 | * Represents image marker's state. 12 | */ 13 | export interface ImageMarkerBaseState extends RectangularBoxMarkerBaseState { 14 | /** 15 | * Type of the image: SVG or bitmap. 16 | */ 17 | imageType?: ImageType; 18 | /** 19 | * SVG markup of the SVG image. 20 | */ 21 | svgString?: string; 22 | /** 23 | * Image source (URL or base64 encoded image). 24 | */ 25 | imageSrc?: string; 26 | } 27 | -------------------------------------------------------------------------------- /src/core/XImageMarker.ts: -------------------------------------------------------------------------------- 1 | import { ImageMarkerBase } from './ImageMarkerBase'; 2 | 3 | /** 4 | * X mark image marker. 5 | * 6 | * @summary X (crossed) image marker. 7 | * @group Markers 8 | */ 9 | export class XImageMarker extends ImageMarkerBase { 10 | public static typeName = 'XImageMarker'; 11 | public static title = 'X image marker'; 12 | 13 | constructor(container: SVGGElement) { 14 | super(container); 15 | 16 | this._svgString = ``; 17 | this.strokeColor = '#d00000'; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/core/LineMarker.ts: -------------------------------------------------------------------------------- 1 | import { LinearMarkerBase } from './LinearMarkerBase'; 2 | 3 | /** 4 | * Line marker represents a simple straight line. 5 | * 6 | * @summary Line marker. 7 | * @group Markers 8 | */ 9 | export class LineMarker extends LinearMarkerBase { 10 | public static typeName = 'LineMarker'; 11 | public static title = 'Line marker'; 12 | 13 | constructor(container: SVGGElement) { 14 | super(container); 15 | 16 | this.strokeColor = '#ff0000'; 17 | this.strokeWidth = 3; 18 | 19 | this.createVisual = this.createVisual.bind(this); 20 | } 21 | 22 | protected getPath(): string { 23 | // svg path for line 24 | const result = `M ${this.x1} ${this.y1} L ${this.x2} ${this.y2}`; 25 | 26 | return result; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/FrameMarker.ts: -------------------------------------------------------------------------------- 1 | import { ShapeOutlineMarkerBase } from './ShapeOutlineMarkerBase'; 2 | 3 | /** 4 | * Frame marker represents unfilled rectangle shape. 5 | * 6 | * @summary Unfilled rectangle marker. 7 | * @group Markers 8 | */ 9 | export class FrameMarker extends ShapeOutlineMarkerBase { 10 | public static typeName = 'FrameMarker'; 11 | public static title = 'Frame marker'; 12 | 13 | constructor(container: SVGGElement) { 14 | super(container); 15 | 16 | this.strokeColor = '#ff0000'; 17 | this.strokeWidth = 3; 18 | } 19 | 20 | protected getPath( 21 | width: number = this.width, 22 | height: number = this.height, 23 | ): string { 24 | const result = `M 0 0 25 | H ${width} 26 | V ${height} 27 | H 0 28 | V 0 Z`; 29 | 30 | return result; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/core/MarkerBaseState.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents marker's state used to save and restore state. 3 | * 4 | * The state can then be serialized and stored for future use like to continue 5 | * annotation in the future, display it in a viewer or render as a static image. 6 | */ 7 | export interface MarkerBaseState { 8 | /** 9 | * Marker's type name. 10 | */ 11 | typeName: string; 12 | /** 13 | * Additional information about the marker. 14 | */ 15 | notes?: string; 16 | 17 | /** 18 | * Marker's stroke (outline) color. 19 | */ 20 | strokeColor?: string; 21 | /** 22 | * Marker's stroke (outline) width. 23 | */ 24 | strokeWidth?: number; 25 | /** 26 | * Marker's stroke (outline) dash array. 27 | */ 28 | strokeDasharray?: string; 29 | /** 30 | * Marker's opacity. 31 | */ 32 | opacity?: number; 33 | } 34 | -------------------------------------------------------------------------------- /src/core/EllipseMarker.ts: -------------------------------------------------------------------------------- 1 | import { ShapeMarkerBase } from './ShapeMarkerBase'; 2 | 3 | /** 4 | * Ellipse marker is a filled ellipse marker. 5 | * 6 | * @summary Filled ellipse marker. 7 | * @group Markers 8 | */ 9 | export class EllipseMarker extends ShapeMarkerBase { 10 | public static typeName = 'EllipseMarker'; 11 | public static title = 'Ellipse marker'; 12 | 13 | constructor(container: SVGGElement) { 14 | super(container); 15 | 16 | this.fillColor = '#ff0000'; 17 | this.strokeColor = '#ff0000'; 18 | } 19 | 20 | protected getPath( 21 | width: number = this.width, 22 | height: number = this.height, 23 | ): string { 24 | const result = `M ${width / 2} 0 25 | a ${width / 2} ${height / 2} 0 1 0 0 ${height} 26 | a ${width / 2} ${height / 2} 0 1 0 0 -${height} z`; 27 | 28 | return result; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/core/CheckImageMarker.ts: -------------------------------------------------------------------------------- 1 | import { ImageMarkerBase } from './ImageMarkerBase'; 2 | 3 | /** 4 | * Check mark marker. 5 | * 6 | * Represents a check mark image marker. Can be used to quickly mark something as correct, or 7 | * similar use cases. 8 | * 9 | * @summary Check mark image marker. 10 | * @group Markers 11 | */ 12 | export class CheckImageMarker extends ImageMarkerBase { 13 | public static typeName = 'CheckImageMarker'; 14 | public static title = 'Check image marker'; 15 | 16 | constructor(container: SVGGElement) { 17 | super(container); 18 | 19 | this._svgString = ``; 20 | this.strokeColor = '#008000'; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/EllipseFrameMarker.ts: -------------------------------------------------------------------------------- 1 | import { ShapeOutlineMarkerBase } from './ShapeOutlineMarkerBase'; 2 | 3 | /** 4 | * Ellipse frame marker represents unfilled circle/ellipse shape. 5 | * 6 | * @summary Unfilled ellipse marker. 7 | * @group Markers 8 | */ 9 | export class EllipseFrameMarker extends ShapeOutlineMarkerBase { 10 | public static typeName = 'EllipseFrameMarker'; 11 | public static title = 'Ellipse frame marker'; 12 | 13 | constructor(container: SVGGElement) { 14 | super(container); 15 | 16 | this.strokeColor = '#ff0000'; 17 | this.strokeWidth = 3; 18 | } 19 | 20 | protected getPath( 21 | width: number = this.width, 22 | height: number = this.height, 23 | ): string { 24 | const result = `M ${width / 2} 0 25 | a ${width / 2} ${height / 2} 0 1 0 0 ${height} 26 | a ${width / 2} ${height / 2} 0 1 0 0 -${height} z`; 27 | 28 | return result; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/core/HighlightMarker.ts: -------------------------------------------------------------------------------- 1 | import { ShapeMarkerBase } from './ShapeMarkerBase'; 2 | 3 | /** 4 | * Highlight marker is a semi-transparent rectangular marker. 5 | * 6 | * @summary Semi-transparent rectangular marker. 7 | * @group Markers 8 | */ 9 | export class HighlightMarker extends ShapeMarkerBase { 10 | public static typeName = 'HighlightMarker'; 11 | public static title = 'Highlight marker'; 12 | public static applyDefaultFilter = false; 13 | 14 | constructor(container: SVGGElement) { 15 | super(container); 16 | 17 | this.fillColor = '#ffff00'; 18 | this.opacity = 0.5; 19 | this.strokeColor = 'transparent'; 20 | this.strokeWidth = 0; 21 | } 22 | 23 | protected getPath( 24 | width: number = this.width, 25 | height: number = this.height, 26 | ): string { 27 | const result = `M 0 0 28 | H ${width} 29 | V ${height} 30 | H 0 31 | V 0 Z`; 32 | 33 | return result; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/core/CoverMarker.ts: -------------------------------------------------------------------------------- 1 | import { ShapeMarkerBase } from './ShapeMarkerBase'; 2 | 3 | /** 4 | * Cover marker is a filled rectangle marker. 5 | * 6 | * A typical use case is to cover some area of the image with a colored rectangle as a "redaction". 7 | * 8 | * @summary Filled rectangle marker. 9 | * @group Markers 10 | */ 11 | export class CoverMarker extends ShapeMarkerBase { 12 | public static typeName = 'CoverMarker'; 13 | public static title = 'Cover marker'; 14 | public static applyDefaultFilter = false; 15 | 16 | constructor(container: SVGGElement) { 17 | super(container); 18 | 19 | this.fillColor = '#000000'; 20 | this.strokeColor = 'transparent'; 21 | this.strokeWidth = 0; 22 | } 23 | 24 | protected getPath( 25 | width: number = this.width, 26 | height: number = this.height, 27 | ): string { 28 | const result = `M 0 0 29 | H ${width} 30 | V ${height} 31 | H 0 32 | V 0 Z`; 33 | 34 | return result; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/core/TransformMatrix.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a simplified version of the SVGMatrix. 3 | */ 4 | export interface ITransformMatrix { 5 | a: number; 6 | b: number; 7 | c: number; 8 | d: number; 9 | e: number; 10 | f: number; 11 | } 12 | 13 | /** 14 | * A utility class to transform between SVGMatrix and its simplified representation. 15 | */ 16 | export class TransformMatrix { 17 | public static toITransformMatrix(matrix: SVGMatrix): ITransformMatrix { 18 | return { 19 | a: matrix.a, 20 | b: matrix.b, 21 | c: matrix.c, 22 | d: matrix.d, 23 | e: matrix.e, 24 | f: matrix.f, 25 | }; 26 | } 27 | public static toSVGMatrix( 28 | currentMatrix: SVGMatrix, 29 | newMatrix: ITransformMatrix, 30 | ): SVGMatrix { 31 | currentMatrix.a = newMatrix.a; 32 | currentMatrix.b = newMatrix.b; 33 | currentMatrix.c = newMatrix.c; 34 | currentMatrix.d = newMatrix.d; 35 | currentMatrix.e = newMatrix.e; 36 | currentMatrix.f = newMatrix.f; 37 | return currentMatrix; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Create Release 17 | run: gh release create ${GITHUB_REF#refs/*/} -t ${GITHUB_REF#refs/*/} 18 | env: 19 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | - name: Get the version 22 | id: get_version 23 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 24 | 25 | - name: Run a multi-line script 26 | run: | 27 | yarn install 28 | yarn build 29 | cd dist && zip -r ../markerjs3-${{ steps.get_version.outputs.VERSION }}.zip ./* 30 | 31 | - name: Upload Release Asset 32 | run: | 33 | gh release upload ${GITHUB_REF#refs/*/} ./markerjs3-${{ steps.get_version.outputs.VERSION }}.zip --clobber 34 | env: 35 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /src/core/RectangularBoxMarkerBaseState.ts: -------------------------------------------------------------------------------- 1 | import { MarkerBaseState } from '../core/MarkerBaseState'; 2 | import { ITransformMatrix } from './TransformMatrix'; 3 | 4 | /** 5 | * Represents a state snapshot of a RectangularBoxMarkerBase. 6 | */ 7 | export interface RectangularBoxMarkerBaseState extends MarkerBaseState { 8 | /** 9 | * x coordinate of the top-left corner. 10 | */ 11 | left: number; 12 | /** 13 | * y coordinate of the top-left corner. 14 | */ 15 | top: number; 16 | /** 17 | * Marker's width. 18 | */ 19 | width: number; 20 | /** 21 | * Marker's height. 22 | */ 23 | height: number; 24 | /** 25 | * Marker's rotation angle. 26 | */ 27 | rotationAngle: number; 28 | 29 | /** 30 | * Visual transform matrix. 31 | * 32 | * Used to correctly position and rotate marker. 33 | */ 34 | visualTransformMatrix?: ITransformMatrix; 35 | /** 36 | * Container transform matrix. 37 | * 38 | * Used to correctly position and rotate marker. 39 | */ 40 | containerTransformMatrix?: ITransformMatrix; 41 | } 42 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; 2 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 3 | import globals from "globals"; 4 | import tsParser from "@typescript-eslint/parser"; 5 | import path from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | import js from "@eslint/js"; 8 | import { FlatCompat } from "@eslint/eslintrc"; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all 16 | }); 17 | 18 | export default defineConfig([globalIgnores(["**/*.svg"]), { 19 | extends: compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"), 20 | 21 | plugins: { 22 | "@typescript-eslint": typescriptEslint, 23 | }, 24 | 25 | languageOptions: { 26 | globals: { 27 | ...globals.browser, 28 | ...globals.node, 29 | }, 30 | 31 | parser: tsParser, 32 | }, 33 | }]); -------------------------------------------------------------------------------- /src/core/AnnotationState.ts: -------------------------------------------------------------------------------- 1 | import { MarkerBaseState } from './MarkerBaseState'; 2 | 3 | /** 4 | * Represents the state of the annotation. 5 | * 6 | * The state is returned by {@link Editor!MarkerArea.getState | MarkerArea.getState()} and can be used to 7 | * restore the annotation in {@link Editor!MarkerArea | MarkerArea} 8 | * with {@link Editor!MarkerArea.restoreState | MarkerArea.restoreState()} 9 | * or passed to {@link Viewer!MarkerView.show | MakerView.show()} 10 | * or {@link Renderer!Renderer.rasterize | Renderer.rasterize()}. 11 | */ 12 | export interface AnnotationState { 13 | /** 14 | * Version of the annotation state format. 15 | * 16 | * Equals to 3 for the current version. 17 | */ 18 | version?: number; 19 | 20 | /** 21 | * Width of the annotation. 22 | */ 23 | width: number; 24 | /** 25 | * Height of the annotation. 26 | */ 27 | height: number; 28 | 29 | /** 30 | * Default SVG filter to apply to markers in the annotation. 31 | * (e.g. drop shadow, outline, glow) 32 | * 33 | * @since 3.2.0 34 | */ 35 | defaultFilter?: string; 36 | 37 | /** 38 | * Array of marker states for markers in the annotation. 39 | */ 40 | markers: MarkerBaseState[]; 41 | } 42 | -------------------------------------------------------------------------------- /rollup.config.dev.mjs: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import htmlTemplate from 'rollup-plugin-generate-html-template'; 3 | import dev from 'rollup-plugin-dev'; 4 | import livereload from 'rollup-plugin-livereload'; 5 | // import del from 'rollup-plugin-delete'; 6 | import copy from 'rollup-plugin-copy'; 7 | import svgo from 'rollup-plugin-svgo'; 8 | import resolve from '@rollup/plugin-node-resolve'; 9 | import json from '@rollup/plugin-json'; 10 | 11 | export default { 12 | preserveSymlinks: false, 13 | input: ['test/manual/index.ts'], 14 | output: { 15 | dir: 'build-dev', 16 | format: 'umd', 17 | sourcemap: true, 18 | name: 'markerjs3', 19 | }, 20 | plugins: [ 21 | //del({ targets: 'build-dev/*' }), 22 | resolve(), 23 | json(), 24 | typescript(), 25 | svgo(), 26 | htmlTemplate({ 27 | template: 'test/manual/template.html', 28 | target: 'index.html', 29 | }), 30 | copy({ 31 | targets: [ 32 | { 33 | src: 'test/manual/images/**/*', 34 | dest: 'build-dev/images', 35 | }, 36 | ], 37 | copyOnce: true, 38 | }), 39 | dev({ host: '0.0.0.0', dirs: ['build-dev'], port: 8088 }), 40 | livereload('build-dev'), 41 | ], 42 | }; 43 | -------------------------------------------------------------------------------- /src/editor/CaptionFrameMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { CaptionFrameMarker } from '../core'; 2 | import { MarkerEditorProperties } from './MarkerEditorProperties'; 3 | import { TextMarkerEditor } from './TextMarkerEditor'; 4 | 5 | /** 6 | * Editor for caption frame markers. 7 | * 8 | * @summary Caption frame marker editor. 9 | * @group Editors 10 | */ 11 | export class CaptionFrameMarkerEditor< 12 | TMarkerType extends CaptionFrameMarker = CaptionFrameMarker, 13 | > extends TextMarkerEditor { 14 | constructor(properties: MarkerEditorProperties) { 15 | super(properties); 16 | 17 | this.disabledResizeGrips = []; 18 | this._creationStyle = 'draw'; 19 | } 20 | 21 | protected setSize(): void { 22 | super.setSize(); 23 | this.textBlockEditorContainer.style.transform = `translate(${ 24 | this.marker.left 25 | }px, ${this.marker.top + this.marker.strokeWidth / 2}px)`; 26 | if (this.marker.textBlock.textSize) { 27 | const height = 28 | this.marker.textBlock.textSize.height + this.marker.padding * 2; 29 | this.textBlockEditorContainer.style.width = `${this.marker.width}px`; 30 | this.textBlockEditorContainer.style.height = `${height}px`; 31 | this.textBlockEditor.width = this.marker.width; 32 | this.textBlockEditor.height = height; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | marker.js 3 Linkware License 2 | 3 | Copyright (c) 2024 Alan Mendelevich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | 1. The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | 2. Link back to the Software website displayed during operation of the Software 16 | or an equivalent prominent public attribution must be retained. Alternative 17 | commercial licenses can be obtained to remove this clause. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. -------------------------------------------------------------------------------- /src/editor/ShapeOutlineMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { IPoint, ShapeOutlineMarkerBase } from '../core'; 2 | import { MarkerEditorProperties } from './MarkerEditorProperties'; 3 | import { RectangularBoxMarkerBaseEditor } from './RectangularBoxMarkerBaseEditor'; 4 | 5 | /** 6 | * Editor for shape outline markers. 7 | * 8 | * @summary Shape outline (unfilled shape) marker editor. 9 | * @group Editors 10 | */ 11 | export class ShapeOutlineMarkerEditor< 12 | TMarkerType extends ShapeOutlineMarkerBase = ShapeOutlineMarkerBase, 13 | > extends RectangularBoxMarkerBaseEditor { 14 | constructor(properties: MarkerEditorProperties) { 15 | super(properties); 16 | 17 | this._creationStyle = 'draw'; 18 | } 19 | 20 | public override pointerDown( 21 | point: IPoint, 22 | target?: EventTarget, 23 | ev?: PointerEvent, 24 | ): void { 25 | super.pointerDown(point, target, ev); 26 | if (this.state === 'new') { 27 | this.marker.createVisual(); 28 | 29 | this.marker.moveVisual(point); 30 | 31 | this._state = 'creating'; 32 | } 33 | } 34 | 35 | protected resize(point: IPoint, preserveAspectRatio = false): void { 36 | super.resize(point, preserveAspectRatio); 37 | this.setSize(); 38 | } 39 | 40 | public override pointerUp(point: IPoint, ev?: PointerEvent): void { 41 | super.pointerUp(point, ev); 42 | this.setSize(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/editor/RectangularBoxMarkerGrips.ts: -------------------------------------------------------------------------------- 1 | import { GripLocation } from './Grip'; 2 | import { ResizeGrip } from './ResizeGrip'; 3 | 4 | /** 5 | * RectangularBoxMarkerGrips is a set of resize/rotation grips for a rectangular marker. 6 | */ 7 | export class RectangularBoxMarkerGrips { 8 | public grips = new Map([ 9 | ['topleft', new ResizeGrip()], 10 | ['topcenter', new ResizeGrip()], 11 | ['topright', new ResizeGrip()], 12 | ['leftcenter', new ResizeGrip()], 13 | ['rightcenter', new ResizeGrip()], 14 | ['bottomleft', new ResizeGrip()], 15 | ['bottomcenter', new ResizeGrip()], 16 | ['bottomright', new ResizeGrip()], 17 | ]); 18 | /** 19 | * Creates a new marker grip set. 20 | */ 21 | constructor() { 22 | this.findGripByVisual = this.findGripByVisual.bind(this); 23 | } 24 | 25 | /** 26 | * Returns a marker grip owning the specified visual. 27 | * @param gripVisual - visual for owner to be determined. 28 | */ 29 | public findGripByVisual(gripVisual: EventTarget): ResizeGrip | undefined { 30 | for (const grip of this.grips.values()) { 31 | if (grip.ownsTarget(gripVisual)) { 32 | return grip; 33 | } 34 | } 35 | return undefined; 36 | } 37 | 38 | /** 39 | * Returns a grip by its location. 40 | * @param location 41 | * @returns 42 | */ 43 | public getGrip(location: GripLocation): ResizeGrip { 44 | return this.grips.get(location)!; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/editor/ImageMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { ImageMarkerBase, IPoint } from '../core'; 2 | import { MarkerEditorProperties } from './MarkerEditorProperties'; 3 | import { RectangularBoxMarkerBaseEditor } from './RectangularBoxMarkerBaseEditor'; 4 | 5 | /** 6 | * Editor for image markers. 7 | * 8 | * @summary Image marker editor. 9 | * @group Editors 10 | */ 11 | export class ImageMarkerEditor< 12 | TMarkerType extends ImageMarkerBase = ImageMarkerBase, 13 | > extends RectangularBoxMarkerBaseEditor { 14 | constructor(properties: MarkerEditorProperties) { 15 | super(properties); 16 | 17 | this._creationStyle = 'drop'; 18 | 19 | this.disabledResizeGrips = [ 20 | 'topcenter', 21 | 'bottomcenter', 22 | 'leftcenter', 23 | 'rightcenter', 24 | ]; 25 | 26 | this.pointerDown = this.pointerDown.bind(this); 27 | this.pointerUp = this.pointerUp.bind(this); 28 | this.resize = this.resize.bind(this); 29 | } 30 | 31 | public override pointerDown( 32 | point: IPoint, 33 | target?: EventTarget, 34 | ev?: PointerEvent, 35 | ): void { 36 | super.pointerDown(point, target, ev); 37 | 38 | if (this.state === 'new') { 39 | this.marker.createVisual(); 40 | 41 | this.marker.moveVisual(point); 42 | 43 | this._state = 'creating'; 44 | } 45 | } 46 | 47 | public override pointerUp(point: IPoint, ev?: PointerEvent): void { 48 | super.pointerUp(point, ev); 49 | this.setSize(); 50 | this.adjustControlBox(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/core/CurveMarker.ts: -------------------------------------------------------------------------------- 1 | import { CurveMarkerState } from './CurveMarkerState'; 2 | import { LineMarker } from './LineMarker'; 3 | 4 | /** 5 | * Curve marker represents a curved line. 6 | * 7 | * @summary Curve marker. 8 | * @group Markers 9 | */ 10 | export class CurveMarker extends LineMarker { 11 | public static typeName = 'CurveMarker'; 12 | public static title = 'Curve marker'; 13 | 14 | /** 15 | * x coordinate for the curve control point. 16 | */ 17 | public curveX = 0; 18 | /** 19 | * y coordinate for the curve control point. 20 | */ 21 | public curveY = 0; 22 | 23 | constructor(container: SVGGElement) { 24 | super(container); 25 | 26 | this.fillColor = 'transparent'; 27 | } 28 | 29 | protected getPath(): string { 30 | const result = `M ${this.x1} ${this.y1} Q ${this.curveX} ${this.curveY}, ${this.x2} ${this.y2}`; 31 | return result; 32 | } 33 | 34 | public getState(): CurveMarkerState { 35 | const result: CurveMarkerState = Object.assign( 36 | { 37 | curveX: this.curveX, 38 | curveY: this.curveY, 39 | }, 40 | super.getState(), 41 | ); 42 | 43 | return result; 44 | } 45 | 46 | public restoreState(state: CurveMarkerState): void { 47 | this.curveX = state.curveX; 48 | this.curveY = state.curveY; 49 | 50 | super.restoreState(state); 51 | } 52 | 53 | public override scale(scaleX: number, scaleY: number): void { 54 | super.scale(scaleX, scaleY); 55 | 56 | this.curveX = this.curveX * scaleX; 57 | this.curveY = this.curveY * scaleY; 58 | 59 | this.adjustVisual(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@markerjs/markerjs3", 3 | "version": "3.8.1", 4 | "description": "marker.js 3", 5 | "main": "umd/markerjs3.js", 6 | "module": "markerjs3.js", 7 | "types": "markerjs3.d.ts", 8 | "author": "Alan Mendelevich", 9 | "license": "SEE LICENSE IN LICENSE", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/ailon/markerjs3.git" 13 | }, 14 | "keywords": [ 15 | "image", 16 | "annotation", 17 | "web components" 18 | ], 19 | "scripts": { 20 | "lint": "eslint src", 21 | "build": "yarn lint && rollup --config rollup.config.prod.mjs", 22 | "dev": "rollup --config rollup.config.dev.mjs -w" 23 | }, 24 | "devDependencies": { 25 | "@eslint/eslintrc": "^3.3.1", 26 | "@eslint/js": "^9.27.0", 27 | "@rollup/plugin-json": "^6.1.0", 28 | "@rollup/plugin-node-resolve": "^15.2.3", 29 | "@rollup/plugin-terser": "^0.4.4", 30 | "@rollup/plugin-typescript": "^11.1.5", 31 | "@typescript-eslint/eslint-plugin": "^8.32.1", 32 | "@typescript-eslint/parser": "^8.32.1", 33 | "eslint": "^9.27.0", 34 | "eslint-config-prettier": "^10.1.5", 35 | "globals": "^16.2.0", 36 | "prettier": "^3.5.3", 37 | "rollup": "^4.3.0", 38 | "rollup-plugin-copy": "^3.5.0", 39 | "rollup-plugin-delete": "^2.0.0", 40 | "rollup-plugin-dev": "^2.0.4", 41 | "rollup-plugin-dts": "^6.1.0", 42 | "rollup-plugin-generate-html-template": "^1.7.0", 43 | "rollup-plugin-generate-package-json": "^3.2.0", 44 | "rollup-plugin-livereload": "^2.0.5", 45 | "rollup-plugin-svgo": "^2.0.0", 46 | "svgo": "^3.0.3", 47 | "tslib": "^2.6.2", 48 | "typescript": "^5.2.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/editor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Editor 3 | * @category API Reference 4 | */ 5 | import { MarkerArea } from './MarkerArea'; 6 | 7 | export { 8 | MarkerArea, 9 | MarkerAreaEventData, 10 | MarkerEditorEventData, 11 | MarkerAreaMode, 12 | MarkerAreaEventMap, 13 | } from './MarkerArea'; 14 | export { 15 | MarkerBaseEditor, 16 | MarkerEditorState, 17 | MarkerCreationStyle, 18 | } from './editor/MarkerBaseEditor'; 19 | export { MarkerEditorProperties } from './editor/MarkerEditorProperties'; 20 | export { Grip, GripLocation } from './editor/Grip'; 21 | export { ResizeGrip } from './editor/ResizeGrip'; 22 | export { RotateGrip } from './editor/RotateGrip'; 23 | export { ShapeOutlineMarkerEditor } from './editor/ShapeOutlineMarkerEditor'; 24 | export { ShapeMarkerEditor } from './editor/ShapeMarkerEditor'; 25 | export { LinearMarkerEditor } from './editor/LinearMarkerEditor'; 26 | export { PolygonMarkerEditor } from './editor/PolygonMarkerEditor'; 27 | export { FreehandMarkerEditor } from './editor/FreehandMarkerEditor'; 28 | export { 29 | TextBlockEditor, 30 | BlurHandler, 31 | TextChangedHandler, 32 | } from './editor/TextBlockEditor'; 33 | export { TextMarkerEditor } from './editor/TextMarkerEditor'; 34 | export { ArrowMarkerEditor } from './editor/ArrowMarkerEditor'; 35 | export { CalloutMarkerEditor } from './editor/CalloutMarkerEditor'; 36 | export { ImageMarkerEditor } from './editor/ImageMarkerEditor'; 37 | export { CaptionFrameMarkerEditor } from './editor/CaptionFrameMarkerEditor'; 38 | export { CurveMarkerEditor } from './editor/CurveMarkerEditor'; 39 | 40 | if ( 41 | window && 42 | window.customElements && 43 | window.customElements.get('mjs-marker-area') === undefined 44 | ) { 45 | window.customElements.define('mjs-marker-area', MarkerArea); 46 | } 47 | -------------------------------------------------------------------------------- /src/core/ShapeMarkerBase.ts: -------------------------------------------------------------------------------- 1 | import { MarkerBaseState } from './MarkerBaseState'; 2 | import { ShapeOutlineMarkerBase } from './ShapeOutlineMarkerBase'; 3 | import { ShapeMarkerBaseState } from './ShapeMarkerBaseState'; 4 | import { SvgHelper } from './SvgHelper'; 5 | 6 | /** 7 | * Base class for filled shape markers. 8 | * 9 | * @summary Base class for filled shape markers. 10 | * @group Markers 11 | */ 12 | export abstract class ShapeMarkerBase extends ShapeOutlineMarkerBase { 13 | public static title = 'Shape marker'; 14 | 15 | /** 16 | * Marker's fill color. 17 | */ 18 | protected _fillColor = 'transparent'; 19 | /** 20 | * Applies the fill color to the marker's visual. 21 | * 22 | * If needed, override this method in a derived class to apply the color to the marker's visual. 23 | */ 24 | protected applyFillColor() { 25 | if (this.visual) { 26 | SvgHelper.setAttributes(this.visual, [['fill', this._fillColor]]); 27 | } 28 | } 29 | 30 | constructor(container: SVGGElement) { 31 | super(container); 32 | 33 | this.createVisual = this.createVisual.bind(this); 34 | } 35 | 36 | public createVisual(): void { 37 | super.createVisual(); 38 | if (this.visual) { 39 | SvgHelper.setAttributes(this.visual, [['fill', this._fillColor]]); 40 | } 41 | } 42 | 43 | public getState(): ShapeMarkerBaseState { 44 | const result: ShapeMarkerBaseState = Object.assign( 45 | { 46 | fillColor: this._fillColor, 47 | }, 48 | super.getState(), 49 | ); 50 | 51 | return result; 52 | } 53 | 54 | public restoreState(state: MarkerBaseState): void { 55 | const rectState = state as ShapeMarkerBaseState; 56 | super.restoreState(state); 57 | 58 | this.fillColor = rectState.fillColor; 59 | 60 | this.setSize(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/core/SvgFilters.ts: -------------------------------------------------------------------------------- 1 | import { SvgHelper } from './SvgHelper'; 2 | 3 | /** 4 | * A set of common SVG filters that can be used to make markers more legible 5 | * or just for visual effect. 6 | */ 7 | export class SvgFilters { 8 | /** 9 | * Returns a set of default filters that can be used to make markers more legible. 10 | * @returns array of SVG filters. 11 | */ 12 | public static getDefaultFilterSet(): SVGFilterElement[] { 13 | const dsFilter = SvgHelper.createFilter( 14 | 'dropShadow', 15 | [ 16 | ['x', '-20%'], 17 | ['y', '-20%'], 18 | ['width', '140%'], 19 | ['height', '140%'], 20 | ], 21 | ``, 22 | ); 23 | 24 | const outlineFilter = SvgHelper.createFilter( 25 | 'outline', 26 | [ 27 | ['x', '-5%'], 28 | ['y', '-5%'], 29 | ['width', '110%'], 30 | ['height', '110%'], 31 | ], 32 | ` 33 | 34 | 35 | `, 36 | ); 37 | 38 | const glowFilter = SvgHelper.createFilter( 39 | 'glow', 40 | [ 41 | ['x', '-50%'], 42 | ['y', '-50%'], 43 | ['width', '200%'], 44 | ['height', '200%'], 45 | ], 46 | ` 47 | 48 | 49 | 50 | 51 | 52 | `, 53 | ); 54 | 55 | return [dsFilter, outlineFilter, glowFilter]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/core/Activator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages commercial licenses. 3 | * @ignore 4 | */ 5 | export class Activator { 6 | private static keys: Map = new Map(); 7 | private static keyAddListeners: Array<() => void> = new Array<() => void>(); 8 | 9 | /** 10 | * Add a license key 11 | * @param product product identifier. 12 | * @param key license key sent to you after purchase. 13 | */ 14 | public static addKey(product: string, key: string): void { 15 | Activator.keys.set(product, key); 16 | Activator.keyAddListeners.forEach((listener) => { 17 | listener(); 18 | }); 19 | } 20 | 21 | /** 22 | * Add a function to be called when license key is added. 23 | * @param listener 24 | */ 25 | public static addKeyAddListener(listener: () => void) { 26 | Activator.keyAddListeners.push(listener); 27 | } 28 | 29 | /** 30 | * Remove a function called when key is added. 31 | * @param listener 32 | */ 33 | public static removeKeyAddListener(listener: () => void) { 34 | const li = Activator.keyAddListeners.indexOf(listener); 35 | if (li > -1) { 36 | Activator.keyAddListeners.splice(li, 1); 37 | } 38 | } 39 | 40 | /** 41 | * Returns true if the product is commercially licensed. 42 | * @param product product identifier. 43 | */ 44 | public static isLicensed(product: string): boolean { 45 | // NOTE: 46 | // before removing or modifying this please consider supporting marker.js development 47 | // by visiting https://markerjs.com/ for details 48 | // thank you! 49 | if (Activator.keys.has(product)) { 50 | const keyRegex = new RegExp( 51 | `${product}-[A-Z][0-9]{3}-[A-Z][0-9]{3}-[0-9]{4}`, 52 | 'i', 53 | ); 54 | const key = Activator.keys.get(product); 55 | return key === undefined ? false : keyRegex.test(key); 56 | } else { 57 | return false; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | IPoint, 3 | ISize, 4 | ITransformMatrix, 5 | SvgHelper, 6 | Activator, 7 | AnnotationState, 8 | FrameMarker, 9 | FreehandMarker, 10 | FreehandMarkerState, 11 | LinearMarkerBase, 12 | LinearMarkerBaseState, 13 | MarkerBase, 14 | MarkerStage, 15 | MarkerBaseState, 16 | PolygonMarker, 17 | PolygonMarkerState, 18 | ShapeOutlineMarkerBase, 19 | ShapeOutlineMarkerBaseState, 20 | ShapeMarkerBase, 21 | ShapeMarkerBaseState, 22 | RectangularBoxMarkerBase, 23 | RectangularBoxMarkerBaseState, 24 | TextMarker, 25 | TextMarkerState, 26 | TextBlock, 27 | CoverMarker, 28 | HighlightMarker, 29 | ArrowMarker, 30 | ArrowMarkerState, 31 | ArrowType, 32 | LineMarker, 33 | MeasurementMarker, 34 | CalloutMarker, 35 | CalloutMarkerState, 36 | EllipseFrameMarker, 37 | EllipseMarker, 38 | FontSize, 39 | ImageMarkerBase, 40 | ImageMarkerBaseState, 41 | ImageType, 42 | CustomImageMarker, 43 | CheckImageMarker, 44 | XImageMarker, 45 | CaptionFrameMarker, 46 | CaptionFrameMarkerState, 47 | CurveMarker, 48 | CurveMarkerState, 49 | HighlighterMarker, 50 | SvgFilters, 51 | } from './core'; 52 | 53 | export { 54 | MarkerArea, 55 | MarkerAreaEventData, 56 | MarkerEditorEventData, 57 | MarkerAreaMode, 58 | MarkerAreaEventMap, 59 | FreehandMarkerEditor, 60 | Grip, 61 | GripLocation, 62 | LinearMarkerEditor, 63 | MarkerBaseEditor, 64 | MarkerCreationStyle, 65 | MarkerEditorProperties, 66 | MarkerEditorState, 67 | PolygonMarkerEditor, 68 | ResizeGrip, 69 | RotateGrip, 70 | ShapeOutlineMarkerEditor, 71 | ShapeMarkerEditor, 72 | TextBlockEditor, 73 | TextChangedHandler, 74 | BlurHandler, 75 | TextMarkerEditor, 76 | ArrowMarkerEditor, 77 | CalloutMarkerEditor, 78 | ImageMarkerEditor, 79 | CaptionFrameMarkerEditor, 80 | CurveMarkerEditor, 81 | } from './editor'; 82 | 83 | export { 84 | MarkerView, 85 | MarkerViewEventData, 86 | MarkerEventData, 87 | MarkerViewEventMap, 88 | } from './viewer'; 89 | 90 | export { Renderer } from './Renderer'; 91 | -------------------------------------------------------------------------------- /src/core/MeasurementMarker.ts: -------------------------------------------------------------------------------- 1 | import { IPoint } from './IPoint'; 2 | import { LineMarker } from './LineMarker'; 3 | 4 | /** 5 | * Represents a measurement marker. 6 | * 7 | * Measurement marker is a line with two vertical bars at the ends. 8 | * 9 | * @summary A line with two vertical bars at the ends. 10 | * @group Markers 11 | */ 12 | export class MeasurementMarker extends LineMarker { 13 | public static typeName = 'MeasurementMarker'; 14 | public static title = 'Measurement marker'; 15 | 16 | constructor(container: SVGGElement) { 17 | super(container); 18 | } 19 | 20 | protected getStartTerminatorPath(): string { 21 | const { tipLength, angle } = this.getTerminatorProperties(); 22 | 23 | const startArrowSide1: IPoint = { 24 | x: this.x1 + tipLength * Math.sin(angle), 25 | y: this.y1 - tipLength * Math.cos(angle), 26 | }; 27 | 28 | const startArrowSide2: IPoint = { 29 | x: this.x1 - tipLength * Math.sin(angle), 30 | y: this.y1 + tipLength * Math.cos(angle), 31 | }; 32 | 33 | const result = `M ${startArrowSide1.x} ${startArrowSide1.y} 34 | L ${startArrowSide2.x} ${startArrowSide2.y}`; 35 | 36 | return result; 37 | } 38 | 39 | protected getEndTerminatorPath(): string { 40 | const { tipLength, angle } = this.getTerminatorProperties(); 41 | 42 | const endArrowSide1: IPoint = { 43 | x: this.x2 + tipLength * Math.sin(angle), 44 | y: this.y2 - tipLength * Math.cos(angle), 45 | }; 46 | 47 | const endArrowSide2: IPoint = { 48 | x: this.x2 - tipLength * Math.sin(angle), 49 | y: this.y2 + tipLength * Math.cos(angle), 50 | }; 51 | 52 | // svg path for the arrow 53 | const result = `M ${endArrowSide1.x} ${endArrowSide1.y} L ${endArrowSide2.x} ${endArrowSide2.y}`; 54 | 55 | return result; 56 | } 57 | 58 | private getTerminatorProperties() { 59 | const tipLength = 5 + this.strokeWidth * 3; 60 | 61 | const dx = this.x2 - this.x1; 62 | const dy = this.y2 - this.y1; 63 | const angle = Math.atan2(dy, dx); 64 | return { tipLength, angle }; 65 | } 66 | 67 | protected applyStrokeWidth() { 68 | super.applyStrokeWidth(); 69 | this.adjustVisual(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /rollup.config.prod.mjs: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import del from 'rollup-plugin-delete'; 3 | import copy from 'rollup-plugin-copy'; 4 | import pkg from './package.json' with { type: 'json' }; 5 | import generatePackageJson from 'rollup-plugin-generate-package-json'; 6 | import terser from '@rollup/plugin-terser'; 7 | import dts from 'rollup-plugin-dts'; 8 | import svgo from 'rollup-plugin-svgo'; 9 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 10 | 11 | const outputDir = './dist/'; 12 | 13 | const banner = `/* ********************************** 14 | marker.js 3 version ${pkg.version} 15 | https://markerjs.com 16 | 17 | copyright Alan Mendelevich 18 | see README.md and LICENSE for details 19 | ********************************** */`; 20 | 21 | export default [ 22 | // types 23 | { 24 | input: ['./src/index.ts'], 25 | output: { 26 | dir: './dts/', 27 | }, 28 | plugins: [ 29 | del({ targets: ['dts/*', 'dist/*'] }), 30 | nodeResolve(), 31 | typescript({ 32 | declaration: true, 33 | outDir: './dts/', 34 | //rootDir: './src/', 35 | include: ['./custom.d.ts', './src/**/*.ts'], 36 | exclude: ['./test/**/*', './dts/**/*', './dist/**/*'], 37 | }), 38 | svgo(), 39 | ], 40 | }, 41 | { 42 | input: './dts/index.d.ts', 43 | output: [{ file: outputDir + pkg.types, format: 'es' }], 44 | plugins: [dts()], 45 | }, 46 | 47 | // complete UMD package 48 | { 49 | input: ['src/index.ts'], 50 | output: [ 51 | { 52 | file: outputDir + pkg.main, 53 | name: 'markerjs3', 54 | format: 'umd', 55 | sourcemap: true, 56 | banner: banner, 57 | }, 58 | ], 59 | plugins: [nodeResolve(), typescript(), svgo(), terser()], 60 | }, 61 | 62 | // complete ESM package 63 | { 64 | input: ['src/index.ts'], 65 | output: [ 66 | { 67 | file: outputDir + pkg.module, 68 | format: 'es', 69 | sourcemap: true, 70 | banner: banner, 71 | }, 72 | ], 73 | plugins: [ 74 | //nodeResolve(), 75 | typescript(), 76 | svgo(), 77 | terser(), 78 | generatePackageJson({ 79 | baseContents: (pkg) => { 80 | pkg.scripts = {}; 81 | pkg.devDependencies = {}; 82 | return pkg; 83 | }, 84 | }), 85 | copy({ 86 | targets: [ 87 | { 88 | src: 'README.md', 89 | dest: 'dist', 90 | }, 91 | { 92 | src: 'LICENSE', 93 | dest: 'dist', 94 | }, 95 | ], 96 | }), 97 | // del({ targets: ['dts/*'] }), 98 | ], 99 | }, 100 | ]; 101 | -------------------------------------------------------------------------------- /src/assets/markerjs-logo-m.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/editor/CalloutMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { CalloutMarker, IPoint, SvgHelper } from '../core'; 2 | import { MarkerEditorProperties } from './MarkerEditorProperties'; 3 | import { ResizeGrip } from './ResizeGrip'; 4 | import { TextMarkerEditor } from './TextMarkerEditor'; 5 | 6 | /** 7 | * Editor for callout markers. 8 | * 9 | * @summary Callout marker editor. 10 | * @group Editors 11 | */ 12 | export class CalloutMarkerEditor< 13 | TMarkerType extends CalloutMarker = CalloutMarker, 14 | > extends TextMarkerEditor { 15 | private tipGrip?: ResizeGrip; 16 | 17 | private manipulationStartTipPositionX = 0; 18 | private manipulationStartTipPositionY = 0; 19 | 20 | constructor(properties: MarkerEditorProperties) { 21 | super(properties); 22 | } 23 | 24 | protected addControlGrips(): void { 25 | this.tipGrip = this.createTipGrip(); 26 | 27 | super.addControlGrips(); 28 | } 29 | 30 | private createTipGrip(): ResizeGrip { 31 | const grip = new ResizeGrip(); 32 | grip.zoomLevel = this.zoomLevel; 33 | grip.visual.transform.baseVal.appendItem(SvgHelper.createTransform()); 34 | this.manipulationBox.appendChild(grip.visual); 35 | 36 | return grip; 37 | } 38 | 39 | protected positionGrips() { 40 | super.positionGrips(); 41 | 42 | if (this.tipGrip) { 43 | this.tipGrip.zoomLevel = this.zoomLevel; 44 | const tipGripSize = this.tipGrip.gripSize ?? 0; 45 | this.positionGrip( 46 | this.tipGrip.visual, 47 | this.marker.tipPosition.x - tipGripSize / 2, 48 | this.marker.tipPosition.y - tipGripSize / 2, 49 | ); 50 | } 51 | } 52 | 53 | public ownsTarget(el: EventTarget): boolean { 54 | if (super.ownsTarget(el) || this.tipGrip?.ownsTarget(el)) { 55 | return true; 56 | } else { 57 | return false; 58 | } 59 | } 60 | 61 | public override pointerDown( 62 | point: IPoint, 63 | target?: EventTarget, 64 | ev?: PointerEvent, 65 | ): void { 66 | super.pointerDown(point, target, ev); 67 | 68 | this.manipulationStartTipPositionX = this.marker.tipPosition.x; 69 | this.manipulationStartTipPositionY = this.marker.tipPosition.y; 70 | 71 | if ( 72 | this.tipGrip !== undefined && 73 | target !== undefined && 74 | this.tipGrip.ownsTarget(target) 75 | ) { 76 | this.activeGrip = this.tipGrip; 77 | this._state = 'resize'; 78 | } 79 | } 80 | 81 | protected resize(point: IPoint, preserveAspectRatio = false): void { 82 | const newX = 83 | this.manipulationStartTipPositionX + point.x - this.manipulationStartX; 84 | const newY = 85 | this.manipulationStartTipPositionY + point.y - this.manipulationStartY; 86 | 87 | if (this.activeGrip === this.tipGrip) { 88 | this.marker.tipPosition = { x: newX, y: newY }; 89 | this.adjustControlBox(); 90 | } else { 91 | super.resize(point, preserveAspectRatio); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Core 3 | * @category API Reference 4 | */ 5 | export { IPoint } from './core/IPoint'; 6 | export { ISize } from './core/ISize'; 7 | export { ITransformMatrix } from './core/TransformMatrix'; 8 | export { SvgHelper } from './core/SvgHelper'; 9 | export { Activator } from './core/Activator'; 10 | 11 | export { AnnotationState } from './core/AnnotationState'; 12 | 13 | export { MarkerBase, MarkerStage } from './core/MarkerBase'; 14 | export { MarkerBaseState } from './core/MarkerBaseState'; 15 | 16 | export { ShapeOutlineMarkerBase } from './core/ShapeOutlineMarkerBase'; 17 | export { ShapeOutlineMarkerBaseState } from './core/ShapeOutlineMarkerBaseState'; 18 | export { ShapeMarkerBase } from './core/ShapeMarkerBase'; 19 | export { ShapeMarkerBaseState } from './core/ShapeMarkerBaseState'; 20 | 21 | export { RectangularBoxMarkerBase } from './core/RectangularBoxMarkerBase'; 22 | export { RectangularBoxMarkerBaseState } from './core/RectangularBoxMarkerBaseState'; 23 | 24 | export { FrameMarker } from './core/FrameMarker'; 25 | 26 | export { LinearMarkerBase } from './core/LinearMarkerBase'; 27 | export { LinearMarkerBaseState } from './core/LinearMarkerBaseState'; 28 | export { LineMarker } from './core/LineMarker'; 29 | export { ArrowMarker } from './core/ArrowMarker'; 30 | export { ArrowMarkerState, ArrowType } from './core/ArrowMarkerState'; 31 | export { MeasurementMarker } from './core/MeasurementMarker'; 32 | 33 | export { PolygonMarker } from './core/PolygonMarker'; 34 | export { PolygonMarkerState } from './core/PolygonMarkerState'; 35 | 36 | export { FreehandMarker } from './core/FreehandMarker'; 37 | export { FreehandMarkerState } from './core/FreehandMarkerState'; 38 | 39 | export { TextMarker } from './core/TextMarker'; 40 | export { TextMarkerState } from './core/TextMarkerState'; 41 | export { TextBlock } from './core/TextBlock'; 42 | 43 | export { CoverMarker } from './core/CoverMarker'; 44 | export { HighlightMarker } from './core/HighlightMarker'; 45 | 46 | export { CalloutMarker } from './core/CalloutMarker'; 47 | export { CalloutMarkerState } from './core/CalloutMarkerState'; 48 | 49 | export { EllipseFrameMarker } from './core/EllipseFrameMarker'; 50 | export { EllipseMarker } from './core/EllipseMarker'; 51 | 52 | export { FontSize } from './core/FontSize'; 53 | 54 | export { ImageMarkerBase } from './core/ImageMarkerBase'; 55 | export { ImageMarkerBaseState, ImageType } from './core/ImageMarkerBaseState'; 56 | export { CustomImageMarker } from './core/CustomImageMarker'; 57 | export { CheckImageMarker } from './core/CheckImageMarker'; 58 | export { XImageMarker } from './core/XImageMarker'; 59 | 60 | export { CaptionFrameMarker } from './core/CaptionFrameMarker'; 61 | export { CaptionFrameMarkerState } from './core/CaptionFrameMarkerState'; 62 | 63 | export { CurveMarker } from './core/CurveMarker'; 64 | export { CurveMarkerState } from './core/CurveMarkerState'; 65 | 66 | export { HighlighterMarker } from './core/HighlighterMarker'; 67 | 68 | export { SvgFilters } from './core/SvgFilters'; 69 | -------------------------------------------------------------------------------- /src/editor/UndoRedoManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages undo and redo stacks. 3 | */ 4 | export class UndoRedoManager { 5 | private undoStack: T[] = []; 6 | private redoStack: T[] = []; 7 | 8 | private lastRedoStep?: T; 9 | 10 | /** 11 | * Returns true if there are items in the undo stack. 12 | */ 13 | public get isUndoPossible(): boolean { 14 | return this.undoStack.length > 1; 15 | } 16 | 17 | /** 18 | * Returns true if there are items in the redo stack. 19 | */ 20 | public get isRedoPossible(): boolean { 21 | return this.redoStack.length > 0; 22 | } 23 | 24 | /** 25 | * Returns the number of items in the undo stack 26 | */ 27 | public get undoStepCount(): number { 28 | return this.undoStack.length; 29 | } 30 | 31 | /** 32 | * Returns the number of items in the redo stack 33 | */ 34 | public get redoStepCount(): number { 35 | return this.redoStack.length; 36 | } 37 | 38 | /** 39 | * Adds a step to the undo stack. 40 | * @param stepData data representing a state. 41 | */ 42 | public addUndoStep(stepData: T): boolean { 43 | if ( 44 | this.undoStack.length === 0 || 45 | JSON.stringify(this.undoStack[this.undoStack.length - 1]) !== 46 | JSON.stringify(stepData) 47 | ) { 48 | this.undoStack.push(JSON.parse(JSON.stringify(stepData))); 49 | if (JSON.stringify(this.lastRedoStep) !== JSON.stringify(stepData)) { 50 | this.redoStack.splice(0, this.redoStack.length); 51 | } 52 | return true; 53 | } 54 | return false; 55 | } 56 | 57 | /** 58 | * Replaces the last undo step with step data provided 59 | * @param stepData data representing a state. 60 | */ 61 | public replaceLastUndoStep(stepData: T): void { 62 | if (this.undoStack.length > 0) { 63 | this.undoStack[this.undoStack.length - 1] = JSON.parse( 64 | JSON.stringify(stepData), 65 | ); 66 | } 67 | } 68 | 69 | /** 70 | * Returns the last step in the undo log 71 | */ 72 | public getLastUndoStep(): T | undefined { 73 | if (this.undoStack.length > 0) { 74 | return this.undoStack[this.undoStack.length - 1]; 75 | } else { 76 | return undefined; 77 | } 78 | } 79 | 80 | /** 81 | * Returns data for the previous step in the undo stack and adds last step to the redo stack. 82 | * @returns 83 | */ 84 | public undo(): T | undefined { 85 | if (this.undoStack.length > 1) { 86 | const lastStep = this.undoStack.pop(); 87 | if (lastStep !== undefined) { 88 | this.redoStack.push(lastStep); 89 | } 90 | return this.undoStack.length > 0 91 | ? this.undoStack[this.undoStack.length - 1] 92 | : undefined; 93 | } 94 | } 95 | 96 | /** 97 | * Returns most recent item in the redo stack. 98 | * @returns 99 | */ 100 | public redo(): T | undefined { 101 | this.lastRedoStep = this.redoStack.pop(); 102 | if (this.lastRedoStep !== undefined) { 103 | this.undoStack.push(this.lastRedoStep); 104 | } 105 | return this.lastRedoStep; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/core/ShapeOutlineMarkerBase.ts: -------------------------------------------------------------------------------- 1 | import { MarkerBaseState } from './MarkerBaseState'; 2 | import { RectangularBoxMarkerBase } from './RectangularBoxMarkerBase'; 3 | import { SvgHelper } from './SvgHelper'; 4 | 5 | /** 6 | * Shape outline marker is a base class for all markers that represent a shape outline. 7 | * 8 | * @summary Base class for shape outline (unfilled shape) markers. 9 | * @group Markers 10 | */ 11 | export class ShapeOutlineMarkerBase extends RectangularBoxMarkerBase { 12 | public static title = 'Shape outline marker'; 13 | 14 | protected applyStrokeColor() { 15 | if (this.visual) { 16 | SvgHelper.setAttributes(this.visual, [['stroke', this._strokeColor]]); 17 | } 18 | } 19 | 20 | protected applyStrokeWidth() { 21 | if (this.visual) { 22 | SvgHelper.setAttributes(this.visual, [ 23 | ['stroke-width', this._strokeWidth.toString()], 24 | ]); 25 | } 26 | } 27 | 28 | protected applyStrokeDasharray() { 29 | if (this.visual) { 30 | SvgHelper.setAttributes(this.visual, [ 31 | ['stroke-dasharray', this._strokeDasharray], 32 | ]); 33 | } 34 | } 35 | 36 | protected applyOpacity() { 37 | if (this.visual) { 38 | SvgHelper.setAttributes(this.visual, [ 39 | ['opacity', this._opacity.toString()], 40 | ]); 41 | } 42 | } 43 | 44 | constructor(container: SVGGElement) { 45 | super(container); 46 | 47 | this.createVisual = this.createVisual.bind(this); 48 | } 49 | 50 | public ownsTarget(el: EventTarget): boolean { 51 | if (super.ownsTarget(el) || el === this.visual) { 52 | return true; 53 | } else { 54 | return false; 55 | } 56 | } 57 | 58 | protected getPath( 59 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 60 | width: number = this.width, 61 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 62 | height: number = this.height, 63 | ): string { 64 | return 'M0,0'; 65 | } 66 | 67 | public getOutline(): string { 68 | return this.getPath(this.defaultSize.width, this.defaultSize.height); 69 | } 70 | 71 | /** 72 | * Creates marker's visual. 73 | */ 74 | public createVisual(): void { 75 | this.visual = SvgHelper.createPath(this.getPath(), [ 76 | ['fill', 'transparent'], 77 | ['stroke', this._strokeColor], 78 | ['stroke-width', this._strokeWidth.toString()], 79 | ['stroke-dasharray', this._strokeDasharray], 80 | ['opacity', this._opacity.toString()], 81 | ]); 82 | this.addMarkerVisualToContainer(this.visual); 83 | } 84 | 85 | /** 86 | * Adjusts marker's visual according to the current state 87 | * (color, width, etc.). 88 | */ 89 | public adjustVisual(): void { 90 | if (this.visual) { 91 | SvgHelper.setAttributes(this.visual, [ 92 | ['d', this.getPath()], 93 | ['fill', 'transparent'], 94 | ['stroke', this._strokeColor], 95 | ['stroke-width', this._strokeWidth.toString()], 96 | ['stroke-dasharray', this._strokeDasharray], 97 | ['opacity', this._opacity.toString()], 98 | ]); 99 | } 100 | } 101 | 102 | public setSize(): void { 103 | super.setSize(); 104 | if (this.visual) { 105 | SvgHelper.setAttributes(this.visual, [['d', this.getPath()]]); 106 | } 107 | } 108 | 109 | public restoreState(state: MarkerBaseState): void { 110 | this.createVisual(); 111 | super.restoreState(state); 112 | this.adjustVisual(); 113 | } 114 | 115 | public scale(scaleX: number, scaleY: number): void { 116 | super.scale(scaleX, scaleY); 117 | 118 | this.strokeWidth *= (scaleX + scaleY) / 2; 119 | 120 | this.setSize(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # marker.js 3 — Add image annotation to your web apps 2 | 3 | marker.js 3 is a JavaScript browser library to enable image annotation in your web applications. Add marker.js 3 to your web app and enable users to annotate and mark up images. You can save, share or otherwise process the results. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install @markerjs/markerjs3 9 | ``` 10 | 11 | The library includes TypeScript type definitions out of the box. 12 | 13 | ## Usage 14 | 15 | marker.js 3 is a "headless" library. In this case "headless" means that it doesn't come with any toolbars, property editors, and placement preconceptions. This way the library focuses on the core features you need, and you can make it feel totally native in the application you are building without resorting to any tricks or hacks. 16 | 17 | With that out of the way, here are the simplest usage scenarios... 18 | 19 | ### MarkerArea (The Editor) 20 | 21 | Import `MarkerArea` from `@markerjs/markerjs3`: 22 | 23 | ```js 24 | import { MarkerArea } from '@markerjs/markerjs3'; 25 | ``` 26 | 27 | In the code below we assume that you have an `HTMLImageElement` as `targetImage`. It can be a reference to an image you already have on the page or you can simply create it with something like this: 28 | 29 | ```js 30 | const targetImg = document.createElement('img'); 31 | targetImg.src = './sample.jpg'; 32 | ``` 33 | 34 | Now you just need to create an instance of `MarkerArea`, set its `targetImage` property and add it to the page: 35 | 36 | ```js 37 | const markerArea = new MarkerArea(); 38 | markerArea.targetImage = targetImg; 39 | editorContainerDiv.appendChild(markerArea); 40 | ``` 41 | 42 | To initiate creation of a marker you just call `createMarker()` and pass it the name (or type) of the marker you want to create. So, if you have a button with id `addFrameButton` you can make it create a new `FrameMarker` with something like this: 43 | 44 | ```js 45 | document.querySelector('#addButton')?.addEventListener('click', () => { 46 | markerArea.createMarker('FrameMarker'); 47 | }); 48 | ``` 49 | 50 | And whenever you want to save state (current annotation) you just call `getState()`: 51 | 52 | ```js 53 | document.querySelector('#saveStateButton')?.addEventListener('click', () => { 54 | const state = markerArea.getState(); 55 | console.log(state); 56 | }); 57 | ``` 58 | 59 | ### Rendering static images 60 | 61 | To render the annotation as a static image you use `Renderer`. 62 | 63 | ```js 64 | import { MarkerArea, Renderer } from '@markerjs/markerjs3'; 65 | ``` 66 | 67 | Just create an instance of it and pass the annotation state to the `rasterize()` method: 68 | 69 | ```js 70 | const renderer = new Renderer(); 71 | renderer.targetImage = targetImg; 72 | const dataUrl = await renderer.rasterize(markerArea.getState()); 73 | 74 | const img = document.createElement('img'); 75 | img.src = dataUrl; 76 | 77 | someDiv.appendChild(img); 78 | ``` 79 | 80 | ### MarkerView (The Viewer) 81 | 82 | To show dynamic annotation overlays on top of the original image you use `MarkerView`. 83 | 84 | ```js 85 | import { MarkerView } from '@markerjs/markerjs3'; 86 | 87 | const markerView = new MarkerView(); 88 | markerView.targetImage = targetImg; 89 | viewerContainer.appendChild(markerView); 90 | 91 | markerView.show(savedState); 92 | ``` 93 | 94 | ## Demos 95 | 96 | Check out the [demos on markerjs.com](https://markerjs.com/demos). 97 | 98 | ## More docs and tutorials 99 | 100 | You can find marker.js 3 reference and tutorials [here](https://markerjs.com/docs-v3/). 101 | 102 | ## License 103 | 104 | Linkware (see [LICENSE](https://github.com/ailon/markerjs3/blob/master/LICENSE) for details) - the UI displays a small link back to the marker.js 3 website which should be retained. 105 | 106 | Alternative licenses are available through the [marker.js website](https://markerjs.com). 107 | -------------------------------------------------------------------------------- /src/editor/Grip.ts: -------------------------------------------------------------------------------- 1 | import { SvgHelper } from '../core/SvgHelper'; 2 | 3 | /** 4 | * Represents location of the manipulation grips. 5 | */ 6 | export type GripLocation = 7 | | 'topleft' 8 | | 'topcenter' 9 | | 'topright' 10 | | 'leftcenter' 11 | | 'rightcenter' 12 | | 'bottomleft' 13 | | 'bottomcenter' 14 | | 'bottomright'; 15 | 16 | /** 17 | * Represents a single resize-manipulation grip used in marker's manipulation controls. 18 | */ 19 | export class Grip { 20 | /** 21 | * Grip's visual element. 22 | */ 23 | protected _visual?: SVGGraphicsElement; 24 | /** 25 | * Grip's visual element. 26 | */ 27 | public get visual(): SVGGraphicsElement { 28 | if (!this._visual) { 29 | this.createVisual(); 30 | } 31 | return this._visual!; 32 | } 33 | 34 | private _selectorElement?: SVGGraphicsElement; 35 | private _visibleElement?: SVGGraphicsElement; 36 | 37 | /** 38 | * Grip's size (radius). 39 | */ 40 | public gripSize = 5; 41 | 42 | private _zoomLevel = 1; 43 | 44 | /** 45 | * Returns the current zoom level. 46 | * 47 | * @remarks 48 | * This set by the MarkerArea based on its current zoom level. 49 | * 50 | * @since 3.6.0 51 | */ 52 | public get zoomLevel(): number { 53 | return this._zoomLevel; 54 | } 55 | 56 | /** 57 | * Sets the current zoom level. 58 | * 59 | * @remarks 60 | * This set by the MarkerArea based on its current zoom level. 61 | * 62 | * @since 3.6.0 63 | */ 64 | public set zoomLevel(value: number) { 65 | this._zoomLevel = value; 66 | this.adjustVisual(); 67 | } 68 | 69 | /** 70 | * Grip's fill color. 71 | */ 72 | public fillColor = 'rgba(255,255,255,0.9)'; 73 | /** 74 | * Grip's stroke color. 75 | */ 76 | public strokeColor = '#0ea5e9'; 77 | 78 | /** 79 | * Creates a new grip. 80 | */ 81 | constructor() { 82 | this.createVisual = this.createVisual.bind(this); 83 | this.adjustVisual = this.adjustVisual.bind(this); 84 | } 85 | 86 | /** 87 | * Creates grip's visual. 88 | */ 89 | protected createVisual() { 90 | this._visual = SvgHelper.createGroup(); 91 | this._selectorElement = SvgHelper.createCircle(this.gripSize * 2, [ 92 | ['fill', 'transparent'], 93 | ['cx', (this.gripSize / 2).toString()], 94 | ['cy', (this.gripSize / 2).toString()], 95 | ]); 96 | this._visual.appendChild(this._selectorElement); 97 | this._visibleElement = SvgHelper.createCircle(this.gripSize, [ 98 | ['fill-opacity', '1'], 99 | ['stroke-width', '1'], 100 | ['stroke-opacity', '1'], 101 | ]); 102 | this._visibleElement.style.fill = `var(--mjs-grip-fill, ${this.fillColor})`; 103 | this._visibleElement.style.stroke = `var(--mjs-grip-stroke, ${this.strokeColor})`; 104 | this._visibleElement.style.filter = 105 | 'drop-shadow(0px 0px 2px rgba(0, 0, 0, .7))'; 106 | this._visual.appendChild(this._visibleElement); 107 | } 108 | 109 | protected adjustVisual() { 110 | if (this._selectorElement && this._visibleElement) { 111 | this._selectorElement.setAttribute( 112 | 'r', 113 | ((this.gripSize * 2) / this.zoomLevel).toString(), 114 | ); 115 | this._visibleElement.setAttribute( 116 | 'r', 117 | (this.gripSize / this.zoomLevel).toString(), 118 | ); 119 | this._visibleElement.setAttribute( 120 | 'stroke-width', 121 | (1 / this.zoomLevel).toString(), 122 | ); 123 | } 124 | } 125 | 126 | /** 127 | * Returns true if passed SVG element belongs to the grip. False otherwise. 128 | * 129 | * @param el - target element. 130 | */ 131 | public ownsTarget(el: EventTarget): boolean { 132 | if (el === this._visual) { 133 | return true; 134 | } else { 135 | let found = false; 136 | this._visual?.childNodes.forEach((child) => { 137 | if (child === el) { 138 | found = true; 139 | } 140 | }); 141 | return found; 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/core/ArrowMarker.ts: -------------------------------------------------------------------------------- 1 | import { ArrowMarkerState, ArrowType } from './ArrowMarkerState'; 2 | import { IPoint } from './IPoint'; 3 | import { LineMarker } from './LineMarker'; 4 | import { MarkerBaseState } from './MarkerBaseState'; 5 | 6 | /** 7 | * Arrow marker represents a line with arrow heads at the ends. 8 | * 9 | * @summary A line with arrow heads at the ends. 10 | * 11 | * @group Markers 12 | */ 13 | export class ArrowMarker extends LineMarker { 14 | public static typeName = 'ArrowMarker'; 15 | public static title = 'Arrow marker'; 16 | 17 | private _arrowType: ArrowType = 'end'; 18 | /** 19 | * Type of the arrow. 20 | * 21 | * Specify whether the arrow should be drawn at the start, end, both ends or none. 22 | */ 23 | public get arrowType(): ArrowType { 24 | return this._arrowType; 25 | } 26 | public set arrowType(value: ArrowType) { 27 | this._arrowType = value; 28 | this.adjustVisual(); 29 | } 30 | 31 | constructor(container: SVGGElement) { 32 | super(container); 33 | 34 | this.getArrowProperties = this.getArrowProperties.bind(this); 35 | this.getStartTerminatorPath = this.getStartTerminatorPath.bind(this); 36 | this.getEndTerminatorPath = this.getEndTerminatorPath.bind(this); 37 | } 38 | 39 | private getArrowProperties() { 40 | const arrowHeight = 10 + this.strokeWidth * 2; 41 | const arrowWidth = Math.min( 42 | Math.max(5, this.strokeWidth * 2), 43 | this.strokeWidth + 5, 44 | ); 45 | const arrowDipFactor = 0.7; // arrow base "bend factor" 46 | 47 | const dx = this.x2 - this.x1; 48 | const dy = this.y2 - this.y1; 49 | const angle = Math.atan2(dy, dx); 50 | return { arrowHeight, arrowDipFactor, angle, arrowWidth }; 51 | } 52 | 53 | protected getStartTerminatorPath(): string { 54 | const { arrowHeight, arrowDipFactor, angle, arrowWidth } = 55 | this.getArrowProperties(); 56 | 57 | // Start arrow 58 | const startArrowBasePoint: IPoint = { 59 | x: this.x1 + arrowHeight * arrowDipFactor * Math.cos(angle), 60 | y: this.y1 + arrowHeight * arrowDipFactor * Math.sin(angle), 61 | }; 62 | 63 | const startArrowTipBasePoint: IPoint = { 64 | x: this.x1 + arrowHeight * Math.cos(angle), 65 | y: this.y1 + arrowHeight * Math.sin(angle), 66 | }; 67 | 68 | const startArrowSide1: IPoint = { 69 | x: startArrowTipBasePoint.x + arrowWidth * Math.sin(angle), 70 | y: startArrowTipBasePoint.y - arrowWidth * Math.cos(angle), 71 | }; 72 | 73 | const startArrowSide2: IPoint = { 74 | x: startArrowTipBasePoint.x - arrowWidth * Math.sin(angle), 75 | y: startArrowTipBasePoint.y + arrowWidth * Math.cos(angle), 76 | }; 77 | 78 | const startSegment = 79 | this.arrowType === 'start' || this.arrowType === 'both' 80 | ? `M ${startArrowBasePoint.x} ${startArrowBasePoint.y} 81 | L ${startArrowSide1.x} ${startArrowSide1.y} L ${this.x1} ${this.y1} L ${startArrowSide2.x} ${startArrowSide2.y} L ${startArrowBasePoint.x} ${startArrowBasePoint.y} 82 | L ${startArrowBasePoint.x} ${startArrowBasePoint.y}` 83 | : ``; 84 | 85 | return startSegment; 86 | } 87 | 88 | protected getEndTerminatorPath(): string { 89 | const { arrowHeight, arrowDipFactor, angle, arrowWidth } = 90 | this.getArrowProperties(); 91 | 92 | // End arrow 93 | const endArrowBasePoint: IPoint = { 94 | x: this.x2 - arrowHeight * arrowDipFactor * Math.cos(angle), 95 | y: this.y2 - arrowHeight * arrowDipFactor * Math.sin(angle), 96 | }; 97 | 98 | const endArrowTipBasePoint: IPoint = { 99 | x: this.x2 - arrowHeight * Math.cos(angle), 100 | y: this.y2 - arrowHeight * Math.sin(angle), 101 | }; 102 | 103 | const endArrowSide1: IPoint = { 104 | x: endArrowTipBasePoint.x + arrowWidth * Math.sin(angle), 105 | y: endArrowTipBasePoint.y - arrowWidth * Math.cos(angle), 106 | }; 107 | 108 | const endArrowSide2: IPoint = { 109 | x: endArrowTipBasePoint.x - arrowWidth * Math.sin(angle), 110 | y: endArrowTipBasePoint.y + arrowWidth * Math.cos(angle), 111 | }; 112 | 113 | const endSegment = 114 | this.arrowType === 'end' || this.arrowType === 'both' 115 | ? `M ${endArrowBasePoint.x} ${endArrowBasePoint.y} 116 | L ${endArrowSide1.x} ${endArrowSide1.y} L ${this.x2} ${this.y2} L ${endArrowSide2.x} ${endArrowSide2.y} L ${endArrowBasePoint.x} ${endArrowBasePoint.y} Z` 117 | : ``; 118 | 119 | return endSegment; 120 | } 121 | 122 | protected applyStrokeWidth() { 123 | super.applyStrokeWidth(); 124 | this.adjustVisual(); 125 | } 126 | 127 | public getState(): ArrowMarkerState { 128 | const result: ArrowMarkerState = Object.assign( 129 | { 130 | arrowType: this.arrowType, 131 | }, 132 | super.getState(), 133 | ); 134 | result.typeName = ArrowMarker.typeName; 135 | 136 | return result; 137 | } 138 | 139 | public restoreState(state: MarkerBaseState): void { 140 | const arrowState = state as ArrowMarkerState; 141 | this.arrowType = arrowState.arrowType; 142 | 143 | super.restoreState(state); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/editor/FreehandMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { FreehandMarker, IPoint, SvgHelper } from '../core'; 2 | import { MarkerBaseEditor } from './MarkerBaseEditor'; 3 | import { MarkerEditorProperties } from './MarkerEditorProperties'; 4 | 5 | /** 6 | * Editor for freehand markers. 7 | * 8 | * @summary Freehand marker editor. 9 | * @group Editors 10 | */ 11 | export class FreehandMarkerEditor< 12 | TMarkerType extends FreehandMarker = FreehandMarker, 13 | > extends MarkerBaseEditor { 14 | /** 15 | * Pointer X coordinate at the start of move or resize. 16 | */ 17 | protected manipulationStartX = 0; 18 | /** 19 | * Pointer Y coordinate at the start of move or resize. 20 | */ 21 | protected manipulationStartY = 0; 22 | 23 | /** 24 | * Container for control elements. 25 | */ 26 | protected controlBox?: SVGGElement; 27 | private controlRect?: SVGRectElement; 28 | 29 | constructor(properties: MarkerEditorProperties) { 30 | super(properties); 31 | 32 | this._continuousCreation = true; 33 | 34 | this.ownsTarget = this.ownsTarget.bind(this); 35 | 36 | this.setupControlBox = this.setupControlBox.bind(this); 37 | this.adjustControlBox = this.adjustControlBox.bind(this); 38 | 39 | this.manipulate = this.manipulate.bind(this); 40 | this.pointerDown = this.pointerDown.bind(this); 41 | this.pointerUp = this.pointerUp.bind(this); 42 | } 43 | 44 | public ownsTarget(el: EventTarget): boolean { 45 | if ( 46 | super.ownsTarget(el) || 47 | this.marker.ownsTarget(el) || 48 | el === this.controlRect 49 | ) { 50 | return true; 51 | } else { 52 | return false; 53 | } 54 | } 55 | 56 | public override pointerDown( 57 | point: IPoint, 58 | target?: EventTarget, 59 | ev?: PointerEvent, 60 | ): void { 61 | super.pointerDown(point, target, ev); 62 | 63 | this.manipulationStartX = point.x; 64 | this.manipulationStartY = point.y; 65 | 66 | if (this.state === 'new') { 67 | this.setupControlBox(); 68 | this.startCreation(point); 69 | } else if (this.state !== 'move') { 70 | this.select(); 71 | this._state = 'move'; 72 | } 73 | } 74 | 75 | private startCreation(point: IPoint) { 76 | this.marker.stage = 'creating'; 77 | this.marker.points.push(point); 78 | this.marker.createVisual(); 79 | this.marker.adjustVisual(); 80 | this._state = 'creating'; 81 | } 82 | 83 | private addNewPointWhileCreating(point: IPoint) { 84 | this.marker.points.push(point); 85 | this.marker.adjustVisual(); 86 | } 87 | 88 | private finishCreation() { 89 | this.marker.stage = 'normal'; 90 | this.marker.adjustVisual(); 91 | this._state = 'select'; 92 | if (this.onMarkerCreated) { 93 | this.onMarkerCreated(this); 94 | } 95 | } 96 | 97 | public override pointerUp(point: IPoint, ev?: PointerEvent): void { 98 | super.pointerUp(point, ev); 99 | this.manipulate(point, ev); 100 | if (this._state === 'creating') { 101 | this.finishCreation(); 102 | } 103 | this.state = 'select'; 104 | this.stateChanged(); 105 | } 106 | 107 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 108 | public override manipulate(point: IPoint, ev?: PointerEvent): void { 109 | if (this.state === 'creating') { 110 | this.addNewPointWhileCreating(point); 111 | } else if (this.state === 'move') { 112 | this.marker.points.forEach((p) => { 113 | p.x += point.x - this.manipulationStartX; 114 | p.y += point.y - this.manipulationStartY; 115 | }); 116 | this.manipulationStartX = point.x; 117 | this.manipulationStartY = point.y; 118 | this.marker.adjustVisual(); 119 | this.adjustControlBox(); 120 | } 121 | } 122 | 123 | /** 124 | * Creates control box for manipulation controls. 125 | */ 126 | protected setupControlBox(): void { 127 | if (this.controlBox) return; 128 | 129 | this.controlBox = SvgHelper.createGroup(); 130 | this.container.appendChild(this.controlBox); 131 | 132 | this.controlRect = SvgHelper.createRect(0, 0, [ 133 | ['stroke', 'black'], 134 | ['stroke-width', '1'], 135 | ['stroke-opacity', '0.5'], 136 | ['stroke-dasharray', '3, 2'], 137 | ['fill', 'transparent'], 138 | ]); 139 | 140 | this.controlBox.appendChild(this.controlRect); 141 | 142 | this.controlBox.style.display = 'none'; 143 | } 144 | 145 | protected adjustControlBox() { 146 | if (!this.controlBox) { 147 | this.setupControlBox(); 148 | } 149 | if (this.marker.points.length > 0) { 150 | const left = Math.min(...this.marker.points.map((p) => p.x)); 151 | const top = Math.min(...this.marker.points.map((p) => p.y)); 152 | const right = Math.max(...this.marker.points.map((p) => p.x)); 153 | const bottom = Math.max(...this.marker.points.map((p) => p.y)); 154 | 155 | if (this.controlRect) { 156 | SvgHelper.setAttributes(this.controlRect, [ 157 | ['x', (left - this.strokeWidth).toString()], 158 | ['y', (top - this.strokeWidth).toString()], 159 | ['width', (right - left + this.strokeWidth * 2).toString()], 160 | ['height', (bottom - top + this.strokeWidth * 2).toString()], 161 | ]); 162 | } 163 | } 164 | } 165 | 166 | public select(): void { 167 | super.select(); 168 | this.adjustControlBox(); 169 | if (this.controlBox) { 170 | this.controlBox.style.display = ''; 171 | } 172 | } 173 | 174 | public deselect(): void { 175 | super.deselect(); 176 | if (this.controlBox) { 177 | this.controlBox.style.display = 'none'; 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/editor/CurveMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { CurveMarker, IPoint, SvgHelper } from '../core'; 2 | import { LinearMarkerEditor } from './LinearMarkerEditor'; 3 | import { ResizeGrip } from './ResizeGrip'; 4 | 5 | export class CurveMarkerEditor< 6 | TMarkerType extends CurveMarker = CurveMarker, 7 | > extends LinearMarkerEditor { 8 | /** 9 | * Curve manipulation grip. 10 | */ 11 | protected curveGrip?: ResizeGrip; 12 | 13 | private manipulationStartCurveX = 0; 14 | private manipulationStartCurveY = 0; 15 | 16 | private curveControlLine1?: SVGLineElement; 17 | private curveControlLine2?: SVGLineElement; 18 | 19 | public ownsTarget(el: EventTarget): boolean { 20 | if (super.ownsTarget(el) || this.curveGrip?.ownsTarget(el)) { 21 | return true; 22 | } else { 23 | return false; 24 | } 25 | } 26 | 27 | public override pointerDown( 28 | point: IPoint, 29 | target?: EventTarget, 30 | ev?: PointerEvent, 31 | ): void { 32 | if (this.state === 'new') { 33 | this.marker.curveX = point.x; 34 | this.marker.curveY = point.y; 35 | } 36 | 37 | this.manipulationStartCurveX = this.marker.curveX; 38 | this.manipulationStartCurveY = this.marker.curveY; 39 | 40 | super.pointerDown(point, target, ev); 41 | 42 | if (this.state !== 'new' && this.state !== 'creating') { 43 | if (target && this.curveGrip?.ownsTarget(target)) { 44 | this.activeGrip = this.curveGrip; 45 | } 46 | 47 | if (this.activeGrip) { 48 | this._state = 'resize'; 49 | } else { 50 | this._state = 'move'; 51 | } 52 | } 53 | } 54 | 55 | protected resize(point: IPoint): void { 56 | super.resize(point); 57 | 58 | if (this.activeGrip === this.curveGrip) { 59 | this.marker.curveX = point.x; 60 | this.marker.curveY = point.y; 61 | 62 | this.marker.adjustVisual(); 63 | this.adjustControlBox(); 64 | } 65 | 66 | if (this.state === 'creating') { 67 | this.marker.curveX = 68 | this.marker.x1 + (this.marker.x2 - this.marker.x1) / 2; 69 | this.marker.curveY = 70 | this.marker.y1 + (this.marker.y2 - this.marker.y1) / 2; 71 | } 72 | } 73 | 74 | public override manipulate(point: IPoint, ev?: PointerEvent): void { 75 | if (this.state === 'move') { 76 | this.marker.curveX = 77 | this.manipulationStartCurveX + point.x - this.manipulationStartX; 78 | this.marker.curveY = 79 | this.manipulationStartCurveY + point.y - this.manipulationStartY; 80 | } 81 | super.manipulate(point, ev); 82 | } 83 | 84 | protected setupControlBox(): void { 85 | super.setupControlBox(); 86 | 87 | const strokeWidth = 1 / this.zoomLevel; 88 | 89 | this.curveControlLine1 = SvgHelper.createLine( 90 | this.marker.x1, 91 | this.marker.y1, 92 | this.marker.curveX, 93 | this.marker.curveY, 94 | [ 95 | ['stroke', 'black'], 96 | ['stroke-width', strokeWidth.toString()], 97 | ['stroke-opacity', '0.5'], 98 | ['stroke-dasharray', '3, 2'], 99 | ['fill', 'transparent'], 100 | ['pointer-events', 'none'], 101 | ], 102 | ); 103 | this.curveControlLine2 = SvgHelper.createLine( 104 | this.marker.x2, 105 | this.marker.y2, 106 | this.marker.curveX, 107 | this.marker.curveY, 108 | [ 109 | ['stroke', 'black'], 110 | ['stroke-width', strokeWidth.toString()], 111 | ['stroke-opacity', '0.5'], 112 | ['stroke-dasharray', '3, 2'], 113 | ['fill', 'transparent'], 114 | ['pointer-events', 'none'], 115 | ], 116 | ); 117 | 118 | // super creates the control box, so we know it exists 119 | this._controlBox!.insertBefore( 120 | this.curveControlLine1, 121 | this._controlBox!.firstChild, 122 | ); 123 | this._controlBox!.insertBefore( 124 | this.curveControlLine2, 125 | this._controlBox!.firstChild, 126 | ); 127 | } 128 | 129 | protected adjustControlBox() { 130 | super.adjustControlBox(); 131 | 132 | const strokeWidth = 1 / this.zoomLevel; 133 | if (this.curveControlLine1 && this.curveControlLine2 && this.curveGrip) { 134 | this.curveControlLine1.setAttribute('x1', this.marker.x1.toString()); 135 | this.curveControlLine1.setAttribute('y1', this.marker.y1.toString()); 136 | this.curveControlLine1.setAttribute('x2', this.marker.curveX.toString()); 137 | this.curveControlLine1.setAttribute('y2', this.marker.curveY.toString()); 138 | this.curveControlLine1.setAttribute( 139 | 'stroke-width', 140 | strokeWidth.toString(), 141 | ); 142 | 143 | this.curveControlLine2.setAttribute('x1', this.marker.x2.toString()); 144 | this.curveControlLine2.setAttribute('y1', this.marker.y2.toString()); 145 | this.curveControlLine2.setAttribute('x2', this.marker.curveX.toString()); 146 | this.curveControlLine2.setAttribute('y2', this.marker.curveY.toString()); 147 | this.curveControlLine2.setAttribute( 148 | 'stroke-width', 149 | strokeWidth.toString(), 150 | ); 151 | this.curveGrip.zoomLevel = this.zoomLevel; 152 | } 153 | } 154 | 155 | protected addControlGrips(): void { 156 | super.addControlGrips(); 157 | 158 | this.curveGrip = this.createGrip(); 159 | this.curveGrip.zoomLevel = this.zoomLevel; 160 | this.positionGrips(); 161 | } 162 | 163 | protected positionGrips(): void { 164 | super.positionGrips(); 165 | 166 | if (this.curveGrip) { 167 | const gripSize = this.curveGrip.gripSize; 168 | 169 | this.positionGrip( 170 | this.curveGrip.visual, 171 | this.marker.curveX - gripSize / 2, 172 | this.marker.curveY - gripSize / 2, 173 | ); 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/core/FreehandMarker.ts: -------------------------------------------------------------------------------- 1 | import { FreehandMarkerState } from './FreehandMarkerState'; 2 | import { IPoint } from './IPoint'; 3 | import { MarkerBase } from './MarkerBase'; 4 | import { MarkerBaseState } from './MarkerBaseState'; 5 | import { SvgHelper } from './SvgHelper'; 6 | 7 | /** 8 | * Freehand marker represents a hand drawing. 9 | * 10 | * Unlike v2 in v3 freehand marker is represented by an SVG path element. 11 | * This means that the line properties like stroke color, width, dasharray, etc. 12 | * can be modified after drawing. 13 | * 14 | * @summary Freehand drawing marker. 15 | * @group Markers 16 | */ 17 | export class FreehandMarker extends MarkerBase { 18 | public static typeName = 'FreehandMarker'; 19 | public static title = 'Freehand marker'; 20 | public static applyDefaultFilter = false; 21 | 22 | /** 23 | * Points of the freehand line. 24 | */ 25 | public points: IPoint[] = []; 26 | 27 | /** 28 | * Marker's main visual. 29 | */ 30 | public visual: SVGGraphicsElement | undefined; 31 | 32 | /** 33 | * Wider invisible visual to make it easier to select and manipulate the marker. 34 | */ 35 | protected selectorVisual: SVGGraphicsElement | undefined; 36 | /** 37 | * Visible visual of the marker. 38 | */ 39 | public visibleVisual: SVGGraphicsElement | undefined; 40 | 41 | protected applyStrokeColor() { 42 | if (this.visibleVisual) { 43 | SvgHelper.setAttributes(this.visibleVisual, [ 44 | ['stroke', this._strokeColor], 45 | ]); 46 | } 47 | } 48 | 49 | protected applyStrokeWidth() { 50 | if (this.visibleVisual) { 51 | SvgHelper.setAttributes(this.visibleVisual, [ 52 | ['stroke-width', this._strokeWidth.toString()], 53 | ]); 54 | } 55 | if (this.selectorVisual) { 56 | SvgHelper.setAttributes(this.selectorVisual, [ 57 | ['stroke-width', Math.max(this._strokeWidth, 8).toString()], 58 | ]); 59 | } 60 | } 61 | 62 | protected applyStrokeDasharray() { 63 | if (this.visibleVisual) { 64 | SvgHelper.setAttributes(this.visibleVisual, [ 65 | ['stroke-dasharray', this._strokeDasharray], 66 | ]); 67 | } 68 | } 69 | 70 | protected applyOpacity() { 71 | if (this.visibleVisual) { 72 | SvgHelper.setAttributes(this.visibleVisual, [ 73 | ['opacity', this._opacity.toString()], 74 | ]); 75 | } 76 | } 77 | 78 | constructor(container: SVGGElement) { 79 | super(container); 80 | 81 | this.strokeColor = '#ff0000'; 82 | this.strokeWidth = 3; 83 | 84 | this.createVisual = this.createVisual.bind(this); 85 | this.adjustVisual = this.adjustVisual.bind(this); 86 | this.getState = this.getState.bind(this); 87 | this.restoreState = this.restoreState.bind(this); 88 | this.scale = this.scale.bind(this); 89 | } 90 | 91 | public ownsTarget(el: EventTarget): boolean { 92 | if ( 93 | super.ownsTarget(el) || 94 | el === this.visual || 95 | el === this.selectorVisual || 96 | el === this.visibleVisual 97 | ) { 98 | return true; 99 | } else { 100 | return false; 101 | } 102 | } 103 | 104 | /** 105 | * Returns SVG path string representing the freehand line. 106 | * 107 | * @returns SVG path string representing the freehand line. 108 | */ 109 | protected getPath(): string { 110 | if (this.points.length > 1) { 111 | return this.points 112 | .map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`) 113 | .join(' '); 114 | } 115 | return 'M0,0'; 116 | } 117 | 118 | /** 119 | * Creates the visual elements comprising the marker's visual. 120 | */ 121 | public createVisual(): void { 122 | this.visual = SvgHelper.createGroup(); 123 | this.selectorVisual = SvgHelper.createPath(this.getPath(), [ 124 | ['stroke', 'transparent'], 125 | ['fill', 'transparent'], 126 | ['stroke-width', Math.max(this.strokeWidth, 8).toString()], 127 | ]); 128 | this.visibleVisual = SvgHelper.createPath(this.getPath(), [ 129 | ['stroke', this.strokeColor], 130 | ['fill', 'transparent'], 131 | ['stroke-width', this.strokeWidth.toString()], 132 | ['opacity', this.opacity.toString()], 133 | ]); 134 | this.visual.appendChild(this.selectorVisual); 135 | this.visual.appendChild(this.visibleVisual); 136 | 137 | this.addMarkerVisualToContainer(this.visual); 138 | } 139 | 140 | /** 141 | * Adjusts marker visual after manipulation or with new points. 142 | */ 143 | public adjustVisual(): void { 144 | if (this.selectorVisual && this.visibleVisual) { 145 | const path = this.getPath(); 146 | SvgHelper.setAttributes(this.selectorVisual, [['d', path]]); 147 | SvgHelper.setAttributes(this.visibleVisual, [['d', path]]); 148 | 149 | SvgHelper.setAttributes(this.visibleVisual, [ 150 | ['stroke', this.strokeColor], 151 | ['stroke-width', this.strokeWidth.toString()], 152 | ['stroke-dasharray', this.strokeDasharray.toString()], 153 | ['stroke-dasharray', this.strokeDasharray.toString()], 154 | ['opacity', this.opacity.toString()], 155 | ]); 156 | } 157 | } 158 | 159 | public getState(): FreehandMarkerState { 160 | const result: FreehandMarkerState = Object.assign( 161 | { 162 | points: this.points, 163 | }, 164 | super.getState(), 165 | ); 166 | 167 | return result; 168 | } 169 | 170 | public restoreState(state: MarkerBaseState): void { 171 | super.restoreState(state); 172 | 173 | const pmState = state as FreehandMarkerState; 174 | this.points = pmState.points; 175 | 176 | this.createVisual(); 177 | this.adjustVisual(); 178 | } 179 | 180 | public scale(scaleX: number, scaleY: number): void { 181 | super.scale(scaleX, scaleY); 182 | 183 | this.points.forEach((p) => { 184 | p.x = p.x * scaleX; 185 | p.y = p.y * scaleY; 186 | }); 187 | 188 | this.adjustVisual(); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/editor/TextMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { FontSize, IPoint, SvgHelper, TextMarker } from '../core'; 2 | import { MarkerEditorProperties } from './MarkerEditorProperties'; 3 | import { RectangularBoxMarkerBaseEditor } from './RectangularBoxMarkerBaseEditor'; 4 | import { TextBlockEditor } from './TextBlockEditor'; 5 | 6 | /** 7 | * Editor for text markers. 8 | * 9 | * @summary Text marker editor. 10 | * @group Editors 11 | */ 12 | export class TextMarkerEditor< 13 | TMarkerType extends TextMarker = TextMarker, 14 | > extends RectangularBoxMarkerBaseEditor { 15 | /** 16 | * Container for text block editor. 17 | */ 18 | protected textBlockEditorContainer: SVGForeignObjectElement = 19 | SvgHelper.createForeignObject(); 20 | /** 21 | * Text block editor. 22 | */ 23 | protected textBlockEditor: TextBlockEditor; 24 | 25 | /** 26 | * Text color. 27 | */ 28 | public set color(color: string) { 29 | this.marker.color = color; 30 | this.stateChanged(); 31 | } 32 | /** 33 | * Text color. 34 | */ 35 | public get color(): string { 36 | return this.marker.color; 37 | } 38 | 39 | /** 40 | * Sets text's font family. 41 | */ 42 | public set fontFamily(font: string) { 43 | this.marker.fontFamily = font; 44 | this.stateChanged(); 45 | } 46 | /** 47 | * Returns text's font family. 48 | */ 49 | public get fontFamily(): string { 50 | return this.marker.fontFamily; 51 | } 52 | 53 | /** 54 | * Sets text's font size. 55 | */ 56 | public set fontSize(size: FontSize) { 57 | this.marker.fontSize = size; 58 | this.stateChanged(); 59 | } 60 | /** 61 | * Returns text's font size. 62 | */ 63 | public get fontSize(): FontSize { 64 | return this.marker.fontSize; 65 | } 66 | 67 | constructor(properties: MarkerEditorProperties) { 68 | super(properties); 69 | 70 | this.disabledResizeGrips = [ 71 | 'topleft', 72 | 'topcenter', 73 | 'topright', 74 | 'bottomleft', 75 | 'bottomcenter', 76 | 'bottomright', 77 | 'leftcenter', 78 | 'rightcenter', 79 | ]; 80 | 81 | this._creationStyle = 'drop'; 82 | 83 | this.textBlockEditor = new TextBlockEditor(); 84 | this.marker.onSizeChanged = this.markerSizeChanged; 85 | 86 | this.showEditor = this.showEditor.bind(this); 87 | this.hideEditor = this.hideEditor.bind(this); 88 | this.pointerDown = this.pointerDown.bind(this); 89 | this.pointerUp = this.pointerUp.bind(this); 90 | this.resize = this.resize.bind(this); 91 | this.markerSizeChanged = this.markerSizeChanged.bind(this); 92 | } 93 | 94 | private _pointerDownTime: number = Number.MAX_VALUE; 95 | private _pointerDownPoint: IPoint = { x: 0, y: 0 }; 96 | 97 | public override pointerDown( 98 | point: IPoint, 99 | target?: EventTarget, 100 | ev?: PointerEvent, 101 | ): void { 102 | super.pointerDown(point, target, ev); 103 | 104 | this._pointerDownTime = Date.now(); 105 | this._pointerDownPoint = point; 106 | 107 | if (this.state === 'new') { 108 | this.marker.createVisual(); 109 | 110 | this.marker.moveVisual(point); 111 | 112 | this._state = 'creating'; 113 | } 114 | } 115 | 116 | public override dblClick( 117 | point: IPoint, 118 | target?: EventTarget, 119 | ev?: MouseEvent, 120 | ): void { 121 | super.dblClick(point, target, ev); 122 | if (this.state !== 'edit') { 123 | this.showEditor(); 124 | } 125 | } 126 | 127 | protected setSize(): void { 128 | super.setSize(); 129 | this.textBlockEditorContainer.style.display = 'flex'; 130 | this.textBlockEditorContainer.style.transform = `translate(${this.marker.left}px, ${this.marker.top}px)`; 131 | this.textBlockEditorContainer.style.width = `${this.marker.width}px`; 132 | this.textBlockEditorContainer.setAttribute('width', `${this.marker.width}`); 133 | this.textBlockEditorContainer.style.height = `${this.marker.height}px`; 134 | this.textBlockEditorContainer.setAttribute( 135 | 'height', 136 | `${this.marker.height}`, 137 | ); 138 | this.textBlockEditor.width = this.marker.width; 139 | this.textBlockEditor.height = this.marker.height; 140 | } 141 | 142 | protected resize(point: IPoint, preserveAspectRatio = false): void { 143 | super.resize(point, preserveAspectRatio); 144 | this.setSize(); 145 | } 146 | 147 | public override pointerUp(point: IPoint, ev?: PointerEvent): void { 148 | const inState = this.state; 149 | super.pointerUp(point, ev); 150 | this.setSize(); 151 | 152 | if ( 153 | inState === 'creating' || 154 | (Date.now() - this._pointerDownTime > 500 && 155 | Math.abs(this._pointerDownPoint.x - point.x) < 5 && 156 | Math.abs(this._pointerDownPoint.y - point.y) < 5) 157 | ) { 158 | this.showEditor(); 159 | } 160 | 161 | this.adjustControlBox(); 162 | } 163 | 164 | private showEditor(): void { 165 | this.textBlockEditor.text = this.marker.text; 166 | this.textBlockEditor.textColor = this.marker.color; 167 | this.textBlockEditor.bgColor = this.marker.fillColor; 168 | this.textBlockEditor.fontFamily = this.marker.fontFamily; 169 | this.textBlockEditor.fontSize = `${this.marker.fontSize.value}${this.marker.fontSize.units}`; 170 | 171 | if (this.textBlockEditor.onTextChanged === undefined) { 172 | this.textBlockEditor.onTextChanged = (text: string) => { 173 | this.marker.text = text; 174 | }; 175 | } 176 | if (this.textBlockEditor.onBlur === undefined) { 177 | this.textBlockEditor.onBlur = () => { 178 | this.hideEditor(); 179 | this.deselect(); 180 | }; 181 | } 182 | this.textBlockEditorContainer.appendChild( 183 | this.textBlockEditor.getEditorUi(), 184 | ); 185 | this.container.appendChild(this.textBlockEditorContainer); 186 | 187 | this.marker.hideVisual(); 188 | this.hideControlBox(); 189 | 190 | this.textBlockEditor.focus(); 191 | } 192 | 193 | private hideEditor(): void { 194 | this.marker.text = this.textBlockEditor.text; 195 | this.marker.showVisual(); 196 | this.showControlBox(); 197 | this.state = 'select'; 198 | this.container.removeChild(this.textBlockEditorContainer); 199 | } 200 | 201 | private markerSizeChanged = () => { 202 | this.setSize(); 203 | }; 204 | } 205 | -------------------------------------------------------------------------------- /src/editor/TextBlockEditor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Text changed event handler type. 3 | */ 4 | export type TextChangedHandler = (text: string) => void; 5 | 6 | /** 7 | * Blur event handler type. 8 | */ 9 | export type BlurHandler = () => void; 10 | 11 | /** 12 | * Represents a text block editor element. 13 | */ 14 | export class TextBlockEditor { 15 | private textEditor: HTMLTextAreaElement; 16 | private isInFocus = false; 17 | 18 | private _width = 0; 19 | /** 20 | * Returns editor width in pixels. 21 | */ 22 | public get width() { 23 | return this._width; 24 | } 25 | /** 26 | * Sets editor width in pixels. 27 | */ 28 | public set width(value) { 29 | this._width = value; 30 | this.textEditor.style.width = `${this.width}px`; 31 | } 32 | 33 | private _height = 0; 34 | /** 35 | * Returns editor height in pixels. 36 | */ 37 | public get height() { 38 | return this._height; 39 | } 40 | /** 41 | * Sets editor height in pixels. 42 | */ 43 | public set height(value) { 44 | this._height = value; 45 | this.textEditor.style.height = `${this.height}px`; 46 | } 47 | 48 | private _left = 0; 49 | /** 50 | * Returns the horizontal (X) location of the editor's left corner (in pixels). 51 | */ 52 | public get left() { 53 | return this._left; 54 | } 55 | /** 56 | * Sets the horizontal (X) location of the editor's left corner (in pixels). 57 | */ 58 | public set left(value) { 59 | this._left = value; 60 | this.textEditor.style.left = `${this.left}px`; 61 | } 62 | 63 | private _top = 0; 64 | /** 65 | * Returns the vertical (Y) location of the editor's top left corner (in pixels). 66 | */ 67 | public get top() { 68 | return this._top; 69 | } 70 | /** 71 | * Sets the vertical (Y) location of the editor's top left corner (in pixels). 72 | */ 73 | public set top(value) { 74 | this._top = value; 75 | this.textEditor.style.top = `${this.top}px`; 76 | } 77 | 78 | private _text = ''; 79 | /** 80 | * Returns the text block text. 81 | */ 82 | public get text() { 83 | return this._text; 84 | } 85 | /** 86 | * Sets the text block text. 87 | */ 88 | public set text(value) { 89 | this._text = value; 90 | } 91 | 92 | private _fontFamily = 'sans-serif'; 93 | /** 94 | * Returns text block's font family. 95 | */ 96 | public get fontFamily() { 97 | return this._fontFamily; 98 | } 99 | /** 100 | * Sets the text block's font family. 101 | */ 102 | public set fontFamily(value) { 103 | this._fontFamily = value; 104 | this.textEditor.style.fontFamily = this._fontFamily; 105 | } 106 | 107 | private _fontSize = '1rem'; 108 | /** 109 | * Returns text block's font size. 110 | */ 111 | public get fontSize() { 112 | return this._fontSize; 113 | } 114 | /** 115 | * Sets text block's font size. 116 | */ 117 | public set fontSize(value) { 118 | this._fontSize = value; 119 | this.textEditor.style.fontSize = this._fontSize; 120 | } 121 | 122 | private _textColor = '#000'; 123 | /** 124 | * Returns text block's font color. 125 | */ 126 | public get textColor() { 127 | return this._textColor; 128 | } 129 | /** 130 | * Returns text block's font color. 131 | */ 132 | public set textColor(value) { 133 | this._textColor = value; 134 | this.textEditor.style.color = this.textColor; 135 | } 136 | 137 | private _bgColor = 'transparent'; 138 | /** 139 | * Returns text block's background color. 140 | */ 141 | public get bgColor() { 142 | return this._bgColor; 143 | } 144 | /** 145 | * Sets text block's background color. 146 | */ 147 | public set bgColor(value) { 148 | this._bgColor = value; 149 | this.textEditor.style.backgroundColor = this.bgColor; 150 | } 151 | 152 | /** 153 | * Text changed event handler. 154 | */ 155 | public onTextChanged?: TextChangedHandler; 156 | 157 | /** 158 | * Blur event handler. 159 | */ 160 | public onBlur?: BlurHandler; 161 | 162 | /** 163 | * Creates a new text block editor instance. 164 | */ 165 | constructor() { 166 | this.textEditor = document.createElement('textarea'); 167 | 168 | this.getEditorUi = this.getEditorUi.bind(this); 169 | this.focus = this.focus.bind(this); 170 | this.setup = this.setup.bind(this); 171 | } 172 | 173 | private isSetupCompleted = false; 174 | private setup() { 175 | this.textEditor.style.pointerEvents = 'auto'; 176 | this.textEditor.style.width = `${this._width}px`; 177 | this.textEditor.style.height = `${this._height}px`; 178 | this.textEditor.style.overflow = 'hidden'; 179 | this.textEditor.style.textAlign = 'center'; 180 | this.textEditor.style.alignContent = 'center'; 181 | this.textEditor.style.padding = '0px'; 182 | this.textEditor.style.margin = '0px'; 183 | this.textEditor.style.fontFamily = this._fontFamily; 184 | this.textEditor.style.fontSize = this._fontSize; 185 | this.textEditor.style.lineHeight = '1em'; 186 | // remove all borders 187 | this.textEditor.style.outline = 'none'; 188 | this.textEditor.style.border = 'none'; 189 | // disable resizing 190 | this.textEditor.style.resize = 'none'; 191 | 192 | this.textEditor.value = this._text; 193 | this.textEditor.style.color = this._textColor; 194 | this.textEditor.style.whiteSpace = 'pre'; 195 | this.textEditor.addEventListener('pointerdown', (ev) => { 196 | ev.stopPropagation(); 197 | }); 198 | this.textEditor.addEventListener('pointerup', (ev) => { 199 | ev.stopPropagation(); 200 | }); 201 | this.textEditor.addEventListener('keydown', (ev) => { 202 | if (ev.key === 'Escape') { 203 | ev.preventDefault(); 204 | this.textEditor.blur(); 205 | } 206 | }); 207 | this.textEditor.addEventListener('keyup', (ev) => { 208 | ev.cancelBubble = true; 209 | this._text = this.textEditor.value; 210 | if (this.onTextChanged !== undefined) { 211 | this.onTextChanged(this._text); 212 | } 213 | }); 214 | this.textEditor.addEventListener('blur', () => { 215 | this._text = this.textEditor.value; 216 | if (this.onTextChanged !== undefined) { 217 | this.onTextChanged(this._text); 218 | } 219 | if (this.onBlur !== undefined) { 220 | this.onBlur(); 221 | } 222 | }); 223 | 224 | this.isSetupCompleted = true; 225 | } 226 | 227 | /** 228 | * Returns editor's UI, 229 | * @returns UI in a div element. 230 | */ 231 | public getEditorUi(): HTMLTextAreaElement { 232 | if (!this.isSetupCompleted) { 233 | this.setup(); 234 | } 235 | 236 | return this.textEditor; 237 | } 238 | 239 | /** 240 | * Focuses text editing in the editor. 241 | */ 242 | public focus() { 243 | this.textEditor.focus(); 244 | } 245 | /** 246 | * Unfocuses the editor. 247 | */ 248 | public blur() { 249 | this.textEditor.blur(); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/core/PolygonMarker.ts: -------------------------------------------------------------------------------- 1 | import { IPoint } from './IPoint'; 2 | import { MarkerBase } from './MarkerBase'; 3 | import { MarkerBaseState } from './MarkerBaseState'; 4 | import { PolygonMarkerState } from './PolygonMarkerState'; 5 | import { SvgHelper } from './SvgHelper'; 6 | 7 | /** 8 | * Polygon marker is a multi-point marker that represents a polygon. 9 | * 10 | * @summary Polygon marker. 11 | * @group Markers 12 | */ 13 | export class PolygonMarker extends MarkerBase { 14 | public static typeName = 'PolygonMarker'; 15 | public static title = 'Polygon marker'; 16 | 17 | /** 18 | * Marker's points. 19 | */ 20 | public points: IPoint[] = []; 21 | 22 | /** 23 | * Marker's main visual. 24 | */ 25 | public visual: SVGGraphicsElement | undefined; 26 | 27 | public selectorVisual: SVGGElement | undefined; 28 | public selectorVisualLines: SVGLineElement[] = []; 29 | public visibleVisual: SVGGraphicsElement | undefined; 30 | 31 | protected applyStrokeColor() { 32 | if (this.visibleVisual) { 33 | SvgHelper.setAttributes(this.visibleVisual, [ 34 | ['stroke', this._strokeColor], 35 | ]); 36 | } 37 | } 38 | 39 | protected applyFillColor() { 40 | if (this.visibleVisual) { 41 | SvgHelper.setAttributes(this.visibleVisual, [['fill', this._fillColor]]); 42 | } 43 | } 44 | 45 | protected applyStrokeWidth() { 46 | if (this.visibleVisual) { 47 | SvgHelper.setAttributes(this.visibleVisual, [ 48 | ['stroke-width', this._strokeWidth.toString()], 49 | ]); 50 | } 51 | if (this.selectorVisual) { 52 | SvgHelper.setAttributes(this.selectorVisual, [ 53 | ['stroke-width', Math.max(this._strokeWidth, 8).toString()], 54 | ]); 55 | } 56 | } 57 | 58 | protected applyStrokeDasharray() { 59 | if (this.visibleVisual) { 60 | SvgHelper.setAttributes(this.visibleVisual, [ 61 | ['stroke-dasharray', this._strokeDasharray], 62 | ]); 63 | } 64 | } 65 | 66 | protected applyOpacity() { 67 | if (this.visibleVisual) { 68 | SvgHelper.setAttributes(this.visibleVisual, [ 69 | ['opacity', this._opacity.toString()], 70 | ]); 71 | } 72 | } 73 | 74 | constructor(container: SVGGElement) { 75 | super(container); 76 | 77 | this.strokeColor = '#ff0000'; 78 | this.strokeWidth = 3; 79 | 80 | this.createVisual = this.createVisual.bind(this); 81 | this.adjustVisual = this.adjustVisual.bind(this); 82 | this.getState = this.getState.bind(this); 83 | this.restoreState = this.restoreState.bind(this); 84 | this.scale = this.scale.bind(this); 85 | } 86 | 87 | public ownsTarget(el: EventTarget): boolean { 88 | if ( 89 | super.ownsTarget(el) || 90 | el === this.visual || 91 | el === this.selectorVisual || 92 | el === this.visibleVisual || 93 | this.selectorVisualLines.some((l) => l === el) 94 | ) { 95 | return true; 96 | } else { 97 | return false; 98 | } 99 | } 100 | 101 | /** 102 | * Returns SVG path string for the polygon. 103 | * 104 | * @returns Path string for the polygon. 105 | */ 106 | protected getPath(): string { 107 | if (this.points.length > 1) { 108 | return ( 109 | this.points 110 | .map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`) 111 | .join(' ') + (this.stage !== 'creating' ? ' Z' : '') 112 | ); 113 | } 114 | return 'M0,0'; 115 | } 116 | 117 | /** 118 | * Creates marker's main visual. 119 | */ 120 | public createVisual(): void { 121 | this.visual = SvgHelper.createGroup(); 122 | this.visibleVisual = SvgHelper.createPath(this.getPath(), [ 123 | ['stroke', this.strokeColor], 124 | ['fill', this.fillColor], 125 | ['stroke-width', this.strokeWidth.toString()], 126 | ['opacity', this.opacity.toString()], 127 | ]); 128 | this.visual.appendChild(this.visibleVisual); 129 | 130 | this.createSelectorVisual(); 131 | 132 | this.addMarkerVisualToContainer(this.visual); 133 | } 134 | 135 | /** 136 | * Creates selector visual. 137 | * 138 | * Selector visual is a transparent wider visual that allows easier selection of the marker. 139 | */ 140 | private createSelectorVisual() { 141 | if (this.visual) { 142 | this.selectorVisual = SvgHelper.createGroup(); 143 | this.visual.appendChild(this.selectorVisual); 144 | 145 | this.points.forEach(() => { 146 | this.addSelectorLine(); 147 | }); 148 | } 149 | } 150 | 151 | /** 152 | * Adjusts marker visual after manipulation when needed. 153 | */ 154 | public adjustVisual(): void { 155 | if (this.selectorVisual && this.visibleVisual) { 156 | SvgHelper.setAttributes(this.visibleVisual, [ 157 | ['d', this.getPath()], 158 | ['stroke', this.strokeColor], 159 | ['stroke-width', this.strokeWidth.toString()], 160 | ['stroke-dasharray', this.strokeDasharray.toString()], 161 | ['fill', this.fillColor], 162 | ['opacity', this.opacity.toString()], 163 | ]); 164 | 165 | this.adjustSelectorVisual(); 166 | } 167 | } 168 | 169 | private adjustSelectorVisual() { 170 | if (this.selectorVisual) { 171 | // adjust number of lines 172 | const missingLines = this.points.length - this.selectorVisualLines.length; 173 | if (missingLines > 0) { 174 | for (let i = 0; i < missingLines; i++) { 175 | this.addSelectorLine(); 176 | } 177 | } else if (missingLines < 0) { 178 | for (let i = 0; i < -missingLines; i++) { 179 | this.selectorVisual!.removeChild(this.selectorVisualLines.pop()!); 180 | } 181 | } 182 | 183 | // adjust line coordinates 184 | this.selectorVisualLines.forEach((line, i) => { 185 | SvgHelper.setAttributes(line, [ 186 | ['x1', this.points[i].x.toString()], 187 | ['y1', this.points[i].y.toString()], 188 | ['x2', this.points[(i + 1) % this.points.length].x.toString()], 189 | ['y2', this.points[(i + 1) % this.points.length].y.toString()], 190 | ]); 191 | }); 192 | } 193 | } 194 | 195 | private addSelectorLine() { 196 | const line = SvgHelper.createLine(0, 0, 0, 0, [ 197 | ['stroke', 'transparent'], 198 | ['stroke-width', Math.max(this.strokeWidth, 8).toString()], 199 | ]); 200 | this.selectorVisual!.appendChild(line); 201 | this.selectorVisualLines.push(line); 202 | } 203 | 204 | public getState(): PolygonMarkerState { 205 | const result: PolygonMarkerState = Object.assign( 206 | { 207 | points: this.points, 208 | fillColor: this.fillColor, 209 | }, 210 | super.getState(), 211 | ); 212 | result.typeName = PolygonMarker.typeName; 213 | 214 | return result; 215 | } 216 | 217 | public restoreState(state: MarkerBaseState): void { 218 | super.restoreState(state); 219 | 220 | const pmState = state as PolygonMarkerState; 221 | this.points = pmState.points; 222 | if (pmState.fillColor !== undefined) { 223 | this.fillColor = pmState.fillColor; 224 | } 225 | 226 | this.createVisual(); 227 | this.adjustVisual(); 228 | } 229 | 230 | public scale(scaleX: number, scaleY: number): void { 231 | super.scale(scaleX, scaleY); 232 | 233 | this.points.forEach((p) => { 234 | p.x = p.x * scaleX; 235 | p.y = p.y * scaleY; 236 | }); 237 | 238 | this.adjustVisual(); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/core/CaptionFrameMarker.ts: -------------------------------------------------------------------------------- 1 | import { CaptionFrameMarkerState } from './CaptionFrameMarkerState'; 2 | import { MarkerBaseState } from './MarkerBaseState'; 3 | import { SvgHelper } from './SvgHelper'; 4 | import { TextMarker } from './TextMarker'; 5 | 6 | /** 7 | * Caption frame marker is a combination of a frame (rectangle) and a text caption that goes with it. 8 | * 9 | * @summary A combination of a frame (rectangle) and a text caption that goes with it. 10 | * 11 | * @group Markers 12 | */ 13 | export class CaptionFrameMarker extends TextMarker { 14 | public static typeName = 'CaptionFrameMarker'; 15 | 16 | public static title = 'Caption frame marker'; 17 | 18 | private _outerFrameVisual: SVGPathElement = SvgHelper.createPath('M0,0'); 19 | private _captionFrameVisual: SVGPathElement = SvgHelper.createPath('M0,0'); 20 | private _frameVisual: SVGGElement = SvgHelper.createGroup(); 21 | 22 | constructor(container: SVGGElement) { 23 | super(container); 24 | 25 | this.color = '#ffffff'; 26 | this.fillColor = '#ff0000'; 27 | this.strokeColor = '#ff0000'; 28 | this.strokeWidth = 3; 29 | this.padding = 5; 30 | 31 | this.createVisual = this.createVisual.bind(this); 32 | this.adjustVisual = this.adjustVisual.bind(this); 33 | this.adjustFrameVisual = this.adjustFrameVisual.bind(this); 34 | this.getPaths = this.getPaths.bind(this); 35 | } 36 | 37 | protected applyStrokeColor() { 38 | SvgHelper.setAttributes(this._outerFrameVisual, [ 39 | ['stroke', this._strokeColor], 40 | ]); 41 | SvgHelper.setAttributes(this._captionFrameVisual, [ 42 | ['stroke', this._strokeColor], 43 | ]); 44 | } 45 | 46 | protected applyStrokeWidth() { 47 | SvgHelper.setAttributes(this._outerFrameVisual, [ 48 | ['stroke-width', this._strokeWidth.toString()], 49 | ]); 50 | SvgHelper.setAttributes(this._captionFrameVisual, [ 51 | ['stroke-width', this._strokeWidth.toString()], 52 | ]); 53 | this.adjustTextPosition(); 54 | this.adjustFrameVisual(); 55 | } 56 | 57 | protected applyStrokeDasharray() { 58 | SvgHelper.setAttributes(this._outerFrameVisual, [ 59 | ['stroke-dasharray', this._strokeDasharray], 60 | ]); 61 | SvgHelper.setAttributes(this._captionFrameVisual, [ 62 | ['stroke-dasharray', this._strokeDasharray], 63 | ]); 64 | } 65 | 66 | protected applyOpacity() { 67 | if (this.visual) { 68 | SvgHelper.setAttributes(this.visual, [ 69 | ['opacity', this._opacity.toString()], 70 | ]); 71 | } 72 | } 73 | 74 | protected applyFillColor() { 75 | SvgHelper.setAttributes(this._captionFrameVisual, [ 76 | ['fill', this._fillColor], 77 | ]); 78 | } 79 | 80 | /** 81 | * Returns the SVG path strings for the frame and the caption background. 82 | * 83 | * @param width 84 | * @param height 85 | * @returns SVG path strings for the frame and the caption background. 86 | */ 87 | protected getPaths( 88 | width: number = this.width, 89 | height: number = this.height, 90 | ): { frame: string; caption: string } { 91 | const titleHeight = 92 | (this.textBlock.textSize?.height ?? 40) + 93 | this.padding * 2 + 94 | this.strokeWidth; 95 | return { 96 | frame: `M 0 0 97 | V ${height} 98 | H ${width} 99 | V 0 100 | Z`, 101 | caption: `M 0 0 102 | H ${width} 103 | V ${titleHeight} 104 | H 0 105 | Z`, 106 | }; 107 | } 108 | 109 | public createVisual(): void { 110 | super.createVisual(); 111 | 112 | const paths = this.getPaths(); 113 | 114 | if (this.visual) { 115 | SvgHelper.setAttributes(this.visual, [ 116 | ['opacity', this._opacity.toString()], 117 | ]); 118 | } 119 | this._outerFrameVisual = SvgHelper.createPath(paths.frame, [ 120 | ['fill', 'transparent'], 121 | ['stroke', this._strokeColor], 122 | ['stroke-width', this._strokeWidth.toString()], 123 | ['stroke-dasharray', this._strokeDasharray], 124 | ]); 125 | this._captionFrameVisual = SvgHelper.createPath(paths.caption, [ 126 | ['fill', 'this._fillColor'], 127 | ['fill-rule', 'evenodd'], 128 | ['stroke', this._strokeColor], 129 | ['stroke-width', this._strokeWidth.toString()], 130 | ['stroke-dasharray', this._strokeDasharray], 131 | ]); 132 | this._frameVisual.appendChild(this._outerFrameVisual); 133 | this._frameVisual.appendChild(this._captionFrameVisual); 134 | this.visual?.insertBefore(this._frameVisual, this.textBlock.textElement); 135 | } 136 | 137 | public adjustVisual(): void { 138 | super.adjustVisual(); 139 | 140 | this.adjustTextPosition(); 141 | this.adjustFrameVisual(); 142 | } 143 | 144 | /** 145 | * Adjusts text position inside the caption frame. 146 | */ 147 | protected adjustTextPosition(): void { 148 | if (this.textBlock.textSize) { 149 | this.textBlock.textElement.style.transform = `translate(${ 150 | this.width / 2 - this.textBlock.textSize?.width / 2 - this.padding 151 | }px, ${this.strokeWidth / 2}px)`; 152 | } 153 | } 154 | 155 | /** 156 | * Adjusts frame visual according to the current marker properties. 157 | */ 158 | protected adjustFrameVisual(): void { 159 | const paths = this.getPaths(); 160 | if (this.visual) { 161 | SvgHelper.setAttributes(this.visual, [ 162 | ['opacity', this._opacity.toString()], 163 | ]); 164 | } 165 | 166 | if (this._outerFrameVisual) { 167 | SvgHelper.setAttributes(this._outerFrameVisual, [ 168 | ['d', paths.frame], 169 | ['stroke', this._strokeColor], 170 | ['stroke-width', this._strokeWidth.toString()], 171 | ['stroke-dasharray', this._strokeDasharray], 172 | ]); 173 | } 174 | if (this._captionFrameVisual) { 175 | SvgHelper.setAttributes(this._captionFrameVisual, [ 176 | ['d', paths.caption], 177 | ['fill', this._fillColor], 178 | ['stroke', this._strokeColor], 179 | ['stroke-width', this._strokeWidth.toString()], 180 | ['stroke-dasharray', this._strokeDasharray], 181 | ]); 182 | } 183 | } 184 | 185 | public ownsTarget(el: EventTarget): boolean { 186 | if ( 187 | super.ownsTarget(el) || 188 | this._outerFrameVisual === el || 189 | this._captionFrameVisual === el 190 | ) { 191 | return true; 192 | } else { 193 | return false; 194 | } 195 | } 196 | 197 | public setSize(): void { 198 | super.setSize(); 199 | this.adjustTextPosition(); 200 | this.adjustFrameVisual(); 201 | } 202 | protected setSizeFromTextSize(): void {} 203 | 204 | /** 205 | * Hides the marker visual. 206 | * 207 | * Used by the editor to hide rendered marker while editing the text. 208 | */ 209 | public hideVisual(): void { 210 | this.textBlock.hide(); 211 | } 212 | /** 213 | * Shows the marker visual. 214 | * 215 | * Used by the editor to show rendered marker after editing the text. 216 | */ 217 | public showVisual() { 218 | this.textBlock.show(); 219 | this.textBlock.renderText(); 220 | } 221 | 222 | public getState(): CaptionFrameMarkerState { 223 | const result: CaptionFrameMarkerState = Object.assign( 224 | { 225 | fillColor: this.fillColor, 226 | }, 227 | super.getState(), 228 | ); 229 | 230 | return result; 231 | } 232 | 233 | public restoreState(state: MarkerBaseState): void { 234 | const captionState = state as CaptionFrameMarkerState; 235 | super.restoreState(state); 236 | this.fillColor = captionState.fillColor; 237 | 238 | this.adjustVisual(); 239 | } 240 | 241 | public scale(scaleX: number, scaleY: number): void { 242 | super.scale(scaleX, scaleY); 243 | 244 | this.strokeWidth *= (scaleX + scaleY) / 2; 245 | 246 | this.setSize(); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/core/ImageMarkerBase.ts: -------------------------------------------------------------------------------- 1 | import { ImageMarkerBaseState, ImageType } from './ImageMarkerBaseState'; 2 | import { RectangularBoxMarkerBase } from './RectangularBoxMarkerBase'; 3 | import { SvgHelper } from './SvgHelper'; 4 | 5 | /** 6 | * Base class for image markers. 7 | * 8 | * This class isn't meant to be used directly. Use one of the derived classes instead. 9 | * 10 | * @summary Image marker base class. 11 | * @group Markers 12 | */ 13 | export class ImageMarkerBase extends RectangularBoxMarkerBase { 14 | public static title = 'Image marker'; 15 | 16 | /** 17 | * Main SVG or image element of the marker. 18 | */ 19 | protected SVGImage?: SVGSVGElement | SVGImageElement; 20 | /** 21 | * Type of the image: SVG or bitmap. 22 | */ 23 | protected imageType: ImageType = 'svg'; 24 | 25 | /** 26 | * For SVG images this holds the SVG markup of the image. 27 | */ 28 | protected _svgString?: string; 29 | /** 30 | * For SVG images this holds the SVG markup of the image. 31 | */ 32 | public get svgString() { 33 | return this._svgString; 34 | } 35 | public set svgString(value) { 36 | this._svgString = value; 37 | if (this.SVGImage && this.imageType === 'svg') { 38 | if (value !== undefined) { 39 | this.SVGImage.outerHTML = value; 40 | } else { 41 | this.SVGImage.outerHTML = ''; 42 | } 43 | } 44 | } 45 | 46 | /** 47 | * For bitmap images this holds the base64 encoded image. 48 | */ 49 | protected _imageSrc?: string; // = 'data:image/png;base64,...'; 50 | /** 51 | * For bitmap images this holds the base64 encoded image. 52 | * 53 | * @remarks 54 | * Technically this could be any URL but due to browser security constraints 55 | * an external image will almost certainly cause bitmap rendering of the image to fail. 56 | * 57 | * In cases you know you will never render the annotation as a static image, 58 | * it should be safe to use external URLs. Otherwise, use base64 encoded images 59 | * like 'data:image/png;base64,...'. 60 | */ 61 | public get imageSrc() { 62 | return this._imageSrc; 63 | } 64 | public set imageSrc(value) { 65 | this._imageSrc = value; 66 | if (this.SVGImage && this.imageType === 'bitmap') { 67 | if (value !== undefined) { 68 | SvgHelper.setAttributes(this.SVGImage, [['href', value]]); 69 | } else { 70 | SvgHelper.setAttributes(this.SVGImage, [['href', '']]); 71 | } 72 | } 73 | } 74 | 75 | /** 76 | * Natural (real) width of the image. 77 | */ 78 | protected naturalWidth = 24; 79 | /** 80 | * Natural (real) height of the image. 81 | */ 82 | protected naturalHeight = 24; 83 | 84 | constructor(container: SVGGElement) { 85 | super(container); 86 | 87 | this.defaultSize = { width: this.naturalWidth, height: this.naturalHeight }; 88 | 89 | this.createImage = this.createImage.bind(this); 90 | this.createVisual = this.createVisual.bind(this); 91 | this.adjustVisual = this.adjustVisual.bind(this); 92 | this.adjustImage = this.adjustImage.bind(this); 93 | } 94 | 95 | protected applyOpacity() { 96 | if (this.visual) { 97 | SvgHelper.setAttributes(this.visual, [ 98 | ['opacity', this.opacity.toString()], 99 | ]); 100 | } 101 | } 102 | 103 | /** 104 | * Creates the image element based on the image type and source. 105 | */ 106 | protected createImage(): void { 107 | if (this._svgString !== undefined) { 108 | this.imageType = 'svg'; 109 | // Import into current document to avoid cross-document issues 110 | const parser = new DOMParser(); 111 | const doc = parser.parseFromString(this._svgString, 'image/svg+xml'); 112 | const element = doc.documentElement; 113 | if (!(element instanceof SVGSVGElement)) { 114 | throw new Error('Invalid SVG string'); 115 | } 116 | const svgElement = element; 117 | this.SVGImage = this.container.ownerDocument.importNode(svgElement, true); 118 | } else { 119 | this.imageType = 'bitmap'; 120 | this.SVGImage = SvgHelper.createImage([['href', this._imageSrc ?? '']]); 121 | } 122 | } 123 | 124 | /** 125 | * Creates marker's visual, including its image element. 126 | */ 127 | public createVisual(): void { 128 | this.createImage(); 129 | if (this.SVGImage !== undefined) { 130 | this.visual = SvgHelper.createGroup(); 131 | 132 | if (this.imageType === 'svg') { 133 | SvgHelper.setAttributes(this.visual, [ 134 | ['viewBox', `0 0 ${this.naturalWidth} ${this.naturalHeight}`], 135 | ['fill', this._fillColor], 136 | ['stroke', this._strokeColor], 137 | ['color', this._strokeColor], 138 | ['stroke-width', this.strokeWidth.toString()], 139 | ['stroke-dasharray', this.strokeDasharray], 140 | ['opacity', this.opacity.toString()], 141 | ['pointer-events', 'bounding-box'], 142 | ]); 143 | // } else if (this.imageType === 'bitmap') { 144 | } 145 | this.adjustImage(); 146 | this.visual.appendChild(this.SVGImage); 147 | this.addMarkerVisualToContainer(this.visual); 148 | } 149 | } 150 | 151 | /** 152 | * Adjusts marker's visual according to the current state 153 | * (color, width, etc.). 154 | */ 155 | public adjustVisual(): void { 156 | if (this.visual) { 157 | SvgHelper.setAttributes(this.visual, [ 158 | ['opacity', this._opacity.toString()], 159 | ]); 160 | } 161 | } 162 | 163 | /** 164 | * Adjusts the image size and position. 165 | */ 166 | public adjustImage(): void { 167 | if (this.SVGImage !== undefined) { 168 | this.SVGImage.setAttribute('x', `0px`); 169 | this.SVGImage.setAttribute('y', `0px`); 170 | this.SVGImage.setAttribute('width', `${this.width}px`); 171 | this.SVGImage.setAttribute('height', `${this.height}px`); 172 | } 173 | } 174 | 175 | private isDescendant(parent: Element, target: EventTarget): boolean { 176 | if (parent === target) { 177 | return true; 178 | } 179 | 180 | for (let i = 0; i < parent.children.length; i++) { 181 | if (this.isDescendant(parent.children[i], target)) { 182 | return true; 183 | } 184 | } 185 | return false; 186 | } 187 | 188 | public ownsTarget(el: EventTarget): boolean { 189 | return ( 190 | super.ownsTarget(el) || 191 | (this.SVGImage !== undefined && this.isDescendant(this.SVGImage, el)) 192 | ); 193 | } 194 | 195 | public setSize(): void { 196 | super.setSize(); 197 | if (this.visual) { 198 | SvgHelper.setAttributes(this.visual, [ 199 | ['width', `${this.width}px`], 200 | ['height', `${this.height}px`], 201 | ]); 202 | this.adjustImage(); 203 | } 204 | } 205 | 206 | public getState(): ImageMarkerBaseState { 207 | const result: ImageMarkerBaseState = Object.assign( 208 | { 209 | imageType: this.imageType, 210 | svgString: this.svgString, 211 | imageSrc: this.imageSrc, 212 | }, 213 | super.getState(), 214 | ); 215 | 216 | return result; 217 | } 218 | 219 | protected applyStrokeColor() { 220 | if (this.visual) { 221 | SvgHelper.setAttributes(this.visual, [['color', this._strokeColor]]); 222 | } 223 | } 224 | 225 | public restoreState(state: ImageMarkerBaseState): void { 226 | const imgState = state as ImageMarkerBaseState; 227 | if (imgState.imageType !== undefined) { 228 | this.imageType = imgState.imageType; 229 | } 230 | if (imgState.svgString !== undefined) { 231 | this._svgString = imgState.svgString; 232 | } 233 | if (imgState.imageSrc !== undefined) { 234 | this._imageSrc = imgState.imageSrc; 235 | } 236 | this.createVisual(); 237 | super.restoreState(state); 238 | this.setSize(); 239 | this.adjustVisual(); 240 | } 241 | 242 | public scale(scaleX: number, scaleY: number): void { 243 | super.scale(scaleX, scaleY); 244 | 245 | this.setSize(); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /test/manual/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | marker.js 3 manual testing 7 | 15 | 60 | 61 | 62 | test image 68 |
80 | 81 |
82 | 83 |
84 |
85 | 86 | 87 |
88 | 89 |
90 | 91 | 92 | 93 | 96 | 99 | 102 | 103 | 104 | 105 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 |
118 | 119 |
120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 |
133 | 134 |
135 | 136 | 137 |
138 |
139 | 140 | 144 | 145 | 165 | 166 | 179 | 180 | 189 | 190 |
191 | 192 |
193 | 194 |
205 | 206 |
207 | 208 |
209 |
210 | 211 | 212 | 213 |
214 |
215 | 216 | 217 | 218 | 219 | -------------------------------------------------------------------------------- /src/core/RectangularBoxMarkerBase.ts: -------------------------------------------------------------------------------- 1 | import { IPoint } from './IPoint'; 2 | import { MarkerBase } from './MarkerBase'; 3 | import { MarkerBaseState } from './MarkerBaseState'; 4 | import { RectangularBoxMarkerBaseState } from './RectangularBoxMarkerBaseState'; 5 | import { SvgHelper } from './SvgHelper'; 6 | import { TransformMatrix } from './TransformMatrix'; 7 | 8 | /** 9 | * RectangularBoxMarkerBase is a base class for all marker's that conceptually fit into a rectangle 10 | * such as all rectangle markers, ellipse, text and callout markers. 11 | * 12 | * @summary Base class for all markers that conceptually fit into a rectangle. 13 | * @group Markers 14 | */ 15 | export class RectangularBoxMarkerBase extends MarkerBase { 16 | /** 17 | * x coordinate of the top-left corner. 18 | */ 19 | public left = 0; 20 | /** 21 | * y coordinate of the top-left corner. 22 | */ 23 | public top = 0; 24 | /** 25 | * Marker width. 26 | */ 27 | public width = 0; 28 | /** 29 | * Marker height. 30 | */ 31 | public height = 0; 32 | 33 | /** 34 | * Marker's rotation angle. 35 | */ 36 | public rotationAngle = 0; 37 | 38 | /** 39 | * x coordinate of the marker's center. 40 | */ 41 | public get centerX(): number { 42 | return this.left + this.width / 2; 43 | } 44 | /** 45 | * y coordinate of the marker's center. 46 | */ 47 | public get centerY(): number { 48 | return this.top + this.height / 2; 49 | } 50 | 51 | private _visual?: SVGGraphicsElement; 52 | /** 53 | * Container for the marker's visual. 54 | */ 55 | protected get visual(): SVGGraphicsElement | undefined { 56 | return this._visual; 57 | } 58 | protected set visual(value: SVGGraphicsElement) { 59 | this._visual = value; 60 | const translate = SvgHelper.createTransform(); 61 | this._visual.transform.baseVal.appendItem(translate); 62 | } 63 | 64 | constructor(container: SVGGElement) { 65 | super(container); 66 | 67 | this.rotatePoint = this.rotatePoint.bind(this); 68 | this.unrotatePoint = this.unrotatePoint.bind(this); 69 | 70 | // add rotation transform 71 | this.container.transform.baseVal.appendItem(SvgHelper.createTransform()); 72 | } 73 | 74 | /** 75 | * Moves visual to the specified coordinates. 76 | * @param point - coordinates of the new top-left corner of the visual. 77 | */ 78 | public moveVisual(point: IPoint): void { 79 | if (this.visual) { 80 | this.visual.style.transform = `translate(${point.x}px, ${point.y}px)`; 81 | } 82 | } 83 | 84 | /** 85 | * Adjusts marker's size. 86 | */ 87 | public setSize(): void { 88 | this.moveVisual({ x: this.left, y: this.top }); 89 | } 90 | 91 | /** 92 | * Rotates marker around the center. 93 | * @param point - coordinates of the rotation point. 94 | */ 95 | public rotate(point: IPoint) { 96 | // avoid glitch when crossing the 0 rotation point 97 | if (Math.abs(point.x - this.centerX) > 0.1) { 98 | const sign = Math.sign(point.x - this.centerX); 99 | this.rotationAngle = 100 | (Math.atan((point.y - this.centerY) / (point.x - this.centerX)) * 180) / 101 | Math.PI + 102 | 90 * sign; 103 | this.applyRotation(); 104 | } 105 | } 106 | 107 | private applyRotation() { 108 | const rotate = this.container.transform.baseVal.getItem(0); 109 | rotate.setRotate(this.rotationAngle, this.centerX, this.centerY); 110 | this.container.transform.baseVal.replaceItem(rotate, 0); 111 | } 112 | 113 | /** 114 | * Returns point coordinates based on the actual screen coordinates and marker's rotation. 115 | * @param point - original pointer coordinates 116 | */ 117 | public rotatePoint(point: IPoint): IPoint { 118 | if (this.rotationAngle === 0) { 119 | return point; 120 | } 121 | 122 | const matrix = this.container.getCTM(); 123 | if (matrix === null) { 124 | return point; 125 | } 126 | let svgPoint = SvgHelper.createPoint(point.x, point.y); 127 | svgPoint = svgPoint.matrixTransform(matrix); 128 | 129 | const result = { x: svgPoint.x, y: svgPoint.y }; 130 | 131 | return result; 132 | } 133 | 134 | /** 135 | * Returns original point coordinates based on coordinates with rotation applied. 136 | * @param point - rotated point coordinates. 137 | */ 138 | public unrotatePoint(point: IPoint): IPoint { 139 | if (this.rotationAngle === 0) { 140 | return point; 141 | } 142 | 143 | let matrix = this.container.getCTM(); 144 | if (matrix === null) { 145 | return point; 146 | } 147 | matrix = matrix.inverse(); 148 | let svgPoint = SvgHelper.createPoint(point.x, point.y); 149 | svgPoint = svgPoint.matrixTransform(matrix); 150 | 151 | const result = { x: svgPoint.x, y: svgPoint.y }; 152 | 153 | return result; 154 | } 155 | 156 | public getOutline(): string { 157 | const result = `M 0 0 158 | H ${this.defaultSize} 159 | V ${this.defaultSize} 160 | H 0 161 | V 0 Z`; 162 | 163 | return result; 164 | } 165 | 166 | public getState(): RectangularBoxMarkerBaseState { 167 | const result: RectangularBoxMarkerBaseState = Object.assign( 168 | { 169 | left: this.left, 170 | top: this.top, 171 | width: this.width, 172 | height: this.height, 173 | rotationAngle: this.rotationAngle, 174 | visualTransformMatrix: TransformMatrix.toITransformMatrix( 175 | this.visual!.transform.baseVal.getItem(0).matrix, 176 | ), 177 | containerTransformMatrix: TransformMatrix.toITransformMatrix( 178 | this.container.transform.baseVal.getItem(0).matrix, 179 | ), 180 | }, 181 | super.getState(), 182 | ); 183 | 184 | return result; 185 | } 186 | 187 | public restoreState(state: MarkerBaseState): void { 188 | super.restoreState(state); 189 | const rbmState = state as RectangularBoxMarkerBaseState; 190 | this.left = rbmState.left; 191 | this.top = rbmState.top; 192 | this.width = rbmState.width; 193 | this.height = rbmState.height; 194 | this.rotationAngle = rbmState.rotationAngle; 195 | 196 | this.moveVisual({ x: this.left, y: this.top }); 197 | 198 | if (rbmState.visualTransformMatrix && rbmState.containerTransformMatrix) { 199 | this.visual!.transform.baseVal.getItem(0).setMatrix( 200 | TransformMatrix.toSVGMatrix( 201 | this.visual!.transform.baseVal.getItem(0).matrix, 202 | rbmState.visualTransformMatrix, 203 | ), 204 | ); 205 | this.container.transform.baseVal 206 | .getItem(0) 207 | .setMatrix( 208 | TransformMatrix.toSVGMatrix( 209 | this.container.transform.baseVal.getItem(0).matrix, 210 | rbmState.containerTransformMatrix, 211 | ), 212 | ); 213 | } else { 214 | this.applyRotation(); 215 | } 216 | } 217 | 218 | public scale(scaleX: number, scaleY: number): void { 219 | super.scale(scaleX, scaleY); 220 | 221 | const rPoint = this.rotatePoint({ x: this.left, y: this.top }); 222 | const point = this.unrotatePoint({ 223 | x: rPoint.x * scaleX, 224 | y: rPoint.y * scaleY, 225 | }); 226 | 227 | this.left = point.x; 228 | this.top = point.y; 229 | this.width = this.width * scaleX; 230 | this.height = this.height * scaleY; 231 | } 232 | 233 | public getBBox(): DOMRect { 234 | const rotatedTL = this.rotatePoint({ x: this.left, y: this.top }); 235 | const rotatedTR = this.rotatePoint({ 236 | x: this.left + this.width, 237 | y: this.top, 238 | }); 239 | const rotatedBL = this.rotatePoint({ 240 | x: this.left, 241 | y: this.top + this.height, 242 | }); 243 | const rotatedBR = this.rotatePoint({ 244 | x: this.left + this.width, 245 | y: this.top + this.height, 246 | }); 247 | const absTL = { 248 | x: Math.min(rotatedTL.x, rotatedTR.x, rotatedBL.x, rotatedBR.x), 249 | y: Math.min(rotatedTL.y, rotatedTR.y, rotatedBL.y, rotatedBR.y), 250 | }; 251 | const absBR = { 252 | x: Math.max(rotatedTL.x, rotatedTR.x, rotatedBL.x, rotatedBR.x), 253 | y: Math.max(rotatedTL.y, rotatedTR.y, rotatedBL.y, rotatedBR.y), 254 | }; 255 | return new DOMRect(absTL.x, absTL.y, absBR.x - absTL.x, absBR.y - absTL.y); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/editor/LinearMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { IPoint, LinearMarkerBase, SvgHelper } from '../core'; 2 | import { MarkerBaseEditor } from './MarkerBaseEditor'; 3 | import { MarkerEditorProperties } from './MarkerEditorProperties'; 4 | import { ResizeGrip } from './ResizeGrip'; 5 | 6 | /** 7 | * Editor for linear markers. 8 | * 9 | * @summary Editor for line-like markers. 10 | * @group Editors 11 | */ 12 | export class LinearMarkerEditor< 13 | TMarkerType extends LinearMarkerBase = LinearMarkerBase, 14 | > extends MarkerBaseEditor { 15 | /** 16 | * Default line length when marker is created with a simple click (without dragging). 17 | */ 18 | protected defaultLength = 50; 19 | 20 | /** 21 | * Pointer X coordinate at the start of move or resize. 22 | */ 23 | protected manipulationStartX = 0; 24 | /** 25 | * Pointer Y coordinate at the start of move or resize. 26 | */ 27 | protected manipulationStartY = 0; 28 | 29 | private manipulationStartX1 = 0; 30 | private manipulationStartY1 = 0; 31 | private manipulationStartX2 = 0; 32 | private manipulationStartY2 = 0; 33 | 34 | /** 35 | * Container for manipulation grips. 36 | */ 37 | protected manipulationBox: SVGGElement = SvgHelper.createGroup(); 38 | 39 | /** 40 | * First manipulation grip 41 | */ 42 | protected grip1?: ResizeGrip; 43 | /** 44 | * Second manipulation grip. 45 | */ 46 | protected grip2?: ResizeGrip; 47 | /** 48 | * Active manipulation grip. 49 | */ 50 | protected activeGrip?: ResizeGrip; 51 | 52 | constructor(properties: MarkerEditorProperties) { 53 | super(properties); 54 | 55 | this.ownsTarget = this.ownsTarget.bind(this); 56 | 57 | this.setupControlBox = this.setupControlBox.bind(this); 58 | this.adjustControlBox = this.adjustControlBox.bind(this); 59 | 60 | this.addControlGrips = this.addControlGrips.bind(this); 61 | this.createGrip = this.createGrip.bind(this); 62 | this.positionGrip = this.positionGrip.bind(this); 63 | this.positionGrips = this.positionGrips.bind(this); 64 | 65 | this.resize = this.resize.bind(this); 66 | 67 | this.manipulate = this.manipulate.bind(this); 68 | this.pointerDown = this.pointerDown.bind(this); 69 | this.pointerUp = this.pointerUp.bind(this); 70 | } 71 | 72 | public ownsTarget(el: EventTarget): boolean { 73 | if (super.ownsTarget(el) || this.marker.ownsTarget(el)) { 74 | return true; 75 | } else if (this.grip1?.ownsTarget(el) || this.grip2?.ownsTarget(el)) { 76 | return true; 77 | } else { 78 | return false; 79 | } 80 | } 81 | 82 | public override pointerDown( 83 | point: IPoint, 84 | target?: EventTarget, 85 | ev?: PointerEvent, 86 | ): void { 87 | super.pointerDown(point, target, ev); 88 | 89 | this.manipulationStartX = point.x; 90 | this.manipulationStartY = point.y; 91 | 92 | if (this.state === 'new') { 93 | this.setupControlBox(); 94 | this.marker.x1 = point.x; 95 | this.marker.y1 = point.y; 96 | this.marker.x2 = point.x; 97 | this.marker.y2 = point.y; 98 | } 99 | 100 | this.manipulationStartX1 = this.marker.x1; 101 | this.manipulationStartY1 = this.marker.y1; 102 | this.manipulationStartX2 = this.marker.x2; 103 | this.manipulationStartY2 = this.marker.y2; 104 | 105 | if (this.state === 'new') { 106 | this.marker.createVisual(); 107 | this.marker.adjustVisual(); 108 | 109 | this._state = 'creating'; 110 | } else { 111 | this.select(this.isMultiSelected); 112 | if (target && this.grip1?.ownsTarget(target)) { 113 | this.activeGrip = this.grip1; 114 | } else if (target && this.grip2?.ownsTarget(target)) { 115 | this.activeGrip = this.grip2; 116 | } else { 117 | this.activeGrip = undefined; 118 | } 119 | 120 | if (this.activeGrip) { 121 | this._state = 'resize'; 122 | } else { 123 | this._state = 'move'; 124 | } 125 | } 126 | } 127 | 128 | public override pointerUp(point: IPoint, ev?: PointerEvent): void { 129 | const inState = this.state; 130 | super.pointerUp(point, ev); 131 | if ( 132 | this.state === 'creating' && 133 | Math.abs(this.marker.x1 - this.marker.x2) < 10 && 134 | Math.abs(this.marker.y1 - this.marker.y2) < 10 135 | ) { 136 | this.marker.x2 = this.marker.x1 + this.defaultLength; 137 | this.marker.adjustVisual(); 138 | this.adjustControlBox(); 139 | } else { 140 | this.manipulate(point, ev); 141 | } 142 | this._state = 'select'; 143 | if (inState === 'creating' && this.onMarkerCreated) { 144 | this.onMarkerCreated(this); 145 | } 146 | this.stateChanged(); 147 | } 148 | 149 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 150 | public override manipulate(point: IPoint, ev?: PointerEvent): void { 151 | if (this.state === 'creating') { 152 | this.resize(point); 153 | } else if (this.state === 'move') { 154 | this.marker.x1 = 155 | this.manipulationStartX1 + point.x - this.manipulationStartX; 156 | this.marker.y1 = 157 | this.manipulationStartY1 + point.y - this.manipulationStartY; 158 | this.marker.x2 = 159 | this.manipulationStartX2 + point.x - this.manipulationStartX; 160 | this.marker.y2 = 161 | this.manipulationStartY2 + point.y - this.manipulationStartY; 162 | this.marker.adjustVisual(); 163 | this.adjustControlBox(); 164 | } else if (this.state === 'resize') { 165 | this.resize(point); 166 | } 167 | } 168 | 169 | protected resize(point: IPoint): void { 170 | switch (this.activeGrip) { 171 | case this.grip1: 172 | this.marker.x1 = point.x; 173 | this.marker.y1 = point.y; 174 | break; 175 | case this.grip2: 176 | case undefined: 177 | this.marker.x2 = point.x; 178 | this.marker.y2 = point.y; 179 | break; 180 | } 181 | this.marker.adjustVisual(); 182 | this.adjustControlBox(); 183 | } 184 | 185 | /** 186 | * Creates control box for manipulation controls. 187 | */ 188 | protected setupControlBox(): void { 189 | if (this._controlBox) return; 190 | 191 | this._controlBox = SvgHelper.createGroup(); 192 | this.container.appendChild(this._controlBox); 193 | this.manipulationBox = SvgHelper.createGroup(); 194 | this._controlBox.appendChild(this.manipulationBox); 195 | 196 | this.addControlGrips(); 197 | 198 | this._controlBox.style.display = 'none'; 199 | } 200 | 201 | protected adjustControlBox() { 202 | if (!this._controlBox) { 203 | this.setupControlBox(); 204 | } 205 | this.positionGrips(); 206 | } 207 | 208 | /** 209 | * Adds control grips to control box. 210 | */ 211 | protected addControlGrips(): void { 212 | this.grip1 = this.createGrip(); 213 | this.grip2 = this.createGrip(); 214 | 215 | this.positionGrips(); 216 | } 217 | 218 | /** 219 | * Creates manipulation grip. 220 | * @returns - manipulation grip. 221 | */ 222 | protected createGrip(): ResizeGrip { 223 | const grip = new ResizeGrip(); 224 | grip.zoomLevel = this.zoomLevel; 225 | grip.visual.transform.baseVal.appendItem(SvgHelper.createTransform()); 226 | this.manipulationBox.appendChild(grip.visual); 227 | 228 | return grip; 229 | } 230 | 231 | /** 232 | * Updates manipulation grip layout. 233 | */ 234 | protected positionGrips(): void { 235 | if (this.grip1 && this.grip2) { 236 | const gripSize = this.grip1.gripSize; 237 | 238 | this.positionGrip( 239 | this.grip1.visual, 240 | this.marker.x1 - gripSize / 2, 241 | this.marker.y1 - gripSize / 2, 242 | ); 243 | this.positionGrip( 244 | this.grip2.visual, 245 | this.marker.x2 - gripSize / 2, 246 | this.marker.y2 - gripSize / 2, 247 | ); 248 | 249 | this.grip1.zoomLevel = this.zoomLevel; 250 | this.grip2.zoomLevel = this.zoomLevel; 251 | } 252 | } 253 | 254 | /** 255 | * Positions manipulation grip. 256 | * @param grip - grip to position 257 | * @param x - new X coordinate 258 | * @param y - new Y coordinate 259 | */ 260 | protected positionGrip(grip: SVGGraphicsElement, x: number, y: number): void { 261 | const translate = grip.transform.baseVal.getItem(0); 262 | translate.setTranslate(x, y); 263 | grip.transform.baseVal.replaceItem(translate, 0); 264 | } 265 | 266 | public select(multi = false): void { 267 | super.select(multi); 268 | this.adjustControlBox(); 269 | this.manipulationBox.style.display = multi ? 'none' : ''; 270 | this._controlBox!.style.display = ''; 271 | } 272 | 273 | public deselect(): void { 274 | super.deselect(); 275 | if (this._controlBox) { 276 | this._controlBox.style.display = 'none'; 277 | } 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/core/LinearMarkerBase.ts: -------------------------------------------------------------------------------- 1 | import { LinearMarkerBaseState } from './LinearMarkerBaseState'; 2 | import { MarkerBase } from './MarkerBase'; 3 | import { MarkerBaseState } from './MarkerBaseState'; 4 | import { SvgHelper } from './SvgHelper'; 5 | 6 | /** 7 | * Base class for line-like markers. 8 | * 9 | * Use one of the derived classes. 10 | * 11 | * @summary Base class for line-like markers. 12 | * @group Markers 13 | */ 14 | export class LinearMarkerBase extends MarkerBase { 15 | /** 16 | * x coordinate of the first end-point 17 | */ 18 | public x1 = 0; 19 | /** 20 | * y coordinate of the first end-point 21 | */ 22 | public y1 = 0; 23 | /** 24 | * x coordinate of the second end-point 25 | */ 26 | public x2 = 0; 27 | /** 28 | * y coordinate of the second end-point 29 | */ 30 | public y2 = 0; 31 | 32 | /** 33 | * Marker's main visual. 34 | */ 35 | protected visual: SVGGraphicsElement | undefined; 36 | 37 | /** 38 | * Wider invisible visual to make it easier to select and manipulate the marker. 39 | */ 40 | protected selectorVisual: SVGGraphicsElement | undefined; 41 | /** 42 | * Visible visual of the marker. 43 | */ 44 | protected visibleVisual: SVGGraphicsElement | undefined; 45 | /** 46 | * Line visual of the marker. 47 | */ 48 | protected lineVisual: SVGGraphicsElement | undefined; 49 | /** 50 | * Start terminator (ending) visual of the marker. 51 | */ 52 | protected startTerminatorVisual: SVGGraphicsElement | undefined; 53 | /** 54 | * End terminator (ending) visual of the marker. 55 | */ 56 | protected endTerminatorVisual: SVGGraphicsElement | undefined; 57 | 58 | protected applyStrokeColor() { 59 | if (this.lineVisual) { 60 | SvgHelper.setAttributes(this.lineVisual, [['stroke', this._strokeColor]]); 61 | } 62 | if (this.startTerminatorVisual && this.endTerminatorVisual) { 63 | SvgHelper.setAttributes(this.startTerminatorVisual, [ 64 | ['stroke', this._strokeColor], 65 | ['fill', this._strokeColor], 66 | ]); 67 | SvgHelper.setAttributes(this.endTerminatorVisual, [ 68 | ['stroke', this._strokeColor], 69 | ['fill', this._strokeColor], 70 | ]); 71 | } 72 | } 73 | 74 | protected applyStrokeWidth() { 75 | if (this.lineVisual) { 76 | SvgHelper.setAttributes(this.lineVisual, [ 77 | ['stroke-width', this._strokeWidth.toString()], 78 | ]); 79 | } 80 | if (this.selectorVisual) { 81 | SvgHelper.setAttributes(this.selectorVisual, [ 82 | ['stroke-width', Math.max(this._strokeWidth, 8).toString()], 83 | ]); 84 | } 85 | } 86 | 87 | protected applyStrokeDasharray() { 88 | if (this.lineVisual) { 89 | SvgHelper.setAttributes(this.lineVisual, [ 90 | ['stroke-dasharray', this._strokeDasharray], 91 | ]); 92 | } 93 | } 94 | 95 | protected applyOpacity() { 96 | if (this.visibleVisual) { 97 | SvgHelper.setAttributes(this.visibleVisual, [ 98 | ['opacity', this._opacity.toString()], 99 | ]); 100 | } 101 | } 102 | 103 | constructor(container: SVGGElement) { 104 | super(container); 105 | 106 | this.adjustVisual = this.adjustVisual.bind(this); 107 | this.getState = this.getState.bind(this); 108 | this.restoreState = this.restoreState.bind(this); 109 | this.scale = this.scale.bind(this); 110 | } 111 | 112 | public ownsTarget(el: EventTarget): boolean { 113 | if ( 114 | super.ownsTarget(el) || 115 | el === this.visual || 116 | el === this.selectorVisual || 117 | el === this.visibleVisual || 118 | el === this.lineVisual || 119 | el === this.startTerminatorVisual || 120 | el === this.endTerminatorVisual 121 | ) { 122 | return true; 123 | } else { 124 | return false; 125 | } 126 | } 127 | 128 | /** 129 | * The path representing the line part of the marker visual. 130 | * 131 | * When implemented in derived class should return SVG path for the marker. 132 | * 133 | * @returns SVG path for the marker. 134 | */ 135 | protected getPath(): string { 136 | return 'M0,0'; 137 | } 138 | 139 | /** 140 | * The path representing the start terminator (ending) part of the marker visual. 141 | * @returns SVG path 142 | */ 143 | protected getStartTerminatorPath(): string { 144 | return ''; 145 | } 146 | 147 | /** 148 | * The path representing the end terminator (ending) part of the marker visual. 149 | * @returns SVG path 150 | */ 151 | protected getEndTerminatorPath(): string { 152 | return ''; 153 | } 154 | 155 | /** 156 | * Creates marker's visual. 157 | */ 158 | public createVisual(): void { 159 | this.visual = SvgHelper.createGroup(); 160 | this.selectorVisual = SvgHelper.createPath(this.getPath(), [ 161 | ['stroke', 'transparent'], 162 | ['fill', 'transparent'], 163 | ['stroke-width', Math.max(this.strokeWidth, 8).toString()], 164 | ]); 165 | 166 | this.visibleVisual = SvgHelper.createGroup([ 167 | ['opacity', this.opacity.toString()], 168 | ]); 169 | this.lineVisual = SvgHelper.createPath(this.getPath(), [ 170 | ['stroke', this.strokeColor], 171 | ['fill', this.strokeColor], 172 | ['stroke-width', this.strokeWidth.toString()], 173 | ['stroke-linejoin', 'round'], 174 | ['stroke-dasharray', this.strokeDasharray.toString()], 175 | ]); 176 | this.startTerminatorVisual = SvgHelper.createPath( 177 | this.getStartTerminatorPath(), 178 | [ 179 | ['stroke', this.strokeColor], 180 | ['fill', this.strokeColor], 181 | ['stroke-width', this.strokeWidth.toString()], 182 | ['stroke-linejoin', 'round'], 183 | ], 184 | ); 185 | this.endTerminatorVisual = SvgHelper.createPath( 186 | this.getEndTerminatorPath(), 187 | [ 188 | ['stroke', this.strokeColor], 189 | ['fill', this.strokeColor], 190 | ['stroke-width', this.strokeWidth.toString()], 191 | ['stroke-linejoin', 'round'], 192 | ], 193 | ); 194 | 195 | this.visibleVisual.appendChild(this.lineVisual); 196 | this.visibleVisual.appendChild(this.startTerminatorVisual); 197 | this.visibleVisual.appendChild(this.endTerminatorVisual); 198 | 199 | this.visual.appendChild(this.selectorVisual); 200 | this.visual.appendChild(this.visibleVisual); 201 | 202 | this.addMarkerVisualToContainer(this.visual); 203 | } 204 | 205 | /** 206 | * Adjusts marker visual after manipulation when needed. 207 | */ 208 | public adjustVisual(): void { 209 | if ( 210 | this.selectorVisual && 211 | this.visibleVisual && 212 | this.lineVisual && 213 | this.startTerminatorVisual && 214 | this.endTerminatorVisual 215 | ) { 216 | SvgHelper.setAttributes(this.selectorVisual, [['d', this.getPath()]]); 217 | SvgHelper.setAttributes(this.visibleVisual, [ 218 | ['opacity', this.opacity.toString()], 219 | ]); 220 | SvgHelper.setAttributes(this.lineVisual, [ 221 | ['d', this.getPath()], 222 | ['stroke', this.strokeColor], 223 | ['fill', this.fillColor], 224 | ['stroke-width', this.strokeWidth.toString()], 225 | ['stroke-dasharray', this.strokeDasharray.toString()], 226 | ]); 227 | SvgHelper.setAttributes(this.startTerminatorVisual, [ 228 | ['d', this.getStartTerminatorPath()], 229 | ['stroke', this.strokeColor], 230 | ['fill', this.strokeColor], 231 | ['stroke-width', this.strokeWidth.toString()], 232 | ]); 233 | SvgHelper.setAttributes(this.endTerminatorVisual, [ 234 | ['d', this.getEndTerminatorPath()], 235 | ['stroke', this.strokeColor], 236 | ['fill', this.strokeColor], 237 | ['stroke-width', this.strokeWidth.toString()], 238 | ]); 239 | } 240 | } 241 | 242 | public getState(): LinearMarkerBaseState { 243 | const result: LinearMarkerBaseState = Object.assign( 244 | { 245 | x1: this.x1, 246 | y1: this.y1, 247 | x2: this.x2, 248 | y2: this.y2, 249 | }, 250 | super.getState(), 251 | ); 252 | 253 | return result; 254 | } 255 | 256 | public restoreState(state: MarkerBaseState): void { 257 | super.restoreState(state); 258 | 259 | const lmbState = state as LinearMarkerBaseState; 260 | this.x1 = lmbState.x1; 261 | this.y1 = lmbState.y1; 262 | this.x2 = lmbState.x2; 263 | this.y2 = lmbState.y2; 264 | 265 | this.createVisual(); 266 | this.adjustVisual(); 267 | } 268 | 269 | public scale(scaleX: number, scaleY: number): void { 270 | super.scale(scaleX, scaleY); 271 | 272 | this.x1 = this.x1 * scaleX; 273 | this.y1 = this.y1 * scaleY; 274 | this.x2 = this.x2 * scaleX; 275 | this.y2 = this.y2 * scaleY; 276 | 277 | this.strokeWidth *= (scaleX + scaleY) / 2; 278 | 279 | this.adjustVisual(); 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/core/TextMarker.ts: -------------------------------------------------------------------------------- 1 | import { FontSize } from './FontSize'; 2 | import { MarkerBaseState } from './MarkerBaseState'; 3 | import { RectangularBoxMarkerBase } from './RectangularBoxMarkerBase'; 4 | import { SvgHelper } from './SvgHelper'; 5 | import { TextBlock } from './TextBlock'; 6 | import { TextMarkerState } from './TextMarkerState'; 7 | 8 | /** 9 | * Text marker. 10 | * 11 | * Used to represent a text block as well a base class for other text-based markers. 12 | * 13 | * @summary Text marker. 14 | * @group Markers 15 | */ 16 | export class TextMarker extends RectangularBoxMarkerBase { 17 | public static typeName = 'TextMarker'; 18 | 19 | public static title = 'Text marker'; 20 | 21 | /** 22 | * Default text for the marker type. 23 | */ 24 | protected static DEFAULT_TEXT = 'Text'; 25 | // protected static DEFAULT_TEXT = 26 | // 'Longer text to see what happens when it is too long to fit the bounding box.'; 27 | 28 | /** 29 | * Callback to be called when the text size changes. 30 | */ 31 | public onSizeChanged?: (textMarker: TextMarker) => void; 32 | 33 | private _color = 'black'; 34 | /** 35 | * Returns markers's text color. 36 | */ 37 | public get color() { 38 | return this._color; 39 | } 40 | /** 41 | * Sets the markers's text color. 42 | */ 43 | public set color(value) { 44 | this._color = value; 45 | this.textBlock.color = value; 46 | } 47 | 48 | private _fontFamily = 'Helvetica, Arial, sans-serif'; 49 | /** 50 | * Returns the markers's font family. 51 | */ 52 | public get fontFamily() { 53 | return this._fontFamily; 54 | } 55 | /** 56 | * Sets the markers's font family. 57 | */ 58 | public set fontFamily(value) { 59 | this._fontFamily = value; 60 | this.textBlock.fontFamily = value; 61 | } 62 | 63 | private _fontSize: FontSize = { 64 | value: 1, 65 | units: 'rem', 66 | step: 0.1, 67 | }; 68 | /** 69 | * Returns the marker's font size. 70 | */ 71 | public get fontSize(): FontSize { 72 | return this._fontSize; 73 | } 74 | /** 75 | * Sets the marker's font size. 76 | */ 77 | public set fontSize(value: FontSize) { 78 | this._fontSize = value; 79 | this.textBlock.fontSize = value; 80 | } 81 | 82 | /** 83 | * Returns the default text for the marker type. 84 | * @returns marker type's default text. 85 | */ 86 | protected getDefaultText(): string { 87 | return Object.getPrototypeOf(this).constructor.DEFAULT_TEXT; 88 | } 89 | private _text: string = this.getDefaultText(); 90 | /** 91 | * Returns the marker's text. 92 | */ 93 | public get text(): string { 94 | return this.textBlock.text; 95 | } 96 | /** 97 | * Sets the marker's text. 98 | */ 99 | public set text(value: string) { 100 | this._text = value; 101 | this.textBlock.text = this._text; 102 | } 103 | 104 | /** 105 | * Text padding from the bounding box. 106 | */ 107 | public padding = 2; 108 | 109 | /** 110 | * Text's bounding box where text should fit and/or be anchored to. 111 | */ 112 | public textBoundingBox: DOMRect; 113 | 114 | /** 115 | * Text block handling the text rendering. 116 | */ 117 | public textBlock: TextBlock = new TextBlock(this.getDefaultText()); 118 | 119 | constructor(container: SVGGElement) { 120 | super(container); 121 | 122 | this.setColor = this.setColor.bind(this); 123 | this.setFont = this.setFont.bind(this); 124 | this.setFontSize = this.setFontSize.bind(this); 125 | this.setSize = this.setSize.bind(this); 126 | this.textSizeChanged = this.textSizeChanged.bind(this); 127 | this.setSizeFromTextSize = this.setSizeFromTextSize.bind(this); 128 | 129 | this.createVisual = this.createVisual.bind(this); 130 | this.adjustVisual = this.adjustVisual.bind(this); 131 | 132 | this.textBoundingBox = new DOMRect(); 133 | } 134 | 135 | protected applyOpacity() { 136 | if (this.visual) { 137 | SvgHelper.setAttributes(this.visual, [ 138 | ['opacity', this.opacity.toString()], 139 | ]); 140 | } 141 | } 142 | 143 | /** 144 | * Creates marker's visual. 145 | */ 146 | public createVisual(): void { 147 | this.textBlock.fontFamily = this.fontFamily; 148 | this.textBlock.fontSize = this.fontSize; 149 | this.textBlock.color = this.color; 150 | this.textBlock.offsetX = this.padding; 151 | this.textBlock.offsetY = this.padding; 152 | 153 | this.textBlock.onTextSizeChanged = this.textSizeChanged; 154 | 155 | this.visual = SvgHelper.createGroup(); 156 | SvgHelper.setAttributes(this.visual, [ 157 | ['opacity', this._opacity.toString()], 158 | ]); 159 | this.visual.appendChild(this.textBlock.textElement); 160 | this.addMarkerVisualToContainer(this.visual); 161 | 162 | this.textBlock.text = this._text; 163 | } 164 | 165 | /** 166 | * Adjusts marker's visual according to the current state. 167 | */ 168 | public adjustVisual(): void { 169 | if (this.visual) { 170 | SvgHelper.setAttributes(this.visual, [ 171 | ['opacity', this._opacity.toString()], 172 | ]); 173 | } 174 | this.setSize(); 175 | } 176 | 177 | public ownsTarget(el: EventTarget): boolean { 178 | if ( 179 | super.ownsTarget(el) || 180 | el === this.visual || 181 | this.textBlock.ownsTarget(el) 182 | ) { 183 | return true; 184 | } else { 185 | return false; 186 | } 187 | } 188 | 189 | /** 190 | * Sets the text bounding box. 191 | */ 192 | protected setTextBoundingBox() { 193 | this.textBoundingBox.x = this.padding; 194 | this.textBoundingBox.y = this.padding; 195 | this.textBoundingBox.width = Number.MAX_VALUE; // this.width - this.padding * 2; 196 | this.textBoundingBox.height = Number.MAX_VALUE; // this.height - this.padding * 2; 197 | //this.textBlock.boundingBox = this.textBoundingBox; 198 | } 199 | 200 | /** 201 | * Sets (adjusts) the marker's size. 202 | */ 203 | public setSize(): void { 204 | const [prevWidth, prevHeight] = [this.width, this.height]; 205 | 206 | super.setSize(); 207 | this.setSizeFromTextSize(); 208 | 209 | if ( 210 | (prevWidth !== this.width || prevHeight !== this.height) && 211 | this.onSizeChanged 212 | ) { 213 | this.onSizeChanged(this); 214 | } 215 | 216 | this.setTextBoundingBox(); 217 | } 218 | 219 | /** 220 | * Sets the marker's size based on the text size. 221 | */ 222 | protected setSizeFromTextSize(): void { 223 | if (this.textBlock.textSize) { 224 | this.width = this.textBlock.textSize.width + this.padding * 2; 225 | this.height = this.textBlock.textSize.height + this.padding * 2; 226 | } 227 | 228 | this.textBlock.offsetX = this.padding; 229 | this.textBlock.offsetY = this.padding; 230 | } 231 | 232 | private textSizeChanged(): void { 233 | this.adjustVisual(); 234 | } 235 | 236 | /** 237 | * Sets the text color. 238 | * @param color text color 239 | */ 240 | public setColor(color: string): void { 241 | this.color = color; 242 | } 243 | 244 | /** 245 | * Sets the font family. 246 | * @param font font family string 247 | */ 248 | public setFont(font: string): void { 249 | this.fontFamily = font; 250 | } 251 | 252 | /** 253 | * Sets the font size. 254 | * @param fontSize font size 255 | */ 256 | public setFontSize(fontSize: FontSize): void { 257 | this.fontSize = fontSize; 258 | } 259 | 260 | /** 261 | * Hides the marker's visual. 262 | * 263 | * Used when editing the text. 264 | */ 265 | public hideVisual(): void { 266 | if (this.visual) { 267 | this.visual.style.visibility = 'hidden'; 268 | } 269 | } 270 | /** 271 | * Shows the marker's visual. 272 | * 273 | * Eg. when done editing the text. 274 | */ 275 | public showVisual() { 276 | if (this.visual) { 277 | this.visual.style.visibility = 'visible'; 278 | this.textBlock.renderText(); 279 | } 280 | } 281 | 282 | public getState(): TextMarkerState { 283 | const result: TextMarkerState = Object.assign( 284 | { 285 | color: this.color, 286 | fontFamily: this.fontFamily, 287 | fontSize: this.fontSize, 288 | text: this.text, 289 | padding: this.padding, 290 | }, 291 | super.getState(), 292 | ); 293 | return result; 294 | } 295 | 296 | public restoreState(state: MarkerBaseState): void { 297 | const textState = state as TextMarkerState; 298 | this.color = textState.color; 299 | this.fontFamily = textState.fontFamily; 300 | this.fontSize = textState.fontSize; 301 | this.text = textState.text; 302 | if (textState.padding !== undefined) { 303 | this.padding = textState.padding; 304 | } 305 | 306 | this.createVisual(); 307 | 308 | super.restoreState(state); 309 | this.adjustVisual(); 310 | } 311 | 312 | public scale(scaleX: number, scaleY: number): void { 313 | super.scale(scaleX, scaleY); 314 | 315 | const newFontSize = { 316 | ...this.fontSize, 317 | value: this.fontSize.value * Math.min(scaleX, scaleY), 318 | }; 319 | this.fontSize = newFontSize; 320 | 321 | this.padding = this.padding * Math.min(scaleX, scaleY); 322 | 323 | this.adjustVisual(); 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /src/core/MarkerBase.ts: -------------------------------------------------------------------------------- 1 | import { ISize } from './ISize'; 2 | import { MarkerBaseState } from './MarkerBaseState'; 3 | 4 | /** 5 | * Represents a stage in a marker's lifecycle. 6 | * 7 | * Most markers are created immediately after the user clicks on the canvas. 8 | * However, some markers are only finished creating after additional interactions. 9 | */ 10 | export type MarkerStage = 'creating' | 'normal'; 11 | 12 | /** 13 | * Base class for all markers. 14 | * 15 | * When creating custom marker types usually you will want to extend one of the derived classes. 16 | * However, if you cannot find a suitable base class, you can and you should extend this class. 17 | * 18 | * @summary Base class for all markers. 19 | * @group Markers 20 | */ 21 | export class MarkerBase { 22 | /** 23 | * Marker type name. 24 | * 25 | * It's important to set this in each derived class. This value is used to identify marker types 26 | * when restoring marker state and other scenarios. 27 | */ 28 | public static typeName = 'MarkerBase'; 29 | 30 | /** 31 | * Returns marker type name for the object instance. 32 | */ 33 | public get typeName(): string { 34 | return Object.getPrototypeOf(this).constructor.typeName; 35 | } 36 | 37 | /** 38 | * Marker type title (display name) used for accessibility and other attributes. 39 | */ 40 | public static title: string; 41 | 42 | /** 43 | * When true, the default filter is applied to the marker's visual. 44 | * 45 | * @since 3.2.0 46 | */ 47 | public static applyDefaultFilter: boolean = true; 48 | 49 | /** 50 | * SVG container object holding the marker's visual. 51 | * 52 | * It is created and passed to the constructor by marker editor or viewer when creating the marker. 53 | */ 54 | protected _container: SVGGElement; 55 | /** 56 | * SVG container object holding the marker's visual. 57 | */ 58 | /** 59 | * SVG container object holding the marker's visual. 60 | */ 61 | public get container(): SVGGElement { 62 | return this._container; 63 | } 64 | 65 | /** 66 | * Additional information about the marker. 67 | * 68 | * Generally, this isn't used for anything functional. 69 | * However, in a derived type it could be used for storing arbitrary data with no need to create extra properties and state types. 70 | */ 71 | public notes?: string; 72 | 73 | /** 74 | * The default marker size when the marker is created with a click (without dragging). 75 | */ 76 | public defaultSize: ISize = { width: 50, height: 20 }; 77 | 78 | /** 79 | * Marker lifecycle stage. 80 | * 81 | * Most markers are created immediately after the user clicks on the canvas (`normal`). 82 | * However, some markers are only finished creating after additional interactions (`creating`). 83 | */ 84 | public stage: MarkerStage = 'normal'; 85 | 86 | /** 87 | * Stroke (outline) color of the marker. 88 | */ 89 | protected _strokeColor = 'transparent'; 90 | /** 91 | * Stroke (outline) color of the marker. 92 | * 93 | * In a derived class override {@link applyStrokeColor} to apply the color to the marker's visual. 94 | */ 95 | public get strokeColor() { 96 | return this._strokeColor; 97 | } 98 | public set strokeColor(color: string) { 99 | this._strokeColor = color; 100 | this.applyStrokeColor(); 101 | } 102 | /** 103 | * Applies the stroke color to the marker's visual. 104 | * 105 | * Override this method in a derived class to apply the color to the marker's visual. 106 | */ 107 | protected applyStrokeColor() {} 108 | 109 | /** 110 | * Fill color of the marker. 111 | */ 112 | protected _fillColor = 'transparent'; 113 | /** 114 | * Fill color of the marker. 115 | * 116 | * In a derived class override {@link applyFillColor} to apply the color to the marker's visual. 117 | */ 118 | public get fillColor() { 119 | return this._fillColor; 120 | } 121 | public set fillColor(color: string) { 122 | this._fillColor = color; 123 | this.applyFillColor(); 124 | } 125 | /** 126 | * Applies the fill color to the marker's visual. 127 | * 128 | * Override this method in a derived class to apply the color to the marker's visual. 129 | */ 130 | protected applyFillColor() {} 131 | 132 | /** 133 | * Stroke (outline) width of the marker. 134 | */ 135 | protected _strokeWidth = 0; 136 | /** 137 | * Stroke (outline) width of the marker. 138 | * 139 | * In a derived class override {@link applyStrokeWidth} to apply the width to the marker's visual. 140 | */ 141 | public get strokeWidth() { 142 | return this._strokeWidth; 143 | } 144 | public set strokeWidth(value) { 145 | this._strokeWidth = value; 146 | this.applyStrokeWidth(); 147 | } 148 | /** 149 | * Applies the stroke width to the marker's visual. 150 | * 151 | * Override this method in a derived class to apply the width to the marker's visual. 152 | */ 153 | protected applyStrokeWidth() {} 154 | 155 | /** 156 | * Stroke (outline) dash array of the marker. 157 | */ 158 | protected _strokeDasharray = ''; 159 | /** 160 | * Stroke (outline) dash array of the marker. 161 | * 162 | * In a derived class override {@link applyStrokeDasharray} to apply the dash array to the marker's visual. 163 | */ 164 | public get strokeDasharray() { 165 | return this._strokeDasharray; 166 | } 167 | public set strokeDasharray(value) { 168 | this._strokeDasharray = value; 169 | this.applyStrokeDasharray(); 170 | } 171 | /** 172 | * Applies the stroke dash array to the marker's visual. 173 | * 174 | * Override this method in a derived class to apply the dash array to the marker's visual. 175 | */ 176 | protected applyStrokeDasharray() {} 177 | 178 | /** 179 | * Opacity of the marker. 180 | */ 181 | protected _opacity = 1; 182 | /** 183 | * Opacity of the marker. 184 | * 185 | * In a derived class override {@link applyOpacity} to apply the opacity to the marker's visual. 186 | */ 187 | public get opacity() { 188 | return this._opacity; 189 | } 190 | public set opacity(value) { 191 | this._opacity = value; 192 | this.applyOpacity(); 193 | } 194 | /** 195 | * Applies the opacity to the marker's visual. 196 | * 197 | * Override this method in a derived class to apply the opacity to the marker's visual 198 | */ 199 | protected applyOpacity() {} 200 | 201 | /** 202 | * Creates a new marker object. 203 | * 204 | * @param container - SVG container object holding the marker's visual. 205 | */ 206 | constructor(container: SVGGElement) { 207 | this._container = container; 208 | 209 | this.applyFillColor = this.applyFillColor.bind(this); 210 | this.applyStrokeColor = this.applyStrokeColor.bind(this); 211 | this.applyStrokeWidth = this.applyStrokeWidth.bind(this); 212 | this.applyStrokeDasharray = this.applyStrokeDasharray.bind(this); 213 | this.applyOpacity = this.applyOpacity.bind(this); 214 | this.getBBox = this.getBBox.bind(this); 215 | } 216 | 217 | /** 218 | * Returns true if passed SVG element belongs to the marker. False otherwise. 219 | * 220 | * @param el - target element. 221 | * @returns true if the element belongs to the marker. 222 | */ 223 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 224 | public ownsTarget(el: EventTarget): boolean { 225 | return false; 226 | } 227 | 228 | /** 229 | * Disposes the marker and cleans up. 230 | */ 231 | public dispose(): void {} 232 | 233 | protected addMarkerVisualToContainer(element: SVGElement): void { 234 | if (this.container.childNodes.length > 0) { 235 | this.container.insertBefore(element, this.container.childNodes[0]); 236 | } else { 237 | this.container.appendChild(element); 238 | } 239 | } 240 | 241 | /** 242 | * When overridden in a derived class, represents a preliminary outline for markers that can be displayed before the marker is actually created. 243 | * @returns SVG path string. 244 | */ 245 | public getOutline(): string { 246 | return ''; 247 | } 248 | 249 | /** 250 | * Returns current marker state that can be restored in the future. 251 | */ 252 | public getState(): MarkerBaseState { 253 | return { 254 | typeName: Object.getPrototypeOf(this).constructor.typeName, 255 | notes: this.notes, 256 | strokeColor: this._strokeColor, 257 | strokeWidth: this._strokeWidth, 258 | strokeDasharray: this._strokeDasharray, 259 | opacity: this._opacity, 260 | }; 261 | } 262 | 263 | /** 264 | * Restores previously saved marker state. 265 | * 266 | * @param state - previously saved state. 267 | */ 268 | public restoreState(state: MarkerBaseState): void { 269 | this.notes = state.notes; 270 | this._strokeColor = state.strokeColor ?? this._strokeColor; 271 | this._strokeWidth = state.strokeWidth ?? this._strokeWidth; 272 | this._strokeDasharray = state.strokeDasharray ?? this._strokeDasharray; 273 | this._opacity = state.opacity ?? this._opacity; 274 | } 275 | 276 | /** 277 | * Scales marker. Used after resize. 278 | * 279 | * @param scaleX - horizontal scale 280 | * @param scaleY - vertical scale 281 | */ 282 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 283 | public scale(scaleX: number, scaleY: number): void {} 284 | 285 | /** 286 | * Returns markers bounding box. 287 | * 288 | * Override to return a custom bounding box. 289 | * 290 | * @returns rectangle fitting the marker. 291 | */ 292 | public getBBox(): DOMRect { 293 | return this.container.getBBox(); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/core/CalloutMarker.ts: -------------------------------------------------------------------------------- 1 | import { CalloutMarkerState } from './CalloutMarkerState'; 2 | import { IPoint } from './IPoint'; 3 | import { MarkerBaseState } from './MarkerBaseState'; 4 | import { SvgHelper } from './SvgHelper'; 5 | import { TextMarker } from './TextMarker'; 6 | 7 | /** 8 | * Callout marker is a text-based marker with a callout outline with a tip that can point to specific place 9 | * on the underlying image or annotation. 10 | * 11 | * @summary Text-based marker with a callout outline with a tip that can point to specific place 12 | * on the underlying image or annotation. 13 | * 14 | * @group Markers 15 | */ 16 | export class CalloutMarker extends TextMarker { 17 | public static typeName = 'CalloutMarker'; 18 | 19 | public static title = 'Callout marker'; 20 | 21 | private _tipPosition: IPoint = { x: 0, y: 0 }; 22 | /** 23 | * Coordinates of the position of the tip of the callout. 24 | */ 25 | public get tipPosition(): IPoint { 26 | return this._tipPosition; 27 | } 28 | public set tipPosition(value: IPoint) { 29 | this._tipPosition = value; 30 | this.adjustVisual(); 31 | } 32 | 33 | private tipBase1Position: IPoint = { x: 0, y: 0 }; 34 | private tipBase2Position: IPoint = { x: 0, y: 0 }; 35 | 36 | private _calloutVisual: SVGPathElement = SvgHelper.createPath('M0,0'); 37 | 38 | constructor(container: SVGGElement) { 39 | super(container); 40 | 41 | this.color = '#ffffff'; 42 | this.fillColor = '#ff0000'; 43 | this.strokeColor = '#ffffff'; 44 | this.strokeWidth = 3; 45 | this.padding = 20; 46 | 47 | this.createVisual = this.createVisual.bind(this); 48 | this.adjustVisual = this.adjustVisual.bind(this); 49 | this.getPath = this.getPath.bind(this); 50 | this.setTipPoints = this.setTipPoints.bind(this); 51 | } 52 | 53 | protected applyStrokeColor() { 54 | SvgHelper.setAttributes(this._calloutVisual, [ 55 | ['stroke', this._strokeColor], 56 | ]); 57 | } 58 | 59 | protected applyStrokeWidth() { 60 | SvgHelper.setAttributes(this._calloutVisual, [ 61 | ['stroke-width', this._strokeWidth.toString()], 62 | ]); 63 | } 64 | 65 | protected applyStrokeDasharray() { 66 | SvgHelper.setAttributes(this._calloutVisual, [ 67 | ['stroke-dasharray', this._strokeDasharray], 68 | ]); 69 | } 70 | 71 | protected applyOpacity() { 72 | if (this.visual) { 73 | SvgHelper.setAttributes(this.visual, [ 74 | ['opacity', this._opacity.toString()], 75 | ]); 76 | } 77 | } 78 | 79 | protected applyFillColor() { 80 | SvgHelper.setAttributes(this._calloutVisual, [['fill', this._fillColor]]); 81 | } 82 | 83 | /** 84 | * Returns the SVG path string for the callout outline. 85 | * 86 | * @returns Path string for the callout outline. 87 | */ 88 | protected getPath(): string { 89 | const r = 5; 90 | this.setTipPoints(); 91 | 92 | const result = `M ${r} 0 93 | ${ 94 | this.tipBase1Position.y === 0 95 | ? `H ${this.tipBase1Position.x} L ${this.tipPosition.x} ${this.tipPosition.y} L ${this.tipBase2Position.x} 0` 96 | : '' 97 | } 98 | H ${this.width - r} 99 | A ${r} ${r} 0 0 1 ${this.width} ${r} 100 | ${ 101 | this.tipBase1Position.x === this.width 102 | ? `V ${this.tipBase1Position.y} L ${this.tipPosition.x} ${this.tipPosition.y} L ${this.tipBase2Position.x} ${this.tipBase2Position.y}` 103 | : '' 104 | } 105 | V ${this.height - r} 106 | A ${r} ${r} 0 0 1 ${this.width - r} ${this.height} 107 | ${ 108 | this.tipBase1Position.y === this.height 109 | ? `H ${this.tipBase2Position.x} L ${this.tipPosition.x} ${this.tipPosition.y} L ${this.tipBase1Position.x} ${this.height}` 110 | : '' 111 | } 112 | H ${r} 113 | A ${r} ${r} 0 0 1 0 ${this.height - r} 114 | ${ 115 | this.tipBase1Position.x === 0 116 | ? `V ${this.tipBase2Position.y} L ${this.tipPosition.x} ${this.tipPosition.y} L ${this.tipBase1Position.x} ${this.tipBase1Position.y}` 117 | : '' 118 | } 119 | V ${r} 120 | A ${r} ${r} 0 0 1 ${r} 0 121 | Z`; 122 | 123 | return result; 124 | } 125 | 126 | private setTipPoints() { 127 | let offset = Math.min(this.height / 2, 15); 128 | let baseWidth = this.height / 5; 129 | 130 | const cornerAngle = Math.atan(this.height / 2 / (this.width / 2)); 131 | if ( 132 | this.tipPosition.x < this.width / 2 && 133 | this.tipPosition.y < this.height / 2 134 | ) { 135 | // top left 136 | const tipAngle = Math.atan( 137 | (this.height / 2 - this.tipPosition.y) / 138 | (this.width / 2 - this.tipPosition.x), 139 | ); 140 | if (cornerAngle < tipAngle) { 141 | baseWidth = this.width / 5; 142 | offset = Math.min(this.width / 2, 15); 143 | this.tipBase1Position = { x: offset, y: 0 }; 144 | this.tipBase2Position = { x: offset + baseWidth, y: 0 }; 145 | } else { 146 | this.tipBase1Position = { x: 0, y: offset }; 147 | this.tipBase2Position = { x: 0, y: offset + baseWidth }; 148 | } 149 | } else if ( 150 | this.tipPosition.x >= this.width / 2 && 151 | this.tipPosition.y < this.height / 2 152 | ) { 153 | // top right 154 | const tipAngle = Math.atan( 155 | (this.height / 2 - this.tipPosition.y) / 156 | (this.tipPosition.x - this.width / 2), 157 | ); 158 | if (cornerAngle < tipAngle) { 159 | baseWidth = this.width / 5; 160 | offset = Math.min(this.width / 2, 15); 161 | this.tipBase1Position = { x: this.width - offset - baseWidth, y: 0 }; 162 | this.tipBase2Position = { x: this.width - offset, y: 0 }; 163 | } else { 164 | this.tipBase1Position = { x: this.width, y: offset }; 165 | this.tipBase2Position = { x: this.width, y: offset + baseWidth }; 166 | } 167 | } else if ( 168 | this.tipPosition.x >= this.width / 2 && 169 | this.tipPosition.y >= this.height / 2 170 | ) { 171 | // bottom right 172 | const tipAngle = Math.atan( 173 | (this.tipPosition.y - this.height / 2) / 174 | (this.tipPosition.x - this.width / 2), 175 | ); 176 | if (cornerAngle < tipAngle) { 177 | baseWidth = this.width / 5; 178 | offset = Math.min(this.width / 2, 15); 179 | this.tipBase1Position = { 180 | x: this.width - offset - baseWidth, 181 | y: this.height, 182 | }; 183 | this.tipBase2Position = { x: this.width - offset, y: this.height }; 184 | } else { 185 | this.tipBase1Position = { 186 | x: this.width, 187 | y: this.height - offset - baseWidth, 188 | }; 189 | this.tipBase2Position = { x: this.width, y: this.height - offset }; 190 | } 191 | } else { 192 | // bottom left 193 | const tipAngle = Math.atan( 194 | (this.tipPosition.y - this.height / 2) / 195 | (this.width / 2 - this.tipPosition.x), 196 | ); 197 | if (cornerAngle < tipAngle) { 198 | baseWidth = this.width / 5; 199 | offset = Math.min(this.width / 2, 15); 200 | this.tipBase1Position = { x: offset, y: this.height }; 201 | this.tipBase2Position = { x: offset + baseWidth, y: this.height }; 202 | } else { 203 | this.tipBase1Position = { x: 0, y: this.height - offset - baseWidth }; 204 | this.tipBase2Position = { x: 0, y: this.height - offset }; 205 | } 206 | } 207 | } 208 | 209 | public createVisual(): void { 210 | super.createVisual(); 211 | 212 | this._tipPosition = { 213 | // x: -50, 214 | // x: this.width + 50, 215 | x: this.width / 4, 216 | // y: this.height / 4, 217 | // y: -50, 218 | y: this.height + 20, 219 | }; 220 | 221 | this._calloutVisual = SvgHelper.createPath(this.getPath(), [ 222 | ['fill', this._fillColor], 223 | ['stroke', this._strokeColor], 224 | ['stroke-width', this._strokeWidth.toString()], 225 | ['stroke-dasharray', this._strokeDasharray], 226 | ]); 227 | this.visual?.insertBefore(this._calloutVisual, this.textBlock.textElement); 228 | } 229 | 230 | public adjustVisual(): void { 231 | super.adjustVisual(); 232 | if (this._calloutVisual) { 233 | SvgHelper.setAttributes(this._calloutVisual, [ 234 | ['d', this.getPath()], 235 | ['fill', this._fillColor], 236 | ['stroke', this._strokeColor], 237 | ['stroke-width', this._strokeWidth.toString()], 238 | ['stroke-dasharray', this._strokeDasharray], 239 | ]); 240 | } 241 | } 242 | 243 | public ownsTarget(el: EventTarget): boolean { 244 | if (super.ownsTarget(el) || this._calloutVisual === el) { 245 | return true; 246 | } else { 247 | return false; 248 | } 249 | } 250 | 251 | public getState(): CalloutMarkerState { 252 | const result: CalloutMarkerState = Object.assign( 253 | { 254 | fillColor: this.fillColor, 255 | tipPosition: this.tipPosition, 256 | }, 257 | super.getState(), 258 | ); 259 | 260 | return result; 261 | } 262 | 263 | public restoreState(state: MarkerBaseState): void { 264 | const calloutState = state as CalloutMarkerState; 265 | super.restoreState(state); 266 | this.fillColor = calloutState.fillColor; 267 | this.tipPosition = calloutState.tipPosition; 268 | 269 | this.adjustVisual(); 270 | } 271 | 272 | public scale(scaleX: number, scaleY: number): void { 273 | super.scale(scaleX, scaleY); 274 | 275 | // this.width = this.width * scaleX; 276 | // this.height = this.height * scaleY; 277 | 278 | this.strokeWidth *= (scaleX + scaleY) / 2; 279 | 280 | this._tipPosition = { 281 | x: this._tipPosition.x * scaleX, 282 | y: this._tipPosition.y * scaleY, 283 | }; 284 | 285 | this.adjustVisual(); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/editor/PolygonMarkerEditor.ts: -------------------------------------------------------------------------------- 1 | import { IPoint, PolygonMarker, SvgHelper } from '../core'; 2 | import { MarkerBaseEditor } from './MarkerBaseEditor'; 3 | import { MarkerEditorProperties } from './MarkerEditorProperties'; 4 | import { ResizeGrip } from './ResizeGrip'; 5 | 6 | /** 7 | * Editor for polygon markers. 8 | * 9 | * @summary Polygon marker editor. 10 | * @group Editors 11 | */ 12 | export class PolygonMarkerEditor< 13 | TMarkerType extends PolygonMarker = PolygonMarker, 14 | > extends MarkerBaseEditor { 15 | /** 16 | * Default line length when marker is created with a simple click (without dragging). 17 | */ 18 | protected defaultLength = 50; 19 | 20 | /** 21 | * Pointer X coordinate at the start of move or resize. 22 | */ 23 | protected manipulationStartX = 0; 24 | /** 25 | * Pointer Y coordinate at the start of move or resize. 26 | */ 27 | protected manipulationStartY = 0; 28 | 29 | /** 30 | * Container for control elements. 31 | */ 32 | protected controlBox?: SVGGElement; 33 | /** 34 | * Container for manipulation grips. 35 | */ 36 | protected manipulationBox: SVGGElement = SvgHelper.createGroup(); 37 | 38 | /** 39 | * Array of manipulation grips. 40 | */ 41 | protected grips: ResizeGrip[] = []; 42 | /** 43 | * Active manipulation grip. 44 | */ 45 | protected activeGrip?: ResizeGrip; 46 | 47 | constructor(properties: MarkerEditorProperties) { 48 | super(properties); 49 | 50 | this.ownsTarget = this.ownsTarget.bind(this); 51 | 52 | this.setupControlBox = this.setupControlBox.bind(this); 53 | this.adjustControlBox = this.adjustControlBox.bind(this); 54 | 55 | this.adjustControlGrips = this.adjustControlGrips.bind(this); 56 | this.createGrip = this.createGrip.bind(this); 57 | this.positionGrip = this.positionGrip.bind(this); 58 | this.positionGrips = this.positionGrips.bind(this); 59 | 60 | this.resize = this.resize.bind(this); 61 | 62 | this.manipulate = this.manipulate.bind(this); 63 | this.pointerDown = this.pointerDown.bind(this); 64 | this.pointerUp = this.pointerUp.bind(this); 65 | } 66 | 67 | public ownsTarget(el: EventTarget): boolean { 68 | if (super.ownsTarget(el) || this.marker.ownsTarget(el)) { 69 | return true; 70 | } else if (this.grips.some((grip) => grip.ownsTarget(el))) { 71 | return true; 72 | } else { 73 | return false; 74 | } 75 | } 76 | 77 | public override pointerDown( 78 | point: IPoint, 79 | target?: EventTarget, 80 | ev?: PointerEvent, 81 | ): void { 82 | super.pointerDown(point, target, ev); 83 | 84 | this.manipulationStartX = point.x; 85 | this.manipulationStartY = point.y; 86 | 87 | this.adjustControlBox(); 88 | this.controlBox!.style.display = ''; 89 | 90 | if (this.state === 'new') { 91 | this.startCreation(point); 92 | } else if (this._state === 'creating') { 93 | if (this.grips.length > 0 && target && this.grips[0].ownsTarget(target)) { 94 | this.finishCreation(); 95 | } else { 96 | this.addNewPointWhileCreating(point); 97 | } 98 | } else { 99 | this.select(this.isMultiSelected); 100 | this.activeGrip = 101 | target && this.grips.find((grip) => grip.ownsTarget(target)); 102 | 103 | if (this.activeGrip) { 104 | this._state = 'resize'; 105 | } else { 106 | this._state = 'move'; 107 | } 108 | } 109 | } 110 | 111 | private startCreation(point: IPoint) { 112 | this.marker.stage = 'creating'; 113 | this.marker.points.push(point); 114 | this.marker.points.push(point); 115 | this.marker.createVisual(); 116 | this.marker.adjustVisual(); 117 | this.adjustControlGrips(); 118 | if (this.controlBox) { 119 | this.controlBox.style.display = ''; 120 | } 121 | 122 | this.activeGrip = this.grips.at(-1); 123 | if (this.activeGrip) { 124 | this.activeGrip.visual.style.pointerEvents = 'none'; 125 | } 126 | 127 | this._state = 'creating'; 128 | } 129 | 130 | private addNewPointWhileCreating(point: IPoint) { 131 | this.marker.points.push(point); 132 | this.marker.adjustVisual(); 133 | this.adjustControlGrips(); 134 | this.activeGrip = this.grips.at(-1); 135 | if (this.activeGrip) { 136 | this.activeGrip.visual.style.pointerEvents = 'none'; 137 | } 138 | } 139 | 140 | private finishCreation() { 141 | this.marker.stage = 'normal'; 142 | // connected the last point with the first one 143 | // remove the first point 144 | this.marker.points.splice(0, 1); 145 | // single point is not a polygon 146 | if (this.marker.points.length === 1) { 147 | this.marker.points.splice(0, 1); 148 | } 149 | this.marker.adjustVisual(); 150 | this.adjustControlGrips(); 151 | this.grips.forEach((grip) => { 152 | grip.visual.style.pointerEvents = ''; 153 | }); 154 | 155 | this._state = 'select'; 156 | if (this.marker.points.length > 0 && this.onMarkerCreated) { 157 | this.onMarkerCreated(this); 158 | } 159 | } 160 | 161 | public override pointerUp(point: IPoint, ev?: PointerEvent): void { 162 | super.pointerUp(point, ev); 163 | this.manipulate(point, ev); 164 | if (this._state !== 'creating') { 165 | this._state = 'select'; 166 | } 167 | this.stateChanged(); 168 | } 169 | 170 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 171 | public override manipulate(point: IPoint, ev?: PointerEvent): void { 172 | if (this.state === 'creating') { 173 | this.resize(point); 174 | } else if (this.state === 'move') { 175 | this.marker.points.forEach((p) => { 176 | p.x += point.x - this.manipulationStartX; 177 | p.y += point.y - this.manipulationStartY; 178 | }); 179 | this.manipulationStartX = point.x; 180 | this.manipulationStartY = point.y; 181 | this.marker.adjustVisual(); 182 | this.adjustControlBox(); 183 | } else if (this.state === 'resize') { 184 | this.resize(point); 185 | } 186 | } 187 | 188 | protected resize(point: IPoint): void { 189 | const activeGripIndex = this.activeGrip 190 | ? this.grips.indexOf(this.activeGrip) 191 | : -1; 192 | if (activeGripIndex >= 0) { 193 | this.marker.points[activeGripIndex] = point; 194 | this.marker.adjustVisual(); 195 | this.adjustControlBox(); 196 | } 197 | } 198 | 199 | public override dblClick( 200 | point: IPoint, 201 | target?: EventTarget, 202 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 203 | ev?: MouseEvent, 204 | ): void { 205 | if (target && this.state === 'select') { 206 | const selectorLineIndex = this.marker.selectorVisualLines.findIndex( 207 | (l) => l === target, 208 | ); 209 | if (selectorLineIndex > -1) { 210 | this.marker.points.splice(selectorLineIndex + 1, 0, point); 211 | this.marker.adjustVisual(); 212 | this.adjustControlGrips(); 213 | } else { 214 | const gripIndex = this.grips.findIndex((g) => g.ownsTarget(target)); 215 | if (gripIndex > -1) { 216 | this.marker.points.splice(gripIndex, 1); 217 | this.marker.adjustVisual(); 218 | this.adjustControlGrips(); 219 | } 220 | } 221 | } 222 | } 223 | 224 | /** 225 | * Creates control box for manipulation controls. 226 | */ 227 | protected setupControlBox(): void { 228 | if (this.controlBox) return; 229 | 230 | this.controlBox = SvgHelper.createGroup(); 231 | this.container.appendChild(this.controlBox); 232 | this.manipulationBox = SvgHelper.createGroup(); 233 | this.controlBox.appendChild(this.manipulationBox); 234 | 235 | this.adjustControlGrips(); 236 | 237 | this.controlBox.style.display = 'none'; 238 | } 239 | 240 | protected adjustControlBox() { 241 | if (!this.controlBox) { 242 | this.setupControlBox(); 243 | } 244 | // this.positionGrips(); 245 | this.adjustControlGrips(); 246 | } 247 | 248 | /** 249 | * Adds control grips to control box. 250 | */ 251 | protected adjustControlGrips(): void { 252 | const noOfMissingGrips = this.marker.points.length - this.grips.length; 253 | if (noOfMissingGrips > 0) { 254 | for (let i = 0; i < noOfMissingGrips; i++) { 255 | this.grips.push(this.createGrip()); 256 | } 257 | } else if (noOfMissingGrips < 0) { 258 | for (let i = 0; i < -noOfMissingGrips; i++) { 259 | const grip = this.grips.pop(); 260 | if (grip) { 261 | this.manipulationBox.removeChild(grip.visual); 262 | } 263 | } 264 | } 265 | 266 | this.positionGrips(); 267 | } 268 | 269 | /** 270 | * Creates manipulation grip. 271 | * @returns - manipulation grip. 272 | */ 273 | protected createGrip(): ResizeGrip { 274 | const grip = new ResizeGrip(); 275 | grip.zoomLevel = this.zoomLevel; 276 | grip.visual.transform.baseVal.appendItem(SvgHelper.createTransform()); 277 | this.manipulationBox.appendChild(grip.visual); 278 | 279 | return grip; 280 | } 281 | 282 | /** 283 | * Updates manipulation grip layout. 284 | */ 285 | protected positionGrips(): void { 286 | this.grips.forEach((grip, i) => { 287 | grip.zoomLevel = this.zoomLevel; 288 | const point = this.marker.points[i]; 289 | this.positionGrip( 290 | grip.visual, 291 | point.x - grip.gripSize / 2, 292 | point.y - grip.gripSize / 2, 293 | ); 294 | }); 295 | } 296 | 297 | /** 298 | * Positions manipulation grip. 299 | * @param grip - grip to position 300 | * @param x - new X coordinate 301 | * @param y - new Y coordinate 302 | */ 303 | protected positionGrip(grip: SVGGraphicsElement, x: number, y: number): void { 304 | const translate = grip.transform.baseVal.getItem(0); 305 | translate.setTranslate(x, y); 306 | grip.transform.baseVal.replaceItem(translate, 0); 307 | } 308 | 309 | public select(multi = false): void { 310 | super.select(multi); 311 | this.adjustControlBox(); 312 | this.manipulationBox.style.display = multi ? 'none' : ''; 313 | this.controlBox!.style.display = ''; 314 | } 315 | 316 | public deselect(): void { 317 | super.deselect(); 318 | if (this.controlBox) { 319 | this.controlBox.style.display = 'none'; 320 | } 321 | if (this.state === 'creating') { 322 | this.finishCreation(); 323 | } 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /src/core/SvgHelper.ts: -------------------------------------------------------------------------------- 1 | import { IPoint } from './IPoint'; 2 | 3 | /** 4 | * Utility class to simplify SVG operations. 5 | */ 6 | export class SvgHelper { 7 | /** 8 | * Creates SVG "defs". 9 | */ 10 | public static createDefs(): SVGDefsElement { 11 | const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); 12 | 13 | return defs; 14 | } 15 | 16 | /** 17 | * Sets attributes on an arbitrary SVG element 18 | * @param el - target SVG element. 19 | * @param attributes - set of name-value attribute pairs. 20 | */ 21 | public static setAttributes( 22 | el: SVGElement, 23 | attributes: Array<[string, string]>, 24 | ): void { 25 | for (const [attr, value] of attributes) { 26 | el.setAttribute(attr, value); 27 | } 28 | } 29 | 30 | /** 31 | * Creates an SVG rectangle with the specified width and height. 32 | * @param width 33 | * @param height 34 | * @param attributes - additional attributes. 35 | */ 36 | public static createRect( 37 | width: number | string, 38 | height: number | string, 39 | attributes?: Array<[string, string]>, 40 | ): SVGRectElement { 41 | const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 42 | 43 | rect.setAttribute('width', width.toString()); 44 | rect.setAttribute('height', height.toString()); 45 | if (attributes) { 46 | SvgHelper.setAttributes(rect, attributes); 47 | } 48 | 49 | return rect; 50 | } 51 | 52 | /** 53 | * Creates an SVG line with specified end-point coordinates. 54 | * @param x1 55 | * @param y1 56 | * @param x2 57 | * @param y2 58 | * @param attributes - additional attributes. 59 | */ 60 | public static createLine( 61 | x1: number | string, 62 | y1: number | string, 63 | x2: number | string, 64 | y2: number | string, 65 | attributes?: Array<[string, string]>, 66 | ): SVGLineElement { 67 | const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 68 | 69 | line.setAttribute('x1', x1.toString()); 70 | line.setAttribute('y1', y1.toString()); 71 | line.setAttribute('x2', x2.toString()); 72 | line.setAttribute('y2', y2.toString()); 73 | if (attributes) { 74 | SvgHelper.setAttributes(line, attributes); 75 | } 76 | 77 | return line; 78 | } 79 | 80 | /** 81 | * Creates an SVG polygon with specified points. 82 | * @param points - points as string. 83 | * @param attributes - additional attributes. 84 | */ 85 | public static createPolygon( 86 | points: string, 87 | attributes?: Array<[string, string]>, 88 | ): SVGPolygonElement { 89 | const polygon = document.createElementNS( 90 | 'http://www.w3.org/2000/svg', 91 | 'polygon', 92 | ); 93 | 94 | polygon.setAttribute('points', points); 95 | if (attributes) { 96 | SvgHelper.setAttributes(polygon, attributes); 97 | } 98 | 99 | return polygon; 100 | } 101 | 102 | /** 103 | * Creates an SVG circle with the specified radius. 104 | * @param radius 105 | * @param attributes - additional attributes. 106 | */ 107 | public static createCircle( 108 | radius: number, 109 | attributes?: Array<[string, string]>, 110 | ): SVGCircleElement { 111 | const circle = document.createElementNS( 112 | 'http://www.w3.org/2000/svg', 113 | 'circle', 114 | ); 115 | 116 | circle.setAttribute('cx', (radius / 2).toString()); 117 | circle.setAttribute('cy', (radius / 2).toString()); 118 | circle.setAttribute('r', radius.toString()); 119 | if (attributes) { 120 | SvgHelper.setAttributes(circle, attributes); 121 | } 122 | 123 | return circle; 124 | } 125 | 126 | /** 127 | * Creates an SVG ellipse with the specified horizontal and vertical radii. 128 | * @param rx 129 | * @param ry 130 | * @param attributes - additional attributes. 131 | */ 132 | public static createEllipse( 133 | rx: number, 134 | ry: number, 135 | attributes?: Array<[string, string]>, 136 | ): SVGEllipseElement { 137 | const ellipse = document.createElementNS( 138 | 'http://www.w3.org/2000/svg', 139 | 'ellipse', 140 | ); 141 | 142 | ellipse.setAttribute('cx', (rx / 2).toString()); 143 | ellipse.setAttribute('cy', (ry / 2).toString()); 144 | ellipse.setAttribute('rx', (rx / 2).toString()); 145 | ellipse.setAttribute('ry', (ry / 2).toString()); 146 | if (attributes) { 147 | SvgHelper.setAttributes(ellipse, attributes); 148 | } 149 | 150 | return ellipse; 151 | } 152 | 153 | /** 154 | * Creates an SVG group. 155 | * @param attributes - additional attributes. 156 | */ 157 | public static createGroup(attributes?: Array<[string, string]>): SVGGElement { 158 | const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 159 | if (attributes) { 160 | SvgHelper.setAttributes(g, attributes); 161 | } 162 | return g; 163 | } 164 | 165 | /** 166 | * Creates an SVG transform. 167 | */ 168 | public static createTransform(): SVGTransform { 169 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 170 | 171 | return svg.createSVGTransform(); 172 | } 173 | 174 | /** 175 | * Creates an SVG marker. 176 | * @param id 177 | * @param orient 178 | * @param markerWidth 179 | * @param markerHeight 180 | * @param refX 181 | * @param refY 182 | * @param markerElement 183 | */ 184 | public static createMarker( 185 | id: string, 186 | orient: string, 187 | markerWidth: number | string, 188 | markerHeight: number | string, 189 | refX: number | string, 190 | refY: number | string, 191 | markerElement: SVGGraphicsElement, 192 | ): SVGMarkerElement { 193 | const marker = document.createElementNS( 194 | 'http://www.w3.org/2000/svg', 195 | 'marker', 196 | ); 197 | SvgHelper.setAttributes(marker, [ 198 | ['id', id], 199 | ['orient', orient], 200 | ['markerWidth', markerWidth.toString()], 201 | ['markerHeight', markerHeight.toString()], 202 | ['refX', refX.toString()], 203 | ['refY', refY.toString()], 204 | ]); 205 | 206 | marker.appendChild(markerElement); 207 | 208 | return marker; 209 | } 210 | 211 | /** 212 | * Creates an SVG text element. 213 | * @param attributes - additional attributes. 214 | */ 215 | public static createText( 216 | attributes?: Array<[string, string]>, 217 | ): SVGTextElement { 218 | const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 219 | text.setAttribute('x', '0'); 220 | text.setAttribute('y', '0'); 221 | 222 | if (attributes) { 223 | SvgHelper.setAttributes(text, attributes); 224 | } 225 | 226 | return text; 227 | } 228 | 229 | /** 230 | * Creates an SVG TSpan. 231 | * @param text - inner text. 232 | * @param attributes - additional attributes. 233 | */ 234 | public static createTSpan( 235 | text: string, 236 | attributes?: Array<[string, string]>, 237 | ): SVGTSpanElement { 238 | const tspan = document.createElementNS( 239 | 'http://www.w3.org/2000/svg', 240 | 'tspan', 241 | ); 242 | tspan.textContent = text; 243 | 244 | if (attributes) { 245 | SvgHelper.setAttributes(tspan, attributes); 246 | } 247 | 248 | return tspan; 249 | } 250 | 251 | /** 252 | * Creates an SVG image element. 253 | * @param attributes - additional attributes. 254 | */ 255 | public static createImage( 256 | attributes?: Array<[string, string]>, 257 | ): SVGImageElement { 258 | const image = document.createElementNS( 259 | 'http://www.w3.org/2000/svg', 260 | 'image', 261 | ); 262 | 263 | if (attributes) { 264 | SvgHelper.setAttributes(image, attributes); 265 | } 266 | 267 | return image; 268 | } 269 | 270 | /** 271 | * Creates an SVG point with the specified coordinates. 272 | * @param x 273 | * @param y 274 | */ 275 | public static createPoint(x: number, y: number): SVGPoint { 276 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 277 | const svgPoint = svg.createSVGPoint(); 278 | svgPoint.x = x; 279 | svgPoint.y = y; 280 | 281 | return svgPoint; 282 | } 283 | 284 | /** 285 | * Creates an SVG path with the specified shape (d). 286 | * @param d - path shape 287 | * @param attributes - additional attributes. 288 | */ 289 | public static createPath( 290 | d: string, 291 | attributes?: Array<[string, string]>, 292 | ): SVGPathElement { 293 | const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 294 | 295 | path.setAttribute('d', d); 296 | if (attributes) { 297 | SvgHelper.setAttributes(path, attributes); 298 | } 299 | 300 | return path; 301 | } 302 | 303 | /** 304 | * Creates an SVG text element. 305 | * @param attributes - additional attributes. 306 | */ 307 | public static createForeignObject( 308 | attributes?: Array<[string, string]>, 309 | ): SVGForeignObjectElement { 310 | const obj = document.createElementNS( 311 | 'http://www.w3.org/2000/svg', 312 | 'foreignObject', 313 | ); 314 | obj.setAttribute('x', '0'); 315 | obj.setAttribute('y', '0'); 316 | 317 | if (attributes) { 318 | SvgHelper.setAttributes(obj, attributes); 319 | } 320 | 321 | return obj; 322 | } 323 | 324 | /** 325 | * Returns local coordinates relative to the provided `localRoot` of a client (screen) point. 326 | * @param localRoot relative coordinate root 327 | * @param x horizontal client coordinate 328 | * @param y vertical client coordinate 329 | * @param zoomLevel zoom level 330 | * @returns local coordinates relative to `localRoot` 331 | */ 332 | public static clientToLocalCoordinates( 333 | localRoot: SVGElement | undefined, 334 | x: number, 335 | y: number, 336 | zoomLevel = 1, 337 | ): IPoint { 338 | if (localRoot) { 339 | const clientRect = localRoot.getBoundingClientRect(); 340 | return { 341 | x: (x - clientRect.left) / zoomLevel, 342 | y: (y - clientRect.top) / zoomLevel, 343 | }; 344 | } else { 345 | return { x: x, y: y }; 346 | } 347 | } 348 | 349 | /** 350 | * Creates an SVG image element from a supplied inner SVG markup string. 351 | * @param stringSvg SVG markup (without the root svg tags) 352 | * @returns SVG image element 353 | */ 354 | public static createSvgFromString(stringSvg: string): SVGSVGElement { 355 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 356 | svg.innerHTML = stringSvg; 357 | 358 | return svg; 359 | } 360 | 361 | /** 362 | * Creates an SVG filter element. 363 | * @param id filter id 364 | * @param attributes other filter element attributes 365 | * @param innerHTML filter definition as string 366 | * @returns filter element 367 | */ 368 | public static createFilter( 369 | id: string, 370 | attributes?: Array<[string, string]>, 371 | innerHTML?: string, 372 | ): SVGFilterElement { 373 | const filter = document.createElementNS( 374 | 'http://www.w3.org/2000/svg', 375 | 'filter', 376 | ); 377 | filter.id = id; 378 | if (attributes) { 379 | SvgHelper.setAttributes(filter, attributes); 380 | } 381 | if (innerHTML) { 382 | filter.innerHTML = innerHTML; 383 | } 384 | return filter; 385 | } 386 | } 387 | --------------------------------------------------------------------------------