├── .gitignore ├── src ├── js │ ├── core │ │ ├── transform │ │ │ ├── svg │ │ │ │ ├── index.js │ │ │ │ ├── util.js │ │ │ │ └── path.js │ │ │ ├── common.js │ │ │ ├── index.js │ │ │ ├── matrix.js │ │ │ └── Transformable.js │ │ ├── observable │ │ │ ├── index.js │ │ │ └── Observable.js │ │ ├── index.js │ │ ├── clone │ │ │ ├── index.js │ │ │ └── Cloneable.js │ │ ├── Subjx.js │ │ ├── EventDispatcher.js │ │ ├── util │ │ │ ├── util.js │ │ │ └── css-util.js │ │ ├── consts.js │ │ ├── SubjectModel.js │ │ └── Helper.js │ └── index.js └── style │ └── subjx.css ├── jest.config.js ├── examples └── demo.gif ├── scripts ├── publish-local.sh └── npm-login.js ├── index.js ├── types ├── .eslintrc.yml ├── index.d.ts └── options.d.ts ├── jest.setup.js ├── .github └── workflows │ ├── quality.yml │ └── publish.yml ├── dist └── style │ └── subjx.css ├── .releaserc.js ├── test ├── transform.test.js ├── util.test.js ├── css-util.test.js ├── helper.test.js ├── svg-path.test.js ├── matrix.test.js └── subjx.test.js ├── LICENSE ├── .babelrc ├── .eslintrc.cjs ├── package.json ├── rollup.config.js ├── CHANGELOG.md ├── README.md └── public └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dev/ -------------------------------------------------------------------------------- /src/js/core/transform/svg/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './DraggableSVG'; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | setupFilesAfterEnv: ['./jest.setup.js'] 3 | }; -------------------------------------------------------------------------------- /src/js/core/observable/index.js: -------------------------------------------------------------------------------- 1 | export { default as Observable } from './Observable'; -------------------------------------------------------------------------------- /examples/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nichollascarter/subjx/HEAD/examples/demo.gif -------------------------------------------------------------------------------- /src/js/core/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Subjx'; 2 | export { Observable } from './observable'; -------------------------------------------------------------------------------- /scripts/publish-local.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker stop verdaccio 4 | docker run -d --rm --name verdaccio -p 4873:4873 verdaccio/verdaccio 5 | sleep 3 6 | 7 | set -e 8 | 9 | node ./scripts/npm-login.js 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (process.env.NODE_ENV === 'production') { 4 | module.exports = require('./dist/js/subjx.common.js'); 5 | } else { 6 | module.exports = require('./dist/js/subjx.dev.common.js'); 7 | } -------------------------------------------------------------------------------- /src/js/core/clone/index.js: -------------------------------------------------------------------------------- 1 | import Cloneable from './Cloneable'; 2 | import { arrMap } from '../util/util'; 3 | 4 | export default function clone(options) { 5 | if (this.length) { 6 | return new Cloneable( 7 | arrMap.call(this, _ => _), 8 | options 9 | ); 10 | } 11 | } -------------------------------------------------------------------------------- /src/js/core/Subjx.js: -------------------------------------------------------------------------------- 1 | import Helper from './Helper'; 2 | import drag from './transform'; 3 | import clone from './clone'; 4 | 5 | export default class Subjx extends Helper { 6 | 7 | drag() { 8 | return drag.call(this, ...arguments); 9 | } 10 | 11 | clone() { 12 | return clone.call(this, ...arguments); 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import '../style/subjx.css'; 2 | import Subjx, { Observable } from './core'; 3 | 4 | export default function subjx(params) { 5 | return new Subjx(params); 6 | } 7 | 8 | Object.defineProperty(subjx, 'createObservable', { 9 | value: () => new Observable() 10 | }); 11 | 12 | Object.defineProperty(subjx, 'Subjx', { 13 | value: Subjx 14 | }); 15 | 16 | Object.defineProperty(subjx, 'Observable', { 17 | value: Observable 18 | }); -------------------------------------------------------------------------------- /types/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parser: '@typescript-eslint/parser' 2 | 3 | plugins: 4 | - '@typescript-eslint' 5 | 6 | extends: 7 | - plugin:@typescript-eslint/recommended 8 | 9 | rules: 10 | no-use-before-define: "off" 11 | '@typescript-eslint/no-use-before-define': "error" 12 | no-shadow: "off" 13 | '@typescript-eslint/no-shadow': "error" 14 | 15 | object-curly-spacing: ["warn", "always"] 16 | '@typescript-eslint/no-empty-interface': "warn" 17 | '@typescript-eslint/ban-types': "warn" 18 | '@typescript-eslint/adjacent-overload-signatures': "warn" 19 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import JsDOM from 'jsdom'; 2 | 3 | const jsdom = new JsDOM.JSDOM(''); 4 | window = jsdom.window; 5 | 6 | global.window = window; 7 | global.document = window.document; 8 | global.SVGElement = window.SVGElement; 9 | global.MouseEvent = window.MouseEvent; 10 | global.HTMLElement = window.HTMLElement; 11 | global.Element = window.Element; 12 | 13 | window.requestAnimationFrame = function (f) { 14 | return window.setTimeout(f, 1000 / 60); 15 | }; 16 | 17 | window.cancelAnimationFrame = function (requestID) { 18 | window.clearTimeout(requestID); 19 | }; 20 | 21 | jest.setTimeout(10000); -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Test CI 2 | 3 | on: 4 | pull_request: 5 | branches: [master, staging] 6 | 7 | jobs: 8 | quality: 9 | runs-on: self-hosted 10 | strategy: 11 | matrix: 12 | node-version: [22.x] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | 20 | - run: npm ci 21 | - run: npm test 22 | - run: npm run build:all --if-present 23 | #- run: npx semantic-release --dry-run --no-ci --branches $(git branch --show-current) 24 | -------------------------------------------------------------------------------- /dist/style/subjx.css: -------------------------------------------------------------------------------- 1 | .sjx-wrapper{position:absolute;top: 0;left: 0;user-select: none;-webkit-user-select: none;-moz-user-select: none;-o-user-select: none;}.sjx-controls{display:inline-block;top: 0;left: 0;z-index: 2147483647;}.sjx-hdl{left:0;top: 0;width: 10px;height: 10px;border-radius: 50%;border: 1px solid #00a8ff;box-sizing: border-box;background: #fff;margin-top: -5px;margin-left: -5px;}.sjx-hdl-line{background:#00a8ff;margin: 0;padding: 0;}.sjx-hdl-center{border-color:rgb(254,50,50);}.sjx-normal{display:inline-block;border: 0.5px dashed rgb(0,168,255);top: 50%;left: 100%;width: 50px;height: 0;}.sjx-controls,.sjx-hdl,.sjx-normal,.sjx-hdl-line{position:absolute;box-sizing: border-box;}.sjx-hidden{display:none;} -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { DragOptions, CloneOptions } from './options'; 2 | 3 | type Target = string | Element | Array; 4 | 5 | declare class Helper { 6 | constructor(target: Target); 7 | } 8 | 9 | declare class SubjectModel { } 10 | declare class Transformable extends SubjectModel { } 11 | declare class Cloneable extends SubjectModel { } 12 | declare class Draggable extends Transformable { } 13 | declare class DraggableSVG extends Transformable { } 14 | declare class Observable { } 15 | 16 | declare class Subjx extends Helper { 17 | public drag(parameters: DragOptions, observable?: Observable): Draggable | DraggableSVG; 18 | public clone(parameters: CloneOptions): Cloneable; 19 | } 20 | 21 | /** 22 | * Factory function for handling target elements. Accepts "string | Element | Array" 23 | */ 24 | declare function subjx(target: Target): Subjx; 25 | 26 | export default subjx; 27 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/github", 5 | "@semantic-release/changelog", 6 | "@semantic-release/npm", 7 | ["@semantic-release/release-notes-generator", { 8 | "parserOpts": { 9 | "noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES", "BREAKING"] 10 | }, 11 | "writerOpts": { 12 | "commitsSort": ["subject", "scope"] 13 | } 14 | }], 15 | ["@semantic-release/git", { 16 | "assets": ["dist/", "package.json", "CHANGELOG.md", "package-lock.json"], 17 | "message": "chore(release): ${nextRelease.version} \n\n${nextRelease.notes}" 18 | }] 19 | ], 20 | branches: [ 21 | 'master', 22 | { 23 | name: 'staging', 24 | prerelease: 'rc' 25 | } 26 | ] 27 | }; 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish CI 2 | 3 | on: 4 | push: 5 | branches: [master, staging] 6 | 7 | jobs: 8 | publish: 9 | runs-on: self-hosted 10 | strategy: 11 | matrix: 12 | node-version: [22.x] 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | token: ${{ secrets.GH_TOKEN }} 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | registry-url: https://registry.npmjs.org/ 22 | - run: npm config set '//registry.npmjs.org/:_authToken' ${{ secrets.NPM_TOKEN }} 23 | - run: npm whoami --registry https://registry.npmjs.org/ 24 | - run: npm ci 25 | - run: npm run build:all --if-present 26 | - run: npm run semantic-release 27 | env: 28 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /src/js/core/transform/common.js: -------------------------------------------------------------------------------- 1 | export const RAD = Math.PI / 180; 2 | export const DEG = 180 / Math.PI; 3 | 4 | const snapCandidate = (value, gridSize) => ( 5 | gridSize === 0 6 | ? value 7 | : Math.round(value / gridSize) * gridSize 8 | ); 9 | 10 | export const snapToGrid = (value, snap) => { 11 | if (snap === 0) { 12 | return value; 13 | } else { 14 | const result = snapCandidate(value, snap); 15 | 16 | if (result - value < snap) { 17 | return result; 18 | } 19 | } 20 | }; 21 | 22 | export const floatToFixed = (val, size = 6) => ( 23 | Number(val.toFixed(size)) 24 | ); 25 | 26 | export const getMinMaxOfArray = (arr, length = 2) => { 27 | const res = []; 28 | 29 | for (let i = 0; i < length; i++) { 30 | const axisValues = arr.map(e => e[i]); 31 | 32 | res.push([ 33 | Math.min(...axisValues), 34 | Math.max(...axisValues) 35 | ]); 36 | } 37 | 38 | return res; 39 | }; -------------------------------------------------------------------------------- /test/transform.test.js: -------------------------------------------------------------------------------- 1 | import { snapToGrid, floatToFixed, getMinMaxOfArray } from '../src/js/core/transform/common'; 2 | 3 | describe('snapToGrid func', () => { 4 | it('returns value near to grid size', () => { 5 | expect(snapToGrid(15, 30)).toBe(30); 6 | }); 7 | 8 | it('returns value less than grid size', () => { 9 | expect(snapToGrid(10, 50)).toBe(0); 10 | }); 11 | 12 | it('returns value with grid size equals 0', () => { 13 | expect(snapToGrid(15, 0)).toBe(15); 14 | }); 15 | }); 16 | 17 | describe('floatToFixed func', () => { 18 | it('returns rounded value', () => { 19 | expect(floatToFixed(1.23456789)).toBe(1.234568); 20 | }); 21 | }); 22 | 23 | describe('getMinMaxOfArray func', () => { 24 | it('returns min and max values', () => { 25 | const values = [ 26 | [100, 2, 0, 1], 27 | [-1, 5, 0, 1] 28 | ]; 29 | 30 | expect( 31 | getMinMaxOfArray(values) 32 | ).toMatchObject([[-1, 100], [2, 5]]); 33 | }); 34 | }); -------------------------------------------------------------------------------- /src/js/core/EventDispatcher.js: -------------------------------------------------------------------------------- 1 | class Event { 2 | 3 | constructor(name) { 4 | this.name = name; 5 | this.callbacks = []; 6 | } 7 | 8 | registerCallback(cb) { 9 | this.callbacks.push(cb); 10 | } 11 | 12 | removeCallback(cb) { 13 | const ix = this.callbacks(cb); 14 | this.callbacks.splice(ix, 1); 15 | } 16 | 17 | } 18 | 19 | export default class EventDispatcher { 20 | 21 | constructor() { 22 | this.events = {}; 23 | } 24 | 25 | registerEvent(eventName) { 26 | this.events[eventName] = new Event(eventName); 27 | } 28 | 29 | emit(ctx, eventName, eventArgs) { 30 | this.events[eventName].callbacks.forEach((cb) => { 31 | cb.call(ctx, eventArgs); 32 | }); 33 | } 34 | 35 | addEventListener(eventName, cb) { 36 | this.events[eventName].registerCallback(cb); 37 | } 38 | 39 | removeEventListener(eventName, cb) { 40 | this.events[eventName].removeCallback(cb); 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/js/core/transform/index.js: -------------------------------------------------------------------------------- 1 | import { Observable } from '../observable'; 2 | import Draggable from './Draggable'; 3 | import DraggableSVG from './svg'; 4 | import { checkElement } from './svg/util'; 5 | import { arrReduce, arrMap, isDef } from '../util/util'; 6 | 7 | // factory method for creating draggable elements 8 | export default function drag(options, obInstance) { 9 | if (this.length) { 10 | const Ob = (isDef(obInstance) && obInstance instanceof Observable) 11 | ? obInstance 12 | : new Observable(); 13 | 14 | if (this[0] instanceof SVGElement) { 15 | const items = arrReduce.call(this, (result, item) => { 16 | if (checkElement(item)) { 17 | result.push(item); 18 | } 19 | return result; 20 | }, []); 21 | 22 | return new DraggableSVG(items, options, Ob); 23 | } else { 24 | return new Draggable( 25 | arrMap.call(this, _ => _), 26 | options, 27 | Ob 28 | ); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2022 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 | -------------------------------------------------------------------------------- /src/js/core/util/util.js: -------------------------------------------------------------------------------- 1 | export const requestAnimFrame = 2 | window.requestAnimationFrame || 3 | window.mozRequestAnimationFrame || 4 | window.webkitRequestAnimationFrame || 5 | window.msRequestAnimationFrame || 6 | function (f) { 7 | return setTimeout(f, 1000 / 60); 8 | }; 9 | 10 | export const cancelAnimFrame = 11 | window.cancelAnimationFrame || 12 | window.mozCancelAnimationFrame || 13 | function (requestID) { 14 | clearTimeout(requestID); 15 | }; 16 | 17 | export const { 18 | forEach, 19 | slice: arrSlice, 20 | map: arrMap, 21 | reduce: arrReduce 22 | } = Array.prototype; 23 | /* eslint-disable no-console */ 24 | export const { warn } = console; 25 | 26 | export const noop = _ => _; 27 | 28 | /* eslint-disable no-console */ 29 | 30 | export const isDef = val => val !== undefined && val !== null; 31 | 32 | export const isUndef = val => val === undefined || val === null; 33 | 34 | export const isFunc = val => typeof val === 'function'; 35 | 36 | export const createMethod = (fn) => { 37 | return isFunc(fn) 38 | ? function () { 39 | fn.call(this, ...arguments); 40 | } 41 | : noop; 42 | }; -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "esmodules": true 8 | } 9 | } 10 | ] 11 | ], 12 | "env": { 13 | "cjs": { 14 | "presets": [ 15 | [ 16 | "@babel/preset-env", 17 | { 18 | "targets": { 19 | "node": "12", 20 | "browsers": [ 21 | ">0.25%", 22 | "not dead" 23 | ] 24 | }, 25 | "modules": "commonjs" 26 | } 27 | ] 28 | ] 29 | }, 30 | "esm": { 31 | "presets": [ 32 | [ 33 | "@babel/preset-env", 34 | { 35 | "targets": { 36 | "esmodules": true 37 | }, 38 | "modules": false 39 | } 40 | ] 41 | ] 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/style/subjx.css: -------------------------------------------------------------------------------- 1 | .sjx-wrapper { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | user-select: none; 6 | -webkit-user-select: none; 7 | -moz-user-select: none; 8 | -o-user-select: none; 9 | } 10 | 11 | .sjx-controls { 12 | display: inline-block; 13 | top: 0; 14 | left: 0; 15 | z-index: 2147483647; 16 | } 17 | 18 | .sjx-hdl { 19 | left: 0; 20 | top: 0; 21 | width: 10px; 22 | height: 10px; 23 | border-radius: 50%; 24 | border: 1px solid #00a8ff; 25 | box-sizing: border-box; 26 | background: #fff; 27 | margin-top: -5px; 28 | margin-left: -5px; 29 | } 30 | 31 | .sjx-hdl-line { 32 | background: #00a8ff; 33 | margin: 0; 34 | padding: 0; 35 | /* border: 1px dashed #32B5FE; */ 36 | } 37 | 38 | .sjx-hdl-center { 39 | border-color: rgb(254, 50, 50); 40 | } 41 | 42 | .sjx-normal { 43 | display: inline-block; 44 | border: 0.5px dashed rgb(0, 168, 255); 45 | top: 50%; 46 | left: 100%; 47 | width: 50px; 48 | height: 0; 49 | } 50 | 51 | .sjx-controls, 52 | .sjx-hdl, 53 | .sjx-normal, 54 | .sjx-hdl-line { 55 | position: absolute; 56 | box-sizing: border-box; 57 | } 58 | 59 | .sjx-hidden { 60 | display: none; 61 | } -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@babel/eslint-parser', 3 | parserOptions: { 4 | ecmaVersion: 9, 5 | sourceType: 'module', 6 | allowImportExportEverywhere: false, 7 | codeFrame: false, 8 | ecmaFeatures: { 9 | globalReturn: true, 10 | impliedStrict: true, 11 | arrowFunction: true 12 | } 13 | }, 14 | env: { 15 | es6: true, 16 | browser: true, 17 | jest: true 18 | }, 19 | rules: { 20 | 'no-const-assign': 'error', 21 | 'no-var': 'error', 22 | 'no-useless-constructor': 'error', 23 | 'indent': ['error', 4, { 'SwitchCase': 1 }], 24 | 'init-declarations': 'off', 25 | 'no-undef': 'warn', 26 | 'no-console': 'warn', 27 | 'no-inline-comments': 'off', 28 | 'no-irregular-whitespace': 'error', 29 | 'semi': 'error', 30 | 'semi-spacing': 'error', 31 | 'padded-blocks': ['error', { 'blocks': 'never', 'classes': 'always', 'switches': 'always' }], 32 | 'no-unused-vars': ['error', { 'vars': 'all', 'args': 'after-used', 'ignoreRestSiblings': false }], 33 | 'comma-dangle': ['error', { 34 | 'arrays': 'never', 35 | 'objects': 'never', 36 | 'imports': 'never', 37 | 'exports': 'never', 38 | 'functions': 'never' 39 | }], 40 | 'no-trailing-spaces': 'error' 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /scripts/npm-login.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | const fs = require('fs'); 4 | const os = require('os'); 5 | const path = require('path'); 6 | const request = require('request'); 7 | const npmLogin = require('npm-cli-login'); 8 | const { exec } = require('child_process'); 9 | 10 | const NPM_REGISTRY = 'http://localhost:4873'; 11 | 12 | const username = 'test'; 13 | const password = 'test'; 14 | 15 | const addUser = (name, password, registry) => { 16 | request.put({ 17 | headers: { 18 | 'Accept': 'application/json', 19 | 'Content-Type': 'application/json' 20 | }, 21 | url: `${registry}/-/user/org.couchdb.user:${name}`, 22 | json: { 23 | name, 24 | password 25 | } 26 | }, (error, response, body) => { 27 | if (!!response && response.statusCode === 201 && !!body && 'ok' in body && 'token' in body && body.ok) { 28 | const npmrc = path.join(os.homedir(), '.npmrc'); 29 | if (!fs.existsSync(npmrc)) { 30 | fs.closeSync(fs.openSync(npmrc, 'w')); 31 | } 32 | fs.appendFileSync(npmrc, `${registry.substring('http:'.length)}/:_authToken=${body.token}\n`); 33 | npmLogin(name, password, `${name}@${name}.${name}`, registry); 34 | 35 | exec(`npm --registry ${NPM_REGISTRY} publish`, (err) => { 36 | if (err) console.log(err); 37 | }); 38 | } else { 39 | process.exit(1); 40 | } 41 | }); 42 | }; 43 | 44 | addUser(username, password, NPM_REGISTRY); -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | isDef, 3 | isUndef, 4 | isFunc, 5 | createMethod, 6 | requestAnimFrame, 7 | cancelAnimFrame 8 | } from '../src/js/core/util/util'; 9 | 10 | describe('isDef func', () => { 11 | it('Check defined value returns true', () => { 12 | const value = 0; 13 | expect(isDef(value)).toBe(true); 14 | }); 15 | 16 | it('Check undefined value returns false', () => { 17 | const value = undefined; 18 | expect(isDef(value)).toBe(false); 19 | }); 20 | }); 21 | 22 | describe('isUndef func', () => { 23 | it('Check undefined value returns true', () => { 24 | const value = undefined; 25 | expect(isUndef(value)).toBe(true); 26 | }); 27 | 28 | it('Check undefined value returns false', () => { 29 | const value = 0; 30 | expect(isUndef(value)).toBe(false); 31 | }); 32 | }); 33 | 34 | describe('isFunc func', () => { 35 | it('Check isFunc returns true', () => { 36 | const fn = () => { }; 37 | expect(isFunc(fn)).toBe(true); 38 | }); 39 | 40 | it('Check isFunc returns false', () => { 41 | const fn = 0; 42 | expect(isFunc(fn)).toBe(false); 43 | }); 44 | }); 45 | 46 | describe('createMethod func', () => { 47 | it('Check method returns value', () => { 48 | const fn = function () { }; 49 | const method = createMethod(fn); 50 | expect(typeof method).toBe('function'); 51 | }); 52 | }); 53 | 54 | describe('animate func', () => { 55 | it('Check requestAnimFrame returns 1', () => { 56 | const frameId = requestAnimFrame(() => { }); 57 | cancelAnimFrame(frameId); 58 | expect(frameId).toBe(1); 59 | }); 60 | }); -------------------------------------------------------------------------------- /src/js/core/observable/Observable.js: -------------------------------------------------------------------------------- 1 | import { isDef, isUndef } from '../util/util'; 2 | import { NOTIFIER_CONSTANTS } from '../consts'; 3 | 4 | const { 5 | ON_GETSTATE, 6 | ON_APPLY, 7 | ON_MOVE, 8 | ON_RESIZE, 9 | ON_ROTATE 10 | } = NOTIFIER_CONSTANTS; 11 | 12 | export default class Observable { 13 | 14 | constructor() { 15 | this.observers = {}; 16 | } 17 | 18 | subscribe(eventName, sub) { 19 | const obs = this.observers; 20 | 21 | if (isUndef(obs[eventName])) { 22 | Object.defineProperty(obs, eventName, { 23 | value: [] 24 | }); 25 | } 26 | 27 | obs[eventName].push(sub); 28 | 29 | return this; 30 | } 31 | 32 | unsubscribe(eventName, f) { 33 | const obs = this.observers; 34 | 35 | if (isDef(obs[eventName])) { 36 | const index = obs[eventName].indexOf(f); 37 | obs[eventName].splice(index, 1); 38 | } 39 | 40 | return this; 41 | } 42 | 43 | notify(eventName, source, data) { 44 | if (isUndef(this.observers[eventName])) return; 45 | 46 | this.observers[eventName].forEach(observer => { 47 | if (source === observer) return; 48 | switch (eventName) { 49 | 50 | case ON_MOVE: 51 | observer.notifyMove(data); 52 | break; 53 | case ON_ROTATE: 54 | observer.notifyRotate(data); 55 | break; 56 | case ON_RESIZE: 57 | observer.notifyResize(data); 58 | break; 59 | case ON_APPLY: 60 | observer.notifyApply(data); 61 | break; 62 | case ON_GETSTATE: 63 | observer.notifyGetState(data); 64 | break; 65 | default: 66 | break; 67 | 68 | } 69 | }); 70 | } 71 | 72 | } -------------------------------------------------------------------------------- /test/css-util.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getOffset, 3 | addClass, 4 | removeClass, 5 | matrixToCSS, 6 | getStyle 7 | } from '../src/js/core/util/css-util'; 8 | 9 | import { 10 | createIdentityMatrix, 11 | flatMatrix 12 | } from '../src/js/core/transform/matrix'; 13 | 14 | document.body.innerHTML = ` 15 |
16 | `; 17 | 18 | const domElement = document.getElementById('empty-class'); 19 | 20 | describe('dom class test', () => { 21 | it('adds new class to element', () => { 22 | addClass(domElement, 'new-class'); 23 | const isContains = domElement.classList.contains('new-class'); 24 | expect(isContains).toBe(true); 25 | }); 26 | 27 | it('removes class from element', () => { 28 | removeClass(domElement, 'removal'); 29 | const isContains = domElement.classList.contains('removal'); 30 | expect(isContains).toBe(false); 31 | }); 32 | }); 33 | 34 | describe('element style test', () => { 35 | it('creates transform style properties', () => { 36 | const flatArr = flatMatrix(createIdentityMatrix()); 37 | const matrixStr = `matrix3d(${flatArr.join()})`; 38 | const style = matrixToCSS(flatArr); 39 | 40 | expect(style).toMatchObject({ 41 | transform: matrixStr, 42 | webkitTranform: matrixStr, 43 | mozTransform: matrixStr, 44 | msTransform: matrixStr, 45 | otransform: matrixStr 46 | }); 47 | }); 48 | 49 | it('get style properties', () => { 50 | const style = getStyle(domElement, 'display'); 51 | expect(style).toBe('block'); 52 | }); 53 | }); 54 | 55 | describe('element offset test', () => { 56 | it('get elements offset', () => { 57 | const offset = getOffset(domElement); 58 | expect(offset).toMatchObject({ 59 | bottom: 0, 60 | height: 0, 61 | left: 0, 62 | right: 0, 63 | top: 0, 64 | width: 0 65 | }); 66 | }); 67 | }); -------------------------------------------------------------------------------- /test/helper.test.js: -------------------------------------------------------------------------------- 1 | import Helper, { helper } from '../src/js/core/Helper'; 2 | 3 | document.body.innerHTML = ` 4 |
5 | `; 6 | 7 | const domElement = document.getElementById('empty-class'); 8 | 9 | describe('Helper instance test', () => { 10 | it('creates new instance from element', () => { 11 | const _$ = helper(domElement); 12 | expect(_$ instanceof Helper).toBe(true); 13 | }); 14 | 15 | it('creates new instance from elementArray', () => { 16 | const _$ = helper([domElement]); 17 | expect(_$ instanceof Helper).toBe(true); 18 | }); 19 | 20 | it('creates new instance from selector', () => { 21 | const _$ = helper('#empty-class'); 22 | expect(_$ instanceof Helper).toBe(true); 23 | }); 24 | 25 | it('creates new instance from childNodes', () => { 26 | const _$ = helper(document.body.childNodes); 27 | expect(_$ instanceof Helper).toBe(true); 28 | }); 29 | 30 | it('throws error from element', () => { 31 | try { 32 | expect(helper()); 33 | } catch (e) { 34 | expect(e.message).toBe(`Passed parameter must be selector/element/elementArray`); 35 | } 36 | }); 37 | 38 | it('get element style', () => { 39 | const style = helper(domElement).css('display'); 40 | expect(style).toBe('block'); 41 | }); 42 | 43 | it('set element style', () => { 44 | helper(domElement).css({ 'visibility': 'hidden' }); 45 | const style = helper(domElement).css('visibility'); 46 | expect(style).toBe('hidden'); 47 | }); 48 | 49 | it('checks element', () => { 50 | helper(domElement).is('#empty-class'); 51 | expect( 52 | helper(domElement).is('#empty-class') 53 | ).toBe(true); 54 | }); 55 | 56 | it('adds element event listener', () => { 57 | let data = null; 58 | const addClickTrack = () => { 59 | helper(domElement).on('click', (e) => { 60 | data = { 61 | target: e.target 62 | }; 63 | }); 64 | }; 65 | addClickTrack(); 66 | domElement.click(); 67 | 68 | expect(data).toMatchObject({ 69 | target: domElement 70 | }); 71 | }); 72 | 73 | it('removes element event listener', () => { 74 | let data = null; 75 | const eventHandler = (e) => { 76 | data = { 77 | target: e.target 78 | }; 79 | }; 80 | 81 | helper(domElement).on('click', eventHandler); 82 | helper(domElement).off('click', eventHandler); 83 | 84 | domElement.click(); 85 | 86 | expect(data).toBe(null); 87 | }); 88 | }); -------------------------------------------------------------------------------- /src/js/core/consts.js: -------------------------------------------------------------------------------- 1 | export const MIN_SIZE = 2; 2 | export const THEME_COLOR = '#00a8ff'; 3 | export const LIB_CLASS_PREFIX = 'sjx-'; 4 | 5 | const E_MOUSEDOWN = 'mousedown'; 6 | const E_MOUSEUP = 'mouseup'; 7 | const E_MOUSEMOVE = 'mousemove'; 8 | const E_TOUCHSTART = 'touchstart'; 9 | const E_TOUCHEND = 'touchend'; 10 | const E_TOUCHMOVE = 'touchmove'; 11 | 12 | const E_DRAG_START = 'dragStart'; 13 | const E_DRAG = 'drag'; 14 | const E_DRAG_END = 'dragEnd'; 15 | const E_RESIZE_START = 'resizeStart'; 16 | const E_RESIZE = 'resize'; 17 | const E_RESIZE_END = 'resizeEnd'; 18 | const E_ROTATE_START = 'rotateStart'; 19 | const E_ROTATE = 'rotate'; 20 | const E_ROTATE_END ='rotateEnd'; 21 | const E_SET_POINT = 'setPoint'; 22 | const E_SET_POINT_START = 'setPointStart'; 23 | const E_SET_POINT_END = 'setPointEnd'; 24 | 25 | const EMITTER_EVENTS = [ 26 | E_DRAG_START, 27 | E_DRAG, , 28 | E_DRAG_END, 29 | E_RESIZE_START, 30 | E_RESIZE, 31 | E_RESIZE_END, 32 | E_ROTATE_START, 33 | E_ROTATE, 34 | E_ROTATE_END, 35 | E_SET_POINT_START, 36 | E_SET_POINT_END 37 | ]; 38 | 39 | export const CSS_PREFIXES = [ 40 | '', 41 | '-webkit-', 42 | '-moz-', 43 | '-ms-', 44 | '-o-' 45 | ]; 46 | 47 | const ON_GETSTATE = 'ongetstate'; 48 | const ON_APPLY = 'onapply'; 49 | const ON_MOVE = 'onmove'; 50 | const ON_RESIZE = 'onresize'; 51 | const ON_ROTATE = 'onrotate'; 52 | 53 | const NOTIFIER_EVENTS = [ 54 | ON_GETSTATE, 55 | ON_APPLY, 56 | ON_MOVE, 57 | ON_RESIZE, 58 | ON_ROTATE 59 | ]; 60 | 61 | export const NOTIFIER_CONSTANTS = { 62 | NOTIFIER_EVENTS, 63 | ON_GETSTATE, 64 | ON_APPLY, 65 | ON_MOVE, 66 | ON_RESIZE, 67 | ON_ROTATE 68 | }; 69 | 70 | export const EVENT_EMITTER_CONSTANTS = { 71 | EMITTER_EVENTS, 72 | E_DRAG_START, 73 | E_DRAG, 74 | E_DRAG_END, 75 | E_RESIZE_START, 76 | E_RESIZE, 77 | E_RESIZE_END, 78 | E_ROTATE_START, 79 | E_ROTATE, 80 | E_ROTATE_END, 81 | E_SET_POINT, 82 | E_SET_POINT_START, 83 | E_SET_POINT_END 84 | }; 85 | 86 | export const CLIENT_EVENTS_CONSTANTS = { 87 | E_MOUSEDOWN, 88 | E_MOUSEUP, 89 | E_MOUSEMOVE, 90 | E_TOUCHSTART, 91 | E_TOUCHEND, 92 | E_TOUCHMOVE 93 | }; 94 | 95 | const TRANSFORM_HANDLES_KEYS = { 96 | TOP_LEFT: 'tl', 97 | TOP_CENTER: 'tc', 98 | TOP_RIGHT: 'tr', 99 | BOTTOM_LEFT: 'bl', 100 | BOTTOM_RIGHT: 'br', 101 | BOTTOM_CENTER: 'bc', 102 | MIDDLE_LEFT: 'ml', 103 | MIDDLE_RIGHT: 'mr', 104 | CENTER: 'center' 105 | }; 106 | 107 | const TRANSFORM_EDGES_KEYS = { 108 | TOP_EDGE: 'te', 109 | BOTTOM_EDGE: 'be', 110 | LEFT_EDGE: 'le', 111 | RIGHT_EDGE: 're' 112 | }; 113 | 114 | export const TRANSFORM_HANDLES_CONSTANTS = { 115 | TRANSFORM_HANDLES_KEYS, 116 | TRANSFORM_EDGES_KEYS 117 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subjx", 3 | "version": "1.1.2", 4 | "description": "Drag, Rotate, Resize library", 5 | "author": "Karen Sarksyan (https://github.com/nichollascarter)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/nichollascarter/subjx.git" 10 | }, 11 | "type": "module", 12 | "main": "index.js", 13 | "module": "dist/js/subjx.esm.js", 14 | "unpkg": "dist/js/subjx.js", 15 | "exports": { 16 | "require": "./index.js", 17 | "import": "./dist/js/subjx.esm.js" 18 | }, 19 | "scripts": { 20 | "dev": "cross-env NODE_ENV=development rollup -c", 21 | "build": "cross-env NODE_ENV=production rollup -c", 22 | "build:all": "npm run dev && npm run build", 23 | "start": "cross-env NODE_ENV=development LIVE_MODE=enable rollup -c -w", 24 | "lint": "ESLINT_USE_FLAT_CONFIG=false eslint src/", 25 | "lint:fix": "ESLINT_USE_FLAT_CONFIG=false eslint src/ --fix", 26 | "test": "jest", 27 | "test:coverage": "jest --coverage", 28 | "publish:local": "npm run test && npm run build:all && sh ./scripts/publish-local.sh", 29 | "semantic-release": "semantic-release", 30 | "commit": "git add . && git-cz" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.26.0", 34 | "@babel/eslint-parser": "^7.26.0", 35 | "@babel/plugin-transform-runtime": "^7.28.3", 36 | "@babel/preset-env": "^7.26.0", 37 | "@babel/runtime": "^7.28.3", 38 | "@rollup/plugin-babel": "^6.0.4", 39 | "@rollup/plugin-commonjs": "^25.0.7", 40 | "@rollup/plugin-eslint": "^9.0.5", 41 | "@rollup/plugin-node-resolve": "^15.2.3", 42 | "@rollup/plugin-terser": "^0.4.4", 43 | "@semantic-release/changelog": "^6.0.3", 44 | "@semantic-release/git": "^10.0.1", 45 | "@semantic-release/github": "^8.1.0", 46 | "commitizen": "^4.3.1", 47 | "cross-env": "^7.0.3", 48 | "cz-conventional-changelog": "^3.3.0", 49 | "eslint": "^9.34.0", 50 | "eslint-plugin-import": "^2.29.1", 51 | "jest": "^30.1.3", 52 | "jsdom": "^24.0.0", 53 | "rollup": "^4.50.0", 54 | "rollup-plugin-import-css": "^4.0.2", 55 | "rollup-plugin-livereload": "^2.0.5", 56 | "rollup-plugin-serve": "^1.1.0", 57 | "semantic-release": "^24.2.7", 58 | "typescript": "^5.5.4" 59 | }, 60 | "files": [ 61 | "LICENSE", 62 | "README.md", 63 | "index.js", 64 | "dist/", 65 | "types/*.d.ts" 66 | ], 67 | "keywords": [ 68 | "subjx", 69 | "svg", 70 | "resize", 71 | "scale", 72 | "drag", 73 | "rotate", 74 | "vanilla-js", 75 | "resizable", 76 | "scalable", 77 | "draggable", 78 | "rotatable" 79 | ], 80 | "config": { 81 | "commitizen": { 82 | "path": "./node_modules/cz-conventional-changelog" 83 | } 84 | }, 85 | "types": "types/index.d.ts" 86 | } 87 | -------------------------------------------------------------------------------- /src/js/core/util/css-util.js: -------------------------------------------------------------------------------- 1 | import { helper } from '../Helper'; 2 | import { CSS_PREFIXES } from '../consts'; 3 | 4 | const getOffset = node => node.getBoundingClientRect(); 5 | 6 | const addClass = (node, cls) => { 7 | if (!cls) return; 8 | 9 | if (node.classList) { 10 | if (cls.indexOf(' ') > -1) { 11 | cls.split(/\s+/).forEach(cl => { 12 | return node.classList.add(cl); 13 | }); 14 | } else { 15 | return node.classList.add(cls); 16 | } 17 | } 18 | return node; 19 | }; 20 | 21 | const removeClass = (node, cls) => { 22 | if (!cls) return; 23 | 24 | if (node.classList) { 25 | if (cls.indexOf(' ') > -1) { 26 | cls.split(/\s+/).forEach(cl => { 27 | return node.classList.remove(cl); 28 | }); 29 | } else { 30 | return node.classList.remove(cls); 31 | } 32 | } 33 | return node; 34 | }; 35 | 36 | const objectsCollide = (a, b) => { 37 | const { 38 | top: aTop, 39 | left: aLeft 40 | } = getOffset(a), 41 | { 42 | top: bTop, 43 | left: bLeft 44 | } = getOffset(b), 45 | _a = helper(a), 46 | _b = helper(b); 47 | 48 | return !( 49 | ((aTop < bTop) || 50 | (aTop + parseFloat(_a.css('height'))) > (bTop + parseFloat(_b.css('height')))) || 51 | ((aLeft < bLeft) || 52 | (aLeft + parseFloat(_a.css('width'))) > (bLeft + parseFloat(_b.css('width')))) 53 | ); 54 | }; 55 | 56 | const matrixToCSS = (arr) => { 57 | const style = `matrix3d(${arr.join()})`; 58 | 59 | return { 60 | transform: style, 61 | webkitTranform: style, 62 | mozTransform: style, 63 | msTransform: style, 64 | otransform: style 65 | }; 66 | }; 67 | 68 | const getStyle = (el, property) => { 69 | const style = window.getComputedStyle(el); 70 | let value = null; 71 | 72 | for (const prefix of CSS_PREFIXES) { 73 | value = style.getPropertyValue(`${prefix}${property}`) || value; 74 | if (value) break; 75 | } 76 | 77 | return value; 78 | }; 79 | 80 | const getScrollOffset = () => { 81 | const doc = document.documentElement; 82 | return { 83 | left: (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0), 84 | top: (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0) 85 | }; 86 | }; 87 | 88 | const getElementOffset = (el) => { 89 | let left = 0; 90 | let top = 0; 91 | 92 | while (el && !isNaN(el.offsetLeft) && !isNaN(el.offsetTop)) { 93 | left += el.offsetLeft - el.scrollLeft; 94 | top += el.offsetTop - el.scrollTop; 95 | el = el.offsetParent; 96 | } 97 | return { left, top }; 98 | }; 99 | 100 | export { 101 | getOffset, 102 | addClass, 103 | removeClass, 104 | objectsCollide, 105 | matrixToCSS, 106 | getStyle, 107 | getScrollOffset, 108 | getElementOffset 109 | }; 110 | -------------------------------------------------------------------------------- /test/svg-path.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | movePath, 3 | resizePath 4 | } from '../src/js/core/transform/svg/path'; 5 | 6 | import { createSVGMatrix } from '../src/js/core/transform/svg/util'; 7 | 8 | const createElementNS = document.createElementNS; 9 | 10 | beforeEach(() => { 11 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 12 | 13 | document.createElementNS = () => { 14 | const el = svg; 15 | 16 | el.createSVGPoint = () => { 17 | return { 18 | x: 0, 19 | y: 0, 20 | matrixTransform: () => { 21 | return { x: 0, y: 0 }; 22 | } 23 | }; 24 | }; 25 | 26 | el.createSVGMatrix = () => { 27 | return { 28 | a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 29 | }; 30 | }; 31 | 32 | return el; 33 | }; 34 | }); 35 | 36 | afterEach(() => { 37 | document.createElementNS = createElementNS; 38 | }); 39 | 40 | const paths = [ 41 | 'M10 10 H 90 V 90 H 10 L 10 10', 42 | 'M10 10 h 80 v 80 h -80 Z', 43 | 'M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80', 44 | 'M10 80 Q 52.5 10, 95 80 T 180 80', 45 | 'M80 80 A 45 45, 0, 0, 0, 125 125 L 125 80 Z', 46 | 'm 78.022826,217.94212 18.390625,-15.375 91.066399,0.0391 -18.38672,15.375 z' 47 | ]; 48 | 49 | const moved = [ 50 | 'M20,0 L100,0 L100,80 L20,80 L20,0 ', 51 | 'M20,0 L100,0 L100,80 L20,80 Z', 52 | 'M20,70 C50,0,75,0,105,70 C135,140,160,140,190,70 ', 53 | 'M20,70 C48.33333333333333,23.333333333333336,76.66666666666667,23.333333333333336,105,70 C133.33333333333331,116.66666666666666,161.66666666666666,116.66666666666666,190,70 ', 54 | 'M90,70 C90,94.8528137423857,110.1471862576143,115,135,115 L135,70 Z', 55 | 'M88.022826,207.94212 L106.413451,192.56712 L197.47985,192.60621999999998 L179.09313,207.98121999999998 Z' 56 | ]; 57 | 58 | const pathExample = 'M9.573 2.28L5.593 2.21 5.593 13 4.161 15 4.165 2.29.187 2.29.181.961 9.572.964 9.571 2.29z'; 59 | 60 | const complexPath = 'm 468.40073,169.51405 c -81.79095,0.35958 -149.30336,18.16033 -156.21875,41.1875 -0.56516,1.88189 -0.7187,3.75366 -0.46875,5.59375 -0.97168,1.50623 -1.53125,3.24172 -1.53125,5.09375 l 0,340.5 c 0,2.1512 0.74899,4.153 2.03125,5.8125 l -0.21875,0 0.75,0.65625 c 0.54415,0.59928 1.1626,1.14133 1.84375,1.625 0.0188,0.0133 0.0436,0.0181 0.0625,0.0312 l 48.46875,42.3125 51.15625,44.65625 56.0625,-0.0625 56.0938,-0.0625 52.25,-44.5625 52.25,-44.59375 -1.375,0 c 1.2769,-1.6595 2.0312,-3.6613 2.0312,-5.8125 l 0,-340.5 c 0,-1.87506 -0.5369,-3.63711 -1.5312,-5.15625 3.3376,-23.89888 -60.5075,-44.52004 -145.7188,-46.5625 l -15.9375,-0.15625 z m 139.375,88.5625 -0.25,137.71875 -0.25,137.75 -11.125,2.75 c -19.7825,4.90058 -55.9925,9.875 -71.875,9.875 l -5.4063,0 0,-137.3125 0,-137.3125 4.3126,-0.5 c 2.3612,-0.279 10.4058,-1.00088 17.9062,-1.59375 21.0858,-1.66675 43.0386,-5.34696 63.9062,-10.6875 l 2.7813,-0.6875 z'; 61 | 62 | describe('SVG transform', () => { 63 | it('Apply move to svg path', () => { 64 | const result = paths.map((path) => movePath({ path, dx: 10, dy: -10 })); 65 | expect(result).toEqual(moved); 66 | }); 67 | 68 | it('Apply resize to svg path', () => { 69 | const result = paths.map((path) => resizePath({ path, localCTM: createSVGMatrix() })); 70 | expect(result).toBeDefined(); 71 | }); 72 | 73 | it('Apply move to raw path', () => { 74 | const result = movePath({ path: pathExample, dx: 0, dy: 0 }); 75 | expect(result).toEqual('M9.573,2.28 L5.593,2.21 L5.593,13 L4.161,15 L4.165,2.29 L0.187,2.29 L0.181,0.961 L9.572,0.964 L9.571,2.29 Z'); 76 | }); 77 | 78 | it('Apply resize to complex path', () => { 79 | const result = resizePath({ path: complexPath, localCTM: createSVGMatrix() }); 80 | expect(result.lastIndexOf('M') > 0).toEqual(true); 81 | }); 82 | }); 83 | 84 | -------------------------------------------------------------------------------- /types/options.d.ts: -------------------------------------------------------------------------------- 1 | type Axis = 'x' | 'y' | 'xy'; 2 | type Direction = 'n' | 's' | 'w' | 'e'; 3 | 4 | declare function noop(): void; 5 | 6 | type MimicOptions = { 7 | move?: boolean; 8 | resize?: boolean; 9 | rotate?: boolean; 10 | } 11 | 12 | type SnapOptions = { 13 | x?: number; 14 | y?: number; 15 | angle?: number; 16 | } 17 | 18 | export interface DragOptions { 19 | /** 20 | * Mimic behavior with other `Subjx` instances 21 | */ 22 | each?: MimicOptions; 23 | /** 24 | * Snapping to grid 25 | */ 26 | snap?: SnapOptions; 27 | /** 28 | * Constrain movement along an axis 29 | */ 30 | axis?: Axis; 31 | /** 32 | * Cursor style on dragging 33 | */ 34 | cursorMove?: string; 35 | /** 36 | * Cursor style on resizing / scaling 37 | */ 38 | cursorResize?: string; 39 | /** 40 | * Cursor style on rotating 41 | */ 42 | cursorRotate?: string; 43 | /** 44 | * The same as `transformOrigin` 45 | */ 46 | rotationPoint?: boolean; 47 | /** 48 | * The origin of element's transformations 49 | */ 50 | transformOrigin?: boolean; 51 | /** 52 | * Restrict element dragging / resizing / rotation within the target 53 | */ 54 | restrict?: any; 55 | /** 56 | * Allow / deny an action 57 | */ 58 | draggable?: boolean; 59 | /** 60 | * Allow / deny an action 61 | */ 62 | resizable?: boolean; 63 | /** 64 | * Allow / deny an action 65 | */ 66 | rotatable?: boolean; 67 | /** 68 | * Allow / deny an action 69 | */ 70 | scalable?: boolean; 71 | /** 72 | * Allow / deny an action 73 | */ 74 | applyTranslate?: boolean; 75 | /** 76 | * Function called on initialization 77 | */ 78 | onInit?: Function | typeof noop; 79 | /** 80 | * Function called on target dropping 81 | */ 82 | onDrop?: Function | typeof noop; 83 | /** 84 | * Function called on target dragging 85 | */ 86 | onMove?: Function | typeof noop; 87 | /** 88 | * Function called on target resizing / scaling 89 | */ 90 | onResize?: Function | typeof noop; 91 | /** 92 | * Function called on target rotating 93 | */ 94 | onRotate?: Function | typeof noop; 95 | /** 96 | * Function called on disabling 97 | */ 98 | onDestroy?: Function | typeof noop; 99 | /** 100 | * Transformation coordinate system 101 | */ 102 | container?: string | HTMLElement; 103 | /** 104 | * Parent element for `controls` 105 | */ 106 | controlsContainer?: string | HTMLElement; 107 | /** 108 | * Keeps aspect ratio on resizing 109 | */ 110 | proportions?: boolean; 111 | /** 112 | * Rotator control direction 113 | */ 114 | rotatorAnchor?: Direction; 115 | /** 116 | * Rotator control offset 117 | */ 118 | rotatorOffset?: number; 119 | /** 120 | * Show normal line 121 | */ 122 | showNormal?: boolean; 123 | } 124 | 125 | export interface CloneOptions { 126 | /** 127 | * Inline style object 128 | */ 129 | style?: Object; 130 | /** 131 | * Parent element on cloning 132 | */ 133 | appendTo?: string | HTMLElement; 134 | /** 135 | * Target element on dropping 136 | */ 137 | stack?: string | HTMLElement; 138 | /** 139 | * Function called on initialization 140 | */ 141 | onInit?: Function | typeof noop; 142 | /** 143 | * Function called on target dropping 144 | */ 145 | onDrop?: Function | typeof noop; 146 | /** 147 | * Function called on target dragging 148 | */ 149 | onMove?: Function | typeof noop; 150 | /** 151 | * Function called on disabling 152 | */ 153 | onDestroy?: Function | typeof noop; 154 | } 155 | -------------------------------------------------------------------------------- /src/js/core/SubjectModel.js: -------------------------------------------------------------------------------- 1 | import { helper } from './Helper'; 2 | import EventDispatcher from './EventDispatcher'; 3 | import { EVENT_EMITTER_CONSTANTS, CLIENT_EVENTS_CONSTANTS } from './consts'; 4 | 5 | const { E_DRAG } = EVENT_EMITTER_CONSTANTS; 6 | const { 7 | E_MOUSEMOVE, 8 | E_MOUSEUP, 9 | E_TOUCHMOVE, 10 | E_TOUCHEND 11 | } = CLIENT_EVENTS_CONSTANTS; 12 | 13 | export default class SubjectModel { 14 | 15 | constructor(elements) { 16 | this.elements = elements; 17 | this.storage = null; 18 | this.proxyMethods = null; 19 | 20 | this.eventDispatcher = new EventDispatcher(); 21 | 22 | this._onMouseDown = this._onMouseDown.bind(this); 23 | this._onTouchStart = this._onTouchStart.bind(this); 24 | this._onMouseMove = this._onMouseMove.bind(this); 25 | this._onTouchMove = this._onTouchMove.bind(this); 26 | this._onMouseUp = this._onMouseUp.bind(this); 27 | this._onTouchEnd = this._onTouchEnd.bind(this); 28 | this._animate = this._animate.bind(this); 29 | } 30 | 31 | enable(options) { 32 | this._processOptions(options); 33 | this._init(this.elements); 34 | this.proxyMethods.onInit.call(this, this.elements); 35 | } 36 | 37 | disable() { 38 | throwNotImplementedError(); 39 | } 40 | 41 | _init() { 42 | throwNotImplementedError(); 43 | } 44 | 45 | _destroy() { 46 | throwNotImplementedError(); 47 | } 48 | 49 | _processOptions() { 50 | throwNotImplementedError(); 51 | } 52 | 53 | _start() { 54 | throwNotImplementedError(); 55 | } 56 | 57 | _moving() { 58 | throwNotImplementedError(); 59 | } 60 | 61 | _end() { 62 | throwNotImplementedError(); 63 | } 64 | 65 | _animate() { 66 | throwNotImplementedError(); 67 | } 68 | 69 | _drag({ element, dx, dy, ...rest }) { 70 | const transform = this._processMove(element, { dx, dy }); 71 | 72 | const finalArgs = { 73 | dx, 74 | dy, 75 | transform, 76 | ...rest 77 | }; 78 | 79 | this.proxyMethods.onMove.call(this, finalArgs); 80 | this._emitEvent(E_DRAG, finalArgs); 81 | } 82 | 83 | _draw() { 84 | this._animate(); 85 | } 86 | 87 | _onMouseDown(e) { 88 | this._start(e); 89 | helper(document) 90 | .on(E_MOUSEMOVE, this._onMouseMove) 91 | .on(E_MOUSEUP, this._onMouseUp); 92 | } 93 | 94 | _onTouchStart(e) { 95 | this._start(e.touches[0]); 96 | helper(document) 97 | .on(E_TOUCHMOVE, this._onTouchMove) 98 | .on(E_TOUCHEND, this._onTouchEnd); 99 | } 100 | 101 | _onMouseMove(e) { 102 | if (e.preventDefault) { 103 | e.preventDefault(); 104 | } 105 | this._moving(e); 106 | } 107 | 108 | _onTouchMove(e) { 109 | if (e.preventDefault) { 110 | e.preventDefault(); 111 | } 112 | this._moving(e.touches[0]); 113 | } 114 | 115 | _onMouseUp(e) { 116 | helper(document) 117 | .off(E_MOUSEMOVE, this._onMouseMove) 118 | .off(E_MOUSEUP, this._onMouseUp); 119 | 120 | this._end( 121 | e, 122 | this.elements 123 | ); 124 | } 125 | 126 | _onTouchEnd(e) { 127 | helper(document) 128 | .off(E_TOUCHMOVE, this._onTouchMove) 129 | .off(E_TOUCHEND, this._onTouchEnd); 130 | 131 | if (e.touches.length === 0) { 132 | this._end( 133 | e.changedTouches[0], 134 | this.elements 135 | ); 136 | } 137 | } 138 | 139 | _emitEvent() { 140 | this.eventDispatcher.emit(this, ...arguments); 141 | } 142 | 143 | on(name, cb) { 144 | this.eventDispatcher.addEventListener(name, cb); 145 | return this; 146 | } 147 | 148 | off(name, cb) { 149 | this.eventDispatcher.removeEventListener(name, cb); 150 | return this; 151 | } 152 | 153 | } 154 | 155 | const throwNotImplementedError = () => { 156 | throw Error(`Method not implemented`); 157 | }; -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import babel from '@rollup/plugin-babel'; 3 | import css from 'rollup-plugin-import-css'; 4 | import terser from '@rollup/plugin-terser'; 5 | import eslint from '@rollup/plugin-eslint'; 6 | import serve from 'rollup-plugin-serve'; 7 | import livereload from 'rollup-plugin-livereload'; 8 | 9 | const libraryName = 'subjx'; 10 | 11 | const { NODE_ENV = 'production', LIVE_MODE = 'disable' } = process.env; 12 | const liveMode = LIVE_MODE === 'enable'; 13 | 14 | const production = NODE_ENV === 'production'; 15 | const development = NODE_ENV === 'development'; 16 | 17 | const banner = `/*@license 18 | * Drag/Rotate/Resize Library 19 | * Released under the MIT license, 2018-2025 20 | * Karen Sarksyan 21 | * nichollascarter@gmail.com 22 | */`; 23 | 24 | const input = './src/js/index.js'; 25 | const dir = 'dist'; 26 | 27 | let libraryFileName = libraryName; 28 | 29 | if (development) { 30 | libraryFileName += '.dev'; 31 | } 32 | 33 | const plugins = [ 34 | css({ 35 | minify: true, 36 | output: 'style/subjx.css' 37 | }), 38 | eslint({ 39 | exclude: ['node_modules/**', '**.css'], 40 | throwOnError: production 41 | }), 42 | resolve() 43 | ]; 44 | 45 | const babelPlugins = (target) => ([ 46 | babel({ 47 | exclude: 'node_modules/**', 48 | presets: ['@babel/preset-env'], 49 | babelHelpers: 'bundled', 50 | envName: target 51 | }) 52 | ]); 53 | 54 | const umdPlugins = [ 55 | ...babelPlugins('cjs'), 56 | production && terser({ 57 | compress: { 58 | evaluate: false, 59 | join_vars: false 60 | } 61 | }) 62 | ]; 63 | 64 | const bundleConfigs = [ 65 | ...(production ? [{ 66 | input, 67 | output: [{ 68 | dir, 69 | entryFileNames: `js/${libraryName}.esm.js`, 70 | format: 'esm', 71 | banner 72 | }], 73 | plugins: [ 74 | ...plugins, 75 | ...babelPlugins('esm'), 76 | terser() 77 | ] 78 | }] : []), 79 | { 80 | input, 81 | output: [{ 82 | dir, 83 | entryFileNames: `js/${libraryFileName}.common.js`, 84 | format: 'cjs', 85 | banner 86 | }], 87 | plugins: [ 88 | ...plugins, 89 | ...babelPlugins('cjs'), 90 | production && terser() 91 | ] 92 | }, 93 | { 94 | input, 95 | output: [{ 96 | name: libraryName, 97 | dir, 98 | entryFileNames: `js/${libraryFileName}.js`, 99 | format: 'umd', 100 | banner 101 | }], 102 | plugins: [ 103 | ...plugins, 104 | ...umdPlugins 105 | ] 106 | } 107 | ]; 108 | 109 | export default [ 110 | ...( 111 | liveMode 112 | ? [{ 113 | input, 114 | output: [{ 115 | name: libraryName, 116 | file: `dev/${libraryName}.js`, 117 | format: 'umd', 118 | banner 119 | }], 120 | plugins: [ 121 | css({ 122 | output: 'subjx.css' 123 | }), 124 | eslint({ 125 | exclude: ['node_modules/**', '**.css'], 126 | throwOnError: true 127 | }), 128 | resolve(), 129 | babel({ 130 | exclude: 'node_modules/**', 131 | presets: ['@babel/preset-env'], 132 | babelHelpers: 'runtime', 133 | plugins: [ 134 | ['@babel/plugin-transform-runtime', { 135 | helpers: true, 136 | regenerator: true 137 | }] 138 | ], 139 | envName: 'cjs', 140 | }), 141 | serve(['public', 'dev']), 142 | livereload('public', 'dev') 143 | ] 144 | }] 145 | : bundleConfigs 146 | ) 147 | ]; -------------------------------------------------------------------------------- /src/js/core/transform/svg/util.js: -------------------------------------------------------------------------------- 1 | import { warn, forEach } from './../../util/util'; 2 | import { addClass } from '../../util/css-util'; 3 | 4 | export const sepRE = /\s*,\s*|\s+/g; 5 | 6 | const allowedElements = [ 7 | 'circle', 'ellipse', 8 | 'image', 'line', 9 | 'path', 'polygon', 10 | 'polyline', 'rect', 11 | 'text', 'g', 'foreignobject', 12 | 'use' 13 | ]; 14 | 15 | export function createSVGElement(name, classNames = []) { 16 | const element = document.createElementNS('http://www.w3.org/2000/svg', name); 17 | classNames.forEach(className => addClass(element, className)); 18 | return element; 19 | } 20 | 21 | export const createSVGPoint = (x, y) => { 22 | const pt = createSVGElement('svg').createSVGPoint(); 23 | pt.x = x; 24 | pt.y = y; 25 | return pt; 26 | }; 27 | 28 | export const checkChildElements = (element) => { 29 | const arrOfElements = []; 30 | 31 | if (isSVGGroup(element)) { 32 | forEach.call(element.childNodes, item => { 33 | if (item.nodeType === 1) { 34 | const tagName = item.tagName.toLowerCase(); 35 | 36 | if (allowedElements.indexOf(tagName) !== -1) { 37 | if (tagName === 'g') { 38 | arrOfElements.push(...checkChildElements(item)); 39 | } 40 | arrOfElements.push(item); 41 | } 42 | } 43 | }); 44 | } else { 45 | arrOfElements.push(element); 46 | } 47 | 48 | return arrOfElements; 49 | }; 50 | 51 | export const createSVGMatrix = () => { 52 | return createSVGElement('svg').createSVGMatrix(); 53 | }; 54 | 55 | export const createTranslateMatrix = (x, y) => { 56 | const matrix = createSVGMatrix(); 57 | matrix.e = x; 58 | matrix.f = y; 59 | 60 | return matrix; 61 | }; 62 | 63 | export const createRotateMatrix = (sin, cos) => { 64 | const matrix = createSVGMatrix(); 65 | 66 | matrix.a = cos; 67 | matrix.b = sin; 68 | matrix.c = - sin; 69 | matrix.d = cos; 70 | 71 | return matrix; 72 | }; 73 | 74 | export const createScaleMatrix = (x, y) => { 75 | const matrix = createSVGMatrix(); 76 | matrix.a = x; 77 | matrix.d = y; 78 | 79 | return matrix; 80 | }; 81 | 82 | export const getTransformToElement = (toElement, g) => { 83 | const gTransform = (g.getScreenCTM && g.getScreenCTM()) || createSVGMatrix(); 84 | return gTransform.inverse().multiply( 85 | toElement.getScreenCTM() || createSVGMatrix() 86 | ); 87 | }; 88 | 89 | export const matrixToString = (m) => { 90 | const { a, b, c, d, e, f } = m; 91 | return `matrix(${a},${b},${c},${d},${e},${f})`; 92 | }; 93 | 94 | export const pointTo = (ctm, x, y) => { 95 | return createSVGPoint(x, y).matrixTransform(ctm); 96 | }; 97 | 98 | export const cloneMatrix = (b) => { 99 | const a = createSVGMatrix(); 100 | 101 | a.a = b.a; 102 | a.b = b.b; 103 | a.c = b.c; 104 | a.d = b.d; 105 | a.e = b.e; 106 | a.f = b.f; 107 | 108 | return a; 109 | }; 110 | 111 | export const isIdentity = (matrix) => { 112 | const { a, b, c, d, e, f } = matrix; 113 | return a === 1 && 114 | b === 0 && 115 | c === 0 && 116 | d === 1 && 117 | e === 0 && 118 | f === 0; 119 | }; 120 | 121 | export const checkElement = (el) => { 122 | const tagName = el.tagName.toLowerCase(); 123 | 124 | if (allowedElements.indexOf(tagName) === -1) { 125 | warn( 126 | `Selected element "${tagName}" is not allowed to transform. Allowed elements:\n 127 | circle, ellipse, image, line, path, polygon, polyline, rect, text, g` 128 | ); 129 | return false; 130 | } else { 131 | return true; 132 | } 133 | }; 134 | 135 | export const isSVGGroup = (element) => ( 136 | element.tagName.toLowerCase() === 'g' 137 | ); 138 | 139 | export const normalizeString = (str = '') => ( 140 | str.replace(/[\n\r]/g, '') 141 | .replace(/([^e])-/g, '$1 -') 142 | .replace(/ +/g, ' ') 143 | .replace(/(\d*\.)(\d+)(?=\.)/g, '$1$2 ') 144 | ); 145 | 146 | // example "101.3,175.5 92.3,162 110.3,162 " 147 | export const parsePoints = (pts) => ( 148 | normalizeString(pts).trim().split(sepRE).reduce( 149 | (result, _, index, array) => { 150 | if (index % 2 === 0) { 151 | result.push(array.slice(index, index + 2)); 152 | } 153 | return result; 154 | }, 155 | [] 156 | ) 157 | ); 158 | 159 | export const arrayToChunks = (a, size) => 160 | Array.from( 161 | new Array(Math.ceil(a.length / size)), 162 | (_, i) => a.slice(i * size, i * size + size) 163 | ); -------------------------------------------------------------------------------- /src/js/core/clone/Cloneable.js: -------------------------------------------------------------------------------- 1 | import { helper } from '../Helper'; 2 | import SubjectModel from '../SubjectModel'; 3 | import { EVENT_EMITTER_CONSTANTS, CLIENT_EVENTS_CONSTANTS } from '../consts'; 4 | 5 | import { 6 | requestAnimFrame, 7 | cancelAnimFrame, 8 | isDef, 9 | isUndef, 10 | isFunc, 11 | createMethod, 12 | noop 13 | } from '../util/util'; 14 | 15 | import { 16 | getOffset, 17 | objectsCollide 18 | } from '../util/css-util'; 19 | 20 | const { EMITTER_EVENTS } = EVENT_EMITTER_CONSTANTS; 21 | const { E_MOUSEDOWN, E_TOUCHSTART } = CLIENT_EVENTS_CONSTANTS; 22 | 23 | export default class Cloneable extends SubjectModel { 24 | 25 | constructor(elements, options) { 26 | super(elements); 27 | this.enable(options); 28 | } 29 | 30 | _init() { 31 | const { 32 | elements, 33 | options 34 | } = this; 35 | 36 | const { 37 | style, 38 | appendTo 39 | } = options; 40 | 41 | const nextStyle = { 42 | position: 'absolute', 43 | 'z-index': '2147483647', 44 | ...style 45 | }; 46 | 47 | const data = new WeakMap(); 48 | 49 | elements.map(element => ( 50 | data.set(element, { 51 | parent: isDef(appendTo) ? helper(appendTo)[0] : document.body 52 | }) 53 | )); 54 | 55 | this.storage = { 56 | style: nextStyle, 57 | data 58 | }; 59 | 60 | helper(elements).on(E_MOUSEDOWN, this._onMouseDown) 61 | .on(E_TOUCHSTART, this._onTouchStart); 62 | 63 | EMITTER_EVENTS.slice(0, 3).forEach((eventName) => ( 64 | this.eventDispatcher.registerEvent(eventName) 65 | )); 66 | } 67 | 68 | _processOptions(options = {}) { 69 | const { 70 | style = {}, 71 | appendTo = null, 72 | stack = document.body, 73 | onInit = noop, 74 | onMove = noop, 75 | onDrop = noop, 76 | onDestroy = noop 77 | } = options; 78 | 79 | const dropable = helper(stack)[0]; 80 | 81 | const _onDrop = isFunc(onDrop) 82 | ? function (evt) { 83 | const { storage: { clone } = {} } = this; 84 | 85 | const isCollide = objectsCollide(clone, dropable); 86 | 87 | if (isCollide) { 88 | onDrop.call(this, evt, this.elements, clone); 89 | } 90 | } 91 | : noop; 92 | 93 | this.options = { 94 | style, 95 | appendTo, 96 | stack 97 | }; 98 | 99 | this.proxyMethods = { 100 | onInit: createMethod(onInit), 101 | onDrop: _onDrop, 102 | onMove: createMethod(onMove), 103 | onDestroy: createMethod(onDestroy) 104 | }; 105 | } 106 | 107 | _start({ target, clientX, clientY }) { 108 | const { 109 | elements, 110 | storage, 111 | storage: { 112 | data, 113 | style 114 | } 115 | } = this; 116 | 117 | const element = elements.find(el => el === target || el.contains(target)); 118 | 119 | if (!element) return; 120 | 121 | const { 122 | parent = element.parentNode 123 | } = data.get(element) || {}; 124 | 125 | const { left, top } = getOffset(parent); 126 | 127 | style.left = `${(clientX - left)}px`; 128 | style.top = `${(clientY - top)}px`; 129 | 130 | const clone = element.cloneNode(true); 131 | helper(clone).css(style); 132 | 133 | storage.clientX = clientX; 134 | storage.clientY = clientY; 135 | storage.cx = clientX; 136 | storage.cy = clientY; 137 | storage.clone = clone; 138 | 139 | parent.appendChild(clone); 140 | this._draw(); 141 | } 142 | 143 | _moving({ clientX, clientY }) { 144 | const { storage } = this; 145 | 146 | storage.clientX = clientX; 147 | storage.clientY = clientY; 148 | storage.doDraw = true; 149 | storage.doMove = true; 150 | } 151 | 152 | _end(e) { 153 | const { storage } = this; 154 | 155 | const { 156 | clone, 157 | frameId 158 | } = storage; 159 | 160 | storage.doDraw = false; 161 | cancelAnimFrame(frameId); 162 | 163 | if (isUndef(clone)) return; 164 | 165 | this.proxyMethods.onDrop.call(this, e); 166 | clone.parentNode.removeChild(clone); 167 | 168 | delete storage.clone; 169 | } 170 | 171 | _animate() { 172 | const { storage } = this; 173 | 174 | storage.frameId = requestAnimFrame(this._animate); 175 | 176 | const { 177 | doDraw, 178 | clientX, 179 | clientY, 180 | cx, 181 | cy, 182 | clone 183 | } = storage; 184 | 185 | if (!doDraw) return; 186 | storage.doDraw = false; 187 | 188 | this._drag( 189 | { 190 | element: clone, 191 | dx: clientX - cx, 192 | dy: clientY - cy 193 | } 194 | ); 195 | } 196 | 197 | _processMove(_, { dx, dy }) { 198 | const { 199 | storage: { 200 | clone 201 | } = {} 202 | } = this; 203 | 204 | const transformCommand = `translate(${dx}px, ${dy}px)`; 205 | 206 | helper(clone).css({ 207 | transform: transformCommand, 208 | webkitTranform: transformCommand, 209 | mozTransform: transformCommand, 210 | msTransform: transformCommand, 211 | otransform: transformCommand 212 | }); 213 | } 214 | 215 | _destroy() { 216 | const { 217 | storage, 218 | proxyMethods, 219 | elements 220 | } = this; 221 | 222 | if (isUndef(storage)) return; 223 | 224 | helper(elements) 225 | .off(E_MOUSEDOWN, this._onMouseDown) 226 | .off(E_TOUCHSTART, this._onTouchStart); 227 | 228 | proxyMethods.onDestroy.call(this, elements); 229 | delete this.storage; 230 | } 231 | 232 | disable() { 233 | this._destroy(); 234 | } 235 | 236 | } -------------------------------------------------------------------------------- /src/js/core/Helper.js: -------------------------------------------------------------------------------- 1 | import { 2 | isDef, 3 | isUndef, 4 | arrSlice, 5 | warn 6 | } from './util/util'; 7 | 8 | export default class Helper { 9 | 10 | constructor(params) { 11 | if (typeof params === 'string') { 12 | const selector = document.querySelectorAll(params); 13 | this.length = selector.length; 14 | for (let count = 0; count < this.length; count++) { 15 | this[count] = selector[count]; 16 | } 17 | } else if (typeof params === 'object' && 18 | (params.nodeType === 1 || params === document)) { 19 | this[0] = params; 20 | this.length = 1; 21 | } else if (params instanceof Helper) { 22 | this.length = params.length; 23 | for (let count = 0; count < this.length; count++) { 24 | this[count] = params[count]; 25 | } 26 | } else if (isIterable(params)) { 27 | this.length = params.length; 28 | for (let count = 0; count < this.length; count++) { 29 | if (params[count].nodeType === 1) { 30 | this[count] = params[count]; 31 | } 32 | } 33 | } else { 34 | throw new Error(`Passed parameter must be selector/element/elementArray`); 35 | } 36 | } 37 | 38 | css(prop) { 39 | const _getStyle = obj => { 40 | let len = obj.length; 41 | 42 | while (len--) { 43 | if (obj[len].currentStyle) { 44 | return obj[len].currentStyle[prop]; 45 | } else if (document.defaultView && document.defaultView.getComputedStyle) { 46 | return document.defaultView.getComputedStyle(obj[len], '')[prop]; 47 | } else { 48 | return obj[len].style[prop]; 49 | } 50 | } 51 | }; 52 | 53 | const _setStyle = (obj, options) => { 54 | let len = obj.length; 55 | 56 | while (len--) { 57 | for (const property in options) { 58 | obj[len].style[property] = options[property]; 59 | } 60 | } 61 | return obj.style; 62 | }; 63 | 64 | const methods = { 65 | setStyle(options) { 66 | return _setStyle(this, options); 67 | }, 68 | getStyle() { 69 | return _getStyle(this); 70 | } 71 | }; 72 | 73 | if (typeof prop === 'string') { 74 | return methods.getStyle.apply(this, arrSlice.call(arguments, 1)); 75 | } else if (typeof prop === 'object' || !prop) { 76 | return methods.setStyle.apply(this, arguments); 77 | } else { 78 | warn(`Method ${prop} does not exist`); 79 | } 80 | return false; 81 | } 82 | 83 | on() { 84 | let len = this.length; 85 | 86 | while (len--) { 87 | if (!this[len].events) { 88 | this[len].events = {}; 89 | this[len].events[arguments[0]] = []; 90 | } 91 | 92 | if (typeof (arguments[1]) !== 'string') { 93 | if (document.addEventListener) { 94 | this[len].addEventListener( 95 | arguments[0], 96 | arguments[1], 97 | arguments[2] || { passive: false } 98 | ); 99 | } else if (document.attachEvent) { 100 | this[len].attachEvent(`on${arguments[0]}`, arguments[1]); 101 | } else { 102 | this[len][`on${arguments[0]}`] = arguments[1]; 103 | } 104 | } else { 105 | listenerDelegate( 106 | this[len], 107 | arguments[0], 108 | arguments[1], 109 | arguments[2], 110 | arguments[3], 111 | true 112 | ); 113 | } 114 | } 115 | return this; 116 | } 117 | 118 | off() { 119 | let len = this.length; 120 | 121 | while (len--) { 122 | if (!this[len].events) { 123 | this[len].events = {}; 124 | this[len].events[arguments[0]] = []; 125 | } 126 | 127 | if (typeof (arguments[1]) !== 'string') { 128 | if (document.removeEventListener) { 129 | this[len].removeEventListener(arguments[0], arguments[1], arguments[2]); 130 | } else if (document.detachEvent) { 131 | this[len].detachEvent(`on${arguments[0]}`, arguments[1]); 132 | } else { 133 | this[len][`on${arguments[0]}`] = null; 134 | } 135 | } else { 136 | listenerDelegate(this[len], arguments[0], arguments[1], arguments[2], arguments[3], false); 137 | } 138 | } 139 | 140 | return this; 141 | } 142 | 143 | is(selector) { 144 | if (isUndef(selector)) return false; 145 | 146 | const _sel = helper(selector); 147 | let len = this.length; 148 | 149 | while (len--) { 150 | if (this[len] === _sel[len]) return true; 151 | } 152 | return false; 153 | } 154 | 155 | } 156 | 157 | function listenerDelegate(el, evt, sel, handler, options, act) { 158 | const doit = function (event) { 159 | let t = event.target; 160 | while (t && t !== this) { 161 | if (t.matches(sel)) { 162 | handler.call(t, event); 163 | } 164 | t = t.parentNode; 165 | } 166 | }; 167 | 168 | if (act === true) { 169 | if (document.addEventListener) { 170 | el.addEventListener(evt, doit, options || { passive: false }); 171 | } else if (document.attachEvent) { 172 | el.attachEvent(`on${evt}`, doit); 173 | } else { 174 | el[`on${evt}`] = doit; 175 | } 176 | } else { 177 | if (document.removeEventListener) { 178 | el.removeEventListener(evt, doit, options || { passive: false }); 179 | } else if (document.detachEvent) { 180 | el.detachEvent(`on${evt}`, doit); 181 | } else { 182 | el[`on${evt}`] = null; 183 | } 184 | } 185 | } 186 | 187 | function isIterable(obj) { 188 | return isDef(obj) && 189 | typeof obj === 'object' && 190 | ( 191 | Array.isArray(obj) || 192 | ( 193 | isDef(window.Symbol) && 194 | typeof obj[window.Symbol.iterator] === 'function' 195 | ) || 196 | isDef(obj.forEach) || 197 | ( 198 | typeof (obj.length) === 'number' && 199 | (obj.length === 0 || 200 | (obj.length > 0 && 201 | (obj.length - 1) in obj) 202 | ) 203 | ) 204 | ); 205 | } 206 | 207 | export function helper(params) { 208 | return new Helper(params); 209 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.1.2](https://github.com/nichollascarter/subjx/compare/v1.1.1...v1.1.2) (2025-10-13) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **types:** make "observable" argument for drag method optional ([04fb831](https://github.com/nichollascarter/subjx/commit/04fb8312a20c9ec451e9ad57541373942f62762c)) 7 | 8 | ## [1.1.2-rc.1](https://github.com/nichollascarter/subjx/compare/v1.1.1...v1.1.2-rc.1) (2025-09-16) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **types:** make "observable" argument for drag method optional ([04fb831](https://github.com/nichollascarter/subjx/commit/04fb8312a20c9ec451e9ad57541373942f62762c)) 14 | 15 | ## [1.1.1](https://github.com/nichollascarter/subjx/compare/v1.1.0...v1.1.1) (2023-11-04) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * **core:** fixed issue on rotating to 90deg ([163e06d](https://github.com/nichollascarter/subjx/commit/163e06dabbf5ed4e956ad33ce7a0738819c59abb)), closes [#47](https://github.com/nichollascarter/subjx/issues/47) 21 | 22 | ## [1.1.1-rc.1](https://github.com/nichollascarter/subjx/compare/v1.1.0...v1.1.1-rc.1) (2023-01-06) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **core:** fixed issue on rotating to 90deg ([163e06d](https://github.com/nichollascarter/subjx/commit/163e06dabbf5ed4e956ad33ce7a0738819c59abb)), closes [#47](https://github.com/nichollascarter/subjx/issues/47) 28 | 29 | # [1.1.0-rc.5](https://github.com/nichollascarter/subjx/compare/v1.1.0-rc.4...v1.1.0-rc.5) (2022-10-03) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * **core:** fixed issue on rotating to 90deg ([163e06d](https://github.com/nichollascarter/subjx/commit/163e06dabbf5ed4e956ad33ce7a0738819c59abb)), closes [#47](https://github.com/nichollascarter/subjx/issues/47) 35 | 36 | # [1.1.0](https://github.com/nichollascarter/subjx/compare/v1.0.0...v1.1.0) (2022-07-04) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * fix wrong transform-origin position for nested elements ([93af678](https://github.com/nichollascarter/subjx/commit/93af67887834b0c455ab8cbb38b8d7a7765d9ae5)) 42 | * **core:** fixed tests ([5d81abe](https://github.com/nichollascarter/subjx/commit/5d81abe94f745b25d4d539d081bcd14a9870003a)) 43 | * **src:** fixed undefined containerMatrix value on applying alignment ([6b20349](https://github.com/nichollascarter/subjx/commit/6b20349b3ca408297d923f64646bc69ac860245c)) 44 | * **core:** set correct transform origin, allow alignment for groupable html elements ([cf29d29](https://github.com/nichollascarter/subjx/commit/cf29d2910cbea89c83864b98d382cbfdda535531)) 45 | 46 | ### Features 47 | 48 | * **src:** add setCenterPoint method ([7995cf5](https://github.com/nichollascarter/subjx/commit/7995cf504434d98e60bfa6e240a7e14eee8372fb)), closes [#56](https://github.com/nichollascarter/subjx/issues/56) 49 | * added transform origin support ([75e33de](https://github.com/nichollascarter/subjx/commit/75e33de273d896b3b3e64593be123bea4dd6d64f)) 50 | 51 | # [1.1.0-rc.4](https://github.com/nichollascarter/subjx/compare/v1.1.0-rc.3...v1.1.0-rc.4) (2022-03-21) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * **core:** fixed tests ([5d81abe](https://github.com/nichollascarter/subjx/commit/5d81abe94f745b25d4d539d081bcd14a9870003a)) 57 | * **core:** set correct transform origin, allow alignment for groupable html elements ([cf29d29](https://github.com/nichollascarter/subjx/commit/cf29d2910cbea89c83864b98d382cbfdda535531)) 58 | 59 | # [1.1.0-rc.3](https://github.com/nichollascarter/subjx/compare/v1.1.0-rc.2...v1.1.0-rc.3) (2022-03-02) 60 | 61 | 62 | ### Bug Fixes 63 | 64 | * **src:** fixed undefined containerMatrix value on applying alignment ([6b20349](https://github.com/nichollascarter/subjx/commit/6b20349b3ca408297d923f64646bc69ac860245c)) 65 | 66 | # [1.1.0-rc.2](https://github.com/nichollascarter/subjx/compare/v1.1.0-rc.1...v1.1.0-rc.2) (2022-02-14) 67 | 68 | 69 | ### Bug Fixes 70 | 71 | * fix wrong transform-origin position for nested elements ([93af678](https://github.com/nichollascarter/subjx/commit/93af67887834b0c455ab8cbb38b8d7a7765d9ae5)) 72 | 73 | # [1.1.0-rc.2](https://github.com/nichollascarter/subjx/compare/v1.1.0-rc.1...v1.1.0-rc.2) (2022-02-14) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * fix wrong transform-origin position for nested elements ([93af678](https://github.com/nichollascarter/subjx/commit/93af67887834b0c455ab8cbb38b8d7a7765d9ae5)) 79 | 80 | # [1.1.0-rc.2](https://github.com/nichollascarter/subjx/compare/v1.1.0-rc.1...v1.1.0-rc.2) (2022-02-14) 81 | 82 | 83 | ### Bug Fixes 84 | 85 | * fix wrong transform-origin position for nested elements ([93af678](https://github.com/nichollascarter/subjx/commit/93af67887834b0c455ab8cbb38b8d7a7765d9ae5)) 86 | 87 | # [1.1.0-rc.2](https://github.com/nichollascarter/subjx/compare/v1.1.0-rc.1...v1.1.0-rc.2) (2022-02-14) 88 | 89 | 90 | ### Bug Fixes 91 | 92 | * fix wrong transform-origin position for nested elements ([93af678](https://github.com/nichollascarter/subjx/commit/93af67887834b0c455ab8cbb38b8d7a7765d9ae5)) 93 | 94 | # [1.1.0-rc.1](https://github.com/nichollascarter/subjx/compare/v1.0.0...v1.1.0-rc.1) (2022-02-13) 95 | 96 | 97 | ### Features 98 | 99 | * **src:** add setCenterPoint method ([7995cf5](https://github.com/nichollascarter/subjx/commit/7995cf504434d98e60bfa6e240a7e14eee8372fb)), closes [#56](https://github.com/nichollascarter/subjx/issues/56) 100 | * added transform origin support ([75e33de](https://github.com/nichollascarter/subjx/commit/75e33de273d896b3b3e64593be123bea4dd6d64f)) 101 | 102 | # [1.0.0](https://github.com/nichollascarter/subjx/compare/v0.3.9...v1.0.0) (2021-12-15) 103 | 104 | 105 | ### Bug Fixes 106 | 107 | * fix move command issue ([28ef4c7](https://github.com/nichollascarter/subjx/commit/28ef4c7eee521940a48ee54c3c8d4155019fd04d)) 108 | * **src:** fix rotation issue on HTML element and rotation point reseting on SVG element ([4e1a5a3](https://github.com/nichollascarter/subjx/commit/4e1a5a36e2e2c6f40e8a549ce8c20f28fdd3eda8)), closes [#58](https://github.com/nichollascarter/subjx/issues/58) 109 | * **src:** prevent rotation point moving when groupable is rotating ([080cf48](https://github.com/nichollascarter/subjx/commit/080cf486bf3749b636610141cd825d989bc35d4e)), closes [#58](https://github.com/nichollascarter/subjx/issues/58) 110 | 111 | 112 | ### Features 113 | 114 | * prepare prerelease version for supporting groupable feature ([92abb4b](https://github.com/nichollascarter/subjx/commit/92abb4bc12c78c3739233593adefa8b063b73d62)) 115 | 116 | 117 | ### BREAKING CHANGES 118 | 119 | * Change API 120 | 121 | # [1.0.0-rc.4](https://github.com/nichollascarter/subjx/compare/v1.0.0-rc.3...v1.0.0-rc.4) (2021-12-14) 122 | 123 | 124 | ### Bug Fixes 125 | 126 | * **src:** prevent rotation point moving when groupable is rotating ([080cf48](https://github.com/nichollascarter/subjx/commit/080cf486bf3749b636610141cd825d989bc35d4e)), closes [#58](https://github.com/nichollascarter/subjx/issues/58) 127 | 128 | # [1.0.0-rc.3](https://github.com/nichollascarter/subjx/compare/v1.0.0-rc.2...v1.0.0-rc.3) (2021-12-12) 129 | 130 | 131 | ### Bug Fixes 132 | 133 | * **src:** fix rotation issue on HTML element and rotation point reseting on SVG element ([4e1a5a3](https://github.com/nichollascarter/subjx/commit/4e1a5a36e2e2c6f40e8a549ce8c20f28fdd3eda8)), closes [#58](https://github.com/nichollascarter/subjx/issues/58) 134 | 135 | # [1.0.0-rc.2](https://github.com/nichollascarter/subjx/compare/v1.0.0-rc.1...v1.0.0-rc.2) (2021-11-09) 136 | 137 | 138 | ### Bug Fixes 139 | 140 | * fix move command issue ([28ef4c7](https://github.com/nichollascarter/subjx/commit/28ef4c7eee521940a48ee54c3c8d4155019fd04d)) 141 | 142 | # [1.0.0-rc.1](https://github.com/nichollascarter/subjx/compare/v0.3.9...v1.0.0-rc.1) (2021-11-02) 143 | 144 | 145 | ### Features 146 | 147 | * prepare prerelease version for supporting groupable feature ([92abb4b](https://github.com/nichollascarter/subjx/commit/92abb4bc12c78c3739233593adefa8b063b73d62)) 148 | 149 | 150 | ### BREAKING CHANGES 151 | 152 | * Change API 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Subjx(dragging/resizing/rotating) 3 |

4 | 5 |

6 | 7 |

8 | 9 |

10 | Draggable, Resizable, Rotatable library for creating drag-n-drop applications. 11 |

12 | 13 | ## Demos 14 | 15 | ### [Basic example](http://jsfiddle.net/nichollascarter/qgwzch0v/) 16 | 17 | ### [Drag, zoom and pan SVG](https://codesandbox.io/s/svg-drag-pan-zoom-wb95s) 18 | 19 | ## Usage 20 | 21 | Library provides dragging/resizing/rotating/snapping SVG/HTML Elements. 22 | 23 | ## Installation 24 | 25 | Run `npm install` to install with `npm`. 26 | 27 | ``` 28 | npm install subjx 29 | ``` 30 | 31 | Including via a ` 35 | ``` 36 | 37 | ## Get started 38 | 39 | Main function `subjx` returns `Subjx` instance which based on elements finded by 40 | passed parameters: 41 | 42 | ```javascript 43 | import subjx from 'subjx'; 44 | import 'subjx/dist/style/subjx.css'; 45 | 46 | // possible parameters 47 | const xElem = subjx( 'selector' ) | 48 | subjx( element ) | 49 | subjx( elementArray ); 50 | ``` 51 | 52 | ## Transformation(drag/resize/rotate) 53 | 54 | ```javascript 55 | // enabling tool by `drag` method with the optional parameters 56 | // by default just call `.drag()` 57 | const xDraggable = xElem.drag(); 58 | 59 | // for disabling use `disable` method for each object 60 | xDraggable.disable(); 61 | ``` 62 | 63 | 64 | ### "Draggable" API 65 | 66 | ```javascript 67 | // getter returns root DOM element of 'controls' 68 | xDraggable.controls; 69 | 70 | // provides access to useful options 71 | xDraggable.storage; 72 | // for example: to get reference to any handle's DOM 73 | const { 74 | handles: { tl, tr, ...etc } 75 | } = xDraggable.storage; 76 | 77 | // enables dragging 78 | // there is no need to call this method manually 79 | xDraggable.enable(options); 80 | 81 | // disables dragging, removes controls and handles 82 | xDraggable.disable(); 83 | 84 | // adds event listener for some events 85 | xDraggable.on(eventName, cb); 86 | 87 | // removes event listener for some events 88 | xDraggable.off(eventName, cb); 89 | 90 | // Event names 91 | const EVENTS = [ 92 | 'dragStart', 93 | 'drag', 94 | 'dragEnd', 95 | 'resizeStart', 96 | 'resize', 97 | 'resizeEnd', 98 | 'rotateStart', 99 | 'rotate', 100 | 'rotateEnd' 101 | ]; 102 | 103 | // execute dragging manually 104 | xDraggable.exeDrag({ 105 | dx, // drag along the x axis 106 | dy // drag along the y axis 107 | }); 108 | 109 | // execute resizing manually 110 | xDraggable.exeResize({ 111 | dx, // resize along the x axis 112 | dy, // resize along the y axis 113 | revX, // reverse resizing along the x axis 114 | revY, // reverse resizing along the y axis 115 | doW, // allow width resizing 116 | doH // allow height resizing 117 | }); 118 | 119 | // execute rotating manually 120 | xDraggable.exeRotate({ 121 | delta // radians 122 | }); 123 | 124 | // Align element inside container: ['t', 'l', 'r', 'b', 'v', 'h'] 125 | xDraggable.applyAlignment('tr'); 126 | 127 | // Call this method when applying scale or viewBox values changing 128 | // useful when element's container was transformed from outside 129 | xDraggable.fitControlsToSize(); 130 | 131 | // Sets the origin for an element's transformations 132 | xDraggable.setTransformOrigin( 133 | { 134 | x, // absolute the origin's position x coordinate 135 | y, // absolute he origin's position y coordinate 136 | dx, // offset the origin's position x coordinate 137 | dy // offset the origin's position y coordinate 138 | }, 139 | pin // leaves current origin fixed if true or not if false 140 | ); 141 | 142 | // Sets transform origin to default 143 | xDraggable.resetTransformOrigin(); 144 | 145 | // Returns element's current dimensions 146 | xDraggable.getDimensions(); 147 | ``` 148 | 149 | ### Options 150 | 151 | |Property|Description|Type|Default| 152 | |--|--|--|--| 153 | | **container** | Transformation coordinate system | `'selector'` \| `element` | element.parentNode | 154 | | **controlsContainer** | Parent element of 'controls' | `'selector'` \| `element` | element.parentNode | 155 | | **axis** | Constrain movement along an axis | `string`: 'x' \| 'y' \| 'xy' | 'xy' | 156 | | **snap** | Snapping to grid in pixels/radians | `object` | { x: 10, y: 10, angle: 10 } | 157 | | **each** | Mimic behavior with other '.draggable' elements | `object` | { move: false, resize: false, rotate: false } | 158 | | **proportions** | Keep aspect ratio on resizing / scaling | `boolean` | false | 159 | | **draggable** | Allow or deny an action | `boolean` | true | 160 | | **resizable** | Allow or deny an action | `boolean` | true | 161 | | **rotatable** | Allow or deny an action | `boolean` | true | 162 | | **scalable** | Applies scaling only to root element | `boolean` | false | 163 | | **restrict** | Restricts element dragging/resizing/rotation | `'selector'` \| `element` | - | 164 | | **rotatorAnchor** | Rotator anchor direction | `string`: 'n' \| 's' \| 'w' \| 'e' | 'e' | 165 | | **rotatorOffset** | Rotator offset | `number` | 50 | 166 | | **transformOrigin** | Sets the origin for an element's transformations | `boolean` \| Array | false | 167 | 168 | #### Notice: In most cases, it is recommended to use 'proportions' option 169 | 170 | ### Methods 171 | 172 | ```javascript 173 | subjx('.draggable').drag({ 174 | onInit(elements) { 175 | // fires on tool activation 176 | }, 177 | onMove({ clientX, clientY, dx, dy, transform }) { 178 | // fires on moving 179 | }, 180 | onResize({ clientX, clientY, dx, dy, transform, width, height }) { 181 | // fires on resizing 182 | }, 183 | onRotate({ clientX, clientY, delta, transform }) { 184 | // fires on rotation 185 | }, 186 | onDrop({ clientX, clientY }) { 187 | // fires on drop 188 | }, 189 | onDestroy(el) { 190 | // fires on tool deactivation 191 | } 192 | }); 193 | ``` 194 | 195 | Subscribing new draggable element to previously activated(useful with `each` option) 196 | 197 | ```javascript 198 | const options = {}; 199 | const observable = subjx.createObservable(); 200 | subjx('.draggable').drag(options, observable); 201 | 202 | // pass Observable to new element 203 | const createDraggableAndSubscribe = e => { 204 | subjx(e.target).drag(options, observable); 205 | }; 206 | ``` 207 | 208 | Allowed SVG elements: 209 | `g`, `path`, `rect`, `ellipse`, `line`, `polyline`, `polygon`, `circle` 210 | 211 | ## Cloning 212 | 213 | ### Options 214 | 215 | ```javascript 216 | const xCloneable = xElem.clone({ 217 | // dropping area 218 | stack: 'selector', 219 | // set clone parent 220 | appendTo: 'selector', 221 | // set clone additional style 222 | style: { 223 | border: '1px dashed green', 224 | background: 'transparent' 225 | } 226 | }); 227 | ``` 228 | 229 | ### Methods 230 | 231 | ```javascript 232 | subjx('.cloneable').clone({ 233 | onInit(el) { 234 | // fires on tool activation 235 | }, 236 | onMove(dx, dy) { 237 | // fires on moving 238 | }, 239 | onDrop(e) { 240 | // fires on drop 241 | }, 242 | onDestroy() { 243 | // fires on tool deactivation 244 | } 245 | }); 246 | ``` 247 | 248 | Disabling 249 | 250 | ```javascript 251 | xCloneable.disable(); 252 | ``` 253 | 254 | ## License 255 | 256 | MIT (c) Karen Sarksyan 257 | -------------------------------------------------------------------------------- /test/matrix.test.js: -------------------------------------------------------------------------------- 1 | import * as matrixUtil from '../src/js/core/transform/matrix'; 2 | 3 | document.body.innerHTML = ` 4 |
5 |
6 |
10 |
14 |
15 |
16 | `; 17 | 18 | const { 19 | cloneMatrix, 20 | flatMatrix, 21 | createIdentityMatrix, 22 | createTranslateMatrix, 23 | createScaleMatrix, 24 | createRotateMatrix, 25 | dropTranslate, 26 | multiplyMatrixAndPoint, 27 | multiplyMatrix, 28 | matrixInvert, 29 | computeTransformMatrix, 30 | decompose, 31 | getTransform, 32 | getTransformOrigin, 33 | getAbsoluteOffset 34 | } = matrixUtil; 35 | 36 | describe('Matrix util test', () => { 37 | it('creates flat matrix', () => { 38 | const matrix3d = [ 39 | [1, 0, 0, 0], 40 | [0, 1, 0, 0], 41 | [0, 0, 1, 0], 42 | [0, 0, 0, 1] 43 | ]; 44 | 45 | expect(flatMatrix(matrix3d)).toMatchObject([ 46 | 1, 0, 0, 0, 47 | 0, 1, 0, 0, 48 | 0, 0, 1, 0, 49 | 0, 0, 0, 1 50 | ]); 51 | }); 52 | 53 | it('creates identity matrix', () => { 54 | expect(createIdentityMatrix()).toMatchObject([ 55 | [1, 0, 0, 0], 56 | [0, 1, 0, 0], 57 | [0, 0, 1, 0], 58 | [0, 0, 0, 1] 59 | ]); 60 | }); 61 | 62 | it('creates translate matrix', () => { 63 | const x = 10, 64 | y = 20; 65 | 66 | expect(createTranslateMatrix(x, y)).toMatchObject([ 67 | [1, 0, 0, x], 68 | [0, 1, 0, y], 69 | [0, 0, 1, 0], 70 | [0, 0, 0, 1] 71 | ]); 72 | }); 73 | 74 | it('creates scale matrix', () => { 75 | const x = 2, 76 | y = 3; 77 | 78 | expect(createScaleMatrix(x, y)).toMatchObject([ 79 | [x, 0, 0, 0], 80 | [0, y, 0, 0], 81 | [0, 0, 1, 0], 82 | [0, 0, 0, 1] 83 | ]); 84 | }); 85 | 86 | it('creates rotate matrix', () => { 87 | const sin = 2, 88 | cos = 2; 89 | 90 | expect(createRotateMatrix(sin, cos)).toMatchObject([ 91 | [cos, -sin, 0, 0], 92 | [sin, cos, 0, 0], 93 | [0, 0, 1, 0], 94 | [0, 0, 0, 1] 95 | ]); 96 | }); 97 | 98 | it('drops translate effect from matrix', () => { 99 | const translateMatrix = [ 100 | [1.5, 0, 0, 10], 101 | [0, 1.5, 0, 20], 102 | [0, 0, 1.5, 0], 103 | [0, 0, 0, 1] 104 | ]; 105 | 106 | expect(dropTranslate(translateMatrix)).toMatchObject([ 107 | [1.5, 0, 0, 0], 108 | [0, 1.5, 0, 0], 109 | [0, 0, 1.5, 0], 110 | [0, 0, 0, 1] 111 | ]); 112 | }); 113 | 114 | it('clones matrix', () => { 115 | const originMatrix = [ 116 | [1.5, 0, 0, 10], 117 | [0, 1.5, 0, 20], 118 | [0, 0, 1.5, 0], 119 | [0, 0, 0, 1] 120 | ]; 121 | 122 | expect(cloneMatrix(originMatrix)).toMatchObject([ 123 | [1.5, 0, 0, 10], 124 | [0, 1.5, 0, 20], 125 | [0, 0, 1.5, 0], 126 | [0, 0, 0, 1] 127 | ]); 128 | }); 129 | 130 | it('get 3d transform matrix of element', () => { 131 | const el = document.getElementById('draggable'); 132 | expect(getTransform(el)).toMatchObject([ 133 | [1.5, 0, 5, 0], 134 | [0, 1.5, 0, 0], 135 | [0, 0, 1, -7], 136 | [10, 0, 0, 1] 137 | ]); 138 | }); 139 | 140 | it('get transform matrix of element', () => { 141 | const el = document.getElementById('draggable2'); 142 | expect(getTransform(el)).toMatchObject([ 143 | [2, 0, 0, 10], 144 | [0, 1.5, 0, 20], 145 | [0, 0, 1, 0], 146 | [0, 0, 0, 1] 147 | ]); 148 | }); 149 | 150 | it('get transform origin of element', () => { 151 | const el = document.getElementById('draggable'); 152 | expect(getTransformOrigin(el, false)).toMatchObject([0, 0, 0, 1]); 153 | }); 154 | 155 | it('multiplies matrices', () => { 156 | const preMatrix = [ 157 | [1.5, 0, 0, 0], 158 | [0, 1.5, 0, 0], 159 | [0, 0, 1.5, 0], 160 | [0, 0, 0, 1] 161 | ]; 162 | 163 | const postMatrix = [ 164 | [1, 0, 0, 10], 165 | [0, 1, 0, 20], 166 | [0, 0, 1, 0], 167 | [0, 0, 0, 1] 168 | ]; 169 | 170 | expect(multiplyMatrix(preMatrix, postMatrix)).toMatchObject([ 171 | [1.5, 0, 0, 10], 172 | [0, 1.5, 0, 20], 173 | [0, 0, 1.5, 0], 174 | [0, 0, 0, 1] 175 | ]); 176 | }); 177 | 178 | it('inverses matrix', () => { 179 | const matrix = [ 180 | [1.5, 0, 0, 0], 181 | [0, 1.5, 0, 0], 182 | [0, 0, 1.5, 0], 183 | [0, 0, 0, 1] 184 | ]; 185 | 186 | expect(matrixInvert(matrix)).toMatchObject([ 187 | [0.6666666666666666, 0, 0, 0], 188 | [0, 0.6666666666666666, 0, 0], 189 | [0, 0, 0.6666666666666666, 0], 190 | [0, 0, 0, 1] 191 | ]); 192 | }); 193 | 194 | it('applies matrix to point', () => { 195 | const matrix = [ 196 | [1.5, 0, 0, 0], 197 | [0, 1.5, 0, 0], 198 | [0, 0, 1.5, 0], 199 | [0, 0, 0, 1] 200 | ]; 201 | 202 | expect( 203 | multiplyMatrixAndPoint(matrix, [10, 10, 0, 1]) 204 | ).toMatchObject([15, 15, 0, 1]); 205 | }); 206 | 207 | it('computes transform matrix', () => { 208 | const matrix = [ 209 | [1.5, 0, 0, 0], 210 | [0, 1.5, 0, 0], 211 | [0, 0, 1.5, 0], 212 | [0, 0, 0, 1] 213 | ]; 214 | 215 | expect( 216 | computeTransformMatrix(matrix, [10, 10, 0]) 217 | ).toMatchObject([ 218 | [1.5, 0, 0, -5], 219 | [0, 1.5, 0, -5], 220 | [0, 0, 1.5, 0], 221 | [0, 0, 0, 1] 222 | ]); 223 | }); 224 | 225 | it('decomposes transform to components', () => { 226 | const matrix = [ 227 | [1.5, 0, 5, 0], 228 | [0, 1.5, 0, 0], 229 | [0, 0, 1, -7], 230 | [10, 0, 0, 1] 231 | ]; 232 | 233 | expect( 234 | decompose(matrix) 235 | ).toMatchObject({ 236 | rotate: { x: -0, y: 0, z: -1.4219063791853994 }, 237 | translate: { x: 0, y: 0, z: -1.3728129459672884 }, 238 | scale: { sX: 1.5, sY: 1.5, sZ: 5.0990195135927845 } 239 | }); 240 | }); 241 | 242 | it('get offset of element', () => { 243 | const el = document.getElementById('draggable'); 244 | 245 | Object.defineProperty(HTMLElement.prototype, 'offsetParent', { 246 | get() { return this.parentNode; } 247 | }); 248 | 249 | expect( 250 | getAbsoluteOffset(el, el.parentNode) 251 | ).toMatchObject([0, 0, 0, 1]); 252 | }); 253 | 254 | // it('get current transform of element', () => { 255 | // const el = document.getElementById('draggable'); 256 | 257 | // Object.defineProperty(HTMLElement.prototype, 'offsetParent', { 258 | // get() { return this.parentNode; } 259 | // }); 260 | // console.log(getCurrentTransformMatrix(el, el.parentNode)) 261 | // expect(getCurrentTransformMatrix(el, el.parentNode)).toMatchObject([ 262 | // [ 1.5, 0, 5, 0 ], 263 | // [ 0, 1.5, 0, 0 ], 264 | // [ 0, 0, 1, -7 ], 265 | // [ 10, 0, 0, 1 ] 266 | // ]); 267 | // }); 268 | }); -------------------------------------------------------------------------------- /src/js/core/transform/matrix.js: -------------------------------------------------------------------------------- 1 | import { getStyle } from '../util/css-util'; 2 | 3 | export const cloneMatrix = m => m.map(item => [...item]); 4 | 5 | export const flatMatrix = (m) => ( 6 | m.reduce((flat, _, i) => ([...flat, m[0][i], m[1][i], m[2][i], m[3][i]]), []) 7 | ); 8 | 9 | export const createIdentityMatrix = (n = 4) => ( 10 | [...Array(n)].map((_, i, a) => a.map(() => +!i--)) 11 | ); 12 | 13 | export const createTranslateMatrix = (x, y, z = 0) => ( 14 | createIdentityMatrix().map((item, i) => { 15 | item[3] = [x, y, z, 1][i]; 16 | return item; 17 | }) 18 | ); 19 | 20 | export const createScaleMatrix = (x, y, z = 1, w = 1) => ( 21 | createIdentityMatrix().map((item, i) => { 22 | item[i] = [x, y, z, w][i]; 23 | return item; 24 | }) 25 | ); 26 | 27 | export const createRotateMatrix = (sin, cos) => { 28 | const res = createIdentityMatrix(); 29 | 30 | res[0][0] = cos; 31 | res[0][1] = -sin; 32 | res[1][0] = sin; 33 | res[1][1] = cos; 34 | 35 | return res; 36 | }; 37 | 38 | export const dropTranslate = (matrix, clone = true) => { 39 | const nextMatrix = clone ? cloneMatrix(matrix) : matrix; 40 | nextMatrix[0][3] = nextMatrix[1][3] = nextMatrix[2][3] = 0; 41 | return nextMatrix; 42 | }; 43 | 44 | export const multiplyMatrixAndPoint = (mat, point) => { 45 | const out = []; 46 | 47 | for (let i = 0, len = mat.length; i < len; ++i) { 48 | let sum = 0; 49 | for (let j = 0; j < len; ++j) { 50 | sum += +mat[i][j] * point[j]; 51 | } 52 | out[i] = sum; 53 | } 54 | 55 | return out; 56 | }; 57 | 58 | export const multiplyMatrix = (m1, m2) => { 59 | const result = []; 60 | 61 | for (let j = 0; j < m2.length; j++) { 62 | result[j] = []; 63 | 64 | for (let k = 0; k < m1[0].length; k++) { 65 | let sum = 0; 66 | 67 | for (let i = 0; i < m1.length; i++) { 68 | sum += m1[i][k] * m2[j][i]; 69 | } 70 | result[j].push(sum); 71 | } 72 | } 73 | return result; 74 | }; 75 | 76 | export const matrixInvert = (matrix) => { 77 | const A = cloneMatrix(matrix); 78 | const N = A.length; 79 | 80 | let temp, E = []; 81 | 82 | for (let i = 0; i < N; i++) 83 | E[i] = []; 84 | 85 | for (let i = 0; i < N; i++) 86 | for (let j = 0; j < N; j++) { 87 | E[i][j] = 0; 88 | if (i === j) 89 | E[i][j] = 1; 90 | } 91 | 92 | for (let k = 0; k < N; k++) { 93 | temp = A[k][k]; 94 | 95 | if (temp !== 0) { 96 | for (let j = 0; j < N; j++) { 97 | A[k][j] /= temp; 98 | E[k][j] /= temp; 99 | } 100 | } 101 | 102 | for (let i = k + 1; i < N; i++) { 103 | temp = A[i][k]; 104 | 105 | for (let j = 0; j < N; j++) { 106 | A[i][j] -= A[k][j] * temp; 107 | E[i][j] -= E[k][j] * temp; 108 | } 109 | } 110 | } 111 | 112 | for (let k = N - 1; k > 0; k--) { 113 | for (let i = k - 1; i >= 0; i--) { 114 | temp = A[i][k]; 115 | 116 | for (let j = 0; j < N; j++) { 117 | A[i][j] -= A[k][j] * temp; 118 | E[i][j] -= E[k][j] * temp; 119 | } 120 | } 121 | } 122 | 123 | for (let i = 0; i < N; i++) 124 | for (let j = 0; j < N; j++) 125 | A[i][j] = E[i][j]; 126 | 127 | return A; 128 | }; 129 | 130 | export const computeTransformMatrix = (tx, [x, y, z]) => { 131 | const preMul = createTranslateMatrix(-x, -y, -z); 132 | const postMul = createTranslateMatrix(x, y, z); 133 | 134 | return multiplyMatrix( 135 | multiplyMatrix(preMul, tx), 136 | postMul 137 | ); 138 | }; 139 | 140 | export const getCurrentTransformMatrix = (element, container = document.body, newTransform) => { 141 | let matrix = createIdentityMatrix(); 142 | let node = element; 143 | 144 | // set predefined matrix if we need to find new CTM 145 | let nodeTx = newTransform || getTransform(node); 146 | let allowBorderOffset = false; 147 | 148 | while (node && node instanceof Element) { 149 | //const nodeTx = getTransform(node); 150 | const nodeTxOrigin = getTransformOrigin(node, allowBorderOffset); 151 | 152 | matrix = multiplyMatrix( 153 | matrix, 154 | computeTransformMatrix(nodeTx, nodeTxOrigin) 155 | ); 156 | 157 | allowBorderOffset = true; 158 | if (node === container || node.offsetParent === null) break; 159 | node = node.offsetParent; 160 | nodeTx = getTransform(node); 161 | } 162 | 163 | return matrix; 164 | }; 165 | 166 | export const decompose = (m) => { 167 | const sX = Math.sqrt(m[0][0] * m[0][0] + m[1][0] * m[1][0] + m[2][0] * m[2][0]), 168 | sY = Math.sqrt(m[0][1] * m[0][1] + m[1][1] * m[1][1] + m[2][1] * m[2][1]), 169 | sZ = Math.sqrt(m[0][2] * m[0][2] + m[1][2] * m[1][2] + m[2][2] * m[2][2]); 170 | 171 | let rX = Math.atan2(-m[0][3] / sZ, m[1][3] / sZ), 172 | rY = Math.asin(m[3][1] / sZ), 173 | rZ = Math.atan2(-m[3][0] / sY, m[0][0] / sX); 174 | 175 | if (m[0][1] === 1 || m[0][1] === -1) { 176 | rX = 0; 177 | rY = m[0][1] * -Math.PI / 2; 178 | rZ = m[0][1] * Math.atan2(m[1][1] / sY, m[0][1] / sY); 179 | } 180 | 181 | return { 182 | rotate: { 183 | x: rX, 184 | y: rY, 185 | z: rZ 186 | }, 187 | translate: { 188 | x: m[0][3] / sX, 189 | y: m[1][3] / sY, 190 | z: m[2][3] / sZ 191 | }, 192 | scale: { 193 | sX, 194 | sY, 195 | sZ 196 | } 197 | }; 198 | }; 199 | 200 | export const getTransform = (el) => { 201 | const matrixString = getStyle(el, 'transform') || 'none'; 202 | const matrix = createIdentityMatrix(); 203 | 204 | if (matrixString === 'none') return matrix; 205 | 206 | const values = matrixString.split(/\s*[(),]\s*/).slice(1, -1); 207 | 208 | if (values.length === 16) { 209 | for (let i = 0; i < 4; ++i) { 210 | for (let j = 0; j < 4; ++j) { 211 | matrix[j][i] = +values[i * 4 + j]; 212 | } 213 | } 214 | } else { 215 | return [ 216 | [+values[0], +values[2], 0, +values[4]], 217 | [+values[1], +values[3], 0, +values[5]], 218 | [0, 0, 1, 0], 219 | [0, 0, 0, 1] 220 | ]; 221 | } 222 | 223 | return matrix; 224 | }; 225 | 226 | export const getTransformOrigin = (el, allowBorderOffset) => { 227 | const transformOrigin = getStyle(el, 'transform-origin'); 228 | const values = transformOrigin ? transformOrigin.split(' ') : []; 229 | 230 | const out = [ 231 | allowBorderOffset ? -el.clientLeft : 0, 232 | allowBorderOffset ? -el.clientTop : 0, 233 | 0, 234 | 1 235 | ]; 236 | 237 | for (let i = 0; i < values.length; ++i) { 238 | out[i] += parseFloat(values[i]); 239 | } 240 | 241 | return out; 242 | }; 243 | 244 | export const getAbsoluteOffset = (element, container = document.body) => { 245 | let top = 0, left = 0; 246 | let node = element; 247 | 248 | let allowBorderOffset = false; 249 | while (node && node.offsetParent) { 250 | const parentTx = getCurrentTransformMatrix(node.offsetParent); 251 | 252 | const [offsetLeft, offsetTop] = multiplyMatrixAndPoint( 253 | dropTranslate(parentTx, false), 254 | [ 255 | node.offsetLeft + (allowBorderOffset ? node.clientLeft : 0), 256 | node.offsetTop + (allowBorderOffset ? node.clientTop : 0), 257 | 0, 258 | 1 259 | ] 260 | ); 261 | 262 | left += offsetLeft; 263 | top += offsetTop; 264 | 265 | if (container === node) break; 266 | allowBorderOffset = true; 267 | node = node.offsetParent; 268 | } 269 | 270 | return [left, top, 0, 1]; 271 | }; -------------------------------------------------------------------------------- /test/subjx.test.js: -------------------------------------------------------------------------------- 1 | import Subjx from '../src/js/core'; 2 | 3 | function subjx(params) { 4 | return new Subjx(params); 5 | } 6 | 7 | beforeEach(() => { 8 | jest.useFakeTimers(); 9 | 10 | Object.defineProperty(window, 'getComputedStyle', { 11 | value: () => ({ 12 | getPropertyValue: () => { 13 | return ''; 14 | } 15 | }) 16 | }); 17 | 18 | const SVGMatrixMock = () => jest.fn().mockImplementation(() => ({ 19 | martix: jest.fn(() => [[]]), 20 | a: 1, 21 | b: 0, 22 | c: 0, 23 | d: 1, 24 | e: 10, 25 | f: 10, 26 | flipX: jest.fn().mockImplementation(() => window.SVGElement), 27 | flipY: jest.fn().mockImplementation(() => window.SVGElement), 28 | inverse: jest.fn().mockImplementation(() => SVGMatrixMock()()), 29 | multiply: jest.fn().mockImplementation(() => SVGMatrixMock()()), 30 | rotate: jest.fn().mockImplementation(() => ({ 31 | translate: jest.fn().mockImplementation(() => ({ 32 | rotate: jest.fn() 33 | })) 34 | })), 35 | rotateFromVector: jest.fn().mockImplementation(() => window.SVGElement), 36 | scale: jest.fn().mockImplementation(() => window.SVGElement), 37 | scaleNonUniform: jest.fn().mockImplementation(() => window.SVGElement), 38 | skewX: jest.fn().mockImplementation(() => window.SVGElement), 39 | skewY: jest.fn().mockImplementation(() => window.SVGElement), 40 | translate: jest.fn().mockImplementation(() => ({ 41 | multiply: jest.fn().mockImplementation(() => ({ 42 | multiply: jest.fn().mockImplementation(() => window.SVGElement) 43 | })) 44 | })) 45 | })); 46 | 47 | Object.defineProperty(window.SVGElement.prototype, 'createSVGMatrix', { 48 | writable: true, 49 | value: SVGMatrixMock() 50 | }); 51 | 52 | Object.defineProperty(window.SVGElement.prototype, 'createSVGPoint', { 53 | writable: true, 54 | value: jest.fn().mockImplementation(() => ({ 55 | x: 0, 56 | y: 0, 57 | matrixTransform: jest.fn().mockImplementation(() => ({ 58 | x: 150, 59 | y: 150 60 | })) 61 | })) 62 | }); 63 | 64 | Object.defineProperty(window.SVGElement.prototype, 'createSVGTransform', { 65 | writable: true, 66 | value: jest.fn().mockImplementation(() => ({ 67 | angle: 0, 68 | matrix: { 69 | a: 1, 70 | b: 0, 71 | c: 0, 72 | d: 1, 73 | e: 0, 74 | f: 0, 75 | multiply: jest.fn() 76 | }, 77 | setMatrix: jest.fn(), 78 | setTranslate: jest.fn() 79 | })) 80 | }); 81 | 82 | Object.defineProperty(window.SVGElement.prototype, 'getBBox', { 83 | writable: true, 84 | value: jest.fn().mockImplementation(() => ({ 85 | x: 0, 86 | y: 0, 87 | width: 150, 88 | height: 150 89 | })) 90 | }); 91 | 92 | Object.defineProperty(window.SVGElement.prototype, 'getScreenCTM', { 93 | writable: true, 94 | value: SVGMatrixMock() 95 | }); 96 | 97 | Object.assign(window.SVGElement.prototype, { 98 | x1: { baseVal: { value: 0 } }, 99 | x2: { baseVal: { value: 0 } }, 100 | y1: { baseVal: { value: 0 } }, 101 | y2: { baseVal: { value: 0 } } 102 | }); 103 | }); 104 | 105 | afterEach(() => { 106 | jest.useRealTimers(); 107 | }); 108 | 109 | document.body.innerHTML = ` 110 |
111 |
112 |
113 |
114 |
115 | 116 | 117 | 118 | `; 119 | 120 | const domElement = document.getElementById('draggable'); 121 | const draggables = document.getElementsByClassName('draggables'); 122 | const draggableContainer = document.getElementById('container'); 123 | const cloneableElement = document.getElementById('cloneable'); 124 | 125 | const svgElement = document.getElementById('svg-draggable'); 126 | const svgContainerElement = document.getElementById('svg-container'); 127 | 128 | const defaultOptions = { 129 | axis: 'xy', 130 | cursorMove: 'auto', 131 | cursorRotate: 'auto', 132 | cursorResize: 'auto', 133 | rotationPoint: false, 134 | restrict: null, 135 | snap: { x: 10, y: 10, angle: 0.17453292519943295 }, 136 | each: { move: false, resize: false, rotate: false }, 137 | proportions: false, 138 | draggable: true, 139 | resizable: true, 140 | rotatable: true, 141 | scalable: false, 142 | applyTranslate: false, 143 | custom: null, 144 | rotatorAnchor: null, 145 | rotatorOffset: 50, 146 | showNormal: true, 147 | isGrouped: false, 148 | transformOrigin: false 149 | }; 150 | 151 | const options = { 152 | rotationPoint: true, 153 | proportions: true, 154 | axis: 'x', 155 | each: { 156 | move: true, 157 | resize: true, 158 | rotate: true 159 | }, 160 | snap: { 161 | x: 10, 162 | y: 20, 163 | angle: 30 164 | }, 165 | cursorMove: 'move', 166 | cursorRotate: 'crosshair', 167 | cursorResize: 'pointer', 168 | draggable: true, 169 | resizable: true, 170 | rotatable: true, 171 | scalable: true, 172 | applyTranslate: false, 173 | custom: null, 174 | rotatorAnchor: 's', 175 | rotatorOffset: 20, 176 | showNormal: true 177 | }; 178 | 179 | const createEMouseDown = () => 180 | new MouseEvent('mousedown', { 181 | clientX: 10, 182 | clientY: 10, 183 | bubbles: true, 184 | cancelable: true, 185 | view: window 186 | }); 187 | 188 | const createEMouseMove = () => 189 | new MouseEvent('mousemove', { 190 | clientX: 150, 191 | clientY: 150, 192 | bubbles: true, 193 | cancelable: true, 194 | view: window 195 | }); 196 | 197 | const createEMouseUp = () => 198 | new MouseEvent('mouseup', { 199 | clientX: 150, 200 | clientY: 150, 201 | bubbles: true, 202 | cancelable: true, 203 | view: window 204 | }); 205 | 206 | describe('Test subjx "clone" method', () => { 207 | it('init cloneable with defaults', () => { 208 | subjx(cloneableElement).clone(); 209 | }); 210 | }); 211 | 212 | describe('Test subjx "drag" method', () => { 213 | it('init draggable with defaults', () => { 214 | const draggable = subjx(domElement).drag(); 215 | expect(draggable.options).toEqual({ 216 | ...defaultOptions, 217 | container: draggableContainer, 218 | controlsContainer: draggableContainer 219 | }); 220 | 221 | draggable.disable(); 222 | }); 223 | 224 | it('test subjx api', () => { 225 | const draggable = subjx(draggables).drag({ each: { move: true } }); 226 | 227 | expect(() => { 228 | draggable.fitControlsToSize(); 229 | draggable.getBoundingRect(); 230 | draggable.setCenterPoint(); 231 | draggable.setTransformOrigin({ x: 0, y: 0 }); 232 | draggable.getDimensions(); 233 | ['t', 'b', 'l', 'r', 'v', 'h'].map(align => draggable.applyAlignment(align)); 234 | }).not.toThrow(); 235 | }); 236 | 237 | it('init draggable with options', () => { 238 | const nextOptions = { 239 | ...options, 240 | container: '#container', 241 | restrict: '#container' 242 | }; 243 | const $draggables = subjx(draggables).drag({ 244 | ...nextOptions 245 | }); 246 | 247 | expect($draggables.options).toMatchObject({ 248 | ...nextOptions, 249 | restrict: draggableContainer, 250 | container: draggableContainer, 251 | controlsContainer: draggableContainer, 252 | snap: { 253 | ...nextOptions.snap, 254 | angle: 0.5235987755982988 255 | } 256 | }); 257 | 258 | $draggables.disable(); 259 | }); 260 | 261 | it('test subjx hooks', () => { 262 | let init = false, 263 | move = false, 264 | resize = false, 265 | rotate = false, 266 | drop = false, 267 | destroy = false; 268 | 269 | const methods = { 270 | onInit() { 271 | init = true; 272 | }, 273 | onMove() { 274 | move = true; 275 | }, 276 | onResize() { 277 | resize = true; 278 | }, 279 | onRotate() { 280 | rotate = true; 281 | }, 282 | onDrop() { 283 | drop = true; 284 | }, 285 | onDestroy() { 286 | destroy = true; 287 | } 288 | }; 289 | 290 | const draggable = subjx(domElement).drag({ ...methods }); 291 | 292 | // simulate move 293 | draggable.elements[0].dispatchEvent(createEMouseDown()); 294 | 295 | let step = 0; 296 | while (step < 5) { 297 | document.dispatchEvent(createEMouseMove()); 298 | jest.advanceTimersByTime(1001 / 60); 299 | step++; 300 | } 301 | 302 | document.dispatchEvent(createEMouseUp()); 303 | 304 | // simulate resize 305 | draggable.storage.handles.tr.dispatchEvent(createEMouseDown()); 306 | 307 | step = 0; 308 | while (step < 5) { 309 | document.dispatchEvent(createEMouseMove()); 310 | jest.advanceTimersByTime(1001 / 60); 311 | step++; 312 | } 313 | 314 | document.dispatchEvent(createEMouseUp()); 315 | 316 | // simulate rotate 317 | draggable.storage.handles.rotator.dispatchEvent(createEMouseDown()); 318 | 319 | step = 0; 320 | while (step < 5) { 321 | document.dispatchEvent(createEMouseMove()); 322 | jest.advanceTimersByTime(1001 / 60); 323 | step++; 324 | } 325 | 326 | document.dispatchEvent(createEMouseUp()); 327 | 328 | draggable.disable(); 329 | 330 | expect([ 331 | init, 332 | move, 333 | rotate, 334 | resize, 335 | drop, 336 | destroy 337 | ].every((item) => item === true)).toEqual(true); 338 | }); 339 | 340 | it('process move', () => { 341 | const $draggables = subjx(draggables).drag({ each: { move: true } }); 342 | 343 | $draggables.elements[0].dispatchEvent(createEMouseDown()); 344 | 345 | let step = 0; 346 | while (step < 5) { 347 | document.dispatchEvent(createEMouseMove()); 348 | jest.advanceTimersByTime(1001 / 60); 349 | step++; 350 | } 351 | 352 | document.dispatchEvent(createEMouseUp()); 353 | 354 | expect($draggables.storage).toMatchObject({ 355 | clientX: 150, 356 | clientY: 150, 357 | relativeX: 10, 358 | relativeY: 10, 359 | isTarget: true 360 | }); 361 | 362 | $draggables.disable(); 363 | }); 364 | }); 365 | 366 | describe('Test svg subjx "drag" method', () => { 367 | it('init draggable with defaults', () => { 368 | const draggable = subjx(svgElement).drag(); 369 | expect(draggable.options).toEqual({ 370 | ...defaultOptions, 371 | container: svgContainerElement, 372 | controlsContainer: svgContainerElement 373 | }); 374 | 375 | draggable.disable(); 376 | }); 377 | 378 | it('test subjx api', () => { 379 | const draggable = subjx(svgElement).drag({ each: { move: true } }); 380 | 381 | expect(() => { 382 | draggable.fitControlsToSize(); 383 | draggable.getBoundingRect(svgElement); 384 | draggable.setCenterPoint(); 385 | draggable.setTransformOrigin({ x: 0, y: 0 }); 386 | draggable.getDimensions(); 387 | ['t', 'b', 'l', 'r', 'v', 'h'].map((align) => draggable.applyAlignment(align)); 388 | }).not.toThrow(); 389 | }); 390 | 391 | it('init draggable with options', () => { 392 | const nextOptions = { 393 | container: '#svg-container', 394 | restrict: '#svg-container', 395 | ...options 396 | }; 397 | 398 | const $draggable = subjx(svgElement).drag({ 399 | ...nextOptions 400 | }); 401 | 402 | expect($draggable.options).toMatchObject({ 403 | ...nextOptions, 404 | restrict: svgContainerElement, 405 | container: svgContainerElement, 406 | controlsContainer: svgContainerElement, 407 | snap: { 408 | ...nextOptions.snap, 409 | angle: 0.5235987755982988 410 | } 411 | }); 412 | 413 | $draggable.disable(); 414 | }); 415 | 416 | it('test subjx hooks', () => { 417 | let init = false, 418 | move = false, 419 | resize = false, 420 | rotate = false, 421 | drop = false, 422 | destroy = false; 423 | 424 | const methods = { 425 | onInit() { 426 | init = true; 427 | }, 428 | onMove() { 429 | move = true; 430 | }, 431 | onResize() { 432 | resize = true; 433 | }, 434 | onRotate() { 435 | rotate = true; 436 | }, 437 | onDrop() { 438 | drop = true; 439 | }, 440 | onDestroy() { 441 | destroy = true; 442 | } 443 | }; 444 | 445 | const draggable = subjx(svgElement).drag({ ...methods }); 446 | 447 | // simulate move 448 | draggable.elements[0].dispatchEvent(createEMouseDown()); 449 | 450 | let step = 0; 451 | while (step < 5) { 452 | document.dispatchEvent(createEMouseMove()); 453 | jest.advanceTimersByTime(1001 / 60); 454 | step++; 455 | } 456 | 457 | document.dispatchEvent(createEMouseUp()); 458 | 459 | // simulate resize 460 | draggable.storage.handles.tr.dispatchEvent(createEMouseDown()); 461 | 462 | step = 0; 463 | while (step < 5) { 464 | document.dispatchEvent(createEMouseMove()); 465 | jest.advanceTimersByTime(1001 / 60); 466 | step++; 467 | } 468 | 469 | document.dispatchEvent(createEMouseUp()); 470 | 471 | // simulate rotate 472 | draggable.storage.handles.rotator.dispatchEvent(createEMouseDown()); 473 | 474 | step = 0; 475 | while (step < 5) { 476 | document.dispatchEvent(createEMouseMove()); 477 | jest.advanceTimersByTime(1001 / 60); 478 | step++; 479 | } 480 | 481 | document.dispatchEvent(createEMouseUp()); 482 | 483 | draggable.disable(); 484 | 485 | expect([ 486 | init, 487 | move, 488 | rotate, 489 | resize, 490 | drop, 491 | destroy 492 | ].every((item) => item === true)).toEqual(true); 493 | }); 494 | 495 | it('process move', () => { 496 | const $draggables = subjx(svgElement).drag({ each: { move: true } }); 497 | 498 | $draggables.elements[0].dispatchEvent(createEMouseDown()); 499 | 500 | let step = 0; 501 | while (step < 5) { 502 | document.dispatchEvent(createEMouseMove()); 503 | jest.advanceTimersByTime(1001 / 60); 504 | step++; 505 | } 506 | 507 | document.dispatchEvent(createEMouseUp()); 508 | 509 | expect($draggables.storage).toMatchObject({ 510 | clientX: 150, 511 | clientY: 150, 512 | relativeX: 150, 513 | relativeY: 150, 514 | isTarget: true 515 | }); 516 | 517 | $draggables.disable(); 518 | }); 519 | }); -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 63 | 64 | Demo 65 | 66 | 67 | 68 |
69 | 70 | 71 | 73 | 74 | 76 | 77 | 79 | 82 | 83 | 86 | 87 | 89 | 90 | 91 | 94 | 95 | 96 | 97 | 100 | 101 | 104 | 105 | 108 | 109 | 112 | 113 | 116 | 117 | 120 | 121 | 124 | 125 | 128 | 129 | 132 | 133 | 136 | 137 | 140 | 141 | 143 | 146 | 147 | 148 | 150 | 153 | 154 | 155 | 156 | 159 | 160 | 161 | 162 | 163 |
164 |
165 |
167 |
168 |
170 |
171 |
173 |
175 | 176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 | 189 | 190 | 369 | 370 | -------------------------------------------------------------------------------- /src/js/core/transform/svg/path.js: -------------------------------------------------------------------------------- 1 | import { warn } from './../../util/util'; 2 | import { floatToFixed } from '../common'; 3 | 4 | import { 5 | pointTo, 6 | cloneMatrix, 7 | normalizeString, 8 | sepRE, 9 | arrayToChunks 10 | } from './util'; 11 | 12 | // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d 13 | const dRE = /\s*([achlmqstvz])([^achlmqstvz]*)\s*/gi; 14 | 15 | const getCommandValuesLength = (cmd) => ([ 16 | { 17 | size: 2, 18 | condition: ['M', 'm', 'L', 'l', 'T', 't'].includes(cmd) 19 | }, 20 | { 21 | size: 1, 22 | condition: ['H', 'h', 'V', 'v'].includes(cmd) 23 | }, 24 | { 25 | size: 6, 26 | condition: ['C', 'c'].includes(cmd) 27 | }, 28 | { 29 | size: 4, 30 | condition: ['S', 's', 'Q', 'q'].includes(cmd) 31 | }, 32 | { 33 | size: 7, 34 | condition: ['A', 'a'].includes(cmd) 35 | }, 36 | { 37 | size: 1, 38 | condition: true 39 | } 40 | ].find(({ condition }) => !!condition)); 41 | 42 | const parsePath = (path) => { 43 | let match = dRE.lastIndex = 0; 44 | 45 | const serialized = []; 46 | 47 | while ((match = dRE.exec(path))) { 48 | const [, cmd, params] = match; 49 | const upCmd = cmd.toUpperCase(); 50 | 51 | const isRelative = cmd !== upCmd; 52 | 53 | const data = normalizeString(params); 54 | 55 | const values = data.trim().split(sepRE).map(val => { 56 | if (!isNaN(val)) { 57 | return Number(val); 58 | } 59 | }); 60 | 61 | let firstCommand = false; 62 | const isMoveTo = upCmd === 'M'; 63 | 64 | const { size: commandLength } = getCommandValuesLength(cmd); 65 | 66 | // split big command into multiple commands 67 | arrayToChunks(values, commandLength) 68 | .map(chunkedValues => { 69 | const shouldReplace = firstCommand && isMoveTo; 70 | 71 | firstCommand = firstCommand ? firstCommand : isMoveTo; 72 | 73 | return serialized.push({ 74 | relative: isRelative, 75 | key: shouldReplace ? 'L' : upCmd, 76 | cmd: shouldReplace 77 | ? isRelative ? 'l' : 'L' 78 | : cmd, 79 | values: chunkedValues 80 | }); 81 | }); 82 | } 83 | 84 | return reducePathData(absolutizePathData(serialized)); 85 | }; 86 | 87 | export const movePath = (params) => { 88 | const { 89 | path, 90 | dx, 91 | dy 92 | } = params; 93 | 94 | try { 95 | const serialized = parsePath(path); 96 | 97 | let str = ''; 98 | let space = ' '; 99 | 100 | let firstCommand = true; 101 | 102 | for (let i = 0, len = serialized.length; i < len; i++) { 103 | const item = serialized[i]; 104 | 105 | const { 106 | values, 107 | key: cmd, 108 | relative 109 | } = item; 110 | 111 | const coordinates = []; 112 | 113 | switch (cmd) { 114 | 115 | case 'M': { 116 | for (let k = 0, len = values.length; k < len; k += 2) { 117 | let [x, y] = values.slice(k, k + 2); 118 | 119 | if (!(relative && !firstCommand)) { 120 | x += dx; 121 | y += dy; 122 | } 123 | 124 | coordinates.push( 125 | x, 126 | y 127 | ); 128 | 129 | firstCommand = false; 130 | } 131 | break; 132 | } 133 | case 'A': { 134 | for (let k = 0, len = values.length; k < len; k += 7) { 135 | const set = values.slice(k, k + 7); 136 | 137 | if (!relative) { 138 | set[5] += dx; 139 | set[6] += dy; 140 | } 141 | 142 | coordinates.push(...set); 143 | } 144 | break; 145 | } 146 | case 'C': { 147 | for (let k = 0, len = values.length; k < len; k += 6) { 148 | const set = values.slice(k, k + 6); 149 | 150 | if (!relative) { 151 | set[0] += dx; 152 | set[1] += dy; 153 | set[2] += dx; 154 | set[3] += dy; 155 | set[4] += dx; 156 | set[5] += dy; 157 | } 158 | 159 | coordinates.push(...set); 160 | } 161 | break; 162 | } 163 | case 'H': { 164 | for (let k = 0, len = values.length; k < len; k += 1) { 165 | const set = values.slice(k, k + 1); 166 | 167 | if (!relative) { 168 | set[0] += dx; 169 | } 170 | 171 | coordinates.push(set[0]); 172 | } 173 | 174 | break; 175 | } 176 | case 'V': { 177 | for (let k = 0, len = values.length; k < len; k += 1) { 178 | const set = values.slice(k, k + 1); 179 | 180 | if (!relative) { 181 | set[0] += dy; 182 | } 183 | coordinates.push(set[0]); 184 | } 185 | 186 | break; 187 | } 188 | case 'L': 189 | case 'T': { 190 | for (let k = 0, len = values.length; k < len; k += 2) { 191 | let [x, y] = values.slice(k, k + 2); 192 | 193 | if (!relative) { 194 | x += dx; 195 | y += dy; 196 | } 197 | 198 | coordinates.push( 199 | x, 200 | y 201 | ); 202 | } 203 | break; 204 | } 205 | case 'Q': 206 | case 'S': { 207 | for (let k = 0, len = values.length; k < len; k += 4) { 208 | let [x1, y1, x2, y2] = values.slice(k, k + 4); 209 | 210 | if (!relative) { 211 | x1 += dx; 212 | y1 += dy; 213 | x2 += dx; 214 | y2 += dy; 215 | } 216 | 217 | coordinates.push( 218 | x1, 219 | y1, 220 | x2, 221 | y2 222 | ); 223 | } 224 | break; 225 | } 226 | case 'Z': { 227 | values[0] = ''; 228 | space = ''; 229 | break; 230 | } 231 | 232 | } 233 | 234 | str += cmd + coordinates.join(',') + space; 235 | } 236 | 237 | return str; 238 | } catch (err) { 239 | warn('Path parsing error: ' + err); 240 | } 241 | }; 242 | 243 | export const resizePath = (params) => { 244 | const { 245 | path, 246 | localCTM 247 | } = params; 248 | 249 | try { 250 | const serialized = parsePath(path); 251 | 252 | let str = ''; 253 | let space = ' '; 254 | 255 | const res = []; 256 | 257 | let firstCommand = true; 258 | 259 | for (let i = 0, len = serialized.length; i < len; i++) { 260 | const item = serialized[i]; 261 | 262 | const { 263 | values, 264 | key: cmd, 265 | relative = false 266 | } = item; 267 | 268 | switch (cmd) { 269 | 270 | case 'A': { 271 | // A rx ry x-axis-rotation large-arc-flag sweep-flag x y 272 | const coordinates = []; 273 | 274 | const mtrx = cloneMatrix(localCTM); 275 | 276 | if (relative) { 277 | mtrx.e = mtrx.f = 0; 278 | } 279 | 280 | for (let k = 0, len = values.length; k < len; k += 7) { 281 | const [rx, ry, xAxisRot, largeArcFlag, sweepFlag, x, y] = 282 | values.slice(k, k + 7); 283 | 284 | const { 285 | x: resX, 286 | y: resY 287 | } = pointTo( 288 | mtrx, 289 | x, 290 | y 291 | ); 292 | 293 | coordinates.push( 294 | floatToFixed(resX), 295 | floatToFixed(resY) 296 | ); 297 | 298 | mtrx.e = mtrx.f = 0; 299 | 300 | const { 301 | x: newRx, 302 | y: newRy 303 | } = pointTo( 304 | mtrx, 305 | rx, 306 | ry 307 | ); 308 | 309 | coordinates.unshift( 310 | floatToFixed(newRx), 311 | floatToFixed(newRy), 312 | xAxisRot, 313 | largeArcFlag, 314 | sweepFlag 315 | ); 316 | } 317 | 318 | res.push(coordinates); 319 | break; 320 | } 321 | case 'C': { 322 | // C x1 y1, x2 y2, x y (or c dx1 dy1, dx2 dy2, dx dy) 323 | const coordinates = []; 324 | 325 | const mtrx = cloneMatrix(localCTM); 326 | 327 | if (relative) { 328 | mtrx.e = mtrx.f = 0; 329 | } 330 | 331 | for (let k = 0, len = values.length; k < len; k += 6) { 332 | const [x1, y1, x2, y2, x, y] = values.slice(k, k + 6); 333 | 334 | const { 335 | x: resX1, 336 | y: resY1 337 | } = pointTo( 338 | mtrx, 339 | x1, 340 | y1 341 | ); 342 | 343 | const { 344 | x: resX2, 345 | y: resY2 346 | } = pointTo( 347 | mtrx, 348 | x2, 349 | y2 350 | ); 351 | 352 | const { 353 | x: resX, 354 | y: resY 355 | } = pointTo( 356 | mtrx, 357 | x, 358 | y 359 | ); 360 | 361 | coordinates.push( 362 | floatToFixed(resX1), 363 | floatToFixed(resY1), 364 | floatToFixed(resX2), 365 | floatToFixed(resY2), 366 | floatToFixed(resX), 367 | floatToFixed(resY) 368 | ); 369 | } 370 | 371 | res.push(coordinates); 372 | break; 373 | } 374 | // this command makes impossible free transform within group 375 | // it will be converted to L 376 | case 'H': { 377 | // H x (or h dx) 378 | const coordinates = []; 379 | 380 | const mtrx = cloneMatrix(localCTM); 381 | 382 | if (relative) { 383 | mtrx.e = mtrx.f = 0; 384 | } 385 | 386 | for (let k = 0, len = values.length; k < len; k += 1) { 387 | const [x] = values.slice(k, k + 1); 388 | 389 | const { 390 | x: resX 391 | } = pointTo( 392 | mtrx, 393 | x, 394 | 0 395 | ); 396 | 397 | coordinates.push( 398 | floatToFixed(resX) 399 | ); 400 | } 401 | 402 | res.push(coordinates); 403 | break; 404 | } 405 | // this command makes impossible free transform within group 406 | // it will be converted to L 407 | case 'V': { 408 | // V y (or v dy) 409 | const coordinates = []; 410 | 411 | const mtrx = cloneMatrix(localCTM); 412 | 413 | if (relative) { 414 | mtrx.e = mtrx.f = 0; 415 | } 416 | 417 | for (let k = 0, len = values.length; k < len; k += 1) { 418 | const [y] = values.slice(k, k + 1); 419 | 420 | const { 421 | y: resY 422 | } = pointTo( 423 | mtrx, 424 | 0, 425 | y 426 | ); 427 | 428 | coordinates.push( 429 | floatToFixed(resY) 430 | ); 431 | } 432 | 433 | res.push(coordinates); 434 | break; 435 | } 436 | case 'T': 437 | case 'L': { 438 | // T x y (or t dx dy) 439 | // L x y (or l dx dy) 440 | const coordinates = []; 441 | 442 | const mtrx = cloneMatrix(localCTM); 443 | 444 | if (relative) { 445 | mtrx.e = mtrx.f = 0; 446 | } 447 | 448 | for (let k = 0, len = values.length; k < len; k += 2) { 449 | const [x, y] = values.slice(k, k + 2); 450 | 451 | const { 452 | x: resX, 453 | y: resY 454 | } = pointTo( 455 | mtrx, 456 | x, 457 | y 458 | ); 459 | 460 | coordinates.push( 461 | floatToFixed(resX), 462 | floatToFixed(resY) 463 | ); 464 | } 465 | 466 | res.push(coordinates); 467 | break; 468 | } 469 | case 'M': { 470 | // M x y (or dx dy) 471 | const coordinates = []; 472 | 473 | const mtrx = cloneMatrix(localCTM); 474 | 475 | if (relative && !firstCommand) { 476 | mtrx.e = mtrx.f = 0; 477 | } 478 | 479 | for (let k = 0, len = values.length; k < len; k += 2) { 480 | const [x, y] = values.slice(k, k + 2); 481 | 482 | const { 483 | x: resX, 484 | y: resY 485 | } = pointTo( 486 | mtrx, 487 | x, 488 | y 489 | ); 490 | 491 | coordinates.push( 492 | floatToFixed(resX), 493 | floatToFixed(resY) 494 | ); 495 | 496 | firstCommand = false; 497 | } 498 | 499 | res.push(coordinates); 500 | break; 501 | } 502 | case 'Q': { 503 | // Q x1 y1, x y (or q dx1 dy1, dx dy) 504 | const coordinates = []; 505 | 506 | const mtrx = cloneMatrix(localCTM); 507 | 508 | if (relative) { 509 | mtrx.e = mtrx.f = 0; 510 | } 511 | 512 | for (let k = 0, len = values.length; k < len; k += 4) { 513 | const [x1, y1, x, y] = values.slice(k, k + 4); 514 | 515 | const { 516 | x: resX1, 517 | y: resY1 518 | } = pointTo( 519 | mtrx, 520 | x1, 521 | y1 522 | ); 523 | 524 | const { 525 | x: resX, 526 | y: resY 527 | } = pointTo( 528 | mtrx, 529 | x, 530 | y 531 | ); 532 | 533 | coordinates.push( 534 | floatToFixed(resX1), 535 | floatToFixed(resY1), 536 | floatToFixed(resX), 537 | floatToFixed(resY) 538 | ); 539 | } 540 | 541 | res.push(coordinates); 542 | break; 543 | } 544 | case 'S': { 545 | // S x2 y2, x y (or s dx2 dy2, dx dy) 546 | const coordinates = []; 547 | 548 | const mtrx = cloneMatrix(localCTM); 549 | 550 | if (relative) { 551 | mtrx.e = mtrx.f = 0; 552 | } 553 | 554 | for (let k = 0, len = values.length; k < len; k += 4) { 555 | const [x2, y2, x, y] = values.slice(k, k + 4); 556 | 557 | const { 558 | x: resX2, 559 | y: resY2 560 | } = pointTo( 561 | mtrx, 562 | x2, 563 | y2 564 | ); 565 | 566 | const { 567 | x: resX, 568 | y: resY 569 | } = pointTo( 570 | mtrx, 571 | x, 572 | y 573 | ); 574 | 575 | coordinates.push( 576 | floatToFixed(resX2), 577 | floatToFixed(resY2), 578 | floatToFixed(resX), 579 | floatToFixed(resY) 580 | ); 581 | } 582 | 583 | res.push(coordinates); 584 | break; 585 | } 586 | case 'Z': { 587 | res.push(['']); 588 | space = ''; 589 | break; 590 | } 591 | 592 | } 593 | 594 | str += item.key + res[i].join(',') + space; 595 | } 596 | 597 | return str.trim(); 598 | } catch (err) { 599 | warn('Path parsing error: ' + err); 600 | } 601 | }; 602 | 603 | const absolutizePathData = (pathData) => { 604 | let currentX = null, 605 | currentY = null, 606 | subpathX = null, 607 | subpathY = null; 608 | 609 | return pathData.reduce((absolutizedPathData, seg) => { 610 | const { cmd, values } = seg; 611 | 612 | let nextSeg; 613 | 614 | switch (cmd) { 615 | 616 | case 'M': { 617 | const [x, y] = values; 618 | 619 | nextSeg = { key: 'M', values: [x, y] }; 620 | 621 | subpathX = x; 622 | subpathY = y; 623 | 624 | currentX = x; 625 | currentY = y; 626 | break; 627 | } 628 | 629 | case 'm': { 630 | const [x, y] = values; 631 | 632 | const nextX = currentX + x; 633 | const nextY = currentY + y; 634 | 635 | nextSeg = { key: 'M', values: [nextX, nextY] }; 636 | 637 | subpathX = nextX; 638 | subpathY = nextY; 639 | 640 | currentX = nextX; 641 | currentY = nextY; 642 | break; 643 | } 644 | 645 | case 'L': { 646 | const [x, y] = values; 647 | 648 | nextSeg = { key: 'L', values: [x, y] }; 649 | 650 | currentX = x; 651 | currentY = y; 652 | break; 653 | } 654 | 655 | case 'l': { 656 | const [x, y] = values; 657 | const nextX = currentX + x; 658 | const nextY = currentY + y; 659 | 660 | nextSeg = { key: 'L', values: [nextX, nextY] }; 661 | 662 | currentX = nextX; 663 | currentY = nextY; 664 | break; 665 | } 666 | 667 | case 'C': { 668 | const [x1, y1, x2, y2, x, y] = values; 669 | 670 | nextSeg = { key: 'C', values: [x1, y1, x2, y2, x, y] }; 671 | 672 | currentX = x; 673 | currentY = y; 674 | break; 675 | } 676 | 677 | case 'c': { 678 | const [x1, y1, x2, y2, x, y] = values; 679 | 680 | const nextValues = [ 681 | currentX + x1, 682 | currentY + y1, 683 | currentX + x2, 684 | currentY + y2, 685 | currentX + x, 686 | currentY + y 687 | ]; 688 | 689 | nextSeg = { key: 'C', values: [...nextValues] }; 690 | 691 | currentX = nextValues[4]; 692 | currentY = nextValues[5]; 693 | break; 694 | } 695 | 696 | case 'Q': { 697 | const [x1, y1, x, y] = values; 698 | 699 | nextSeg = { key: 'Q', values: [x1, y1, x, y] }; 700 | 701 | currentX = x; 702 | currentY = y; 703 | break; 704 | } 705 | 706 | case 'q': { 707 | const [x1, y1, x, y] = values; 708 | 709 | const nextValues = [ 710 | currentX + x1, 711 | currentY + y1, 712 | currentX + x, 713 | currentY + y 714 | ]; 715 | 716 | absolutizedPathData.push({ key: 'Q', values: [...nextValues] }); 717 | 718 | currentX = nextValues[2]; 719 | currentY = nextValues[3]; 720 | break; 721 | } 722 | 723 | case 'A': { 724 | const [r1, r2, angle, largeArcFlag, sweepFlag, x, y] = values; 725 | 726 | nextSeg = { 727 | key: 'A', 728 | values: [r1, r2, angle, largeArcFlag, sweepFlag, x, y] 729 | }; 730 | 731 | currentX = x; 732 | currentY = y; 733 | break; 734 | } 735 | 736 | case 'a': { 737 | const [r1, r2, angle, largeArcFlag, sweepFlag, x, y] = values; 738 | const nextX = currentX + x; 739 | const nextY = currentY + y; 740 | 741 | nextSeg = { 742 | key: 'A', 743 | values: [r1, r2, angle, largeArcFlag, sweepFlag, nextX, nextY] 744 | }; 745 | 746 | currentX = nextX; 747 | currentY = nextY; 748 | break; 749 | } 750 | 751 | case 'H': { 752 | const [x] = values; 753 | nextSeg = { key: 'H', values: [x] }; 754 | currentX = x; 755 | break; 756 | } 757 | 758 | case 'h': { 759 | const [x] = values; 760 | const nextX = currentX + x; 761 | 762 | nextSeg = { key: 'H', values: [nextX] }; 763 | currentX = nextX; 764 | break; 765 | } 766 | 767 | case 'V': { 768 | const [y] = values; 769 | nextSeg = { key: 'V', values: [y] }; 770 | currentY = y; 771 | break; 772 | } 773 | 774 | case 'v': { 775 | const [y] = values; 776 | const nextY = currentY + y; 777 | nextSeg = { key: 'V', values: [nextY] }; 778 | currentY = nextY; 779 | break; 780 | } 781 | case 'S': { 782 | const [x2, y2, x, y] = values; 783 | 784 | nextSeg = { key: 'S', values: [x2, y2, x, y] }; 785 | 786 | currentX = x; 787 | currentY = y; 788 | break; 789 | } 790 | 791 | case 's': { 792 | const [x2, y2, x, y] = values; 793 | 794 | const nextValues = [ 795 | currentX + x2, 796 | currentY + y2, 797 | currentX + x, 798 | currentY + y 799 | ]; 800 | 801 | nextSeg = { key: 'S', values: [...nextValues] }; 802 | 803 | currentX = nextValues[2]; 804 | currentY = nextValues[3]; 805 | break; 806 | } 807 | 808 | case 'T': { 809 | const [x, y] = values; 810 | 811 | nextSeg = { key: 'T', values: [x, y] }; 812 | 813 | currentX = x; 814 | currentY = y; 815 | break; 816 | } 817 | 818 | case 't': { 819 | const [x, y] = values; 820 | const nextX = currentX + x; 821 | const nextY = currentY + y; 822 | 823 | nextSeg = { key: 'T', values: [nextX, nextY] }; 824 | 825 | currentX = nextX; 826 | currentY = nextY; 827 | break; 828 | } 829 | 830 | case 'Z': 831 | case 'z': { 832 | nextSeg = { key: 'Z', values: [] }; 833 | 834 | currentX = subpathX; 835 | currentY = subpathY; 836 | break; 837 | } 838 | 839 | } 840 | 841 | return [...absolutizedPathData, nextSeg]; 842 | }, []); 843 | }; 844 | 845 | const reducePathData = (pathData) => { 846 | let lastType = null; 847 | 848 | let lastControlX = null; 849 | let lastControlY = null; 850 | 851 | let currentX = null; 852 | let currentY = null; 853 | 854 | let subpathX = null; 855 | let subpathY = null; 856 | 857 | return pathData.reduce((reducedPathData, seg) => { 858 | const { key, values } = seg; 859 | 860 | let nextSeg; 861 | 862 | switch (key) { 863 | 864 | case 'M': { 865 | const [x, y] = values; 866 | 867 | nextSeg = [{ key: 'M', values: [x, y] }]; 868 | 869 | subpathX = x; 870 | subpathY = y; 871 | 872 | currentX = x; 873 | currentY = y; 874 | break; 875 | } 876 | 877 | case 'C': { 878 | const [x1, y1, x2, y2, x, y] = values; 879 | 880 | nextSeg = [{ key: 'C', values: [x1, y1, x2, y2, x, y] }]; 881 | 882 | lastControlX = x2; 883 | lastControlY = y2; 884 | 885 | currentX = x; 886 | currentY = y; 887 | break; 888 | } 889 | 890 | case 'L': { 891 | const [x, y] = values; 892 | 893 | nextSeg = [{ key: 'L', values: [x, y] }]; 894 | 895 | currentX = x; 896 | currentY = y; 897 | break; 898 | } 899 | 900 | case 'H': { 901 | const [x] = values; 902 | 903 | nextSeg = [{ key: 'L', values: [x, currentY] }]; 904 | 905 | currentX = x; 906 | break; 907 | } 908 | 909 | case 'V': { 910 | const [y] = values; 911 | 912 | nextSeg = [{ key: 'L', values: [currentX, y] }]; 913 | 914 | currentY = y; 915 | break; 916 | } 917 | 918 | case 'S': { 919 | const [x2, y2, x, y] = values; 920 | 921 | let cx1, cy1; 922 | 923 | if (lastType === 'C' || lastType === 'S') { 924 | cx1 = currentX + (currentX - lastControlX); 925 | cy1 = currentY + (currentY - lastControlY); 926 | } else { 927 | cx1 = currentX; 928 | cy1 = currentY; 929 | } 930 | 931 | nextSeg = [{ key: 'C', values: [cx1, cy1, x2, y2, x, y] }]; 932 | 933 | lastControlX = x2; 934 | lastControlY = y2; 935 | 936 | currentX = x; 937 | currentY = y; 938 | break; 939 | } 940 | 941 | case 'T': { 942 | const [x, y] = values; 943 | 944 | let x1, y1; 945 | 946 | if (lastType === 'Q' || lastType === 'T') { 947 | x1 = currentX + (currentX - lastControlX); 948 | y1 = currentY + (currentY - lastControlY); 949 | } else { 950 | x1 = currentX; 951 | y1 = currentY; 952 | } 953 | 954 | const cx1 = currentX + 2 * (x1 - currentX) / 3; 955 | const cy1 = currentY + 2 * (y1 - currentY) / 3; 956 | const cx2 = x + 2 * (x1 - x) / 3; 957 | const cy2 = y + 2 * (y1 - y) / 3; 958 | 959 | nextSeg = [{ key: 'C', values: [cx1, cy1, cx2, cy2, x, y] }]; 960 | 961 | lastControlX = x1; 962 | lastControlY = y1; 963 | 964 | currentX = x; 965 | currentY = y; 966 | break; 967 | } 968 | 969 | case 'Q': { 970 | const [x1, y1, x, y] = values; 971 | 972 | const cx1 = currentX + 2 * (x1 - currentX) / 3; 973 | const cy1 = currentY + 2 * (y1 - currentY) / 3; 974 | const cx2 = x + 2 * (x1 - x) / 3; 975 | const cy2 = y + 2 * (y1 - y) / 3; 976 | 977 | nextSeg = [{ key: 'C', values: [cx1, cy1, cx2, cy2, x, y] }]; 978 | 979 | lastControlX = x1; 980 | lastControlY = y1; 981 | 982 | currentX = x; 983 | currentY = y; 984 | break; 985 | } 986 | 987 | case 'A': { 988 | const [r1, r2, angle, largeArcFlag, sweepFlag, x, y] = values; 989 | 990 | if (r1 === 0 || r2 === 0) { 991 | nextSeg = [{ key: 'C', values: [currentX, currentY, x, y, x, y] }]; 992 | 993 | currentX = x; 994 | currentY = y; 995 | } else { 996 | if (currentX !== x || currentY !== y) { 997 | const curves = arcToCubicCurves(currentX, currentY, x, y, r1, r2, angle, largeArcFlag, sweepFlag); 998 | 999 | nextSeg = curves.map(curve => ({ key: 'C', values: curve })); 1000 | 1001 | currentX = x; 1002 | currentY = y; 1003 | } 1004 | } 1005 | break; 1006 | } 1007 | 1008 | case 'Z': { 1009 | nextSeg = [seg]; 1010 | 1011 | currentX = subpathX; 1012 | currentY = subpathY; 1013 | break; 1014 | } 1015 | 1016 | } 1017 | 1018 | lastType = key; 1019 | 1020 | return [...reducedPathData, ...nextSeg]; 1021 | }, []); 1022 | }; 1023 | 1024 | // - a2c() by Dmitry Baranovskiy (MIT License) 1025 | // https://github.com/DmitryBaranovskiy/raphael/blob/v2.1.1/raphael.js#L2216 1026 | const arcToCubicCurves = (x1, y1, x2, y2, rx, ry, xAxisRot, largeArcFlag, sweepFlag, recursive) => { 1027 | const degToRad = deg => (Math.PI * deg) / 180; 1028 | 1029 | const rotate = (x, y, rad) => ({ 1030 | x: x * Math.cos(rad) - y * Math.sin(rad), 1031 | y: x * Math.sin(rad) + y * Math.cos(rad) 1032 | }); 1033 | 1034 | const angleRad = degToRad(xAxisRot); 1035 | let params = []; 1036 | let f1, f2, cx, cy; 1037 | 1038 | if (recursive) { 1039 | f1 = recursive[0]; 1040 | f2 = recursive[1]; 1041 | cx = recursive[2]; 1042 | cy = recursive[3]; 1043 | } else { 1044 | const p1 = rotate(x1, y1, -angleRad); 1045 | x1 = p1.x; 1046 | y1 = p1.y; 1047 | 1048 | const p2 = rotate(x2, y2, -angleRad); 1049 | x2 = p2.x; 1050 | y2 = p2.y; 1051 | 1052 | const x = (x1 - x2) / 2; 1053 | const y = (y1 - y2) / 2; 1054 | let h = (x * x) / (rx * rx) + (y * y) / (ry * ry); 1055 | 1056 | if (h > 1) { 1057 | h = Math.sqrt(h); 1058 | rx = h * rx; 1059 | ry = h * ry; 1060 | } 1061 | 1062 | let sign = largeArcFlag === sweepFlag ? -1 : 1; 1063 | 1064 | const r1Pow = rx * rx; 1065 | const r2Pow = ry * ry; 1066 | 1067 | const left = r1Pow * r2Pow - r1Pow * y * y - r2Pow * x * x; 1068 | const right = r1Pow * y * y + r2Pow * x * x; 1069 | 1070 | let k = sign * Math.sqrt(Math.abs(left / right)); 1071 | 1072 | cx = k * rx * y / ry + (x1 + x2) / 2; 1073 | cy = k * -ry * x / rx + (y1 + y2) / 2; 1074 | 1075 | f1 = Math.asin(parseFloat(((y1 - cy) / ry).toFixed(9))); 1076 | f2 = Math.asin(parseFloat(((y2 - cy) / ry).toFixed(9))); 1077 | 1078 | if (x1 < cx) { 1079 | f1 = Math.PI - f1; 1080 | } 1081 | 1082 | if (x2 < cx) { 1083 | f2 = Math.PI - f2; 1084 | } 1085 | 1086 | if (f1 < 0) { 1087 | f1 = Math.PI * 2 + f1; 1088 | } 1089 | 1090 | if (f2 < 0) { 1091 | f2 = Math.PI * 2 + f2; 1092 | } 1093 | 1094 | if (sweepFlag && f1 > f2) { 1095 | f1 = f1 - Math.PI * 2; 1096 | } 1097 | 1098 | if (!sweepFlag && f2 > f1) { 1099 | f2 = f2 - Math.PI * 2; 1100 | } 1101 | } 1102 | 1103 | let df = f2 - f1; 1104 | 1105 | if (Math.abs(df) > (Math.PI * 120 / 180)) { 1106 | let f2old = f2; 1107 | let x2old = x2; 1108 | let y2old = y2; 1109 | 1110 | const ratio = sweepFlag && f2 > f1 ? 1 : -1; 1111 | 1112 | f2 = f1 + (Math.PI * 120 / 180) * ratio; 1113 | 1114 | x2 = cx + rx * Math.cos(f2); 1115 | y2 = cy + ry * Math.sin(f2); 1116 | params = arcToCubicCurves(x2, y2, x2old, y2old, rx, ry, xAxisRot, 0, sweepFlag, [f2, f2old, cx, cy]); 1117 | } 1118 | 1119 | df = f2 - f1; 1120 | 1121 | let c1 = Math.cos(f1); 1122 | let s1 = Math.sin(f1); 1123 | let c2 = Math.cos(f2); 1124 | let s2 = Math.sin(f2); 1125 | let t = Math.tan(df / 4); 1126 | let hx = 4 / 3 * rx * t; 1127 | let hy = 4 / 3 * ry * t; 1128 | 1129 | let m1 = [x1, y1]; 1130 | let m2 = [x1 + hx * s1, y1 - hy * c1]; 1131 | let m3 = [x2 + hx * s2, y2 - hy * c2]; 1132 | let m4 = [x2, y2]; 1133 | 1134 | m2[0] = 2 * m1[0] - m2[0]; 1135 | m2[1] = 2 * m1[1] - m2[1]; 1136 | 1137 | if (recursive) { 1138 | return [m2, m3, m4, ...params]; 1139 | } else { 1140 | params = [m2, m3, m4, ...params].join().split(','); 1141 | 1142 | const curves = []; 1143 | let curveParams = []; 1144 | 1145 | params.forEach((_, i) => { 1146 | if (i % 2) { 1147 | curveParams.push(rotate(params[i - 1], params[i], angleRad).y); 1148 | } else { 1149 | curveParams.push(rotate(params[i], params[i + 1], angleRad).x); 1150 | } 1151 | 1152 | if (curveParams.length === 6) { 1153 | curves.push(curveParams); 1154 | curveParams = []; 1155 | } 1156 | }); 1157 | 1158 | return curves; 1159 | } 1160 | }; -------------------------------------------------------------------------------- /src/js/core/transform/Transformable.js: -------------------------------------------------------------------------------- 1 | import { helper } from '../Helper'; 2 | import SubjectModel from '../SubjectModel'; 3 | import { getMinMaxOfArray, snapToGrid, RAD } from './common'; 4 | 5 | import { 6 | LIB_CLASS_PREFIX, 7 | NOTIFIER_CONSTANTS, 8 | EVENT_EMITTER_CONSTANTS, 9 | TRANSFORM_HANDLES_CONSTANTS, 10 | CLIENT_EVENTS_CONSTANTS 11 | } from '../consts'; 12 | 13 | import { 14 | requestAnimFrame, 15 | cancelAnimFrame, 16 | isDef, 17 | isUndef, 18 | createMethod, 19 | noop, 20 | warn 21 | } from '../util/util'; 22 | 23 | import { 24 | addClass, 25 | removeClass 26 | } from '../util/css-util'; 27 | 28 | const { 29 | NOTIFIER_EVENTS, 30 | ON_GETSTATE, 31 | ON_APPLY, 32 | ON_MOVE, 33 | ON_RESIZE, 34 | ON_ROTATE 35 | } = NOTIFIER_CONSTANTS; 36 | 37 | const { 38 | EMITTER_EVENTS, 39 | E_DRAG_START, 40 | E_DRAG, 41 | E_DRAG_END, 42 | E_RESIZE_START, 43 | E_RESIZE, 44 | E_RESIZE_END, 45 | E_ROTATE_START, 46 | E_ROTATE, 47 | E_ROTATE_END, 48 | E_SET_POINT, 49 | E_SET_POINT_END 50 | } = EVENT_EMITTER_CONSTANTS; 51 | 52 | const { TRANSFORM_HANDLES_KEYS, TRANSFORM_EDGES_KEYS } = TRANSFORM_HANDLES_CONSTANTS; 53 | const { 54 | E_MOUSEDOWN, 55 | E_TOUCHSTART, 56 | E_MOUSEMOVE, 57 | E_MOUSEUP, 58 | E_TOUCHMOVE, 59 | E_TOUCHEND 60 | } = CLIENT_EVENTS_CONSTANTS; 61 | 62 | const { 63 | TOP_LEFT, 64 | TOP_CENTER, 65 | TOP_RIGHT, 66 | BOTTOM_LEFT, 67 | BOTTOM_RIGHT, 68 | BOTTOM_CENTER, 69 | MIDDLE_LEFT, 70 | MIDDLE_RIGHT 71 | } = TRANSFORM_HANDLES_KEYS; 72 | 73 | const { 74 | TOP_EDGE, 75 | BOTTOM_EDGE, 76 | LEFT_EDGE, 77 | RIGHT_EDGE 78 | } = TRANSFORM_EDGES_KEYS; 79 | 80 | const { keys, values } = Object; 81 | 82 | export default class Transformable extends SubjectModel { 83 | 84 | constructor(elements, options, observable) { 85 | super(elements); 86 | if (this.constructor === Transformable) { 87 | throw new TypeError('Cannot construct Transformable instances directly'); 88 | } 89 | this.observable = observable; 90 | 91 | EMITTER_EVENTS.forEach(eventName => this.eventDispatcher.registerEvent(eventName)); 92 | super.enable(options); 93 | } 94 | 95 | _cursorPoint() { 96 | throw Error(`'_cursorPoint()' method not implemented`); 97 | } 98 | 99 | _rotate({ element, radians, ...rest }) { 100 | const resultMtrx = this._processRotate(element, radians); 101 | const finalArgs = { 102 | transform: resultMtrx, 103 | delta: radians, 104 | ...rest 105 | }; 106 | this.proxyMethods.onRotate.call(this, finalArgs); 107 | super._emitEvent(E_ROTATE, finalArgs); 108 | } 109 | 110 | _resize({ element, dx, dy, ...rest }) { 111 | const finalValues = this._processResize(element, { dx, dy }); 112 | const finalArgs = { 113 | ...finalValues, 114 | dx, 115 | dy, 116 | ...rest 117 | }; 118 | this.proxyMethods.onResize.call(this, finalArgs); 119 | super._emitEvent(E_RESIZE, finalArgs); 120 | } 121 | 122 | _processOptions(options = {}) { 123 | const { elements } = this; 124 | 125 | [...elements].map(element => addClass(element, `${LIB_CLASS_PREFIX}drag`)); 126 | 127 | const { 128 | each = { 129 | move: false, 130 | resize: false, 131 | rotate: false 132 | }, 133 | snap = { 134 | x: 10, 135 | y: 10, 136 | angle: 10 137 | }, 138 | axis = 'xy', 139 | cursorMove = 'auto', 140 | cursorResize = 'auto', 141 | cursorRotate = 'auto', 142 | rotationPoint = false, 143 | transformOrigin = false, 144 | restrict, 145 | draggable = true, 146 | resizable = true, 147 | rotatable = true, 148 | scalable = false, 149 | applyTranslate = false, 150 | onInit = noop, 151 | onDrop = noop, 152 | onMove = noop, 153 | onResize = noop, 154 | onRotate = noop, 155 | onDestroy = noop, 156 | container = elements[0].parentNode, 157 | controlsContainer = container, 158 | proportions = false, 159 | rotatorAnchor = null, 160 | rotatorOffset = 50, 161 | showNormal = true, 162 | custom 163 | } = options; 164 | 165 | this.options = { 166 | axis, 167 | cursorMove, 168 | cursorRotate, 169 | cursorResize, 170 | rotationPoint, 171 | transformOrigin: transformOrigin || rotationPoint, 172 | restrict: restrict 173 | ? helper(restrict)[0] || document.body 174 | : null, 175 | container: helper(container)[0], 176 | controlsContainer: helper(controlsContainer)[0], 177 | snap: { 178 | ...snap, 179 | angle: snap.angle * RAD 180 | }, 181 | each, 182 | proportions, 183 | draggable, 184 | resizable, 185 | rotatable, 186 | scalable, 187 | applyTranslate, 188 | custom: (typeof custom === 'object' && custom) || null, 189 | rotatorAnchor, 190 | rotatorOffset, 191 | showNormal, 192 | isGrouped: elements.length > 1 193 | }; 194 | 195 | this.proxyMethods = { 196 | onInit: createMethod(onInit), 197 | onDrop: createMethod(onDrop), 198 | onMove: createMethod(onMove), 199 | onResize: createMethod(onResize), 200 | onRotate: createMethod(onRotate), 201 | onDestroy: createMethod(onDestroy) 202 | }; 203 | 204 | this.subscribe(each); 205 | } 206 | 207 | _animate() { 208 | const self = this; 209 | const { 210 | observable, 211 | storage, 212 | options, 213 | elements 214 | } = self; 215 | 216 | if (isUndef(storage)) return; 217 | 218 | storage.frame = requestAnimFrame(self._animate); 219 | 220 | if (!storage.doDraw) return; 221 | storage.doDraw = false; 222 | 223 | let { 224 | dox, 225 | doy, 226 | clientX, 227 | clientY, 228 | relativeX, 229 | relativeY, 230 | doDrag, 231 | doResize, 232 | doRotate, 233 | doSetCenter, 234 | revX, 235 | revY, 236 | mouseEvent, 237 | data 238 | } = storage; 239 | 240 | const { 241 | snap, 242 | each: { 243 | move: moveEach, 244 | resize: resizeEach, 245 | rotate: rotateEach 246 | }, 247 | draggable, 248 | resizable, 249 | rotatable, 250 | isGrouped, 251 | restrict 252 | } = options; 253 | 254 | if (doResize && resizable) { 255 | const distX = snapToGrid(clientX - relativeX, snap.x); 256 | const distY = snapToGrid(clientY - relativeY, snap.y); 257 | 258 | const { 259 | cached, 260 | cached: { 261 | dist: { 262 | dx: prevDx = distX, 263 | dy: prevDy = distY 264 | } = {} 265 | } = {} 266 | } = storage; 267 | 268 | const args = { 269 | dx: distX, 270 | dy: distY, 271 | clientX, 272 | clientY, 273 | mouseEvent 274 | }; 275 | 276 | const { x: restX, y: restY } = restrict 277 | ? elements.reduce((res, element) => { 278 | const { 279 | transform: { 280 | // scX, 281 | // scY, 282 | ctm 283 | } 284 | } = data.get(element); 285 | 286 | const { x, y } = !isGrouped 287 | ? this._pointToTransform( 288 | { 289 | x: distX, 290 | y: distY, 291 | matrix: ctm 292 | } 293 | ) 294 | : { x: distX, y: distY }; 295 | 296 | const dx = dox ? (revX ? -x : x) : 0; 297 | const dy = doy ? (revY ? -y : y) : 0; 298 | 299 | const { x: newX, y: newY } = this._processResizeRestrict(element, { dx, dy }); 300 | 301 | return { 302 | x: newX !== null && res.x === null ? distX : res.x, 303 | y: newY !== null && res.y === null ? distY : res.y 304 | }; 305 | }, { x: null, y: null }) 306 | : { x: null, y: null }; 307 | 308 | const isBounding = restrict && (restX !== null || restY !== null); 309 | 310 | const newDx = isBounding ? prevDx : distX; 311 | const newDy = isBounding ? prevDy : distY; 312 | 313 | const nextArgs = { 314 | ...args, 315 | dx: newDx, 316 | dy: newDy, 317 | revX, 318 | revY, 319 | dox, 320 | doy 321 | }; 322 | 323 | elements.map((element) => { 324 | const { 325 | transform: { 326 | // scX, 327 | // scY, 328 | ctm 329 | } 330 | } = data.get(element); 331 | 332 | const { x, y } = !isGrouped 333 | ? this._pointToTransform( 334 | { 335 | x: newDx, 336 | y: newDy, 337 | matrix: ctm 338 | } 339 | ) 340 | : { x: newDx, y: newDy }; 341 | 342 | const dx = dox ? (revX ? -x : x) : 0; 343 | const dy = doy ? (revY ? -y : y) : 0; 344 | 345 | self._resize({ 346 | ...nextArgs, 347 | element, 348 | dx, 349 | dy 350 | }); 351 | }); 352 | 353 | this.storage.cached = { 354 | ...cached, 355 | dist: { 356 | dx: newDx, 357 | dy: newDy 358 | } 359 | }; 360 | 361 | this._processControlsResize({ dx: newDx, dy: newDy }); 362 | 363 | if (resizeEach) { 364 | observable.notify( 365 | ON_RESIZE, 366 | self, 367 | nextArgs 368 | ); 369 | } 370 | } 371 | 372 | if (doDrag && draggable) { 373 | const dx = dox 374 | ? snapToGrid(clientX - relativeX, snap.x) 375 | : 0; 376 | 377 | const dy = doy 378 | ? snapToGrid(clientY - relativeY, snap.y) 379 | : 0; 380 | 381 | const { 382 | cached, 383 | cached: { 384 | dist: { 385 | dx: prevDx = dx, 386 | dy: prevDy = dy 387 | } = {} 388 | } = {} 389 | } = storage; 390 | 391 | const args = { 392 | dx, 393 | dy, 394 | clientX, 395 | clientY, 396 | mouseEvent 397 | }; 398 | 399 | const { x: restX, y: restY } = restrict 400 | ? elements.reduce((res, element) => { 401 | const { x, y } = this._processMoveRestrict(element, args); 402 | 403 | return { 404 | x: res.x === null && restrict ? x : res.x, 405 | y: res.y === null && restrict ? y : res.y 406 | }; 407 | }, { x: null, y: null }) 408 | : { x: null, y: null }; 409 | 410 | const newDx = restX !== null && restrict ? prevDx : dx; 411 | const newDy = restY !== null && restrict ? prevDy : dy; 412 | 413 | const nextArgs = { 414 | ...args, 415 | dx: newDx, 416 | dy: newDy 417 | }; 418 | 419 | this.storage.cached = { 420 | ...cached, 421 | dist: { 422 | dx: newDx, 423 | dy: newDy 424 | } 425 | }; 426 | 427 | elements.map((element) => ( 428 | super._drag({ 429 | element, 430 | ...nextArgs, 431 | dx: newDx, 432 | dy: newDy 433 | }) 434 | )); 435 | 436 | this._processControlsMove({ dx: newDx, dy: newDy }); 437 | 438 | if (moveEach) { 439 | observable.notify( 440 | ON_MOVE, 441 | self, 442 | nextArgs 443 | ); 444 | } 445 | } 446 | 447 | if (doRotate && rotatable) { 448 | const { 449 | pressang, 450 | center 451 | } = storage; 452 | 453 | const delta = Math.atan2( 454 | clientY - center.y, 455 | clientX - center.x 456 | ); 457 | const radians = snapToGrid(delta - pressang, snap.angle); 458 | 459 | if (restrict) { 460 | const isBounding = elements.some((element) => { 461 | const { x: restX, y: restY } = this._processRotateRestrict(element, radians); 462 | return (restX !== null || restY !== null); 463 | }); 464 | 465 | if (isBounding) return; 466 | } 467 | 468 | const args = { 469 | clientX, 470 | clientY, 471 | mouseEvent 472 | }; 473 | 474 | elements.map((element) => ( 475 | self._rotate({ 476 | element, 477 | radians, 478 | ...args 479 | }) 480 | )); 481 | 482 | this._processControlsRotate({ radians }); 483 | 484 | if (rotateEach) { 485 | observable.notify( 486 | ON_ROTATE, 487 | self, 488 | { 489 | radians, 490 | ...args 491 | } 492 | ); 493 | } 494 | } 495 | 496 | if (doSetCenter && rotatable) { 497 | const { 498 | bx, 499 | by 500 | } = storage; 501 | 502 | const { x, y } = this._pointToControls( 503 | { 504 | x: clientX, 505 | y: clientY 506 | } 507 | ); 508 | 509 | self._moveCenterHandle( 510 | x - bx, 511 | y - by 512 | ); 513 | } 514 | } 515 | 516 | _start(e) { 517 | const { clientX, clientY } = e; 518 | const { 519 | elements, 520 | observable, 521 | options: { axis, each }, 522 | storage, 523 | storage: { handles } 524 | } = this; 525 | 526 | const isTarget = values(handles).some((hdl) => helper(e.target).is(hdl)) || 527 | elements.some(element => element.contains(e.target)); 528 | 529 | storage.isTarget = isTarget; 530 | 531 | if (!isTarget) return; 532 | 533 | const computed = this._compute(e, elements); 534 | 535 | keys(computed).map(prop => storage[prop] = computed[prop]); 536 | 537 | const { 538 | onRightEdge, 539 | onBottomEdge, 540 | onTopEdge, 541 | onLeftEdge, 542 | handle, 543 | factor, 544 | revX, 545 | revY, 546 | doW, 547 | doH 548 | } = computed; 549 | 550 | const doResize = onRightEdge || onBottomEdge || onTopEdge || onLeftEdge; 551 | 552 | const { 553 | rotator, 554 | center, 555 | radius 556 | } = handles; 557 | 558 | if (isDef(radius)) removeClass(radius, `${LIB_CLASS_PREFIX}hidden`); 559 | 560 | const doRotate = handle.is(rotator), 561 | doSetCenter = isDef(center) 562 | ? handle.is(center) 563 | : false; 564 | 565 | const doDrag = isTarget && !(doRotate || doResize || doSetCenter); 566 | 567 | const nextStorage = { 568 | mouseEvent: e, 569 | clientX, 570 | clientY, 571 | doResize, 572 | doDrag, 573 | doRotate, 574 | doSetCenter, 575 | onExecution: true, 576 | cursor: null, 577 | dox: /\x/.test(axis) && (doResize 578 | ? 579 | handle.is(handles.ml) || 580 | handle.is(handles.mr) || 581 | handle.is(handles.tl) || 582 | handle.is(handles.tr) || 583 | handle.is(handles.bl) || 584 | handle.is(handles.br) || 585 | handle.is(handles.le) || 586 | handle.is(handles.re) 587 | : true), 588 | doy: /\y/.test(axis) && (doResize 589 | ? 590 | handle.is(handles.br) || 591 | handle.is(handles.bl) || 592 | handle.is(handles.bc) || 593 | handle.is(handles.tr) || 594 | handle.is(handles.tl) || 595 | handle.is(handles.tc) || 596 | handle.is(handles.te) || 597 | handle.is(handles.be) 598 | : true) 599 | }; 600 | 601 | this.storage = { 602 | ...storage, 603 | ...nextStorage 604 | }; 605 | 606 | const eventArgs = { 607 | clientX, 608 | clientY 609 | }; 610 | 611 | if (doResize) { 612 | super._emitEvent(E_RESIZE_START, eventArgs); 613 | } else if (doRotate) { 614 | super._emitEvent(E_ROTATE_START, eventArgs); 615 | } else if (doDrag) { 616 | super._emitEvent(E_DRAG_START, eventArgs); 617 | } 618 | 619 | const { 620 | move, 621 | resize, 622 | rotate 623 | } = each; 624 | 625 | const actionName = doResize 626 | ? E_RESIZE 627 | : (doRotate ? E_ROTATE : E_DRAG); 628 | 629 | const triggerEvent = 630 | (doResize && resize) || 631 | (doRotate && rotate) || 632 | (doDrag && move); 633 | 634 | observable.notify( 635 | ON_GETSTATE, 636 | this, 637 | { 638 | clientX, 639 | clientY, 640 | actionName, 641 | triggerEvent, 642 | factor, 643 | revX, 644 | revY, 645 | doW, 646 | doH 647 | } 648 | ); 649 | 650 | this._draw(); 651 | } 652 | 653 | _moving(e) { 654 | const { storage = {}, options } = this; 655 | 656 | if (!storage.isTarget) return; 657 | 658 | const { x, y } = this._cursorPoint(e); 659 | 660 | storage.mouseEvent = e; 661 | storage.clientX = x; 662 | storage.clientY = y; 663 | storage.doDraw = true; 664 | 665 | let { 666 | doRotate, 667 | doDrag, 668 | doResize, 669 | cursor 670 | } = storage; 671 | 672 | const { 673 | cursorMove, 674 | cursorResize, 675 | cursorRotate 676 | } = options; 677 | 678 | if (isUndef(cursor)) { 679 | if (doDrag) { 680 | cursor = cursorMove; 681 | } else if (doRotate) { 682 | cursor = cursorRotate; 683 | } else if (doResize) { 684 | cursor = cursorResize; 685 | } 686 | helper(document.body).css({ cursor }); 687 | } 688 | } 689 | 690 | _end({ clientX, clientY }) { 691 | const { 692 | elements, 693 | options: { each }, 694 | observable, 695 | storage: { 696 | doResize, 697 | doDrag, 698 | doRotate, 699 | doSetCenter, 700 | frame, 701 | handles: { radius }, 702 | isTarget 703 | }, 704 | proxyMethods 705 | } = this; 706 | 707 | if (!isTarget) return; 708 | 709 | const { actionName = E_DRAG } = [ 710 | { 711 | actionName: E_RESIZE, 712 | condition: doResize 713 | }, 714 | { 715 | actionName: E_DRAG, 716 | condition: doDrag 717 | }, 718 | { 719 | actionName: E_ROTATE, 720 | condition: doRotate 721 | }, 722 | { 723 | actionName: E_SET_POINT, 724 | condition: doSetCenter 725 | } 726 | ].find((({ condition }) => condition)) || {}; 727 | 728 | elements.map(element => this._applyTransformToElement(element, actionName)); 729 | 730 | this._processActions(actionName); 731 | this._updateStorage(); 732 | 733 | const eventArgs = { 734 | clientX, 735 | clientY 736 | }; 737 | 738 | proxyMethods.onDrop.call(this, eventArgs); 739 | 740 | if (doResize) { 741 | super._emitEvent(E_RESIZE_END, eventArgs); 742 | } else if (doRotate) { 743 | super._emitEvent(E_ROTATE_END, eventArgs); 744 | } else if (doDrag) { 745 | super._emitEvent(E_DRAG_END, eventArgs); 746 | } else if (doSetCenter) { 747 | super._emitEvent(E_SET_POINT_END, eventArgs); 748 | } 749 | 750 | const { 751 | move, 752 | resize, 753 | rotate 754 | } = each; 755 | 756 | const triggerEvent = 757 | (doResize && resize) || 758 | (doRotate && rotate) || 759 | (doDrag && move); 760 | 761 | observable.notify( 762 | ON_APPLY, 763 | this, 764 | { 765 | clientX, 766 | clientY, 767 | actionName, 768 | triggerEvent 769 | } 770 | ); 771 | 772 | cancelAnimFrame(frame); 773 | 774 | helper(document.body).css({ cursor: 'auto' }); 775 | if (isDef(radius)) { 776 | addClass(radius, `${LIB_CLASS_PREFIX}hidden`); 777 | } 778 | } 779 | 780 | _compute(e, elements) { 781 | const { 782 | storage: { 783 | handles, 784 | data 785 | } = {} 786 | } = this; 787 | 788 | const handle = helper(e.target); 789 | 790 | const { 791 | revX, 792 | revY, 793 | doW, 794 | doH, 795 | ...rest 796 | } = this._checkHandles(handle, handles); 797 | 798 | const commonState = this._getCommonState(); 799 | 800 | const { x, y } = this._cursorPoint(e); 801 | const { x: bx, y: by } = this._pointToControls({ x, y }, commonState.transform); 802 | 803 | elements.map(element => { 804 | const { transform, ...nextData } = this._getElementState(element, { revX, revY, doW, doH }); 805 | const { x: ex, y: ey } = this._pointToTransform({ x, y, matrix: transform.ctm }); 806 | 807 | data.set(element, { 808 | ...data.get(element), 809 | ...nextData, 810 | transform, 811 | cx: ex, 812 | cy: ey 813 | }); 814 | }); 815 | 816 | const pressang = Math.atan2( 817 | y - commonState.center.y, 818 | x - commonState.center.x 819 | ); 820 | 821 | return { 822 | data, 823 | ...rest, 824 | handle: values(handles).some(hdl => helper(e.target).is(hdl)) 825 | ? handle 826 | : helper(elements[0]), 827 | pressang, 828 | ...commonState, 829 | revX, 830 | revY, 831 | doW, 832 | doH, 833 | relativeX: x, 834 | relativeY: y, 835 | bx, 836 | by 837 | }; 838 | } 839 | 840 | _checkHandles(handle, handles) { 841 | const checkIsHandle = hdl => isDef(hdl) ? handle.is(hdl) : false; 842 | const checkAction = items => items.some(key => checkIsHandle(handles[key])); 843 | 844 | const revX = checkAction([TOP_LEFT, MIDDLE_LEFT, BOTTOM_LEFT, TOP_CENTER, LEFT_EDGE]); 845 | const revY = checkAction([TOP_LEFT, TOP_RIGHT, TOP_CENTER, MIDDLE_LEFT, TOP_EDGE]); 846 | 847 | const onTopEdge = checkAction([TOP_CENTER, TOP_RIGHT, TOP_LEFT, TOP_EDGE]); 848 | const onLeftEdge = checkAction([TOP_LEFT, MIDDLE_LEFT, BOTTOM_LEFT, LEFT_EDGE]); 849 | const onRightEdge = checkAction([TOP_RIGHT, MIDDLE_RIGHT, BOTTOM_RIGHT, RIGHT_EDGE]); 850 | const onBottomEdge = checkAction([BOTTOM_RIGHT, BOTTOM_CENTER, BOTTOM_LEFT, BOTTOM_EDGE]); 851 | 852 | const doW = checkAction([MIDDLE_LEFT, MIDDLE_RIGHT, LEFT_EDGE, RIGHT_EDGE]); 853 | const doH = checkAction([TOP_CENTER, BOTTOM_CENTER, BOTTOM_EDGE, TOP_EDGE]); 854 | 855 | return { 856 | revX, 857 | revY, 858 | onTopEdge, 859 | onLeftEdge, 860 | onRightEdge, 861 | onBottomEdge, 862 | doW, 863 | doH 864 | }; 865 | } 866 | 867 | _restrictHandler(element, matrix) { 868 | let restrictX = null, 869 | restrictY = null; 870 | 871 | const elBox = this.getBoundingRect(element, matrix); 872 | 873 | const containerBBox = this._getRestrictedBBox(); 874 | 875 | const [ 876 | [minX, maxX], 877 | [minY, maxY] 878 | ] = getMinMaxOfArray(containerBBox); 879 | 880 | for (let i = 0, len = elBox.length; i < len; i++) { 881 | const [x, y] = elBox[i]; 882 | 883 | if (x < minX || x > maxX) { 884 | restrictX = x; 885 | } 886 | if (y < minY || y > maxY) { 887 | restrictY = y; 888 | } 889 | } 890 | 891 | return { 892 | x: restrictX, 893 | y: restrictY 894 | }; 895 | } 896 | 897 | _destroy() { 898 | const { 899 | elements, 900 | storage: { 901 | controls, 902 | wrapper 903 | } = {} 904 | } = this; 905 | 906 | [...elements, controls].map(target => ( 907 | helper(target) 908 | .off(E_MOUSEDOWN, this._onMouseDown) 909 | .off(E_TOUCHSTART, this._onTouchStart) 910 | )); 911 | 912 | wrapper.parentNode.removeChild(wrapper); 913 | } 914 | 915 | _updateStorage() { 916 | const { 917 | storage, 918 | storage: { 919 | transformOrigin: prevTransformOrigin, 920 | transform: { 921 | controlsMatrix: prevControlsMatrix 922 | } = {}, 923 | cached: { 924 | transformOrigin = prevTransformOrigin, 925 | controlsMatrix = prevControlsMatrix 926 | } = {} 927 | } 928 | } = this; 929 | 930 | this.storage = { 931 | ...storage, 932 | doResize: false, 933 | doDrag: false, 934 | doRotate: false, 935 | doSetCenter: false, 936 | doDraw: false, 937 | onExecution: false, 938 | cursor: null, 939 | transformOrigin, 940 | controlsMatrix, 941 | cached: {} 942 | }; 943 | } 944 | 945 | notifyMove({ dx, dy }) { 946 | this.elements.map((element) => super._drag({ element, dx, dy })); 947 | this._processControlsMove({ dx, dy }); 948 | } 949 | 950 | notifyRotate({ radians, ...rest }) { 951 | const { 952 | elements, 953 | options: { 954 | snap: { angle } 955 | } = {} 956 | } = this; 957 | 958 | elements.map((element) => ( 959 | this._rotate({ 960 | element, 961 | radians: snapToGrid(radians, angle), 962 | ...rest 963 | }) 964 | )); 965 | 966 | this._processControlsRotate({ radians }); 967 | } 968 | 969 | notifyResize({ dx, dy, revX, revY, dox, doy }) { 970 | const { 971 | elements, 972 | storage: { 973 | data 974 | }, 975 | options: { 976 | isGrouped 977 | } 978 | } = this; 979 | 980 | elements.map((element) => { 981 | const { 982 | transform: { 983 | ctm 984 | } 985 | } = data.get(element); 986 | 987 | const { x, y } = !isGrouped 988 | ? this._pointToTransform( 989 | { 990 | x: dx, 991 | y: dy, 992 | matrix: ctm 993 | } 994 | ) 995 | : { x: dx, y: dy }; 996 | 997 | this._resize({ 998 | element, 999 | dx: dox ? (revX ? -x : x) : 0, 1000 | dy: doy ? (revY ? -y : y) : 0 1001 | }); 1002 | }); 1003 | 1004 | this._processControlsResize({ dx, dy }); 1005 | } 1006 | 1007 | notifyApply({ clientX, clientY, actionName, triggerEvent }) { 1008 | this.proxyMethods.onDrop.call(this, { clientX, clientY }); 1009 | if (triggerEvent) { 1010 | this.elements.map((element) => this._applyTransformToElement(element, actionName)); 1011 | super._emitEvent(`${actionName}End`, { clientX, clientY }); 1012 | } 1013 | } 1014 | 1015 | notifyGetState({ clientX, clientY, actionName, triggerEvent, ...rest }) { 1016 | if (triggerEvent) { 1017 | const { 1018 | elements, 1019 | storage: { 1020 | data 1021 | } 1022 | } = this; 1023 | 1024 | elements.map(element => { 1025 | const nextData = this._getElementState(element, rest); 1026 | 1027 | data.set(element, { 1028 | ...data.get(element), 1029 | ...nextData 1030 | }); 1031 | }); 1032 | 1033 | const recalc = this._getCommonState(); 1034 | 1035 | this.storage = { 1036 | ...this.storage, 1037 | ...recalc 1038 | }; 1039 | 1040 | super._emitEvent(`${actionName}Start`, { clientX, clientY }); 1041 | } 1042 | } 1043 | 1044 | subscribe({ resize, move, rotate }) { 1045 | const { observable: ob } = this; 1046 | 1047 | if (move || resize || rotate) { 1048 | ob.subscribe(ON_GETSTATE, this) 1049 | .subscribe(ON_APPLY, this); 1050 | } 1051 | 1052 | if (move) { 1053 | ob.subscribe(ON_MOVE, this); 1054 | } 1055 | if (resize) { 1056 | ob.subscribe(ON_RESIZE, this); 1057 | } 1058 | if (rotate) { 1059 | ob.subscribe(ON_ROTATE, this); 1060 | } 1061 | } 1062 | 1063 | unsubscribe() { 1064 | const { observable: ob } = this; 1065 | NOTIFIER_EVENTS.map(eventName => ob.unsubscribe(eventName, this)); 1066 | } 1067 | 1068 | disable() { 1069 | const { 1070 | storage, 1071 | proxyMethods, 1072 | elements 1073 | } = this; 1074 | 1075 | if (isUndef(storage)) return; 1076 | 1077 | // unexpected case 1078 | if (storage.onExecution) { 1079 | helper(document) 1080 | .off(E_MOUSEMOVE, this._onMouseMove) 1081 | .off(E_MOUSEUP, this._onMouseUp) 1082 | .off(E_TOUCHMOVE, this._onTouchMove) 1083 | .off(E_TOUCHEND, this._onTouchEnd); 1084 | } 1085 | 1086 | elements.map((element) => removeClass(element, `${LIB_CLASS_PREFIX}drag`)); 1087 | 1088 | this.unsubscribe(); 1089 | this._destroy(); 1090 | 1091 | proxyMethods.onDestroy.call(this, elements); 1092 | delete this.storage; 1093 | } 1094 | 1095 | exeDrag({ dx, dy }) { 1096 | const { 1097 | elements, 1098 | options: { 1099 | draggable 1100 | }, 1101 | storage, 1102 | storage: { 1103 | data 1104 | } 1105 | } = this; 1106 | if (!draggable) return; 1107 | 1108 | const commonState = this._getCommonState(); 1109 | 1110 | elements.map(element => { 1111 | const nextData = this._getElementState(element, { 1112 | revX: false, 1113 | revY: false, 1114 | doW: false, 1115 | doH: false 1116 | }); 1117 | 1118 | data.set(element, { 1119 | ...data.get(element), 1120 | ...nextData 1121 | }); 1122 | }); 1123 | 1124 | this.storage = { 1125 | ...storage, 1126 | ...commonState 1127 | }; 1128 | 1129 | elements.map((element) => { 1130 | super._drag({ element, dx, dy }); 1131 | this._applyTransformToElement(element, E_DRAG); 1132 | }); 1133 | 1134 | this._processControlsMove({ dx, dy }); 1135 | } 1136 | 1137 | exeResize({ 1138 | dx, 1139 | dy, 1140 | revX = false, 1141 | revY = false, 1142 | doW = false, 1143 | doH = false 1144 | }) { 1145 | const { 1146 | elements, 1147 | options: { 1148 | resizable 1149 | }, 1150 | storage, 1151 | storage: { 1152 | data 1153 | } 1154 | } = this; 1155 | if (!resizable) return; 1156 | 1157 | const commonState = this._getCommonState(); 1158 | 1159 | elements.map(element => { 1160 | const nextData = this._getElementState(element, { 1161 | revX, 1162 | revY, 1163 | doW, 1164 | doH 1165 | }); 1166 | 1167 | data.set(element, { 1168 | ...data.get(element), 1169 | ...nextData 1170 | }); 1171 | }); 1172 | 1173 | this.storage = { 1174 | ...storage, 1175 | ...commonState 1176 | }; 1177 | 1178 | elements.map((element) => { 1179 | this._resize({ element, dx, dy }); 1180 | this._applyTransformToElement(element, E_RESIZE); 1181 | }); 1182 | 1183 | this._processControlsMove({ dx, dy }); 1184 | } 1185 | 1186 | exeRotate({ delta }) { 1187 | const { 1188 | elements, 1189 | options: { 1190 | rotatable 1191 | }, 1192 | storage, 1193 | storage: { 1194 | data 1195 | } 1196 | } = this; 1197 | if (!rotatable) return; 1198 | 1199 | const commonState = this._getCommonState(); 1200 | 1201 | elements.map(element => { 1202 | const nextData = this._getElementState(element, { 1203 | revX: false, 1204 | revY: false, 1205 | doW: false, 1206 | doH: false 1207 | }); 1208 | 1209 | data.set(element, { 1210 | ...data.get(element), 1211 | ...nextData 1212 | }); 1213 | }); 1214 | 1215 | this.storage = { 1216 | ...storage, 1217 | ...commonState 1218 | }; 1219 | 1220 | elements.map(element => { 1221 | this._rotate({ element, radians: delta }); 1222 | this._applyTransformToElement(element, E_ROTATE); 1223 | }); 1224 | 1225 | this._processControlsRotate({ radians: delta }); 1226 | } 1227 | 1228 | setCenterPoint() { 1229 | throw Error(`'setCenterPoint()' method not implemented`); 1230 | } 1231 | 1232 | resetCenterPoint() { 1233 | warn('"resetCenterPoint" method is replaced by "resetTransformOrigin" and would be removed soon'); 1234 | this.setTransformOrigin({ dx: 0, dy: 0 }, false); 1235 | } 1236 | 1237 | setTransformOrigin() { 1238 | throw Error(`'setTransformOrigin()' method not implemented`); 1239 | } 1240 | 1241 | resetTransformOrigin() { 1242 | this.setTransformOrigin({ dx: 0, dy: 0 }, false); 1243 | } 1244 | 1245 | get controls() { 1246 | return this.storage.wrapper; 1247 | } 1248 | 1249 | } --------------------------------------------------------------------------------