├── .editorconfig ├── .gitattributes ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── copyMediaToDocs.cjs ├── demo_assets ├── image.png └── image.svg ├── dist ├── imagemapper.es.js ├── imagemapper.es.js.map ├── imagemapper.umd.js ├── imagemapper.umd.js.map ├── index.d.ts └── src │ ├── circle.d.ts │ ├── component.d.ts │ ├── constants.d.ts │ ├── editor.d.ts │ ├── ellipse.d.ts │ ├── events.d.ts │ ├── factory.d.ts │ ├── fsm.d.ts │ ├── globals.d.ts │ ├── handle.d.ts │ ├── onChangeProxy.d.ts │ ├── polygon.d.ts │ ├── rect.d.ts │ ├── style.d.ts │ ├── test │ ├── components.test.d.ts │ ├── editor.test.d.ts │ ├── fsm.test.d.ts │ └── onChangeProxy.test.d.ts │ └── utils.d.ts ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ ├── icons.js │ ├── icons.svg │ ├── main.js │ ├── navigation.js │ ├── search.js │ └── style.css ├── classes │ ├── Circle.html │ ├── Component.html │ ├── CornerShapedElement.html │ ├── Editor.html │ ├── Ellipse.html │ ├── Handle.html │ ├── Polygon.html │ └── Rectangle.html ├── functions │ ├── editor-1.html │ └── view.html ├── hierarchy.html ├── index.html ├── media │ └── header.png ├── modules.html ├── types │ ├── Dim.html │ ├── EditorOptions.html │ ├── Point.html │ └── Style.html └── variables │ └── default.html ├── examples ├── browser │ └── index.html ├── node │ └── index.js └── react │ ├── ImageMapperEditor.js │ ├── ImageMapperEditorCB.js │ ├── examples.js │ └── index.html ├── index.html ├── index.ts ├── jest.config.ts ├── media ├── header.png └── header.svg ├── package-lock.json ├── package.json ├── release.txt ├── src ├── circle.ts ├── component.ts ├── constants.ts ├── editor.ts ├── ellipse.ts ├── events.ts ├── factory.ts ├── fsm.ts ├── globals.ts ├── handle.ts ├── onChangeProxy.ts ├── polygon.ts ├── rect.ts ├── style.ts ├── test │ ├── components.test.ts │ ├── editor.test.ts │ ├── fsm.test.ts │ └── onChangeProxy.test.ts └── utils.ts ├── tsconfig.build.json ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_size = 2 12 | 13 | # Matches multiple files with brace expansion notation 14 | # Set default charset 15 | [*.{js,ts}] 16 | charset = utf-8 17 | indent_style = space 18 | max_line_length = 100 19 | quote_type = single 20 | 21 | # Tab indentation (no size specified) 22 | [Makefile] 23 | indent_style = tab 24 | 25 | # Matches the exact files either package.json or .travis.yml 26 | [{package.json,.travis.yml}] 27 | indent_style = space 28 | 29 | [*.md] 30 | trim_trailing_whitespace = false 31 | insert_final_newline = false -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # git config --global merge.ours.driver true 2 | dist/* merge=ours -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "arrowParens": "avoid", 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "singleQuote": false, 8 | "useTabs": false 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OverlapMedia 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 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imagemapper logo 2 | 3 | Create image maps. View image maps. Interact with image maps by event listeners. Touch events are supported. 4 | 5 | imagemapper provides both drawing and view mode interaction capabilities letting you enable features of your image map adapted to the context of the user. 6 | 7 | * Instantiated as an editor it adds SVG drawing capability (rectangles, circles, ellipses and polygons) on top of your image to let you make image maps. Shapes could be frozen (freeze method) to disallow deleting, resizing and moving.
8 | * Instantiated as a view you can't draw new shapes or change imported shapes, but all other features (eg. importing and event handlers) are still available. 9 | 10 | ## Getting started 11 | 12 | ### Using npm and Node.js 13 | ``` 14 | $ npm install @overlapmedia/imagemapper 15 | ``` 16 | ```js 17 | // Use imagemapper.editor or editor 18 | import imagemapper, { editor, view } from '@overlapmedia/imagemapper'; 19 | 20 | // Editor 21 | const myEditor = imagemapper.editor('editor', { 22 | width: 800, 23 | height: 400, 24 | selectModeHandler: () => console.log('Editor is now in select mode'), 25 | componentDrawnHandler: (component, componentId) => { 26 | // Disabling changes on new components. If you are making a design collaboration tool you probably want 27 | // to do this on components returned by the import function (meaning all existing components you are importing) 28 | // and let all other components drawn by the user respond to changes. 29 | component.freeze(); 30 | 31 | console.log( 32 | `Disabled selecting, deleting, resizing and moving on component with id ${componentId}`, 33 | ); 34 | }, 35 | }); 36 | myEditor.loadImage('image.svg', 700, 350); 37 | myEditor.on('mouseup', (e) => console.log('mouseup event', e)); 38 | myEditor.polygon(); // Let user draw polygons 39 | 40 | // View 41 | const myView = view('view', { 42 | width: 800, 43 | height: 400, 44 | viewClickHandler: (e, id) => console.log('User clicked on', id), 45 | }); 46 | myView.loadImage('image.png', 700, 350); 47 | myView.import( 48 | '{"idCounter":4,"components":[{"id":"rect_1","type":"rect","data":{"x":66,"y":36,"width":253,"height":148}},{"id":"polygon_2","type":"polygon","data":[{"x":376,"y":172},{"x":498,"y":291},{"x":625,"y":174},{"x":500,"y":57}]},{"id":"polygon_3","type":"polygon","data":[{"x":54,"y":249},{"x":234,"y":246},{"x":236,"y":225},{"x":415,"y":270},{"x":237,"y":313},{"x":235,"y":294},{"x":54,"y":292}]}]}', 49 | ); 50 | ``` 51 | 52 | ### From browser 53 | ```html 54 | 55 | 60 | ``` 61 | 62 | ### With React 63 | If you want to use imagemapper in a React app, [these examples](https://overlapmedia.github.io/imagemapper/examples/react/) might get you started. 64 | 65 | ## Demo 66 | Try out the demo of imagemapper [here](https://overlapmedia.github.io/imagemapper/examples/browser/index.html). 67 | 68 | ## Backlog 69 | - feat: Support rotating shapes 70 | - feat: Import data with SVG attrs format (ref. https://github.com/overlapmedia/imagemapper/issues/1) 71 | 72 | ## API Reference 73 | [Go to API documentation](https://overlapmedia.github.io/imagemapper/docs) -------------------------------------------------------------------------------- /copyMediaToDocs.cjs: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | fs.mkdirSync("./docs/media"); 3 | fs.copyFile("./media/header.png", "./docs/media/header.png", () => {}); 4 | -------------------------------------------------------------------------------- /demo_assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/overlapmedia/imagemapper/51c9ebd0cf7da65e790e221ab0bbe6ef1b1a9b76/demo_assets/image.png -------------------------------------------------------------------------------- /demo_assets/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 20 | 21 | 23 | imagemapper 31 | 38 | 45 | 50 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Editor 3 | * @returns {Editor} 4 | */ 5 | export declare const editor: (svgEl: string | SVGElement, options?: Partial | undefined, style?: Partial<{ 6 | component: import("./src/style").Style; 7 | componentHover: { 8 | off: import("./src/style").Style; 9 | on: import("./src/style").Style; 10 | }; 11 | componentSelect: { 12 | off: import("./src/style").Style; 13 | on: import("./src/style").Style; 14 | }; 15 | handle: import("./src/style").Style; 16 | handleHover: import("./src/style").Style; 17 | }> | undefined) => import("./src/editor").Editor; 18 | /** 19 | * View 20 | * @returns {Editor} - an Editor constructor which does not add drawing capabilities 21 | */ 22 | export declare const view: (svgEl: string | SVGElement, options?: Partial | undefined, style?: Partial<{ 23 | component: import("./src/style").Style; 24 | componentHover: { 25 | off: import("./src/style").Style; 26 | on: import("./src/style").Style; 27 | }; 28 | componentSelect: { 29 | off: import("./src/style").Style; 30 | on: import("./src/style").Style; 31 | }; 32 | handle: import("./src/style").Style; 33 | handleHover: import("./src/style").Style; 34 | }> | undefined) => import("./src/editor").Editor; 35 | /** 36 | * @example 37 | * ```js 38 | * import imagemapper from '@overlapmedia/imagemapper'; 39 | * const editor = imagemapper.editor('editor-id'); 40 | * editor.polygon(); 41 | * ``` 42 | * 43 | * @example 44 | * ```js 45 | * import { editor, view } from '@overlapmedia/imagemapper'; 46 | * const myEditor = editor('editor-id'); 47 | * myEditor.polygon(); 48 | * ``` 49 | */ 50 | declare const _default: { 51 | editor: (svgEl: string | SVGElement, options?: Partial | undefined, style?: Partial<{ 52 | component: import("./src/style").Style; 53 | componentHover: { 54 | off: import("./src/style").Style; 55 | on: import("./src/style").Style; 56 | }; 57 | componentSelect: { 58 | off: import("./src/style").Style; 59 | on: import("./src/style").Style; 60 | }; 61 | handle: import("./src/style").Style; 62 | handleHover: import("./src/style").Style; 63 | }> | undefined) => import("./src/editor").Editor; 64 | view: (svgEl: string | SVGElement, options?: Partial | undefined, style?: Partial<{ 65 | component: import("./src/style").Style; 66 | componentHover: { 67 | off: import("./src/style").Style; 68 | on: import("./src/style").Style; 69 | }; 70 | componentSelect: { 71 | off: import("./src/style").Style; 72 | on: import("./src/style").Style; 73 | }; 74 | handle: import("./src/style").Style; 75 | handleHover: import("./src/style").Style; 76 | }> | undefined) => import("./src/editor").Editor; 77 | }; 78 | export default _default; 79 | export { Editor, type EditorOptions } from "./src/editor"; 80 | export { Component } from "./src/component"; 81 | export { CornerShapedElement, type Dim } from "./src/factory"; 82 | export { Rectangle } from "./src/rect"; 83 | export { Circle } from "./src/circle"; 84 | export { Ellipse } from "./src/ellipse"; 85 | export { Polygon, type Point } from "./src/polygon"; 86 | export type { Style } from "./src/style"; 87 | export type { Handle } from "./src/handle"; 88 | -------------------------------------------------------------------------------- /dist/src/circle.d.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor"; 2 | import { CornerShapedElement } from "./factory"; 3 | export declare class Circle extends CornerShapedElement { 4 | constructor(editorOwner: Editor, x: number, y: number, width?: number, height?: number); 5 | } 6 | -------------------------------------------------------------------------------- /dist/src/component.d.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor"; 2 | import { Handle } from "./handle"; 3 | import { getDefaultStyle } from "./style"; 4 | export declare abstract class Component { 5 | readonly editorOwner: Editor; 6 | readonly element: SVGElement; 7 | style: ReturnType; 8 | isSelected: boolean; 9 | isFrozen: boolean; 10 | constructor(editorOwner: Editor, element: SVGElement); 11 | abstract freeze(freeze?: boolean): Component; 12 | abstract isValid(): boolean; 13 | abstract move(deltaX: number, deltaY: number): Component; 14 | abstract setHandlesVisibility(visible?: boolean): Component; 15 | abstract setIsSelected(selected?: boolean): Component; 16 | abstract getHandles(): Handle[]; 17 | abstract setStyle(style: ReturnType): Component; 18 | abstract export(): object; 19 | _logWarnOnOpOnFrozen(op: string): void; 20 | } 21 | -------------------------------------------------------------------------------- /dist/src/constants.d.ts: -------------------------------------------------------------------------------- 1 | declare const SVG_NS = "http://www.w3.org/2000/svg"; 2 | declare const XLINK_NS = "http://www.w3.org/1999/xlink"; 3 | export { SVG_NS, XLINK_NS }; 4 | -------------------------------------------------------------------------------- /dist/src/editor.d.ts: -------------------------------------------------------------------------------- 1 | import { Point, Polygon } from "./polygon"; 2 | import { Rectangle } from "./rect"; 3 | import { Circle } from "./circle"; 4 | import { Ellipse } from "./ellipse"; 5 | import { Handle } from "./handle"; 6 | import { getDefaultStyle } from "./style"; 7 | import { Component } from "./component"; 8 | import { Actor, ActorLogic } from "xstate"; 9 | import { Dim } from "./factory"; 10 | export type EditorOptions = { 11 | /** if you let imagemapper create the SVGElement for you, you could specify width for it here */ 12 | width: number; 13 | /** if you let imagemapper create the SVGElement for you, you could specify height for it here */ 14 | height: number; 15 | /** function being called when finished drawing a valid component (eg. rectangle with width and height greater than 0 or polygon width at least three points), does not apply to importing */ 16 | componentDrawnHandler: (c: Component, id: string) => void; 17 | /** function being called when editor switches to select mode when eg. Esc keydown event or mousedown event on handle is causing it to leave draw mode */ 18 | selectModeHandler: () => void; 19 | /** when using view this function will be called on click events from the shapes */ 20 | viewClickHandler: (e: Event, id: string) => void; 21 | }; 22 | /** 23 | * An Editor or View containing everything needed by the drawing/display board: DOM, event listeners, state and API functions. 24 | * 25 | * @param {string|SVGElement} svgEl - the id of the SVG element to be created or the SVG element itself if it's already made 26 | * @param {object} [options] 27 | * @param {object} [style] - see {@link Editor#setStyle} 28 | */ 29 | export declare class Editor { 30 | width: EditorOptions["width"]; 31 | height: EditorOptions["height"]; 32 | componentDrawnHandler?: EditorOptions["componentDrawnHandler"]; 33 | selectModeHandler?: EditorOptions["selectModeHandler"]; 34 | viewClickHandler?: EditorOptions["viewClickHandler"]; 35 | svg: SVGSVGElement; 36 | cgroup: SVGGElement; 37 | hgroup: SVGGElement; 38 | style: ReturnType; 39 | fsmService: Actor>; 40 | _cacheElementMapping: Record; 41 | _idCounter: number; 42 | _handleIdCounter: number; 43 | constructor(svgEl: SVGElement | string, options?: Partial, style?: Partial>); 44 | /** 45 | * Add an image element into the SVG element. 46 | * 47 | * @param {string} path 48 | * @param {number|string} [width] 49 | * @param {number|string} [height] 50 | * @returns {Editor} 51 | */ 52 | loadImage(path: string, width: string | number, height: string | number): this; 53 | /** 54 | * Completely or partly set current style of components, handles, hovering etc. 55 | * 56 | * @param {object} style 57 | * @returns {Editor} 58 | * 59 | * @example 60 | * ```js 61 | * editor.setStyle({ 62 | * component: { 63 | * fill: 'rgb(102, 102, 102)', 64 | * stroke: 'rgb(51, 51, 51)', 65 | * }, 66 | * componentHover: { 67 | * off: { 68 | * 'stroke-width': 1, 69 | * opacity: 0.5, 70 | * }, 71 | * on: { 72 | * 'stroke-width': 2, 73 | * opacity: 0.6, 74 | * }, 75 | * }, 76 | * componentSelect: { 77 | * off: { 78 | * 'stroke-dasharray': 'none', 79 | * 'stroke-linejoin': 'miter', 80 | * }, 81 | * on: { 82 | * 'stroke-dasharray': '4 3', 83 | * 'stroke-linejoin': 'round', 84 | * }, 85 | * }, 86 | * handle: { 87 | * fill: 'rgb(255, 255, 255)', 88 | * stroke: 'rgb(51, 51, 51)', 89 | * 'stroke-width': 1, 90 | * opacity: 0.3, 91 | * }, 92 | * handleHover: { 93 | * opacity: 0.6, 94 | * }, 95 | * }); 96 | * ``` 97 | */ 98 | setStyle(style: Partial>): this; 99 | /** 100 | * Put editor in draw mode of rectangles. 101 | */ 102 | rect(): void; 103 | /** 104 | * Put editor in draw mode of circles. 105 | */ 106 | circle(): void; 107 | /** 108 | * Put editor in draw mode of ellipses. 109 | */ 110 | ellipse(): void; 111 | /** 112 | * Put editor in draw mode of polygons. 113 | */ 114 | polygon(): void; 115 | /** 116 | * Put editor in select mode. 117 | */ 118 | selectMode(): void; 119 | /** 120 | * @param {string} id 121 | * @returns {Rectangle|Circle|Ellipse|Polygon} 122 | */ 123 | getComponentById(id: string): Handle | Component | undefined; 124 | /** 125 | * Make programmatically selection of a component which basically enables its handles by making them visible. 126 | * Please note that all components will be unselected when leaving select mode or leaving draw mode. 127 | * 128 | * @param {string|Rectangle|Circle|Ellipse|Polygon} componentOrId - a component or a component id 129 | * @returns {Rectangle|Circle|Ellipse|Polygon|null} 130 | */ 131 | selectComponent(componentOrId?: string | Component | Handle): Component | null; 132 | /** 133 | * Remove a component (shape) from the editor or view. 134 | * 135 | * @param {string|Rectangle|Circle|Ellipse|Polygon} componentOrId - a component or a component id 136 | * @returns {Rectangle|Circle|Ellipse|Polygon|null} 137 | */ 138 | removeComponent(componentOrId: string | Component): Component | null; 139 | /** 140 | * Add event listener(s). 141 | * 142 | * @param {string} eventTypes 143 | * @param {EventListenerOrEventListenerObject} handler 144 | * @returns {Editor} 145 | */ 146 | on(eventTypes: string, handler: EventListenerOrEventListenerObject): this; 147 | /** 148 | * Remove event listener(s). 149 | * 150 | * @param {string} eventTypes 151 | * @param {EventListenerOrEventListenerObject} handler 152 | * @returns {Editor} 153 | */ 154 | off(eventTypes: string, handler: EventListenerOrEventListenerObject): this; 155 | /** 156 | * @callback idInterceptor 157 | * @param {string} id - the id to be modified 158 | */ 159 | /** 160 | * Import shapes from JSON. 161 | * 162 | * @param {string} data 163 | * @param {idInterceptor} [idInterceptor] - function to change the imported id to avoid name conflicts, eg. in case user decides to import multiple times or import _after_ drawing 164 | * @returns {Array.} 165 | * 166 | * @example 167 | * ```js 168 | * { 169 | * "components": [{ 170 | * "id": "circle_1", 171 | * "type": "circle", 172 | * "data": { 173 | * "x": 444, 174 | * "y": 71, 175 | * "width": 241, 176 | * "height": 211 177 | * } 178 | * }] 179 | * } 180 | * ``` 181 | * 182 | * @example 183 | * ```js 184 | * { 185 | * "components": [{ 186 | * "id": "rect_1", 187 | * "type": "rect", 188 | * "data": { 189 | * "x": 444, 190 | * "y": 71, 191 | * "width": 241, 192 | * "height": 211 193 | * } 194 | * }] 195 | * } 196 | * ``` 197 | * 198 | * @example 199 | * ```js 200 | * { 201 | * "components": [{ 202 | * "id": "ellipse_1", 203 | * "type": "ellipse", 204 | * "data": { 205 | * "x": 444, 206 | * "y": 71, 207 | * "width": 241, 208 | * "height": 211 209 | * } 210 | * }] 211 | * } 212 | * ``` 213 | * 214 | * @example 215 | * ```js 216 | * { 217 | * "components": [{ 218 | * "id": "polygon_1", 219 | * "type": "polygon", 220 | * "data": [{ 221 | * "x": 603, 222 | * "y": 114 223 | * }, { 224 | * "x": 625, 225 | * "y": 203 226 | * }, { 227 | * "x": 699, 228 | * "y": 124 229 | * }] 230 | * }] 231 | * } 232 | * ``` 233 | */ 234 | import(data: string, idInterceptor: (id: string) => string): any; 235 | /** 236 | * Export drawn shapes as JSON. 237 | * 238 | * @param {boolean} [escape] - whether double quotes should be escaped 239 | * @returns {string} - JSON data 240 | */ 241 | export(escape?: boolean): string; 242 | createRectangle(dim: Dim, id?: string): Rectangle; 243 | createCircle(dim: Dim, id?: string): Circle; 244 | createEllipse(dim: Dim, id?: string): Ellipse; 245 | createPolygon(points: Point[] | Point, id?: string): Polygon; 246 | registerComponent(component: T, id?: string): T; 247 | registerComponentHandle(handle: Handle): Handle; 248 | unregisterComponent(component: Component | Handle): void; 249 | } 250 | declare const _default: (isView?: boolean) => (svgEl: string | SVGElement, options?: Partial | undefined, style?: Partial<{ 251 | component: import("./style").Style; 252 | componentHover: { 253 | off: import("./style").Style; 254 | on: import("./style").Style; 255 | }; 256 | componentSelect: { 257 | off: import("./style").Style; 258 | on: import("./style").Style; 259 | }; 260 | handle: import("./style").Style; 261 | handleHover: import("./style").Style; 262 | }> | undefined) => Editor; 263 | export default _default; 264 | -------------------------------------------------------------------------------- /dist/src/ellipse.d.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor"; 2 | import { CornerShapedElement } from "./factory"; 3 | export declare class Ellipse extends CornerShapedElement { 4 | constructor(editorOwner: Editor, x: number, y: number, width?: number, height?: number); 5 | } 6 | -------------------------------------------------------------------------------- /dist/src/events.d.ts: -------------------------------------------------------------------------------- 1 | declare const addEventListeners: (targets: Element[] | Element | (Window & typeof globalThis), eventTypes: string, handler: EventListenerOrEventListenerObject) => void; 2 | declare const removeEventListeners: (targets: Element[] | Element | (Window & typeof globalThis), eventTypes: string, handler: EventListenerOrEventListenerObject) => void; 3 | export { addEventListeners, removeEventListeners }; 4 | -------------------------------------------------------------------------------- /dist/src/factory.d.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "./component"; 2 | import { Editor } from "./editor"; 3 | import { Handle } from "./handle"; 4 | import { getDefaultStyle } from "./style"; 5 | export type Dim = { 6 | x: number; 7 | y: number; 8 | width: number; 9 | height: number; 10 | }; 11 | export declare abstract class CornerShapedElement extends Component { 12 | dim: Dim; 13 | handles: Handle[]; 14 | constructor(svgElementName: keyof SVGElementTagNameMap, propChangeListener: { 15 | x: (element: SVGElement, x: number, prevX: number, dim: Dim) => void; 16 | y: (element: SVGElement, y: number, prevY: number, dim: Dim) => void; 17 | width: (element: SVGElement, width: number, prevWidth: number, dim: Dim) => void; 18 | height: (element: SVGElement, height: number, prevHeight: number, dim: Dim) => void; 19 | }, editorOwner: Editor, x: number, y: number, width?: number, height?: number); 20 | freeze(freeze?: boolean): this; 21 | resize(x: number, y: number): this; 22 | move(deltaX: number, deltaY: number): this; 23 | isValid(): boolean; 24 | setHandlesVisibility(visible?: boolean): this; 25 | setIsSelected(selected?: boolean): this; 26 | getHandles(): Handle[]; 27 | setStyle(style: ReturnType): this; 28 | export(): { 29 | x: number; 30 | y: number; 31 | width: number; 32 | height: number; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /dist/src/fsm.d.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "./component"; 2 | import { Editor } from "./editor"; 3 | import { Handle } from "./handle"; 4 | type Context = { 5 | unfinishedComponent?: Component; 6 | mouseDownInSelectModeObject?: Component | Handle; 7 | _editor: Editor; 8 | }; 9 | type MT_DOWN_Event = { 10 | type: "MT_DOWN"; 11 | component?: Component | Handle; 12 | offsetX: number; 13 | offsetY: number; 14 | }; 15 | type MT_MOVE_Event = { 16 | type: "MT_MOVE"; 17 | offsetX: number; 18 | offsetY: number; 19 | movementX: number; 20 | movementY: number; 21 | }; 22 | type KEYDOWN_ARROW_Event = { 23 | type: "KEYDOWN_ARROW"; 24 | movementX: number; 25 | movementY: number; 26 | }; 27 | export type ActorEvent = MT_DOWN_Event | MT_MOVE_Event | KEYDOWN_ARROW_Event | { 28 | type: "MT_UP" | "KEYDOWN_ESC" | "KEYDOWN_DEL" | "MODE_SELECT" | "MODE_DRAW_RECT" | "MODE_DRAW_CIRCLE" | "MODE_DRAW_ELLIPSE" | "MODE_DRAW_POLYGON" | "mouseDownInSelectModeUnassign"; 29 | }; 30 | declare const createFSMService: (editor: Editor) => import("xstate").Actor, import("xstate").ProvidedActor, import("xstate").ParameterizedObject, import("xstate").ParameterizedObject, string, import("xstate").StateValue, string, unknown, import("xstate").NonReducibleUnknown, import("xstate").ResolveTypegenMeta>>; 31 | export default createFSMService; 32 | -------------------------------------------------------------------------------- /dist/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare let root: (Window & typeof globalThis) | typeof globalThis; 2 | declare let doc: Document; 3 | export { root, doc }; 4 | -------------------------------------------------------------------------------- /dist/src/handle.d.ts: -------------------------------------------------------------------------------- 1 | import { Style } from "./style"; 2 | export declare class Handle { 3 | moveHandler: (deltaX: number, deltaY: number) => void; 4 | element: SVGCircleElement; 5 | isFrozen: boolean; 6 | constructor(x: number, y: number, moveHandler: (deltaX: number, deltaY: number) => void, frozen?: boolean); 7 | freeze(freeze?: boolean): this; 8 | setAttrX(value: number): this; 9 | setAttrY(value: number): this; 10 | move(deltaX: number, deltaY: number): this; 11 | setVisible(visible?: boolean): this; 12 | setStyle(style: Style, hoverStyle: Style): this; 13 | } 14 | -------------------------------------------------------------------------------- /dist/src/onChangeProxy.d.ts: -------------------------------------------------------------------------------- 1 | declare const onChange: (object: T, onChange: Record void> | ((propName: string, newValue: any, previousValue: any, updatedObject: T) => void), thisArg?: {}) => T; 2 | export { onChange }; 3 | -------------------------------------------------------------------------------- /dist/src/polygon.d.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "./component"; 2 | import { Editor } from "./editor"; 3 | import { Handle } from "./handle"; 4 | import { getDefaultStyle } from "./style"; 5 | export type Point = { 6 | x: number; 7 | y: number; 8 | handle?: Handle; 9 | }; 10 | export declare class Polygon extends Component { 11 | points: Point[]; 12 | constructor(editorOwner: Editor, points?: Point[] | Point); 13 | updateElementPoints(): this; 14 | addPoint(x: number, y: number): this; 15 | moveLastPoint(x: number, y: number): this; 16 | freeze(freeze?: boolean): this; 17 | move(deltaX: number, deltaY: number): this; 18 | isValid(): boolean; 19 | setHandlesVisibility(visible?: boolean): this; 20 | setIsSelected(selected?: boolean): this; 21 | getHandles(): Handle[]; 22 | setStyle(style: ReturnType): this; 23 | export(): { 24 | x: number; 25 | y: number; 26 | }[]; 27 | } 28 | -------------------------------------------------------------------------------- /dist/src/rect.d.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor"; 2 | import { CornerShapedElement } from "./factory"; 3 | export declare class Rectangle extends CornerShapedElement { 4 | constructor(editorOwner: Editor, x: number, y: number, width?: number, height?: number); 5 | } 6 | -------------------------------------------------------------------------------- /dist/src/style.d.ts: -------------------------------------------------------------------------------- 1 | export type Style = Record; 2 | declare const getDefaultStyle: () => { 3 | component: Style; 4 | componentHover: { 5 | off: Style; 6 | on: Style; 7 | }; 8 | componentSelect: { 9 | off: Style; 10 | on: Style; 11 | }; 12 | handle: Style; 13 | handleHover: Style; 14 | }; 15 | declare const setStyle: (element: Element, style: Style) => void; 16 | declare const addHover: (element: Element, defaultStyle: Style, hoverStyle: Style) => void; 17 | export { getDefaultStyle, setStyle, addHover }; 18 | -------------------------------------------------------------------------------- /dist/src/test/components.test.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/src/test/editor.test.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/src/test/fsm.test.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/src/test/onChangeProxy.test.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/src/utils.d.ts: -------------------------------------------------------------------------------- 1 | export declare const isNotNull: (value: T | null | undefined) => value is T; 2 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #001080; 3 | --dark-hl-0: #9CDCFE; 4 | --light-hl-1: #000000; 5 | --dark-hl-1: #D4D4D4; 6 | --light-hl-2: #008000; 7 | --dark-hl-2: #6A9955; 8 | --light-hl-3: #AF00DB; 9 | --dark-hl-3: #C586C0; 10 | --light-hl-4: #A31515; 11 | --dark-hl-4: #CE9178; 12 | --light-hl-5: #0000FF; 13 | --dark-hl-5: #569CD6; 14 | --light-hl-6: #0070C1; 15 | --dark-hl-6: #4FC1FF; 16 | --light-hl-7: #795E26; 17 | --dark-hl-7: #DCDCAA; 18 | --light-hl-8: #098658; 19 | --dark-hl-8: #B5CEA8; 20 | --light-hl-9: #800000; 21 | --dark-hl-9: #808080; 22 | --light-hl-10: #800000; 23 | --dark-hl-10: #569CD6; 24 | --light-hl-11: #000000FF; 25 | --dark-hl-11: #D4D4D4; 26 | --light-hl-12: #E50000; 27 | --dark-hl-12: #9CDCFE; 28 | --light-hl-13: #0000FF; 29 | --dark-hl-13: #CE9178; 30 | --light-code-background: #FFFFFF; 31 | --dark-code-background: #1E1E1E; 32 | } 33 | 34 | @media (prefers-color-scheme: light) { :root { 35 | --hl-0: var(--light-hl-0); 36 | --hl-1: var(--light-hl-1); 37 | --hl-2: var(--light-hl-2); 38 | --hl-3: var(--light-hl-3); 39 | --hl-4: var(--light-hl-4); 40 | --hl-5: var(--light-hl-5); 41 | --hl-6: var(--light-hl-6); 42 | --hl-7: var(--light-hl-7); 43 | --hl-8: var(--light-hl-8); 44 | --hl-9: var(--light-hl-9); 45 | --hl-10: var(--light-hl-10); 46 | --hl-11: var(--light-hl-11); 47 | --hl-12: var(--light-hl-12); 48 | --hl-13: var(--light-hl-13); 49 | --code-background: var(--light-code-background); 50 | } } 51 | 52 | @media (prefers-color-scheme: dark) { :root { 53 | --hl-0: var(--dark-hl-0); 54 | --hl-1: var(--dark-hl-1); 55 | --hl-2: var(--dark-hl-2); 56 | --hl-3: var(--dark-hl-3); 57 | --hl-4: var(--dark-hl-4); 58 | --hl-5: var(--dark-hl-5); 59 | --hl-6: var(--dark-hl-6); 60 | --hl-7: var(--dark-hl-7); 61 | --hl-8: var(--dark-hl-8); 62 | --hl-9: var(--dark-hl-9); 63 | --hl-10: var(--dark-hl-10); 64 | --hl-11: var(--dark-hl-11); 65 | --hl-12: var(--dark-hl-12); 66 | --hl-13: var(--dark-hl-13); 67 | --code-background: var(--dark-code-background); 68 | } } 69 | 70 | :root[data-theme='light'] { 71 | --hl-0: var(--light-hl-0); 72 | --hl-1: var(--light-hl-1); 73 | --hl-2: var(--light-hl-2); 74 | --hl-3: var(--light-hl-3); 75 | --hl-4: var(--light-hl-4); 76 | --hl-5: var(--light-hl-5); 77 | --hl-6: var(--light-hl-6); 78 | --hl-7: var(--light-hl-7); 79 | --hl-8: var(--light-hl-8); 80 | --hl-9: var(--light-hl-9); 81 | --hl-10: var(--light-hl-10); 82 | --hl-11: var(--light-hl-11); 83 | --hl-12: var(--light-hl-12); 84 | --hl-13: var(--light-hl-13); 85 | --code-background: var(--light-code-background); 86 | } 87 | 88 | :root[data-theme='dark'] { 89 | --hl-0: var(--dark-hl-0); 90 | --hl-1: var(--dark-hl-1); 91 | --hl-2: var(--dark-hl-2); 92 | --hl-3: var(--dark-hl-3); 93 | --hl-4: var(--dark-hl-4); 94 | --hl-5: var(--dark-hl-5); 95 | --hl-6: var(--dark-hl-6); 96 | --hl-7: var(--dark-hl-7); 97 | --hl-8: var(--dark-hl-8); 98 | --hl-9: var(--dark-hl-9); 99 | --hl-10: var(--dark-hl-10); 100 | --hl-11: var(--dark-hl-11); 101 | --hl-12: var(--dark-hl-12); 102 | --hl-13: var(--dark-hl-13); 103 | --code-background: var(--dark-code-background); 104 | } 105 | 106 | .hl-0 { color: var(--hl-0); } 107 | .hl-1 { color: var(--hl-1); } 108 | .hl-2 { color: var(--hl-2); } 109 | .hl-3 { color: var(--hl-3); } 110 | .hl-4 { color: var(--hl-4); } 111 | .hl-5 { color: var(--hl-5); } 112 | .hl-6 { color: var(--hl-6); } 113 | .hl-7 { color: var(--hl-7); } 114 | .hl-8 { color: var(--hl-8); } 115 | .hl-9 { color: var(--hl-9); } 116 | .hl-10 { color: var(--hl-10); } 117 | .hl-11 { color: var(--hl-11); } 118 | .hl-12 { color: var(--hl-12); } 119 | .hl-13 { color: var(--hl-13); } 120 | pre, code { background: var(--code-background); } 121 | -------------------------------------------------------------------------------- /docs/assets/icons.js: -------------------------------------------------------------------------------- 1 | (function(svg) { 2 | svg.innerHTML = ``; 3 | svg.style.display = 'none'; 4 | if (location.protocol === 'file:') { 5 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', updateUseElements); 6 | else updateUseElements() 7 | function updateUseElements() { 8 | document.querySelectorAll('use').forEach(el => { 9 | if (el.getAttribute('href').includes('#icon-')) { 10 | el.setAttribute('href', el.getAttribute('href').replace(/.*#/, '#')); 11 | } 12 | }); 13 | } 14 | } 15 | })(document.body.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))) -------------------------------------------------------------------------------- /docs/assets/icons.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/navigation.js: -------------------------------------------------------------------------------- 1 | window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAACoXSy07DMBAF0H+ZdSAkvLOlldhR0SViYeJpY+GXbLcQIf4dEQfq1K6znZtzLY/z8gUOPx008MBMyxEK0MR10EDLibVoSz8/75zgUMA7kxSaqr77Lg5SCa0kSpfAf1HeG4lm3RGNdMlRnGiKPsp1LilzysQ1fp6VnDNtE4sYg5x9JJKmdujnOblSvN8qGdMxyNlnbB2R29TB/1HOL5g4SNdrtOWCiamoL+5vq+s62vCTdkxJe+wn4VzTSrHwxX3DMJyTa9eHt/ZyGM5Jihuy48Gpe2IYeeNoyzGaNlyGGI9+rs1OtsNNS5+cVVN8cxXgPcOPFP2dR+z1ByT0uvegAwAA" -------------------------------------------------------------------------------- /docs/assets/search.js: -------------------------------------------------------------------------------- 1 | window.searchData = "data:application/octet-stream;base64,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"; -------------------------------------------------------------------------------- /docs/functions/editor-1.html: -------------------------------------------------------------------------------- 1 | editor | @overlapmedia/imagemapper
  • Editor

    2 |

    Parameters

    • Rest ...params: [svgEl: string | SVGElement, options: Partial<EditorOptions>, style: Partial<{
          component: Style;
          componentHover: {
              off: Style;
              on: Style;
          };
          componentSelect: {
              off: Style;
              on: Style;
          };
          handle: Style;
          handleHover: Style;
      }>]

    Returns Editor

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/functions/view.html: -------------------------------------------------------------------------------- 1 | view | @overlapmedia/imagemapper
  • View

    2 |

    Parameters

    • Rest ...params: [svgEl: string | SVGElement, options: Partial<EditorOptions>, style: Partial<{
          component: Style;
          componentHover: {
              off: Style;
              on: Style;
          };
          componentSelect: {
              off: Style;
              on: Style;
          };
          handle: Style;
          handleHover: Style;
      }>]

    Returns Editor

      3 |
    • an Editor constructor which does not add drawing capabilities
    • 4 |
    5 |

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/hierarchy.html: -------------------------------------------------------------------------------- 1 | @overlapmedia/imagemapper

@overlapmedia/imagemapper

Class Hierarchy

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/media/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/overlapmedia/imagemapper/51c9ebd0cf7da65e790e221ab0bbe6ef1b1a9b76/docs/media/header.png -------------------------------------------------------------------------------- /docs/modules.html: -------------------------------------------------------------------------------- 1 | @overlapmedia/imagemapper

@overlapmedia/imagemapper

Index

Classes

Type Aliases

Dim 10 | EditorOptions 11 | Point 12 | Style 13 |

Variables

default 14 |

Functions

editor 15 | view 16 |

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/types/Dim.html: -------------------------------------------------------------------------------- 1 | Dim | @overlapmedia/imagemapper
Dim: {
    height: number;
    width: number;
    x: number;
    y: number;
}

Type declaration

  • height: number
  • width: number
  • x: number
  • y: number

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/types/EditorOptions.html: -------------------------------------------------------------------------------- 1 | EditorOptions | @overlapmedia/imagemapper
EditorOptions: {
    componentDrawnHandler: ((c, id) => void);
    height: number;
    selectModeHandler: (() => void);
    viewClickHandler: ((e, id) => void);
    width: number;
}

Type declaration

  • componentDrawnHandler: ((c, id) => void)

    function being called when finished drawing a valid component (eg. rectangle with width and height greater than 0 or polygon width at least three points), does not apply to importing

    2 |
      • (c, id): void
      • Parameters

        Returns void

  • height: number

    if you let imagemapper create the SVGElement for you, you could specify height for it here

    3 |
  • selectModeHandler: (() => void)

    function being called when editor switches to select mode when eg. Esc keydown event or mousedown event on handle is causing it to leave draw mode

    4 |
      • (): void
      • Returns void

  • viewClickHandler: ((e, id) => void)

    when using view this function will be called on click events from the shapes

    5 |
      • (e, id): void
      • Parameters

        • e: Event
        • id: string

        Returns void

  • width: number

    if you let imagemapper create the SVGElement for you, you could specify width for it here

    6 |

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/types/Point.html: -------------------------------------------------------------------------------- 1 | Point | @overlapmedia/imagemapper
Point: {
    handle?: Handle;
    x: number;
    y: number;
}

Type declaration

  • Optional handle?: Handle
  • x: number
  • y: number

Generated using TypeDoc

-------------------------------------------------------------------------------- /docs/types/Style.html: -------------------------------------------------------------------------------- 1 | Style | @overlapmedia/imagemapper
Style: Record<string, string>

Generated using TypeDoc

-------------------------------------------------------------------------------- /examples/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 20 | 21 | 22 | 25 | 32 | 39 | 46 | 53 | 54 |
55 | 65 | 70 | 71 | 72 |
73 | 74 | 75 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /examples/node/index.js: -------------------------------------------------------------------------------- 1 | // $ npm run demo 2 | 3 | import imagemapper, { editor, view } from "../../index"; 4 | // import imagemapper, { editor, view } from '@overlapmedia/imagemapper'; 5 | 6 | // Editor 7 | const myEditor = imagemapper.editor("editor", { 8 | width: 800, 9 | height: 400, 10 | selectModeHandler: () => console.log("Editor is now in select mode"), 11 | }); 12 | myEditor.loadImage("demo_assets/image.svg", 700, 350); 13 | myEditor.on("mouseup", e => console.log("mouseup event", e)); 14 | myEditor.polygon(); 15 | 16 | // View 17 | const myView = view("view", { 18 | width: 800, 19 | height: 400, 20 | viewClickHandler: (e, id) => console.log("User clicked on", id), 21 | }); 22 | myView.loadImage("demo_assets/image.png", 700, 350); 23 | myView.import( 24 | '{"idCounter":4,"components":[{"id":"rect_1","type":"rect","data":{"x":66,"y":36,"width":253,"height":148}},{"id":"polygon_2","type":"polygon","data":[{"x":376,"y":172},{"x":498,"y":291},{"x":625,"y":174},{"x":500,"y":57}]},{"id":"polygon_3","type":"polygon","data":[{"x":54,"y":249},{"x":234,"y":246},{"x":236,"y":225},{"x":415,"y":270},{"x":237,"y":313},{"x":235,"y":294},{"x":54,"y":292}]}]}' 25 | ); 26 | -------------------------------------------------------------------------------- /examples/react/ImageMapperEditor.js: -------------------------------------------------------------------------------- 1 | import { editor, view } from '@overlapmedia/imagemapper'; 2 | import React from 'react'; 3 | 4 | function ImageMapperEditor({ options = {}, style = {}, image = '', mode }) { 5 | const [width = 1200, height = 600] = [options.width, options.height]; 6 | 7 | const editorElementRef = React.useRef(null); 8 | const editorRef = React.useRef(null); 9 | 10 | React.useEffect(() => { 11 | // Only listening to property "mode" in useEffect. Other props are initial. 12 | if (!editorRef.current) { 13 | const editorInstance = editor(editorElementRef.current, options, style); 14 | 15 | // load image from image prop 16 | if (image && image.length) { 17 | editorInstance.loadImage(image, width, height); 18 | } 19 | 20 | // keep ref to the imagemapper editor to allow prop "mode" to change 21 | editorRef.current = editorInstance; 22 | } 23 | }, [options, style, height, width, image]); 24 | 25 | // Listening to property "mode" 26 | React.useEffect(() => { 27 | if (mode) { 28 | switch (mode) { 29 | case Mode.RECT: 30 | editorRef.current.rect(); 31 | break; 32 | case Mode.CIRCLE: 33 | editorRef.current.circle(); 34 | break; 35 | case Mode.ELLIPSE: 36 | editorRef.current.ellipse(); 37 | break; 38 | case Mode.POLYGON: 39 | editorRef.current.polygon(); 40 | break; 41 | case Mode.SELECT: 42 | editorRef.current.selectMode(); 43 | break; 44 | default: 45 | } 46 | } 47 | }, [mode]); 48 | 49 | return ( 50 | 59 | ); 60 | } 61 | 62 | export const Mode = Object.freeze({ 63 | RECT: 'rect', 64 | CIRCLE: 'circle', 65 | ELLIPSE: 'ellipse', 66 | POLYGON: 'polygon', 67 | SELECT: 'selectMode', 68 | }); 69 | 70 | export default ImageMapperEditor; 71 | -------------------------------------------------------------------------------- /examples/react/ImageMapperEditorCB.js: -------------------------------------------------------------------------------- 1 | import { editor, view } from '@overlapmedia/imagemapper'; 2 | import React from 'react'; 3 | 4 | function ImageMapperEditorCB({ options = {}, style = {}, cb }) { 5 | const elementRef = React.useRef(null); 6 | const editorRef = React.useRef(null); 7 | 8 | React.useEffect(() => { 9 | if (!editorRef.current) { 10 | const editorInstance = editor(elementRef.current, options, style); 11 | editorRef.current = editorInstance; 12 | cb && cb(editorInstance); 13 | } 14 | }, [options, style, cb]); 15 | 16 | const [width = 1200, height = 600] = [options.width, options.height]; 17 | 18 | return ( 19 | 28 | ); 29 | } 30 | 31 | export default ImageMapperEditorCB; 32 | -------------------------------------------------------------------------------- /examples/react/examples.js: -------------------------------------------------------------------------------- 1 | import imagemapper, { editor, view } from '@overlapmedia/imagemapper'; 2 | import React from 'react'; 3 | import ImageMapperEditor, { Mode } from './ImageMapperEditor'; 4 | import ImageMapperEditorCB from './ImageMapperEditorCB'; 5 | 6 | /** 7 | * Example using component with limited functionality 8 | */ 9 | export const ImageMapperEditorExample1 = () => { 10 | return ; 11 | }; 12 | 13 | /** 14 | * Example using component where code are put into a callback function 15 | */ 16 | export const ImageMapperEditorExample2 = () => { 17 | return ( 18 | { 20 | editor.loadImage('image.svg', 700, 350); 21 | editor.on('mouseup', (e) => console.log('mouseup event', e)); 22 | editor.polygon(); 23 | }} 24 | options={{ 25 | selectModeHandler: () => console.log('Editor is now in select mode'), 26 | componentDrawnHandler: (shape, id) => 27 | console.log( 28 | `${shape.element.tagName} with id ${id} is drawn. Call its freeze() function to disable selecting, deleting, resizing and moving.`, 29 | ), 30 | }} 31 | /> 32 | ); 33 | }; 34 | 35 | /** 36 | * Example where code are put into an effect and component is bound by ref 37 | */ 38 | export const ImageMapperEditorExample3 = () => { 39 | const elementRef = React.useRef(null); 40 | 41 | React.useEffect(() => { 42 | // --- imagemapper code [start] --- 43 | const myEditor = editor(elementRef.current, { 44 | selectModeHandler: () => console.log('Editor is now in select mode'), 45 | }); 46 | myEditor.loadImage('image.svg', 700, 350); 47 | myEditor.on('mouseup', (e) => console.log('mouseup event', e)); 48 | myEditor.polygon(); 49 | // --- imagemapper code [end] --- 50 | }, []); 51 | 52 | return ( 53 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /examples/react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 21 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import editorFactory from "./src/editor"; 2 | 3 | /** 4 | * Editor 5 | * @returns {Editor} 6 | */ 7 | export const editor = editorFactory(); 8 | 9 | /** 10 | * View 11 | * @returns {Editor} - an Editor constructor which does not add drawing capabilities 12 | */ 13 | export const view = editorFactory(true); 14 | 15 | /** 16 | * @example 17 | * ```js 18 | * import imagemapper from '@overlapmedia/imagemapper'; 19 | * const editor = imagemapper.editor('editor-id'); 20 | * editor.polygon(); 21 | * ``` 22 | * 23 | * @example 24 | * ```js 25 | * import { editor, view } from '@overlapmedia/imagemapper'; 26 | * const myEditor = editor('editor-id'); 27 | * myEditor.polygon(); 28 | * ``` 29 | */ 30 | export default { 31 | editor, 32 | view, 33 | }; 34 | 35 | export { Editor, type EditorOptions } from "./src/editor"; 36 | export { Component } from "./src/component"; 37 | export { CornerShapedElement, type Dim } from "./src/factory"; 38 | export { Rectangle } from "./src/rect"; 39 | export { Circle } from "./src/circle"; 40 | export { Ellipse } from "./src/ellipse"; 41 | export { Polygon, type Point } from "./src/polygon"; 42 | export type { Style } from "./src/style"; 43 | export type { Handle } from "./src/handle"; 44 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "jsdom", 5 | }; 6 | -------------------------------------------------------------------------------- /media/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/overlapmedia/imagemapper/51c9ebd0cf7da65e790e221ab0bbe6ef1b1a9b76/media/header.png -------------------------------------------------------------------------------- /media/header.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@overlapmedia/imagemapper", 3 | "version": "2.0.6", 4 | "description": "Adds SVG drawing capability (rectangles, circles, ellipses and polygons) on top of your image to let you make image maps", 5 | "homepage": "https://overlapmedia.github.io/imagemapper/docs/", 6 | "license": "MIT", 7 | "repository": "overlapmedia/imagemapper", 8 | "author": "Arve Waltin", 9 | "type": "module", 10 | "files": [ 11 | "dist" 12 | ], 13 | "exports": { 14 | ".": { 15 | "import": "./dist/imagemapper.es.js", 16 | "require": "./dist/imagemapper.umd.js", 17 | "default": "./dist/imagemapper.umd.js" 18 | } 19 | }, 20 | "types": "./dist/index.d.ts", 21 | "publishConfig": { 22 | "access": "public" 23 | }, 24 | "scripts": { 25 | "build": "tsc & npm run test && npm run typedoc && vite build && tsc -p tsconfig.build.json", 26 | "build:dev": "vite build --mode development", 27 | "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", 28 | "demo": "vite", 29 | "typedoc": "typedoc index.ts && node copyMediaToDocs.cjs" 30 | }, 31 | "dependencies": { 32 | "ts-deepmerge": "^6.2.0", 33 | "xstate": "^5.3.0" 34 | }, 35 | "devDependencies": { 36 | "@types/jest": "^29.5.11", 37 | "cross-env": "^7.0.3", 38 | "jest": "^29.7.0", 39 | "jest-environment-jsdom": "^29.7.0", 40 | "ts-jest": "^29.1.1", 41 | "ts-node": "^10.9.2", 42 | "typedoc": "^0.25.9", 43 | "typescript": "^5.3.3", 44 | "vite": "^5.0.10" 45 | }, 46 | "keywords": [ 47 | "image map", 48 | "image mapper", 49 | "image annotation", 50 | "link image area", 51 | "design collaboration", 52 | "react" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /release.txt: -------------------------------------------------------------------------------- 1 | On main: 2 | 1) search replace version (not package.json) 3 | 2) $ npm run build 4 | 3) $ git commit "Build " 5 | 4) $ npm login (from GIT bash) 6 | 5) ($ npm install --global np) 7 | 6) $ np -------------------------------------------------------------------------------- /src/circle.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor"; 2 | import { CornerShapedElement } from "./factory"; 3 | 4 | export class Circle extends CornerShapedElement { 5 | constructor(editorOwner: Editor, x: number, y: number, width = 0, height = 0) { 6 | super( 7 | "circle", 8 | { 9 | // move 10 | x: (element, x, _prevX, dim) => { 11 | element.setAttribute("cx", String(x + dim.width / 2)); 12 | }, 13 | // move 14 | y: (element, y, _prevY, dim) => { 15 | element.setAttribute("cy", String(y + dim.height / 2)); 16 | }, 17 | // resize 18 | width: (element, width, _prevWidth, dim) => { 19 | element.setAttribute( 20 | "r", 21 | String(Math.min(Math.abs(width), Math.abs(dim.height)) / 2) 22 | ); 23 | element.setAttribute("cx", String(dim.x + width / 2)); 24 | }, 25 | // resize 26 | height: (element, height, _prevHeight, dim) => { 27 | element.setAttribute( 28 | "r", 29 | String(Math.min(Math.abs(height), Math.abs(dim.width)) / 2) 30 | ); 31 | element.setAttribute("cy", String(dim.y + height / 2)); 32 | }, 33 | }, 34 | editorOwner, 35 | x, 36 | y, 37 | width, 38 | height 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/component.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor"; 2 | import { Handle } from "./handle"; 3 | import { getDefaultStyle } from "./style"; 4 | 5 | export abstract class Component { 6 | style: ReturnType = getDefaultStyle(); 7 | isSelected = false; 8 | isFrozen = false; 9 | 10 | constructor(readonly editorOwner: Editor, readonly element: SVGElement) {} 11 | 12 | abstract freeze(freeze?: boolean): Component; 13 | 14 | abstract isValid(): boolean; 15 | 16 | abstract move(deltaX: number, deltaY: number): Component; 17 | 18 | abstract setHandlesVisibility(visible?: boolean): Component; 19 | 20 | abstract setIsSelected(selected?: boolean): Component; 21 | 22 | abstract getHandles(): Handle[]; 23 | 24 | abstract setStyle(style: ReturnType): Component; 25 | 26 | abstract export(): object; 27 | 28 | _logWarnOnOpOnFrozen(op: string) { 29 | if (this.isFrozen) { 30 | console.warn(`${op} frozen ${this.element.tagName} with id ${this.element.id}`); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | const SVG_NS = 'http://www.w3.org/2000/svg'; 2 | const XLINK_NS = 'http://www.w3.org/1999/xlink'; 3 | 4 | export { SVG_NS, XLINK_NS }; 5 | -------------------------------------------------------------------------------- /src/ellipse.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor"; 2 | import { CornerShapedElement } from "./factory"; 3 | 4 | export class Ellipse extends CornerShapedElement { 5 | constructor(editorOwner: Editor, x: number, y: number, width = 0, height = 0) { 6 | super( 7 | "ellipse", 8 | { 9 | // move 10 | x: (element, x, _prevX, dim) => { 11 | element.setAttribute("cx", String(x + dim.width / 2)); 12 | }, 13 | // move 14 | y: (element, y, _prevY, dim) => { 15 | element.setAttribute("cy", String(y + dim.height / 2)); 16 | }, 17 | // resize 18 | width: (element, width, _prevWidth, dim) => { 19 | element.setAttribute("rx", String(Math.abs(width) / 2)); 20 | element.setAttribute("cx", String(dim.x + width / 2)); 21 | }, 22 | // resize 23 | height: (element, height, _prevHeight, dim) => { 24 | element.setAttribute("ry", String(Math.abs(height) / 2)); 25 | element.setAttribute("cy", String(dim.y + height / 2)); 26 | }, 27 | }, 28 | editorOwner, 29 | x, 30 | y, 31 | width, 32 | height 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | const addEventListeners = function ( 2 | targets: Element[] | Element | (Window & typeof globalThis), 3 | eventTypes: string, 4 | handler: EventListenerOrEventListenerObject 5 | ) { 6 | [targets].flat().forEach(target => { 7 | eventTypes.split(" ").forEach(eventType => { 8 | target.addEventListener(eventType, handler, { 9 | passive: false, 10 | }); 11 | }); 12 | }); 13 | }; 14 | 15 | const removeEventListeners = function ( 16 | targets: Element[] | Element | (Window & typeof globalThis), 17 | eventTypes: string, 18 | handler: EventListenerOrEventListenerObject 19 | ) { 20 | [targets].flat().forEach(target => { 21 | eventTypes.split(" ").forEach(eventType => { 22 | target.removeEventListener(eventType, handler); 23 | }); 24 | }); 25 | }; 26 | 27 | export { addEventListeners, removeEventListeners }; 28 | -------------------------------------------------------------------------------- /src/factory.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "./component"; 2 | import { SVG_NS } from "./constants"; 3 | import { Editor } from "./editor"; 4 | import { doc } from "./globals"; 5 | import { Handle } from "./handle"; 6 | import { onChange } from "./onChangeProxy"; 7 | import { addHover, getDefaultStyle, setStyle } from "./style"; 8 | 9 | export type Dim = { x: number; y: number; width: number; height: number }; 10 | 11 | export abstract class CornerShapedElement extends Component { 12 | dim: Dim; 13 | handles: Handle[]; 14 | 15 | constructor( 16 | svgElementName: keyof SVGElementTagNameMap, 17 | propChangeListener: { 18 | x: (element: SVGElement, x: number, prevX: number, dim: Dim) => void; 19 | y: (element: SVGElement, y: number, prevY: number, dim: Dim) => void; 20 | width: (element: SVGElement, width: number, prevWidth: number, dim: Dim) => void; 21 | height: (element: SVGElement, height: number, prevHeight: number, dim: Dim) => void; 22 | }, 23 | editorOwner: Editor, 24 | x: number, 25 | y: number, 26 | width = 0, 27 | height = 0 28 | ) { 29 | super(editorOwner, doc.createElementNS(SVG_NS, svgElementName)); 30 | this.dim = onChange( 31 | { x, y, width: 0, height: 0 }, 32 | { 33 | /* 34 | this.handles[] 35 | index location: 36 | 37 | 0_______2 38 | | | 39 | |_____| 40 | 1 3 41 | */ 42 | // move 43 | x: (x, prevX, dim) => { 44 | this._logWarnOnOpOnFrozen("Dimension property x changed on"); 45 | 46 | propChangeListener.x.call(this, this.element, x, prevX, dim); 47 | this.handles[0].setAttrX(x); 48 | this.handles[1].setAttrX(x); 49 | this.handles[2].setAttrX(x + dim.width); 50 | this.handles[3].setAttrX(x + dim.width); 51 | }, 52 | // move 53 | y: (y, prevY, dim) => { 54 | this._logWarnOnOpOnFrozen("Dimension property y changed on"); 55 | 56 | propChangeListener.y.call(this, this.element, y, prevY, dim); 57 | this.handles[0].setAttrY(y); 58 | this.handles[1].setAttrY(y + dim.height); 59 | this.handles[2].setAttrY(y); 60 | this.handles[3].setAttrY(y + dim.height); 61 | }, 62 | // resize 63 | width: (width, prevWidth, dim) => { 64 | this._logWarnOnOpOnFrozen("Dimension property width changed on"); 65 | 66 | propChangeListener.width.call(this, this.element, width, prevWidth, dim); 67 | this.handles[2].setAttrX(dim.x + width); 68 | this.handles[3].setAttrX(dim.x + width); 69 | }, 70 | // resize 71 | height: (height, prevHeight, dim) => { 72 | this._logWarnOnOpOnFrozen("Dimension property height changed on"); 73 | 74 | propChangeListener.height.call(this, this.element, height, prevHeight, dim); 75 | this.handles[1].setAttrY(dim.y + height); 76 | this.handles[3].setAttrY(dim.y + height); 77 | }, 78 | }, 79 | this 80 | ); 81 | 82 | this.handles = [ 83 | new Handle( 84 | x, 85 | y, 86 | (deltaX, deltaY) => { 87 | this.dim.x += deltaX; 88 | this.dim.width -= deltaX; 89 | 90 | this.dim.y += deltaY; 91 | this.dim.height -= deltaY; 92 | }, 93 | this.isFrozen 94 | ), 95 | new Handle( 96 | x, 97 | y, 98 | (deltaX, deltaY) => { 99 | this.dim.x += deltaX; 100 | this.dim.width -= deltaX; 101 | 102 | this.dim.height += deltaY; 103 | }, 104 | this.isFrozen 105 | ), 106 | new Handle( 107 | x, 108 | y, 109 | (deltaX, deltaY) => { 110 | this.dim.width += deltaX; 111 | 112 | this.dim.y += deltaY; 113 | this.dim.height -= deltaY; 114 | }, 115 | this.isFrozen 116 | ), 117 | new Handle( 118 | x, 119 | y, 120 | (deltaX, deltaY) => { 121 | this.dim.width += deltaX; 122 | this.dim.height += deltaY; 123 | }, 124 | this.isFrozen 125 | ), 126 | ]; 127 | this.handles.forEach(h => { 128 | this.editorOwner.registerComponentHandle(h); 129 | }); 130 | 131 | // we want to resize when importing shape data too 132 | [this.dim.width, this.dim.height] = [width, height]; 133 | } 134 | 135 | override freeze(freeze?: boolean) { 136 | this.isFrozen = freeze != null ? !!freeze : true; 137 | this.handles.forEach(handle => handle.freeze(freeze)); 138 | return this; 139 | } 140 | 141 | resize(x: number, y: number) { 142 | this.dim.width = x - this.dim.x; 143 | this.dim.height = y - this.dim.y; 144 | return this; 145 | } 146 | 147 | override move(deltaX: number, deltaY: number) { 148 | this.dim.x += deltaX; 149 | this.dim.y += deltaY; 150 | return this; 151 | } 152 | 153 | override isValid() { 154 | return this.dim.width !== 0 && this.dim.height !== 0; 155 | } 156 | 157 | override setHandlesVisibility(visible?: boolean) { 158 | this.handles.forEach(handle => handle.setVisible(visible)); 159 | return this; 160 | } 161 | 162 | override setIsSelected(selected?: boolean) { 163 | this._logWarnOnOpOnFrozen("Select/unselect performed on"); 164 | 165 | this.isSelected = selected = selected != null ? !!selected : true; 166 | this.setHandlesVisibility(selected); 167 | this.style && 168 | setStyle( 169 | this.element, 170 | selected ? this.style.componentSelect.on : this.style.componentSelect.off 171 | ); 172 | return this; 173 | } 174 | 175 | override getHandles() { 176 | return this.handles; 177 | } 178 | 179 | override setStyle(style: ReturnType) { 180 | this.style = style; 181 | setStyle(this.element, style.component); 182 | setStyle(this.element, style.componentHover.off); 183 | setStyle(this.element, style.componentSelect.off); 184 | 185 | addHover(this.element, style.componentHover.off, style.componentHover.on); 186 | return this; 187 | } 188 | 189 | override export() { 190 | const { x, y, width, height } = this.dim; 191 | return { x, y, width, height }; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/fsm.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "./component"; 2 | import { Editor } from "./editor"; 3 | import { CornerShapedElement } from "./factory"; 4 | import { Handle } from "./handle"; 5 | import { createMachine, assign, createActor, enqueueActions } from "xstate"; 6 | import { Polygon } from "./polygon"; 7 | 8 | /* 9 | Machine 10 | ------- 11 | States: 12 | idle (no shapes being drawn) 13 | selectMode (shapes could be selected) 14 | mouseIsDown 15 | mouseIsUp 16 | drawMode (a shape type is chosen, but not started drawing) 17 | rect 18 | circle 19 | ellipse 20 | polygon 21 | drawing (shape started, but not finished) 22 | rect 23 | mouseIsDown 24 | circle 25 | mouseIsDown 26 | ellipse 27 | mouseIsDown 28 | polygon 29 | mouseIsDown 30 | mouseIsUp 31 | 32 | Events: 33 | Mouse and keyboard: 34 | MT_DOWN 35 | MT_UP 36 | MT_MOVE 37 | KEYDOWN_ESC 38 | KEYDOWN_DEL 39 | KEYDOWN_ARROW 40 | API: 41 | MODE_SELECT 42 | MODE_DRAW_RECT 43 | MODE_DRAW_CIRCLE 44 | MODE_DRAW_ELLIPSE 45 | MODE_DRAW_POLYGON 46 | 47 | */ 48 | 49 | type Context = { 50 | unfinishedComponent?: Component; 51 | mouseDownInSelectModeObject?: Component | Handle; 52 | _editor: Editor; 53 | }; 54 | 55 | type MT_DOWN_Event = { 56 | type: "MT_DOWN"; 57 | component?: Component | Handle; 58 | offsetX: number; 59 | offsetY: number; 60 | }; 61 | 62 | type MT_MOVE_Event = { 63 | type: "MT_MOVE"; 64 | offsetX: number; 65 | offsetY: number; 66 | movementX: number; 67 | movementY: number; 68 | }; 69 | 70 | type KEYDOWN_ARROW_Event = { 71 | type: "KEYDOWN_ARROW"; 72 | movementX: number; 73 | movementY: number; 74 | }; 75 | 76 | export type ActorEvent = 77 | | MT_DOWN_Event 78 | | MT_MOVE_Event 79 | | KEYDOWN_ARROW_Event 80 | | { 81 | type: 82 | | "MT_UP" 83 | | "KEYDOWN_ESC" 84 | | "KEYDOWN_DEL" 85 | | "MODE_SELECT" 86 | | "MODE_DRAW_RECT" 87 | | "MODE_DRAW_CIRCLE" 88 | | "MODE_DRAW_ELLIPSE" 89 | | "MODE_DRAW_POLYGON" 90 | | "mouseDownInSelectModeUnassign"; 91 | }; 92 | 93 | const idleDrawModeStates = { 94 | rect: { 95 | on: { 96 | MT_DOWN: { 97 | actions: ["createRectangle", "selectUnfinished"], 98 | target: "#drawing.rect.mouseIsDown", 99 | }, 100 | }, 101 | }, 102 | circle: { 103 | on: { 104 | MT_DOWN: { 105 | actions: ["createCircle", "selectUnfinished"], 106 | target: "#drawing.circle.mouseIsDown", 107 | }, 108 | }, 109 | }, 110 | ellipse: { 111 | on: { 112 | MT_DOWN: { 113 | actions: ["createEllipse", "selectUnfinished"], 114 | target: "#drawing.ellipse.mouseIsDown", 115 | }, 116 | }, 117 | }, 118 | polygon: { 119 | on: { 120 | MT_DOWN: { 121 | actions: ["createPolygon", "selectUnfinished"], 122 | target: "#drawing.polygon.mouseIsDown", 123 | }, 124 | }, 125 | }, 126 | }; 127 | 128 | const drawingSpecificComponentStates = { 129 | rect: { 130 | initial: "mouseIsUp", 131 | states: { 132 | mouseIsDown: { 133 | on: { 134 | MT_UP: "#idle.drawMode.rect", // consider selection if mouse has not moved 135 | KEYDOWN_ESC: "#idle.drawMode.rect", 136 | MT_MOVE: { 137 | actions: "resizeUnfinished", 138 | }, 139 | }, 140 | }, 141 | mouseIsUp: {}, 142 | }, 143 | }, 144 | circle: { 145 | initial: "mouseIsUp", 146 | states: { 147 | mouseIsDown: { 148 | on: { 149 | MT_UP: "#idle.drawMode.circle", // consider selection if mouse has not moved 150 | KEYDOWN_ESC: "#idle.drawMode.circle", 151 | MT_MOVE: { 152 | actions: "resizeUnfinished", 153 | }, 154 | }, 155 | }, 156 | mouseIsUp: {}, 157 | }, 158 | }, 159 | ellipse: { 160 | initial: "mouseIsUp", 161 | states: { 162 | mouseIsDown: { 163 | on: { 164 | MT_UP: "#idle.drawMode.ellipse", // consider selection if mouse has not moved 165 | KEYDOWN_ESC: "#idle.drawMode.ellipse", 166 | MT_MOVE: { 167 | actions: "resizeUnfinished", 168 | }, 169 | }, 170 | }, 171 | mouseIsUp: {}, 172 | }, 173 | }, 174 | polygon: { 175 | on: { 176 | KEYDOWN_ESC: "#idle.drawMode.polygon", 177 | }, 178 | initial: "mouseIsUp", 179 | states: { 180 | mouseIsDown: { 181 | on: { 182 | MT_UP: "mouseIsUp", 183 | MT_MOVE: { 184 | actions: "moveLastPoint", 185 | }, 186 | }, 187 | }, 188 | mouseIsUp: { 189 | on: { 190 | MT_DOWN: [ 191 | { 192 | guard: "isHandle", 193 | target: "#idle.drawMode.polygon", 194 | }, 195 | { 196 | // else 197 | actions: "addPoint", 198 | target: "mouseIsDown", 199 | }, 200 | ], 201 | }, 202 | }, 203 | }, 204 | }, 205 | }; 206 | 207 | const createFSM = (editor: Editor) => { 208 | return createMachine( 209 | { 210 | context: { 211 | unfinishedComponent: undefined, 212 | mouseDownInSelectModeObject: undefined, 213 | _editor: editor, 214 | } as Context, 215 | on: { 216 | MODE_SELECT: ".idle.selectMode", 217 | MODE_DRAW_RECT: ".idle.drawMode.rect", 218 | MODE_DRAW_CIRCLE: ".idle.drawMode.circle", 219 | MODE_DRAW_ELLIPSE: ".idle.drawMode.ellipse", 220 | MODE_DRAW_POLYGON: ".idle.drawMode.polygon", 221 | }, 222 | initial: "idle", 223 | states: { 224 | idle: { 225 | id: "idle", 226 | initial: "selectMode", 227 | states: { 228 | selectMode: { 229 | initial: "mouseIsUp", 230 | states: { 231 | mouseIsUp: { 232 | on: { 233 | MT_DOWN: { 234 | actions: [ 235 | "selectComponent", 236 | "mouseDownInSelectModeAssign", 237 | ], 238 | target: "mouseIsDown", 239 | }, 240 | KEYDOWN_ARROW: { 241 | actions: "mouseDownInSelectModeObjectMove", 242 | }, 243 | }, 244 | }, 245 | mouseIsDown: { 246 | on: { 247 | MT_UP: "mouseIsUp", 248 | MT_MOVE: { 249 | actions: "mouseDownInSelectModeObjectMove", 250 | }, 251 | }, 252 | }, 253 | }, 254 | on: { 255 | KEYDOWN_ESC: { 256 | actions: ["unselectAll", "mouseDownInSelectModeUnassign"], 257 | }, 258 | KEYDOWN_DEL: { 259 | actions: ["deleteComponent", "mouseDownInSelectModeUnassign"], 260 | }, 261 | mouseDownInSelectModeUnassign: { 262 | actions: "mouseDownInSelectModeUnassign", 263 | }, 264 | }, 265 | entry: "selectModeEntry", 266 | exit: ["unselectAll", "mouseDownInSelectModeUnassign"], 267 | }, 268 | drawMode: { 269 | initial: "polygon", 270 | states: idleDrawModeStates, 271 | on: { 272 | KEYDOWN_ESC: "#idle.selectMode", 273 | }, 274 | }, 275 | }, 276 | }, 277 | drawing: { 278 | id: "drawing", 279 | initial: "polygon", 280 | states: drawingSpecificComponentStates, 281 | exit: enqueueActions(({ enqueue, check }) => { 282 | if (check("unfinishedIsValid")) { 283 | enqueue("unselectAll"); 284 | enqueue("validComponentFinished"); 285 | } else { 286 | enqueue("discardUnfinished"); 287 | } 288 | }), 289 | }, 290 | }, 291 | }, 292 | { 293 | actions: { 294 | createRectangle: assign({ 295 | unfinishedComponent: a => 296 | a.context._editor.createRectangle({ 297 | x: a.event.offsetX, 298 | y: a.event.offsetY, 299 | width: 0, 300 | height: 0, 301 | }), 302 | }), 303 | createCircle: assign({ 304 | unfinishedComponent: a => 305 | a.context._editor.createCircle({ 306 | x: a.event.offsetX, 307 | y: a.event.offsetY, 308 | width: 0, 309 | height: 0, 310 | }), 311 | }), 312 | createEllipse: assign({ 313 | unfinishedComponent: a => 314 | a.context._editor.createEllipse({ 315 | x: a.event.offsetX, 316 | y: a.event.offsetY, 317 | width: 0, 318 | height: 0, 319 | }), 320 | }), 321 | createPolygon: assign({ 322 | unfinishedComponent: a => 323 | a.context._editor.createPolygon({ 324 | x: a.event.offsetX, 325 | y: a.event.offsetY, 326 | }), 327 | }), 328 | discardUnfinished: a => { 329 | if (a.context.unfinishedComponent) { 330 | a.context._editor.unregisterComponent(a.context.unfinishedComponent); 331 | } 332 | }, 333 | resizeUnfinished: a => { 334 | if (a.context.unfinishedComponent instanceof CornerShapedElement) { 335 | a.context.unfinishedComponent.resize(a.event.offsetX, a.event.offsetY); 336 | } 337 | }, 338 | selectUnfinished: a => { 339 | a.context._editor.selectComponent(a.context.unfinishedComponent); 340 | }, 341 | validComponentFinished: a => { 342 | const c = a.context.unfinishedComponent; 343 | if (c) { 344 | a.context._editor.componentDrawnHandler && 345 | a.context._editor.componentDrawnHandler(c, c.element.id); 346 | } 347 | }, 348 | // polygons only 349 | addPoint: a => { 350 | if (a.context.unfinishedComponent instanceof Polygon) { 351 | a.context.unfinishedComponent.addPoint(a.event.offsetX, a.event.offsetY); 352 | } 353 | 354 | a.context._editor.selectComponent(a.context.unfinishedComponent); // send('selectUnfinished'); ? 355 | }, 356 | // polygons only 357 | moveLastPoint: a => { 358 | if (a.context.unfinishedComponent instanceof Polygon) { 359 | a.context.unfinishedComponent.moveLastPoint( 360 | a.event.offsetX, 361 | a.event.offsetY 362 | ); 363 | } 364 | }, 365 | mouseDownInSelectModeAssign: assign({ 366 | mouseDownInSelectModeObject: a => a.event.component, 367 | }), 368 | mouseDownInSelectModeUnassign: assign({ 369 | mouseDownInSelectModeObject: undefined, 370 | }), 371 | mouseDownInSelectModeObjectMove: a => { 372 | const mouseDownObj = a.context.mouseDownInSelectModeObject; 373 | mouseDownObj && 374 | mouseDownObj.move && 375 | mouseDownObj.move(a.event.movementX, a.event.movementY); 376 | }, 377 | selectComponent: a => { 378 | // When a.event.component is undefined, this operation works as unselectAll. 379 | a.context._editor.selectComponent(a.event.component); 380 | }, 381 | deleteComponent: a => { 382 | const mouseDownObj = a.context.mouseDownInSelectModeObject; 383 | mouseDownObj && a.context._editor.unregisterComponent(mouseDownObj); 384 | }, 385 | unselectAll: a => { 386 | a.context._editor.selectComponent(undefined); 387 | }, 388 | selectModeEntry: a => { 389 | a.context._editor.selectModeHandler && a.context._editor.selectModeHandler(); 390 | }, 391 | }, 392 | guards: { 393 | isHandle: a => a.event.component instanceof Handle, 394 | unfinishedIsValid: a => !!a.context.unfinishedComponent?.isValid(), 395 | }, 396 | } 397 | ); 398 | }; 399 | 400 | const createFSMService = (editor: Editor) => createActor(createFSM(editor)); 401 | export default createFSMService; 402 | -------------------------------------------------------------------------------- /src/globals.ts: -------------------------------------------------------------------------------- 1 | let root: (Window & typeof globalThis) | typeof globalThis; 2 | let doc: Document; 3 | 4 | if (window != null) { 5 | root = window; 6 | doc = root.document; 7 | } else { 8 | root = globalThis; 9 | doc = root.document; 10 | } 11 | 12 | export { root, doc }; 13 | -------------------------------------------------------------------------------- /src/handle.ts: -------------------------------------------------------------------------------- 1 | import { SVG_NS } from "./constants"; 2 | import { doc } from "./globals"; 3 | import { Style, addHover, setStyle } from "./style"; 4 | 5 | export class Handle { 6 | moveHandler: (deltaX: number, deltaY: number) => void; 7 | element: SVGCircleElement; 8 | isFrozen: boolean; 9 | 10 | constructor( 11 | x: number, 12 | y: number, 13 | moveHandler: (deltaX: number, deltaY: number) => void, 14 | frozen?: boolean 15 | ) { 16 | this.moveHandler = moveHandler; 17 | 18 | this.element = doc.createElementNS<"circle">(SVG_NS, "circle"); 19 | this.element.setAttribute("cx", String(x)); 20 | this.element.setAttribute("cy", String(y)); 21 | this.element.setAttribute("r", "5"); 22 | this.element.setAttribute("visibility", "hidden"); 23 | 24 | this.isFrozen = frozen != null ? !!frozen : false; 25 | } 26 | 27 | freeze(freeze?: boolean) { 28 | this.isFrozen = freeze != null ? !!freeze : true; 29 | this.isFrozen && this.setVisible(false); 30 | return this; 31 | } 32 | 33 | setAttrX(value: number) { 34 | this.element.setAttribute("cx", String(value)); 35 | return this; 36 | } 37 | 38 | setAttrY(value: number) { 39 | this.element.setAttribute("cy", String(value)); 40 | return this; 41 | } 42 | 43 | move(deltaX: number, deltaY: number) { 44 | this.moveHandler(deltaX, deltaY); 45 | return this; 46 | } 47 | 48 | setVisible(visible?: boolean) { 49 | visible = visible != null ? !!visible : true; 50 | this.element.setAttribute("visibility", visible ? "visible" : "hidden"); 51 | return this; 52 | } 53 | 54 | setStyle(style: Style, hoverStyle: Style) { 55 | setStyle(this.element, style); 56 | addHover(this.element, style, hoverStyle); 57 | return this; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/onChangeProxy.ts: -------------------------------------------------------------------------------- 1 | const onChange = ( 2 | object: T, 3 | onChange: 4 | | ((propName: string, newValue: any, previousValue: any, updatedObject: T) => void) 5 | | Record void>, 6 | thisArg?: {} 7 | ) => { 8 | const handler = { 9 | defineProperty( 10 | target: Record, 11 | property: string, 12 | descriptor: PropertyDescriptor & ThisType 13 | ) { 14 | if (!Object.is(descriptor.value, target[property])) { 15 | if (typeof onChange === "function") { 16 | // propName, newValue, previousValue, updatedObject 17 | onChange.call( 18 | thisArg || this, 19 | property, 20 | descriptor.value, 21 | target[property], 22 | object 23 | ); 24 | } else { 25 | // Separate listener for each property 26 | // newValue, previousValue, updatedObject 27 | onChange[property].call( 28 | thisArg || this, 29 | descriptor.value, 30 | target[property], 31 | object 32 | ); 33 | } 34 | } 35 | return Reflect.defineProperty(target, property, descriptor); 36 | }, 37 | }; 38 | 39 | return new Proxy(object, handler); 40 | }; 41 | 42 | export { onChange }; 43 | -------------------------------------------------------------------------------- /src/polygon.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "./component"; 2 | import { SVG_NS } from "./constants"; 3 | import { Editor } from "./editor"; 4 | import { doc } from "./globals"; 5 | import { Handle } from "./handle"; 6 | import { onChange } from "./onChangeProxy"; 7 | import { addHover, getDefaultStyle, setStyle } from "./style"; 8 | import { isNotNull } from "./utils"; 9 | 10 | export type Point = { 11 | x: number; 12 | y: number; 13 | handle?: Handle; 14 | }; 15 | export class Polygon extends Component { 16 | points: Point[] = []; // proxied points 17 | 18 | constructor(editorOwner: Editor, points?: Point[] | Point) { 19 | super(editorOwner, doc.createElementNS(SVG_NS, "polygon")); 20 | points && [points].flat().forEach(p => this.addPoint(p.x, p.y)); 21 | } 22 | 23 | updateElementPoints() { 24 | this.element.setAttribute("points", this.points.map(p => `${p.x},${p.y}`).join(" ")); 25 | return this; 26 | } 27 | 28 | addPoint(x: number, y: number) { 29 | const point: Point = { x, y }; 30 | const pointProxy = onChange(point, (prop, newValue, _prevValue, obj) => { 31 | this._logWarnOnOpOnFrozen("Point moved on"); 32 | this.updateElementPoints(); 33 | prop === "x" && obj.handle?.setAttrX(newValue); 34 | prop === "y" && obj.handle?.setAttrY(newValue); 35 | }); 36 | 37 | // don't observe handle assignment 38 | point.handle = new Handle( 39 | x, 40 | y, 41 | (deltaX, deltaY) => { 42 | pointProxy.x += deltaX; 43 | pointProxy.y += deltaY; 44 | }, 45 | this.isFrozen 46 | ); 47 | this.editorOwner.registerComponentHandle(point.handle); 48 | 49 | this.points.push(pointProxy); 50 | this.updateElementPoints(); 51 | 52 | return this; 53 | } 54 | 55 | moveLastPoint(x: number, y: number) { 56 | const lastPoint = this.points[this.points.length - 1]; 57 | [lastPoint.x, lastPoint.y] = [x, y]; 58 | return this; 59 | } 60 | 61 | override freeze(freeze?: boolean) { 62 | this.isFrozen = freeze != null ? !!freeze : true; 63 | this.getHandles().forEach(handle => handle?.freeze(freeze)); 64 | return this; 65 | } 66 | 67 | // TODO: move by transform:translate instead? 68 | override move(deltaX: number, deltaY: number) { 69 | this.points.forEach(p => { 70 | p.x += deltaX; 71 | p.y += deltaY; 72 | }); 73 | return this; 74 | } 75 | 76 | override isValid() { 77 | return this.points.length >= 3; 78 | } 79 | 80 | override setHandlesVisibility(visible?: boolean) { 81 | this.getHandles().forEach(handle => handle.setVisible(visible)); 82 | return this; 83 | } 84 | 85 | override setIsSelected(selected?: boolean) { 86 | this._logWarnOnOpOnFrozen("Select/unselect performed on"); 87 | 88 | this.isSelected = selected = selected != null ? !!selected : true; 89 | this.setHandlesVisibility(selected); 90 | this.style && 91 | setStyle( 92 | this.element, 93 | selected ? this.style.componentSelect.on : this.style.componentSelect.off 94 | ); 95 | return this; 96 | } 97 | 98 | override getHandles() { 99 | return this.points.map(p => p.handle).filter(isNotNull); 100 | } 101 | 102 | override setStyle(style: ReturnType) { 103 | this.style = style; 104 | setStyle(this.element, style.component); 105 | setStyle(this.element, style.componentHover.off); 106 | setStyle(this.element, style.componentSelect.off); 107 | 108 | addHover(this.element, style.componentHover.off, style.componentHover.on); 109 | return this; 110 | } 111 | 112 | override export() { 113 | return this.points.map(p => ({ x: p.x, y: p.y })); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/rect.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor"; 2 | import { CornerShapedElement } from "./factory"; 3 | 4 | export class Rectangle extends CornerShapedElement { 5 | constructor(editorOwner: Editor, x: number, y: number, width = 0, height = 0) { 6 | super( 7 | "rect", 8 | { 9 | // move 10 | x: (element, x, _prevX, dim) => { 11 | element.setAttribute("x", String(dim.width < 0 ? x + dim.width : x)); 12 | }, 13 | // move 14 | y: (element, y, _prevY, dim) => { 15 | element.setAttribute("y", String(dim.height < 0 ? y + dim.height : y)); 16 | }, 17 | // resize 18 | width: (element, width, _prevWidth, dim) => { 19 | element.setAttribute("width", String(Math.abs(width))); 20 | element.setAttribute("x", String(width < 0 ? dim.x + width : dim.x)); 21 | }, 22 | // resize 23 | height: (element, height, _prevHeight, dim) => { 24 | element.setAttribute("height", String(Math.abs(height))); 25 | element.setAttribute("y", String(height < 0 ? dim.y + height : dim.y)); 26 | }, 27 | }, 28 | editorOwner, 29 | x, 30 | y, 31 | width, 32 | height 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/style.ts: -------------------------------------------------------------------------------- 1 | import { addEventListeners } from "./events"; 2 | 3 | export type Style = Record; 4 | 5 | const componentDefault: Style = { 6 | fill: "rgb(102, 102, 102)", 7 | stroke: "rgb(51, 51, 51)", 8 | cursor: "pointer", 9 | }; 10 | 11 | const componentHoverDefault: { 12 | off: Style; 13 | on: Style; 14 | } = { 15 | off: { 16 | strokeWidth: "1", 17 | opacity: "0.5", 18 | }, 19 | on: { 20 | strokeWidth: "2", 21 | opacity: "0.6", 22 | }, 23 | }; 24 | 25 | // TODO: should not be overridden by unhovering 26 | const componentSelectDefault: { 27 | off: Style; 28 | on: Style; 29 | } = { 30 | off: { 31 | strokeDasharray: "none", // alt. 'initial' 32 | strokeLinejoin: "miter", 33 | }, 34 | on: { 35 | strokeDasharray: "4 3", 36 | strokeLinejoin: "round", 37 | }, 38 | }; 39 | 40 | const handleDefault: Style = { 41 | fill: "rgb(255, 255, 255)", 42 | stroke: "rgb(51, 51, 51)", 43 | strokeWidth: "1", 44 | opacity: "0.3", 45 | cursor: "pointer", 46 | }; 47 | 48 | const handleHoverDefault: Style = { 49 | opacity: "0.6", 50 | }; 51 | 52 | const getDefaultStyle = () => ({ 53 | component: Object.assign({}, componentDefault), 54 | componentHover: Object.assign({}, componentHoverDefault), 55 | componentSelect: Object.assign({}, componentSelectDefault), 56 | handle: Object.assign({}, handleDefault), 57 | handleHover: Object.assign({}, handleHoverDefault), 58 | }); 59 | 60 | const setStyle = (element: Element, style: Style) => 61 | Object.entries(style).forEach(([attr, value]) => element.setAttribute(attr, value)); 62 | 63 | const addHover = (element: Element, defaultStyle: Style, hoverStyle: Style) => { 64 | addEventListeners(element, "mouseenter touchstart", () => setStyle(element, hoverStyle)); 65 | addEventListeners(element, "mouseleave touchend touchleave", () => 66 | setStyle(element, defaultStyle) 67 | ); 68 | }; 69 | 70 | export { getDefaultStyle, setStyle, addHover }; 71 | -------------------------------------------------------------------------------- /src/test/components.test.ts: -------------------------------------------------------------------------------- 1 | import editorFactory from "../editor"; 2 | import { SVG_NS } from "../constants"; 3 | import { doc } from "../globals"; 4 | import { Rectangle } from "../rect"; 5 | import { Circle } from "../circle"; 6 | import { Ellipse } from "../ellipse"; 7 | import { Polygon } from "../polygon"; 8 | 9 | describe("Components", () => { 10 | const editorConstr = editorFactory(); 11 | const svgEl = doc.createElementNS(SVG_NS, "svg"); 12 | const editor = editorConstr(svgEl); 13 | 14 | const components = [ 15 | new Rectangle(editor, 0, 0), 16 | new Circle(editor, 0, 0), 17 | new Ellipse(editor, 0, 0), 18 | new Polygon(editor), 19 | ]; 20 | 21 | test("has element", () => { 22 | components.every(c => expect(c.element).toBeInstanceOf(SVGElement)); 23 | }); 24 | 25 | test("has function move", () => { 26 | components.every(c => expect(typeof c.move).toBe("function")); 27 | }); 28 | 29 | test("has function isValid", () => { 30 | components.every(c => expect(typeof c.isValid).toBe("function")); 31 | }); 32 | 33 | test("has function setHandlesVisibility", () => { 34 | components.every(c => expect(typeof c.setHandlesVisibility).toBe("function")); 35 | }); 36 | 37 | test("has function setIsSelected", () => { 38 | components.every(c => expect(typeof c.setIsSelected).toBe("function")); 39 | }); 40 | 41 | test("has function getHandles", () => { 42 | components.every(c => expect(typeof c.getHandles).toBe("function")); 43 | }); 44 | 45 | test("has function setStyle", () => { 46 | components.every(c => expect(typeof c.setStyle).toBe("function")); 47 | }); 48 | 49 | test("has function export", () => { 50 | components.every(c => expect(typeof c.export).toBe("function")); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/test/editor.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import editorFactory, { Editor } from "../editor"; 3 | import { SVG_NS } from "../constants"; 4 | import { doc } from "../globals"; 5 | 6 | describe("Editor", () => { 7 | const editorConstr = editorFactory(); 8 | 9 | const makeValidPolygon = (editor: Editor) => { 10 | editor.polygon(); 11 | 12 | // Making valid polygon (having at least three points) 13 | editor.svg.dispatchEvent(new MouseEvent("mousedown")); 14 | editor.svg.dispatchEvent(new MouseEvent("mouseup")); 15 | editor.svg.dispatchEvent(new MouseEvent("mousedown")); 16 | editor.svg.dispatchEvent(new MouseEvent("mouseup")); 17 | editor.svg.dispatchEvent(new MouseEvent("mousedown")); 18 | editor.svg.dispatchEvent(new MouseEvent("mouseup")); 19 | }; 20 | 21 | test("with SVGElement", () => { 22 | const svgEl = doc.createElementNS(SVG_NS, "svg"); 23 | const editor = editorConstr(svgEl); 24 | expect(editor.svg).toBe(svgEl); 25 | }); 26 | 27 | test("with existing SVG id", () => { 28 | document.body.innerHTML = ''; 29 | 30 | const editor = editorConstr("svgid"); 31 | expect(editor.svg).toBeInstanceOf(SVGElement); 32 | expect(editor.svg.id).toEqual("svgid"); 33 | expect(editor.svg.getAttribute("width")).toEqual("1337"); 34 | }); 35 | 36 | test("with SVG element to be created", () => { 37 | document.body.innerHTML = ''; 38 | 39 | const editor = editorConstr("svgidother", { width: 1337 }); 40 | expect(editor.svg).toBeInstanceOf(SVGElement); 41 | expect(editor.svg.id).toEqual("svgidother"); 42 | expect(editor.svg.getAttribute("width")).toEqual("1337"); 43 | }); 44 | 45 | test("drawing component should cache a new valid component", () => { 46 | const editor = editorConstr("svgid"); 47 | makeValidPolygon(editor); 48 | const polygon = editor.getComponentById("polygon_1"); 49 | 50 | expect(polygon?.element instanceof SVGElement).toBeTruthy(); 51 | expect(polygon?.element.tagName).toEqual("polygon"); 52 | expect(polygon?.element.id).toEqual("polygon_1"); 53 | }); 54 | 55 | test("should get callback on componentDrawnHandler when finished drawing", () => { 56 | const componentDrawnHandler = jest.fn(); 57 | const editor = editorConstr("svgid", { componentDrawnHandler }); 58 | 59 | makeValidPolygon(editor); 60 | editor.fsmService.send({ type: "KEYDOWN_ESC" }); // exit drawing 61 | 62 | expect(componentDrawnHandler).toBeCalledTimes(1); 63 | expect(componentDrawnHandler).toBeCalledWith( 64 | expect.objectContaining({ 65 | element: expect.any(SVGElement), 66 | }), 67 | "polygon_1" 68 | ); 69 | }); 70 | 71 | test("should get callback on selectModeHandler before drawing", () => { 72 | const selectModeHandler = jest.fn(); 73 | const editor = editorConstr("svgid", { selectModeHandler }); 74 | 75 | editor.rect(); 76 | editor.fsmService.send({ type: "KEYDOWN_ESC" }); // TODO: should preferably simulate keydown with key="Escape" 77 | expect(selectModeHandler).toBeCalledTimes(2); // called initially by fsm too 78 | }); 79 | 80 | test("should get callback on selectModeHandler when started drawing", () => { 81 | const selectModeHandler = jest.fn(); 82 | const editor = editorConstr("svgid", { selectModeHandler }); 83 | 84 | // TODO: event emitter removing handles when discarding component is not working in jsdom env 85 | // editor.svg.dispatchEvent(new MouseEvent('mousedown')); 86 | 87 | // Instead we skip discardUnfinished by making a valid polygon (having at least three points) 88 | makeValidPolygon(editor); 89 | 90 | editor.fsmService.send({ type: "KEYDOWN_ESC" }); // to drawMode 91 | editor.fsmService.send({ type: "KEYDOWN_ESC" }); // to selectMode 92 | expect(selectModeHandler).toBeCalledTimes(2); // called initially by fsm too 93 | }); 94 | }); 95 | 96 | describe("View", () => { 97 | const editorConstr = editorFactory(); 98 | const viewConstr = editorFactory(true); 99 | 100 | test("should get callback on viewClickHandler", () => { 101 | const viewClickHandler = jest.fn(); 102 | const view = viewConstr("view", { viewClickHandler }); 103 | 104 | const mouseEvent = new MouseEvent("click"); // we skip testing target as it is readonly in MouseEvent 105 | view.cgroup.dispatchEvent(mouseEvent); 106 | 107 | expect(viewClickHandler).toBeCalledTimes(1); 108 | expect(viewClickHandler).toBeCalledWith(mouseEvent, ""); 109 | }); 110 | 111 | test("should be able to import Editor`s exported data", () => { 112 | const editor = editorConstr("editor"); 113 | const view = viewConstr("view"); 114 | 115 | // First create some data 116 | editor.rect(); 117 | // can't use MouseEvent as offsetX and offsetY properties are readonly 118 | editor.fsmService.send({ 119 | type: "MT_DOWN", 120 | offsetX: 0, 121 | offsetY: 0, 122 | }); 123 | editor.fsmService.send({ 124 | type: "MT_MOVE", 125 | offsetX: 1, 126 | offsetY: 1, 127 | }); 128 | 129 | // Now import the data to the view 130 | view.import(editor.export(), id => id + "_test-import"); 131 | const viewRect = view.getComponentById("rect_1_test-import"); 132 | 133 | expect(viewRect?.element instanceof SVGElement).toBeTruthy(); 134 | expect(viewRect?.element.tagName).toEqual("rect"); 135 | expect(viewRect?.element.id).toEqual("rect_1_test-import"); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/test/fsm.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import createFSMService from "../fsm"; 3 | import { Editor } from "../editor"; 4 | jest.mock("../editor"); 5 | 6 | describe("State machine", () => { 7 | const fsmService = createFSMService(new Editor("mock")).start(); 8 | 9 | test('transition by MT_DOWN event in "selectMode"', done => { 10 | fsmService.subscribe(state => { 11 | if (state.matches("idle.selectMode.mouseIsDown")) { 12 | done(); 13 | } 14 | }); 15 | 16 | fsmService.send({ type: "MT_DOWN" }); 17 | }); 18 | 19 | test('transition by MT_DOWN event in "drawMode"', done => { 20 | fsmService.subscribe(state => { 21 | if (state.matches("drawing.rect.mouseIsDown")) { 22 | done(); 23 | } 24 | }); 25 | 26 | // failing with "Expected done to be called once, but it was called multiple times." (???) 27 | //fsmService.send(['MODE_DRAW_RECT', 'MT_DOWN']); 28 | fsmService.send({ type: "MODE_DRAW_RECT" }); 29 | fsmService.send({ type: "MT_DOWN" }); 30 | }); 31 | 32 | test('transition by KEYDOWN_ESC event in "drawMode"', done => { 33 | fsmService.subscribe(state => { 34 | if (state.matches("idle.selectMode.mouseIsUp")) { 35 | done(); 36 | } 37 | }); 38 | 39 | fsmService.send({ type: "MODE_DRAW_RECT" }); 40 | fsmService.send({ type: "KEYDOWN_ESC" }); 41 | }); 42 | 43 | test('transition by KEYDOWN_ESC event in "drawing"', done => { 44 | let counter = 0; 45 | 46 | fsmService.subscribe(state => { 47 | if (state.matches("idle.drawMode.rect")) { 48 | counter++ && counter === 2 && done(); 49 | } 50 | }); 51 | 52 | fsmService.send({ type: "MODE_DRAW_RECT" }); // first match 53 | fsmService.send({ type: "MT_DOWN" }); 54 | fsmService.send({ type: "KEYDOWN_ESC" }); // second match 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/test/onChangeProxy.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import { onChange } from "../onChangeProxy"; 3 | 4 | describe("onChange with handler function", () => { 5 | const mockHandler = jest.fn(); 6 | const unproxied = { someprop: 1 }; 7 | const proxied = onChange(unproxied, mockHandler); 8 | 9 | afterEach(() => { 10 | mockHandler.mockRestore(); 11 | }); 12 | 13 | test("listens to proxied object", () => { 14 | proxied.someprop = 2; 15 | expect(mockHandler).toBeCalledWith("someprop", 2, 1, { someprop: 2 }); 16 | }); 17 | 18 | test("does not listen to unproxied object", () => { 19 | unproxied.someprop = 2; 20 | expect(mockHandler).not.toBeCalled(); 21 | }); 22 | }); 23 | 24 | describe("onChange with handler map of functions", () => { 25 | const mockHandlers = { 26 | prop1: jest.fn(), 27 | prop2: jest.fn(), 28 | prop3: jest.fn(), 29 | }; 30 | const unproxied = { prop1: 1 } as { prop1: number; prop2?: number }; 31 | const proxied = onChange(unproxied, mockHandlers); 32 | 33 | afterEach(() => { 34 | mockHandlers.prop1.mockRestore(); 35 | mockHandlers.prop2.mockRestore(); 36 | mockHandlers.prop3.mockRestore(); 37 | }); 38 | 39 | test("listens to proxied object", () => { 40 | proxied.prop1 = 2; 41 | proxied.prop2 = 2; 42 | expect(mockHandlers.prop1).toBeCalledWith(2, 1, { prop1: 2, prop2: 2 }); 43 | expect(mockHandlers.prop2).toBeCalledWith(2, undefined, { prop1: 2, prop2: 2 }); 44 | expect(mockHandlers.prop3).not.toBeCalled(); 45 | }); 46 | 47 | test("does not listen to unproxied object", () => { 48 | unproxied.prop1 = 2; 49 | expect(mockHandlers.prop1).not.toBeCalled(); 50 | expect(mockHandlers.prop2).not.toBeCalled(); 51 | expect(mockHandlers.prop3).not.toBeCalled(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const isNotNull = (value: T | null | undefined): value is T => value != null; 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", // Expand the tsconfig.json configuration 3 | "compilerOptions": { 4 | // Emit 5 | "noEmit": false, // allow file generation 6 | "declaration": true, // Needs to be set to true to support types 7 | "emitDeclarationOnly": true, // Only type files are generated 8 | "declarationDir": "dist" // Export directory for type files 9 | }, 10 | "include": ["index.ts", "src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Language and Environment 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["ESNext", "DOM"], 7 | 8 | // Modules 9 | "module": "ESNext", 10 | "moduleResolution": "Node", 11 | "resolveJsonModule": true, 12 | 13 | // Type Checking 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | 19 | // Emit 20 | "sourceMap": true, 21 | "noEmit": true, 22 | 23 | // Interop Constraints 24 | "isolatedModules": true, 25 | "esModuleInterop": true, 26 | 27 | // Completeness 28 | "skipLibCheck": true 29 | }, 30 | "include": ["index.ts", "src/**/*", "examples/**/*"] 31 | } 32 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig(({ mode }) => { 5 | 6 | return { 7 | build: { 8 | lib: { 9 | entry: resolve(__dirname, './index.ts'), 10 | name: 'imagemapper', 11 | fileName: (format) => mode === "development" ? 'imagemapper.js' : `imagemapper.${format}.js` 12 | }, 13 | sourcemap: true, 14 | rollupOptions: { 15 | output: { 16 | exports: "named" 17 | } 18 | } 19 | } 20 | } 21 | }); --------------------------------------------------------------------------------