28041 lines
1.0 MiB
Plaintext
28041 lines
1.0 MiB
Plaintext
//https://cdn.jsdelivr.net/npm/fabric@6.4.3/dist/index.js
|
|
|
|
(function (global, factory) {
|
|
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
|
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.fabric = {}));
|
|
})(this, (function (exports) { 'use strict';
|
|
|
|
function _defineProperty(e, r, t) {
|
|
return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
value: t,
|
|
enumerable: !0,
|
|
configurable: !0,
|
|
writable: !0
|
|
}) : e[r] = t, e;
|
|
}
|
|
function ownKeys(e, r) {
|
|
var t = Object.keys(e);
|
|
if (Object.getOwnPropertySymbols) {
|
|
var o = Object.getOwnPropertySymbols(e);
|
|
r && (o = o.filter(function (r) {
|
|
return Object.getOwnPropertyDescriptor(e, r).enumerable;
|
|
})), t.push.apply(t, o);
|
|
}
|
|
return t;
|
|
}
|
|
function _objectSpread2(e) {
|
|
for (var r = 1; r < arguments.length; r++) {
|
|
var t = null != arguments[r] ? arguments[r] : {};
|
|
r % 2 ? ownKeys(Object(t), !0).forEach(function (r) {
|
|
_defineProperty(e, r, t[r]);
|
|
}) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) {
|
|
Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));
|
|
});
|
|
}
|
|
return e;
|
|
}
|
|
function _objectWithoutProperties(e, t) {
|
|
if (null == e) return {};
|
|
var o,
|
|
r,
|
|
i = _objectWithoutPropertiesLoose(e, t);
|
|
if (Object.getOwnPropertySymbols) {
|
|
var n = Object.getOwnPropertySymbols(e);
|
|
for (r = 0; r < n.length; r++) o = n[r], t.indexOf(o) >= 0 || {}.propertyIsEnumerable.call(e, o) && (i[o] = e[o]);
|
|
}
|
|
return i;
|
|
}
|
|
function _objectWithoutPropertiesLoose(r, e) {
|
|
if (null == r) return {};
|
|
var t = {};
|
|
for (var n in r) if ({}.hasOwnProperty.call(r, n)) {
|
|
if (e.indexOf(n) >= 0) continue;
|
|
t[n] = r[n];
|
|
}
|
|
return t;
|
|
}
|
|
function _taggedTemplateLiteral(e, t) {
|
|
return t || (t = e.slice(0)), Object.freeze(Object.defineProperties(e, {
|
|
raw: {
|
|
value: Object.freeze(t)
|
|
}
|
|
}));
|
|
}
|
|
function _toPrimitive(t, r) {
|
|
if ("object" != typeof t || !t) return t;
|
|
var e = t[Symbol.toPrimitive];
|
|
if (void 0 !== e) {
|
|
var i = e.call(t, r || "default");
|
|
if ("object" != typeof i) return i;
|
|
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
}
|
|
return ("string" === r ? String : Number)(t);
|
|
}
|
|
function _toPropertyKey(t) {
|
|
var i = _toPrimitive(t, "string");
|
|
return "symbol" == typeof i ? i : i + "";
|
|
}
|
|
|
|
class BaseConfiguration {
|
|
constructor() {
|
|
/**
|
|
* Browser-specific constant to adjust CanvasRenderingContext2D.shadowBlur value,
|
|
* which is unitless and not rendered equally across browsers.
|
|
*
|
|
* Values that work quite well (as of October 2017) are:
|
|
* - Chrome: 1.5
|
|
* - Edge: 1.75
|
|
* - Firefox: 0.9
|
|
* - Safari: 0.95
|
|
*
|
|
* @since 2.0.0
|
|
* @type Number
|
|
* @default 1
|
|
*/
|
|
_defineProperty(this, "browserShadowBlurConstant", 1);
|
|
/**
|
|
* Pixel per Inch as a default value set to 96. Can be changed for more realistic conversion.
|
|
*/
|
|
_defineProperty(this, "DPI", 96);
|
|
/**
|
|
* Device Pixel Ratio
|
|
* @see https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/HTML-canvas-guide/SettingUptheCanvas/SettingUptheCanvas.html
|
|
*/
|
|
_defineProperty(this, "devicePixelRatio", typeof window !== 'undefined' ? window.devicePixelRatio : 1);
|
|
// eslint-disable-line no-restricted-globals
|
|
/**
|
|
* Pixel limit for cache canvases. 1Mpx , 4Mpx should be fine.
|
|
* @since 1.7.14
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "perfLimitSizeTotal", 2097152);
|
|
/**
|
|
* Pixel limit for cache canvases width or height. IE fixes the maximum at 5000
|
|
* @since 1.7.14
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "maxCacheSideLimit", 4096);
|
|
/**
|
|
* Lowest pixel limit for cache canvases, set at 256PX
|
|
* @since 1.7.14
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "minCacheSideLimit", 256);
|
|
/**
|
|
* When 'true', style information is not retained when copy/pasting text, making
|
|
* pasted text use destination style.
|
|
* Defaults to 'false'.
|
|
* @type Boolean
|
|
* @default
|
|
* @deprecated
|
|
*/
|
|
_defineProperty(this, "disableStyleCopyPaste", false);
|
|
/**
|
|
* Enable webgl for filtering picture is available
|
|
* A filtering backend will be initialized, this will both take memory and
|
|
* time since a default 2048x2048 canvas will be created for the gl context
|
|
* @since 2.0.0
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "enableGLFiltering", true);
|
|
/**
|
|
* if webgl is enabled and available, textureSize will determine the size
|
|
* of the canvas backend
|
|
*
|
|
* In order to support old hardware set to `2048` to avoid OOM
|
|
*
|
|
* @since 2.0.0
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "textureSize", 4096);
|
|
/**
|
|
* Skip performance testing of setupGLContext and force the use of putImageData that seems to be the one that works best on
|
|
* Chrome + old hardware. if your users are experiencing empty images after filtering you may try to force this to true
|
|
* this has to be set before instantiating the filtering backend ( before filtering the first image )
|
|
* @type Boolean
|
|
* @default false
|
|
*/
|
|
_defineProperty(this, "forceGLPutImageData", false);
|
|
/**
|
|
* If disabled boundsOfCurveCache is not used. For apps that make heavy usage of pencil drawing probably disabling it is better
|
|
* With the standard behaviour of fabric to translate all curves in absolute commands and by not subtracting the starting point from
|
|
* the curve is very hard to hit any cache.
|
|
* Enable only if you know why it could be useful.
|
|
* Candidate for removal/simplification
|
|
* @default false
|
|
*/
|
|
_defineProperty(this, "cachesBoundsOfCurve", false);
|
|
/**
|
|
* Map of font files
|
|
* Map<fontFamily, pathToFile> of font files
|
|
*/
|
|
_defineProperty(this, "fontPaths", {});
|
|
/**
|
|
* Defines the number of fraction digits to use when serializing object values.
|
|
* Used in exporting methods (`toObject`, `toJSON`, `toSVG`)
|
|
* You can use it to increase/decrease precision of such values like left, top, scaleX, scaleY, etc.
|
|
*/
|
|
_defineProperty(this, "NUM_FRACTION_DIGITS", 4);
|
|
}
|
|
}
|
|
class Configuration extends BaseConfiguration {
|
|
constructor(config) {
|
|
super();
|
|
this.configure(config);
|
|
}
|
|
configure() {
|
|
let config = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
Object.assign(this, config);
|
|
}
|
|
|
|
/**
|
|
* Map<fontFamily, pathToFile> of font files
|
|
*/
|
|
addFonts() {
|
|
let paths = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
this.fontPaths = _objectSpread2(_objectSpread2({}, this.fontPaths), paths);
|
|
}
|
|
removeFonts() {
|
|
let fontFamilys = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
fontFamilys.forEach(fontFamily => {
|
|
delete this.fontPaths[fontFamily];
|
|
});
|
|
}
|
|
clearFonts() {
|
|
this.fontPaths = {};
|
|
}
|
|
restoreDefaults(keys) {
|
|
const defaults = new BaseConfiguration();
|
|
const config = (keys === null || keys === void 0 ? void 0 : keys.reduce((acc, key) => {
|
|
acc[key] = defaults[key];
|
|
return acc;
|
|
}, {})) || defaults;
|
|
this.configure(config);
|
|
}
|
|
}
|
|
const config = new Configuration();
|
|
|
|
const log = function (severity) {
|
|
for (var _len = arguments.length, optionalParams = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
|
|
optionalParams[_key - 1] = arguments[_key];
|
|
}
|
|
return (
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
console[severity]('fabric', ...optionalParams)
|
|
);
|
|
};
|
|
class FabricError extends Error {
|
|
constructor(message, options) {
|
|
super("fabric: ".concat(message), options);
|
|
}
|
|
}
|
|
class SignalAbortedError extends FabricError {
|
|
constructor(context) {
|
|
super("".concat(context, " 'options.signal' is in 'aborted' state"));
|
|
}
|
|
}
|
|
|
|
class GLProbe {}
|
|
|
|
/**
|
|
* Lazy initialize WebGL constants
|
|
*/
|
|
class WebGLProbe extends GLProbe {
|
|
/**
|
|
* Tests if webgl supports certain precision
|
|
* @param {WebGL} Canvas WebGL context to test on
|
|
* @param {GLPrecision} Precision to test can be any of following
|
|
* @returns {Boolean} Whether the user's browser WebGL supports given precision.
|
|
*/
|
|
testPrecision(gl, precision) {
|
|
const fragmentSource = "precision ".concat(precision, " float;\nvoid main(){}");
|
|
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
|
|
if (!fragmentShader) {
|
|
return false;
|
|
}
|
|
gl.shaderSource(fragmentShader, fragmentSource);
|
|
gl.compileShader(fragmentShader);
|
|
return !!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS);
|
|
}
|
|
|
|
/**
|
|
* query browser for WebGL
|
|
*/
|
|
queryWebGL(canvas) {
|
|
const gl = canvas.getContext('webgl');
|
|
if (gl) {
|
|
this.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
this.GLPrecision = ['highp', 'mediump', 'lowp'].find(precision => this.testPrecision(gl, precision));
|
|
gl.getExtension('WEBGL_lose_context').loseContext();
|
|
log('log', "WebGL: max texture size ".concat(this.maxTextureSize));
|
|
}
|
|
}
|
|
isSupported(textureSize) {
|
|
return !!this.maxTextureSize && this.maxTextureSize >= textureSize;
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-restricted-globals */
|
|
const copyPasteData = {};
|
|
const getEnv$1 = () => {
|
|
return {
|
|
document,
|
|
window,
|
|
isTouchSupported: 'ontouchstart' in window || 'ontouchstart' in document || window && window.navigator && window.navigator.maxTouchPoints > 0,
|
|
WebGLProbe: new WebGLProbe(),
|
|
dispose() {
|
|
// noop
|
|
},
|
|
copyPasteData
|
|
};
|
|
};
|
|
|
|
/**
|
|
* This file is consumed by fabric.
|
|
* The `./node` and `./browser` files define the env variable that is used by this module.
|
|
* The `./browser` module is defined to be the default env and doesn't set the env at all.
|
|
* This is done in order to support isomorphic usage for browser and node applications
|
|
* since window and document aren't defined at time of import in SSR, we can't set env so we avoid it by deferring to the default env.
|
|
*/
|
|
|
|
let env;
|
|
|
|
/**
|
|
* Sets the environment variables used by fabric.\
|
|
* This is exposed for special cases, such as configuring a test environment, and should be used with care.
|
|
*
|
|
* **CAUTION**: Must be called before using the package.
|
|
*
|
|
* @example
|
|
* <caption>Passing `window` and `document` objects to fabric (in case they are mocked or something)</caption>
|
|
* import { getEnv, setEnv } from 'fabric';
|
|
* // we want fabric to use the `window` and `document` objects exposed by the environment we are running in.
|
|
* setEnv({ ...getEnv(), window, document });
|
|
* // done with setup, using fabric is now safe
|
|
*/
|
|
const setEnv = value => {
|
|
env = value;
|
|
};
|
|
|
|
/**
|
|
* In order to support SSR we **MUST** access the browser env only after the window has loaded
|
|
*/
|
|
const getEnv = () => env || (env = getEnv$1());
|
|
const getFabricDocument = () => getEnv().document;
|
|
const getFabricWindow = () => getEnv().window;
|
|
|
|
/**
|
|
* @returns the config value if defined, fallbacks to the environment value
|
|
*/
|
|
const getDevicePixelRatio = () => {
|
|
var _config$devicePixelRa;
|
|
return Math.max((_config$devicePixelRa = config.devicePixelRatio) !== null && _config$devicePixelRa !== void 0 ? _config$devicePixelRa : getFabricWindow().devicePixelRatio, 1);
|
|
};
|
|
|
|
class Cache {
|
|
constructor() {
|
|
/**
|
|
* Cache of widths of chars in text rendering.
|
|
*/
|
|
_defineProperty(this, "charWidthsCache", {});
|
|
/**
|
|
* This object keeps the results of the boundsOfCurve calculation mapped by the joined arguments necessary to calculate it.
|
|
* It does speed up calculation, if you parse and add always the same paths, but in case of heavy usage of freedrawing
|
|
* you do not get any speed benefit and you get a big object in memory.
|
|
* The object was a private variable before, while now is appended to the lib so that you have access to it and you
|
|
* can eventually clear it.
|
|
* It was an internal variable, is accessible since version 2.3.4
|
|
*/
|
|
_defineProperty(this, "boundsOfCurveCache", {});
|
|
}
|
|
/**
|
|
* @return {Object} reference to cache
|
|
*/
|
|
getFontCache(_ref) {
|
|
let {
|
|
fontFamily,
|
|
fontStyle,
|
|
fontWeight
|
|
} = _ref;
|
|
fontFamily = fontFamily.toLowerCase();
|
|
if (!this.charWidthsCache[fontFamily]) {
|
|
this.charWidthsCache[fontFamily] = {};
|
|
}
|
|
const fontCache = this.charWidthsCache[fontFamily];
|
|
const cacheKey = "".concat(fontStyle.toLowerCase(), "_").concat((fontWeight + '').toLowerCase());
|
|
if (!fontCache[cacheKey]) {
|
|
fontCache[cacheKey] = {};
|
|
}
|
|
return fontCache[cacheKey];
|
|
}
|
|
|
|
/**
|
|
* Clear char widths cache for the given font family or all the cache if no
|
|
* fontFamily is specified.
|
|
* Use it if you know you are loading fonts in a lazy way and you are not waiting
|
|
* for custom fonts to load properly when adding text objects to the canvas.
|
|
* If a text object is added when its own font is not loaded yet, you will get wrong
|
|
* measurement and so wrong bounding boxes.
|
|
* After the font cache is cleared, either change the textObject text content or call
|
|
* initDimensions() to trigger a recalculation
|
|
* @param {String} [fontFamily] font family to clear
|
|
*/
|
|
clearFontCache(fontFamily) {
|
|
fontFamily = (fontFamily || '').toLowerCase();
|
|
if (!fontFamily) {
|
|
this.charWidthsCache = {};
|
|
} else if (this.charWidthsCache[fontFamily]) {
|
|
delete this.charWidthsCache[fontFamily];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given current aspect ratio, determines the max width and height that can
|
|
* respect the total allowed area for the cache.
|
|
* @param {number} ar aspect ratio
|
|
* @return {number[]} Limited dimensions X and Y
|
|
*/
|
|
limitDimsByArea(ar) {
|
|
const {
|
|
perfLimitSizeTotal
|
|
} = config;
|
|
const roughWidth = Math.sqrt(perfLimitSizeTotal * ar);
|
|
// we are not returning a point on purpose, to avoid circular dependencies
|
|
// this is an internal utility
|
|
return [Math.floor(roughWidth), Math.floor(perfLimitSizeTotal / roughWidth)];
|
|
}
|
|
}
|
|
const cache = new Cache();
|
|
|
|
var version = "6.4.3";
|
|
|
|
// use this syntax so babel plugin see this import here
|
|
const VERSION = version;
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
function noop() {}
|
|
const halfPI = Math.PI / 2;
|
|
const twoMathPi = Math.PI * 2;
|
|
const PiBy180 = Math.PI / 180;
|
|
const iMatrix = Object.freeze([1, 0, 0, 1, 0, 0]);
|
|
const DEFAULT_SVG_FONT_SIZE = 16;
|
|
const ALIASING_LIMIT = 2;
|
|
|
|
/* "magic number" for bezier approximations of arcs (http://itc.ktu.lt/itc354/Riskus354.pdf) */
|
|
const kRect = 1 - 0.5522847498;
|
|
const CENTER = 'center';
|
|
const LEFT = 'left';
|
|
const TOP = 'top';
|
|
const BOTTOM = 'bottom';
|
|
const RIGHT = 'right';
|
|
const NONE = 'none';
|
|
const reNewline = /\r?\n/;
|
|
const MOVING = 'moving';
|
|
const SCALING = 'scaling';
|
|
const ROTATING = 'rotating';
|
|
const ROTATE = 'rotate';
|
|
const SKEWING = 'skewing';
|
|
const RESIZING = 'resizing';
|
|
const MODIFY_POLY = 'modifyPoly';
|
|
const MODIFY_PATH = 'modifyPath';
|
|
const CHANGED = 'changed';
|
|
const SCALE = 'scale';
|
|
const SCALE_X = 'scaleX';
|
|
const SCALE_Y = 'scaleY';
|
|
const SKEW_X = 'skewX';
|
|
const SKEW_Y = 'skewY';
|
|
const FILL = 'fill';
|
|
const STROKE = 'stroke';
|
|
const MODIFIED = 'modified';
|
|
|
|
/*
|
|
* This Map connects the objects type value with their
|
|
* class implementation. It used from any object to understand which are
|
|
* the classes to enlive when requesting a object.type = 'path' for example.
|
|
* Objects uses it for clipPath, Canvas uses it for everything.
|
|
* This is necessary for generic code to run and enlive instances from serialized representation.
|
|
* You can customize which classes get enlived from SVG parsing using this classRegistry.
|
|
* The Registry start empty and gets filled in depending which files you import.
|
|
* If you want to be able to parse arbitrary SVGs or JSON representation of canvases, coming from
|
|
* different sources you will need to import all fabric because you may need all classes.
|
|
*/
|
|
|
|
const JSON$1 = 'json';
|
|
const SVG = 'svg';
|
|
class ClassRegistry {
|
|
constructor() {
|
|
this[JSON$1] = new Map();
|
|
this[SVG] = new Map();
|
|
}
|
|
has(classType) {
|
|
return this[JSON$1].has(classType);
|
|
}
|
|
getClass(classType) {
|
|
const constructor = this[JSON$1].get(classType);
|
|
if (!constructor) {
|
|
throw new FabricError("No class registered for ".concat(classType));
|
|
}
|
|
return constructor;
|
|
}
|
|
setClass(classConstructor, classType) {
|
|
if (classType) {
|
|
this[JSON$1].set(classType, classConstructor);
|
|
} else {
|
|
this[JSON$1].set(classConstructor.type, classConstructor);
|
|
// legacy
|
|
// @TODO: needs to be removed in fabric 7 or 8
|
|
this[JSON$1].set(classConstructor.type.toLowerCase(), classConstructor);
|
|
}
|
|
}
|
|
getSVGClass(SVGTagName) {
|
|
return this[SVG].get(SVGTagName);
|
|
}
|
|
setSVGClass(classConstructor, SVGTagName) {
|
|
this[SVG].set(SVGTagName !== null && SVGTagName !== void 0 ? SVGTagName : classConstructor.type.toLowerCase(), classConstructor);
|
|
}
|
|
}
|
|
const classRegistry = new ClassRegistry();
|
|
|
|
/**
|
|
* Array holding all running animations
|
|
*/
|
|
class AnimationRegistry extends Array {
|
|
/**
|
|
* Remove a single animation using an animation context
|
|
* @param {AnimationBase} context
|
|
*/
|
|
remove(context) {
|
|
const index = this.indexOf(context);
|
|
index > -1 && this.splice(index, 1);
|
|
}
|
|
|
|
/**
|
|
* Cancel all running animations on the next frame
|
|
*/
|
|
cancelAll() {
|
|
const animations = this.splice(0);
|
|
animations.forEach(animation => animation.abort());
|
|
return animations;
|
|
}
|
|
|
|
/**
|
|
* Cancel all running animations attached to a canvas on the next frame
|
|
* @param {StaticCanvas} canvas
|
|
*/
|
|
cancelByCanvas(canvas) {
|
|
if (!canvas) {
|
|
return [];
|
|
}
|
|
const animations = this.filter(animation => {
|
|
var _animation$target;
|
|
return animation.target === canvas || typeof animation.target === 'object' && ((_animation$target = animation.target) === null || _animation$target === void 0 ? void 0 : _animation$target.canvas) === canvas;
|
|
});
|
|
animations.forEach(animation => animation.abort());
|
|
return animations;
|
|
}
|
|
|
|
/**
|
|
* Cancel all running animations for target on the next frame
|
|
* @param target
|
|
*/
|
|
cancelByTarget(target) {
|
|
if (!target) {
|
|
return [];
|
|
}
|
|
const animations = this.filter(animation => animation.target === target);
|
|
animations.forEach(animation => animation.abort());
|
|
return animations;
|
|
}
|
|
}
|
|
const runningAnimations = new AnimationRegistry();
|
|
|
|
/**
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-2#events}
|
|
* @see {@link http://fabricjs.com/events|Events demo}
|
|
*/
|
|
class Observable {
|
|
constructor() {
|
|
_defineProperty(this, "__eventListeners", {});
|
|
}
|
|
/**
|
|
* Observes specified event
|
|
* @alias on
|
|
* @param {string} eventName Event name (eg. 'after:render')
|
|
* @param {EventRegistryObject} handlers key/value pairs (eg. {'after:render': handler, 'selection:cleared': handler})
|
|
* @param {Function} handler Function that receives a notification when an event of the specified type occurs
|
|
* @return {Function} disposer
|
|
*/
|
|
on(arg0, handler) {
|
|
if (!this.__eventListeners) {
|
|
this.__eventListeners = {};
|
|
}
|
|
if (typeof arg0 === 'object') {
|
|
// one object with key/value pairs was passed
|
|
Object.entries(arg0).forEach(_ref => {
|
|
let [eventName, handler] = _ref;
|
|
this.on(eventName, handler);
|
|
});
|
|
return () => this.off(arg0);
|
|
} else if (handler) {
|
|
const eventName = arg0;
|
|
if (!this.__eventListeners[eventName]) {
|
|
this.__eventListeners[eventName] = [];
|
|
}
|
|
this.__eventListeners[eventName].push(handler);
|
|
return () => this.off(eventName, handler);
|
|
} else {
|
|
// noop
|
|
return () => false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Observes specified event **once**
|
|
* @alias once
|
|
* @param {string} eventName Event name (eg. 'after:render')
|
|
* @param {EventRegistryObject} handlers key/value pairs (eg. {'after:render': handler, 'selection:cleared': handler})
|
|
* @param {Function} handler Function that receives a notification when an event of the specified type occurs
|
|
* @return {Function} disposer
|
|
*/
|
|
|
|
once(arg0, handler) {
|
|
if (typeof arg0 === 'object') {
|
|
// one object with key/value pairs was passed
|
|
const disposers = [];
|
|
Object.entries(arg0).forEach(_ref2 => {
|
|
let [eventName, handler] = _ref2;
|
|
disposers.push(this.once(eventName, handler));
|
|
});
|
|
return () => disposers.forEach(d => d());
|
|
} else if (handler) {
|
|
const disposer = this.on(arg0, function onceHandler() {
|
|
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
|
|
args[_key] = arguments[_key];
|
|
}
|
|
handler.call(this, ...args);
|
|
disposer();
|
|
});
|
|
return disposer;
|
|
} else {
|
|
// noop
|
|
return () => false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {string} eventName
|
|
* @param {Function} [handler]
|
|
*/
|
|
_removeEventListener(eventName, handler) {
|
|
if (!this.__eventListeners[eventName]) {
|
|
return;
|
|
}
|
|
if (handler) {
|
|
const eventListener = this.__eventListeners[eventName];
|
|
const index = eventListener.indexOf(handler);
|
|
index > -1 && eventListener.splice(index, 1);
|
|
} else {
|
|
this.__eventListeners[eventName] = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe all event listeners for eventname.
|
|
* Do not use this pattern. You could kill internal fabricJS events.
|
|
* We know we should have protected events for internal flows, but we don't have yet
|
|
* @deprecated
|
|
* @param {string} eventName event name (eg. 'after:render')
|
|
*/
|
|
|
|
/**
|
|
* unsubscribe an event listener
|
|
* @param {string} eventName event name (eg. 'after:render')
|
|
* @param {TEventCallback} handler event listener to unsubscribe
|
|
*/
|
|
|
|
/**
|
|
* unsubscribe event listeners
|
|
* @param handlers handlers key/value pairs (eg. {'after:render': handler, 'selection:cleared': handler})
|
|
*/
|
|
|
|
/**
|
|
* unsubscribe all event listeners
|
|
*/
|
|
|
|
off(arg0, handler) {
|
|
if (!this.__eventListeners) {
|
|
return;
|
|
}
|
|
|
|
// remove all key/value pairs (event name -> event handler)
|
|
if (typeof arg0 === 'undefined') {
|
|
for (const eventName in this.__eventListeners) {
|
|
this._removeEventListener(eventName);
|
|
}
|
|
}
|
|
// one object with key/value pairs was passed
|
|
else if (typeof arg0 === 'object') {
|
|
Object.entries(arg0).forEach(_ref3 => {
|
|
let [eventName, handler] = _ref3;
|
|
this._removeEventListener(eventName, handler);
|
|
});
|
|
} else {
|
|
this._removeEventListener(arg0, handler);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fires event with an optional options object
|
|
* @param {String} eventName Event name to fire
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
fire(eventName, options) {
|
|
var _this$__eventListener;
|
|
if (!this.__eventListeners) {
|
|
return;
|
|
}
|
|
const listenersForEvent = (_this$__eventListener = this.__eventListeners[eventName]) === null || _this$__eventListener === void 0 ? void 0 : _this$__eventListener.concat();
|
|
if (listenersForEvent) {
|
|
for (let i = 0; i < listenersForEvent.length; i++) {
|
|
listenersForEvent[i].call(this, options || {});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes value from an array.
|
|
* Presence of value (and its position in an array) is determined via `Array.prototype.indexOf`
|
|
* @param {Array} array
|
|
* @param {*} value
|
|
* @return {Array} original array
|
|
*/
|
|
const removeFromArray = (array, value) => {
|
|
const idx = array.indexOf(value);
|
|
if (idx !== -1) {
|
|
array.splice(idx, 1);
|
|
}
|
|
return array;
|
|
};
|
|
|
|
/**
|
|
* Calculate the cos of an angle, avoiding returning floats for known results
|
|
* This function is here just to avoid getting 0.999999999999999 when dealing
|
|
* with numbers that are really 1 or 0.
|
|
* @param {TRadian} angle the angle
|
|
* @return {Number} the cosin value for angle.
|
|
*/
|
|
const cos = angle => {
|
|
if (angle === 0) {
|
|
return 1;
|
|
}
|
|
const angleSlice = Math.abs(angle) / halfPI;
|
|
switch (angleSlice) {
|
|
case 1:
|
|
case 3:
|
|
return 0;
|
|
case 2:
|
|
return -1;
|
|
}
|
|
return Math.cos(angle);
|
|
};
|
|
|
|
/**
|
|
* Calculate the cos of an angle, avoiding returning floats for known results
|
|
* This function is here just to avoid getting 0.999999999999999 when dealing
|
|
* with numbers that are really 1 or 0.
|
|
* @param {TRadian} angle the angle
|
|
* @return {Number} the sin value for angle.
|
|
*/
|
|
const sin = angle => {
|
|
if (angle === 0) {
|
|
return 0;
|
|
}
|
|
const angleSlice = angle / halfPI;
|
|
const value = Math.sign(angle);
|
|
switch (angleSlice) {
|
|
case 1:
|
|
return value;
|
|
case 2:
|
|
return 0;
|
|
case 3:
|
|
return -value;
|
|
}
|
|
return Math.sin(angle);
|
|
};
|
|
|
|
/**
|
|
* Adaptation of work of Kevin Lindsey(kevin@kevlindev.com)
|
|
*/
|
|
class Point {
|
|
constructor() {
|
|
let arg0 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
|
|
let y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
|
|
if (typeof arg0 === 'object') {
|
|
this.x = arg0.x;
|
|
this.y = arg0.y;
|
|
} else {
|
|
this.x = arg0;
|
|
this.y = y;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds another point to this one and returns another one
|
|
* @param {XY} that
|
|
* @return {Point} new Point instance with added values
|
|
*/
|
|
add(that) {
|
|
return new Point(this.x + that.x, this.y + that.y);
|
|
}
|
|
|
|
/**
|
|
* Adds another point to this one
|
|
* @param {XY} that
|
|
* @return {Point} thisArg
|
|
* @chainable
|
|
* @deprecated
|
|
*/
|
|
addEquals(that) {
|
|
this.x += that.x;
|
|
this.y += that.y;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Adds value to this point and returns a new one
|
|
* @param {Number} scalar
|
|
* @return {Point} new Point with added value
|
|
*/
|
|
scalarAdd(scalar) {
|
|
return new Point(this.x + scalar, this.y + scalar);
|
|
}
|
|
|
|
/**
|
|
* Adds value to this point
|
|
* @param {Number} scalar
|
|
* @return {Point} thisArg
|
|
* @chainable
|
|
* @deprecated
|
|
*/
|
|
scalarAddEquals(scalar) {
|
|
this.x += scalar;
|
|
this.y += scalar;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Subtracts another point from this point and returns a new one
|
|
* @param {XY} that
|
|
* @return {Point} new Point object with subtracted values
|
|
*/
|
|
subtract(that) {
|
|
return new Point(this.x - that.x, this.y - that.y);
|
|
}
|
|
|
|
/**
|
|
* Subtracts another point from this point
|
|
* @param {XY} that
|
|
* @return {Point} thisArg
|
|
* @chainable
|
|
* @deprecated
|
|
*/
|
|
subtractEquals(that) {
|
|
this.x -= that.x;
|
|
this.y -= that.y;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Subtracts value from this point and returns a new one
|
|
* @param {Number} scalar
|
|
* @return {Point}
|
|
*/
|
|
scalarSubtract(scalar) {
|
|
return new Point(this.x - scalar, this.y - scalar);
|
|
}
|
|
|
|
/**
|
|
* Subtracts value from this point
|
|
* @param {Number} scalar
|
|
* @return {Point} thisArg
|
|
* @chainable
|
|
* @deprecated
|
|
*/
|
|
scalarSubtractEquals(scalar) {
|
|
this.x -= scalar;
|
|
this.y -= scalar;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Multiplies this point by another value and returns a new one
|
|
* @param {XY} that
|
|
* @return {Point}
|
|
*/
|
|
multiply(that) {
|
|
return new Point(this.x * that.x, this.y * that.y);
|
|
}
|
|
|
|
/**
|
|
* Multiplies this point by a value and returns a new one
|
|
* @param {Number} scalar
|
|
* @return {Point}
|
|
*/
|
|
scalarMultiply(scalar) {
|
|
return new Point(this.x * scalar, this.y * scalar);
|
|
}
|
|
|
|
/**
|
|
* Multiplies this point by a value
|
|
* @param {Number} scalar
|
|
* @return {Point} thisArg
|
|
* @chainable
|
|
* @deprecated
|
|
*/
|
|
scalarMultiplyEquals(scalar) {
|
|
this.x *= scalar;
|
|
this.y *= scalar;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Divides this point by another and returns a new one
|
|
* @param {XY} that
|
|
* @return {Point}
|
|
*/
|
|
divide(that) {
|
|
return new Point(this.x / that.x, this.y / that.y);
|
|
}
|
|
|
|
/**
|
|
* Divides this point by a value and returns a new one
|
|
* @param {Number} scalar
|
|
* @return {Point}
|
|
*/
|
|
scalarDivide(scalar) {
|
|
return new Point(this.x / scalar, this.y / scalar);
|
|
}
|
|
|
|
/**
|
|
* Divides this point by a value
|
|
* @param {Number} scalar
|
|
* @return {Point} thisArg
|
|
* @chainable
|
|
* @deprecated
|
|
*/
|
|
scalarDivideEquals(scalar) {
|
|
this.x /= scalar;
|
|
this.y /= scalar;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this point is equal to another one
|
|
* @param {XY} that
|
|
* @return {Boolean}
|
|
*/
|
|
eq(that) {
|
|
return this.x === that.x && this.y === that.y;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this point is less than another one
|
|
* @param {XY} that
|
|
* @return {Boolean}
|
|
*/
|
|
lt(that) {
|
|
return this.x < that.x && this.y < that.y;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this point is less than or equal to another one
|
|
* @param {XY} that
|
|
* @return {Boolean}
|
|
*/
|
|
lte(that) {
|
|
return this.x <= that.x && this.y <= that.y;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this point is greater another one
|
|
* @param {XY} that
|
|
* @return {Boolean}
|
|
*/
|
|
gt(that) {
|
|
return this.x > that.x && this.y > that.y;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this point is greater than or equal to another one
|
|
* @param {XY} that
|
|
* @return {Boolean}
|
|
*/
|
|
gte(that) {
|
|
return this.x >= that.x && this.y >= that.y;
|
|
}
|
|
|
|
/**
|
|
* Returns new point which is the result of linear interpolation with this one and another one
|
|
* @param {XY} that
|
|
* @param {Number} t , position of interpolation, between 0 and 1 default 0.5
|
|
* @return {Point}
|
|
*/
|
|
lerp(that) {
|
|
let t = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0.5;
|
|
t = Math.max(Math.min(1, t), 0);
|
|
return new Point(this.x + (that.x - this.x) * t, this.y + (that.y - this.y) * t);
|
|
}
|
|
|
|
/**
|
|
* Returns distance from this point and another one
|
|
* @param {XY} that
|
|
* @return {Number}
|
|
*/
|
|
distanceFrom(that) {
|
|
const dx = this.x - that.x,
|
|
dy = this.y - that.y;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
/**
|
|
* Returns the point between this point and another one
|
|
* @param {XY} that
|
|
* @return {Point}
|
|
*/
|
|
midPointFrom(that) {
|
|
return this.lerp(that);
|
|
}
|
|
|
|
/**
|
|
* Returns a new point which is the min of this and another one
|
|
* @param {XY} that
|
|
* @return {Point}
|
|
*/
|
|
min(that) {
|
|
return new Point(Math.min(this.x, that.x), Math.min(this.y, that.y));
|
|
}
|
|
|
|
/**
|
|
* Returns a new point which is the max of this and another one
|
|
* @param {XY} that
|
|
* @return {Point}
|
|
*/
|
|
max(that) {
|
|
return new Point(Math.max(this.x, that.x), Math.max(this.y, that.y));
|
|
}
|
|
|
|
/**
|
|
* Returns string representation of this point
|
|
* @return {String}
|
|
*/
|
|
toString() {
|
|
return "".concat(this.x, ",").concat(this.y);
|
|
}
|
|
|
|
/**
|
|
* Sets x/y of this point
|
|
* @param {Number} x
|
|
* @param {Number} y
|
|
* @chainable
|
|
*/
|
|
setXY(x, y) {
|
|
this.x = x;
|
|
this.y = y;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Sets x of this point
|
|
* @param {Number} x
|
|
* @chainable
|
|
*/
|
|
setX(x) {
|
|
this.x = x;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Sets y of this point
|
|
* @param {Number} y
|
|
* @chainable
|
|
*/
|
|
setY(y) {
|
|
this.y = y;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Sets x/y of this point from another point
|
|
* @param {XY} that
|
|
* @chainable
|
|
*/
|
|
setFromPoint(that) {
|
|
this.x = that.x;
|
|
this.y = that.y;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Swaps x/y of this point and another point
|
|
* @param {XY} that
|
|
*/
|
|
swap(that) {
|
|
const x = this.x,
|
|
y = this.y;
|
|
this.x = that.x;
|
|
this.y = that.y;
|
|
that.x = x;
|
|
that.y = y;
|
|
}
|
|
|
|
/**
|
|
* return a cloned instance of the point
|
|
* @return {Point}
|
|
*/
|
|
clone() {
|
|
return new Point(this.x, this.y);
|
|
}
|
|
|
|
/**
|
|
* Rotates `point` around `origin` with `radians`
|
|
* @static
|
|
* @memberOf fabric.util
|
|
* @param {XY} origin The origin of the rotation
|
|
* @param {TRadian} radians The radians of the angle for the rotation
|
|
* @return {Point} The new rotated point
|
|
*/
|
|
rotate(radians) {
|
|
let origin = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ZERO;
|
|
// TODO benchmark and verify the add and subtract how much cost
|
|
// and then in case early return if no origin is passed
|
|
const sinus = sin(radians),
|
|
cosinus = cos(radians);
|
|
const p = this.subtract(origin);
|
|
const rotated = new Point(p.x * cosinus - p.y * sinus, p.x * sinus + p.y * cosinus);
|
|
return rotated.add(origin);
|
|
}
|
|
|
|
/**
|
|
* Apply transform t to point p
|
|
* @static
|
|
* @memberOf fabric.util
|
|
* @param {TMat2D} t The transform
|
|
* @param {Boolean} [ignoreOffset] Indicates that the offset should not be applied
|
|
* @return {Point} The transformed point
|
|
*/
|
|
transform(t) {
|
|
let ignoreOffset = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
|
|
return new Point(t[0] * this.x + t[2] * this.y + (ignoreOffset ? 0 : t[4]), t[1] * this.x + t[3] * this.y + (ignoreOffset ? 0 : t[5]));
|
|
}
|
|
}
|
|
const ZERO = new Point(0, 0);
|
|
|
|
const isCollection = fabricObject => {
|
|
return !!fabricObject && Array.isArray(fabricObject._objects);
|
|
};
|
|
function createCollectionMixin(Base) {
|
|
class Collection extends Base {
|
|
constructor() {
|
|
super(...arguments);
|
|
/**
|
|
* @type {FabricObject[]}
|
|
* @TODO needs to end up in the constructor too
|
|
*/
|
|
_defineProperty(this, "_objects", []);
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
_onObjectAdded(object) {
|
|
// subclasses should override this method
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
_onObjectRemoved(object) {
|
|
// subclasses should override this method
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
_onStackOrderChanged(object) {
|
|
// subclasses should override this method
|
|
}
|
|
|
|
/**
|
|
* Adds objects to collection
|
|
* Objects should be instances of (or inherit from) FabricObject
|
|
* @param {...FabricObject[]} objects to add
|
|
* @returns {number} new array length
|
|
*/
|
|
add() {
|
|
for (var _len = arguments.length, objects = new Array(_len), _key = 0; _key < _len; _key++) {
|
|
objects[_key] = arguments[_key];
|
|
}
|
|
const size = this._objects.push(...objects);
|
|
objects.forEach(object => this._onObjectAdded(object));
|
|
return size;
|
|
}
|
|
|
|
/**
|
|
* Inserts an object into collection at specified index
|
|
* @param {number} index Index to insert object at
|
|
* @param {...FabricObject[]} objects Object(s) to insert
|
|
* @returns {number} new array length
|
|
*/
|
|
insertAt(index) {
|
|
for (var _len2 = arguments.length, objects = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {
|
|
objects[_key2 - 1] = arguments[_key2];
|
|
}
|
|
this._objects.splice(index, 0, ...objects);
|
|
objects.forEach(object => this._onObjectAdded(object));
|
|
return this._objects.length;
|
|
}
|
|
|
|
/**
|
|
* Removes objects from a collection, then renders canvas (if `renderOnAddRemove` is not `false`)
|
|
* @private
|
|
* @param {...FabricObject[]} objects objects to remove
|
|
* @returns {FabricObject[]} removed objects
|
|
*/
|
|
remove() {
|
|
const array = this._objects,
|
|
removed = [];
|
|
for (var _len3 = arguments.length, objects = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) {
|
|
objects[_key3] = arguments[_key3];
|
|
}
|
|
objects.forEach(object => {
|
|
const index = array.indexOf(object);
|
|
// only call onObjectRemoved if an object was actually removed
|
|
if (index !== -1) {
|
|
array.splice(index, 1);
|
|
removed.push(object);
|
|
this._onObjectRemoved(object);
|
|
}
|
|
});
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Executes given function for each object in this group
|
|
* A simple shortcut for getObjects().forEach, before es6 was more complicated,
|
|
* now is just a shortcut.
|
|
* @param {Function} callback
|
|
* Callback invoked with current object as first argument,
|
|
* index - as second and an array of all objects - as third.
|
|
*/
|
|
forEachObject(callback) {
|
|
this.getObjects().forEach((object, index, objects) => callback(object, index, objects));
|
|
}
|
|
|
|
/**
|
|
* Returns an array of children objects of this instance
|
|
* @param {...String} [types] When specified, only objects of these types are returned
|
|
* @return {Array}
|
|
*/
|
|
getObjects() {
|
|
for (var _len4 = arguments.length, types = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) {
|
|
types[_key4] = arguments[_key4];
|
|
}
|
|
if (types.length === 0) {
|
|
return [...this._objects];
|
|
}
|
|
return this._objects.filter(o => o.isType(...types));
|
|
}
|
|
|
|
/**
|
|
* Returns object at specified index
|
|
* @param {Number} index
|
|
* @return {Object} object at index
|
|
*/
|
|
item(index) {
|
|
return this._objects[index];
|
|
}
|
|
|
|
/**
|
|
* Returns true if collection contains no objects
|
|
* @return {Boolean} true if collection is empty
|
|
*/
|
|
isEmpty() {
|
|
return this._objects.length === 0;
|
|
}
|
|
|
|
/**
|
|
* Returns a size of a collection (i.e: length of an array containing its objects)
|
|
* @return {Number} Collection size
|
|
*/
|
|
size() {
|
|
return this._objects.length;
|
|
}
|
|
|
|
/**
|
|
* Returns true if collection contains an object.\
|
|
* **Prefer using {@link FabricObject#isDescendantOf} for performance reasons**
|
|
* instead of `a.contains(b)` use `b.isDescendantOf(a)`
|
|
* @param {Object} object Object to check against
|
|
* @param {Boolean} [deep=false] `true` to check all descendants, `false` to check only `_objects`
|
|
* @return {Boolean} `true` if collection contains an object
|
|
*/
|
|
contains(object, deep) {
|
|
if (this._objects.includes(object)) {
|
|
return true;
|
|
} else if (deep) {
|
|
return this._objects.some(obj => obj instanceof Collection && obj.contains(object, true));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns number representation of a collection complexity
|
|
* @return {Number} complexity
|
|
*/
|
|
complexity() {
|
|
return this._objects.reduce((memo, current) => {
|
|
memo += current.complexity ? current.complexity() : 0;
|
|
return memo;
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Moves an object or the objects of a multiple selection
|
|
* to the bottom of the stack of drawn objects
|
|
* @param {fabric.Object} object Object to send to back
|
|
* @returns {boolean} true if change occurred
|
|
*/
|
|
sendObjectToBack(object) {
|
|
if (!object || object === this._objects[0]) {
|
|
return false;
|
|
}
|
|
removeFromArray(this._objects, object);
|
|
this._objects.unshift(object);
|
|
this._onStackOrderChanged(object);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Moves an object or the objects of a multiple selection
|
|
* to the top of the stack of drawn objects
|
|
* @param {fabric.Object} object Object to send
|
|
* @returns {boolean} true if change occurred
|
|
*/
|
|
bringObjectToFront(object) {
|
|
if (!object || object === this._objects[this._objects.length - 1]) {
|
|
return false;
|
|
}
|
|
removeFromArray(this._objects, object);
|
|
this._objects.push(object);
|
|
this._onStackOrderChanged(object);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Moves an object or a selection down in stack of drawn objects
|
|
* An optional parameter, `intersecting` allows to move the object in behind
|
|
* the first intersecting object. Where intersection is calculated with
|
|
* bounding box. If no intersection is found, there will not be change in the
|
|
* stack.
|
|
* @param {fabric.Object} object Object to send
|
|
* @param {boolean} [intersecting] If `true`, send object behind next lower intersecting object
|
|
* @returns {boolean} true if change occurred
|
|
*/
|
|
sendObjectBackwards(object, intersecting) {
|
|
if (!object) {
|
|
return false;
|
|
}
|
|
const idx = this._objects.indexOf(object);
|
|
if (idx !== 0) {
|
|
// if object is not on the bottom of stack
|
|
const newIdx = this.findNewLowerIndex(object, idx, intersecting);
|
|
removeFromArray(this._objects, object);
|
|
this._objects.splice(newIdx, 0, object);
|
|
this._onStackOrderChanged(object);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Moves an object or a selection up in stack of drawn objects
|
|
* An optional parameter, intersecting allows to move the object in front
|
|
* of the first intersecting object. Where intersection is calculated with
|
|
* bounding box. If no intersection is found, there will not be change in the
|
|
* stack.
|
|
* @param {fabric.Object} object Object to send
|
|
* @param {boolean} [intersecting] If `true`, send object in front of next upper intersecting object
|
|
* @returns {boolean} true if change occurred
|
|
*/
|
|
bringObjectForward(object, intersecting) {
|
|
if (!object) {
|
|
return false;
|
|
}
|
|
const idx = this._objects.indexOf(object);
|
|
if (idx !== this._objects.length - 1) {
|
|
// if object is not on top of stack (last item in an array)
|
|
const newIdx = this.findNewUpperIndex(object, idx, intersecting);
|
|
removeFromArray(this._objects, object);
|
|
this._objects.splice(newIdx, 0, object);
|
|
this._onStackOrderChanged(object);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Moves an object to specified level in stack of drawn objects
|
|
* @param {fabric.Object} object Object to send
|
|
* @param {number} index Position to move to
|
|
* @returns {boolean} true if change occurred
|
|
*/
|
|
moveObjectTo(object, index) {
|
|
if (object === this._objects[index]) {
|
|
return false;
|
|
}
|
|
removeFromArray(this._objects, object);
|
|
this._objects.splice(index, 0, object);
|
|
this._onStackOrderChanged(object);
|
|
return true;
|
|
}
|
|
findNewLowerIndex(object, idx, intersecting) {
|
|
let newIdx;
|
|
if (intersecting) {
|
|
newIdx = idx;
|
|
// traverse down the stack looking for the nearest intersecting object
|
|
for (let i = idx - 1; i >= 0; --i) {
|
|
if (object.isOverlapping(this._objects[i])) {
|
|
newIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
newIdx = idx - 1;
|
|
}
|
|
return newIdx;
|
|
}
|
|
findNewUpperIndex(object, idx, intersecting) {
|
|
let newIdx;
|
|
if (intersecting) {
|
|
newIdx = idx;
|
|
// traverse up the stack looking for the nearest intersecting object
|
|
for (let i = idx + 1; i < this._objects.length; ++i) {
|
|
if (object.isOverlapping(this._objects[i])) {
|
|
newIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
newIdx = idx + 1;
|
|
}
|
|
return newIdx;
|
|
}
|
|
|
|
/**
|
|
* Given a bounding box, return all the objects of the collection that are contained in the bounding box.
|
|
* If `includeIntersecting` is true, return also the objects that intersect the bounding box as well.
|
|
* This is meant to work with selection. Is not a generic method.
|
|
* @param {TBBox} bbox a bounding box in scene coordinates
|
|
* @param {{ includeIntersecting?: boolean }} options an object with includeIntersecting
|
|
* @returns array of objects contained in the bounding box, ordered from top to bottom stacking wise
|
|
*/
|
|
collectObjects(_ref) {
|
|
let {
|
|
left,
|
|
top,
|
|
width,
|
|
height
|
|
} = _ref;
|
|
let {
|
|
includeIntersecting = true
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const objects = [],
|
|
tl = new Point(left, top),
|
|
br = tl.add(new Point(width, height));
|
|
|
|
// we iterate reverse order to collect top first in case of click.
|
|
for (let i = this._objects.length - 1; i >= 0; i--) {
|
|
const object = this._objects[i];
|
|
if (object.selectable && object.visible && (includeIntersecting && object.intersectsWithRect(tl, br) || object.isContainedWithinRect(tl, br) || includeIntersecting && object.containsPoint(tl) || includeIntersecting && object.containsPoint(br))) {
|
|
objects.push(object);
|
|
}
|
|
}
|
|
return objects;
|
|
}
|
|
}
|
|
|
|
// https://github.com/microsoft/TypeScript/issues/32080
|
|
return Collection;
|
|
}
|
|
|
|
class CommonMethods extends Observable {
|
|
/**
|
|
* Sets object's properties from options, for initialization only
|
|
* @protected
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
_setOptions() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
for (const prop in options) {
|
|
this.set(prop, options[prop]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_setObject(obj) {
|
|
for (const prop in obj) {
|
|
this._set(prop, obj[prop]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets property to a given value. When changing position/dimension -related properties (left, top, scale, angle, etc.) `set` does not update position of object's borders/controls. If you need to update those, call `setCoords()`.
|
|
* @param {String|Object} key Property name or object (if object, iterate over the object properties)
|
|
* @param {Object|Function} value Property value (if function, the value is passed into it and its return value is used as a new one)
|
|
*/
|
|
set(key, value) {
|
|
if (typeof key === 'object') {
|
|
this._setObject(key);
|
|
} else {
|
|
this._set(key, value);
|
|
}
|
|
return this;
|
|
}
|
|
_set(key, value) {
|
|
this[key] = value;
|
|
}
|
|
|
|
/**
|
|
* Toggles specified property from `true` to `false` or from `false` to `true`
|
|
* @param {String} property Property to toggle
|
|
*/
|
|
toggle(property) {
|
|
const value = this.get(property);
|
|
if (typeof value === 'boolean') {
|
|
this.set(property, !value);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Basic getter
|
|
* @param {String} property Property name
|
|
* @return {*} value of a property
|
|
*/
|
|
get(property) {
|
|
return this[property];
|
|
}
|
|
}
|
|
|
|
function requestAnimFrame(callback) {
|
|
return getFabricWindow().requestAnimationFrame(callback);
|
|
}
|
|
function cancelAnimFrame(handle) {
|
|
return getFabricWindow().cancelAnimationFrame(handle);
|
|
}
|
|
|
|
let id = 0;
|
|
const uid = () => id++;
|
|
|
|
/**
|
|
* Creates canvas element
|
|
* @return {CanvasElement} initialized canvas element
|
|
*/
|
|
const createCanvasElement = () => {
|
|
const element = getFabricDocument().createElement('canvas');
|
|
if (!element || typeof element.getContext === 'undefined') {
|
|
throw new FabricError('Failed to create `canvas` element');
|
|
}
|
|
return element;
|
|
};
|
|
|
|
/**
|
|
* Creates image element (works on client and node)
|
|
* @return {HTMLImageElement} HTML image element
|
|
*/
|
|
const createImage = () => getFabricDocument().createElement('img');
|
|
|
|
/**
|
|
* Creates a canvas element that is a copy of another and is also painted
|
|
* @param {CanvasElement} canvas to copy size and content of
|
|
* @return {CanvasElement} initialized canvas element
|
|
*/
|
|
const copyCanvasElement = canvas => {
|
|
var _newCanvas$getContext;
|
|
const newCanvas = createCanvasElement();
|
|
newCanvas.width = canvas.width;
|
|
newCanvas.height = canvas.height;
|
|
(_newCanvas$getContext = newCanvas.getContext('2d')) === null || _newCanvas$getContext === void 0 || _newCanvas$getContext.drawImage(canvas, 0, 0);
|
|
return newCanvas;
|
|
};
|
|
|
|
/**
|
|
* since 2.6.0 moved from canvas instance to utility.
|
|
* possibly useless
|
|
* @param {CanvasElement} canvasEl to copy size and content of
|
|
* @param {String} format 'jpeg' or 'png', in some browsers 'webp' is ok too
|
|
* @param {Number} quality <= 1 and > 0
|
|
* @return {String} data url
|
|
*/
|
|
const toDataURL = (canvasEl, format, quality) => canvasEl.toDataURL("image/".concat(format), quality);
|
|
const isHTMLCanvas = canvas => {
|
|
return !!canvas && canvas.getContext !== undefined;
|
|
};
|
|
|
|
/**
|
|
* Transforms degrees to radians.
|
|
* @param {TDegree} degrees value in degrees
|
|
* @return {TRadian} value in radians
|
|
*/
|
|
const degreesToRadians = degrees => degrees * PiBy180;
|
|
|
|
/**
|
|
* Transforms radians to degrees.
|
|
* @param {TRadian} radians value in radians
|
|
* @return {TDegree} value in degrees
|
|
*/
|
|
const radiansToDegrees = radians => radians / PiBy180;
|
|
|
|
const isIdentityMatrix = mat => mat.every((value, index) => value === iMatrix[index]);
|
|
|
|
/**
|
|
* Apply transform t to point p
|
|
* @deprecated use {@link Point#transform}
|
|
* @param {Point | XY} p The point to transform
|
|
* @param {Array} t The transform
|
|
* @param {Boolean} [ignoreOffset] Indicates that the offset should not be applied
|
|
* @return {Point} The transformed point
|
|
*/
|
|
const transformPoint = (p, t, ignoreOffset) => new Point(p).transform(t, ignoreOffset);
|
|
|
|
/**
|
|
* Invert transformation t
|
|
* @param {Array} t The transform
|
|
* @return {Array} The inverted transform
|
|
*/
|
|
const invertTransform = t => {
|
|
const a = 1 / (t[0] * t[3] - t[1] * t[2]),
|
|
r = [a * t[3], -a * t[1], -a * t[2], a * t[0], 0, 0],
|
|
{
|
|
x,
|
|
y
|
|
} = new Point(t[4], t[5]).transform(r, true);
|
|
r[4] = -x;
|
|
r[5] = -y;
|
|
return r;
|
|
};
|
|
|
|
/**
|
|
* Multiply matrix A by matrix B to nest transformations
|
|
* @param {TMat2D} a First transformMatrix
|
|
* @param {TMat2D} b Second transformMatrix
|
|
* @param {Boolean} is2x2 flag to multiply matrices as 2x2 matrices
|
|
* @return {TMat2D} The product of the two transform matrices
|
|
*/
|
|
const multiplyTransformMatrices = (a, b, is2x2) => [a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1], a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3], is2x2 ? 0 : a[0] * b[4] + a[2] * b[5] + a[4], is2x2 ? 0 : a[1] * b[4] + a[3] * b[5] + a[5]];
|
|
|
|
/**
|
|
* Multiplies {@link matrices} such that a matrix defines the plane for the rest of the matrices **after** it
|
|
*
|
|
* `multiplyTransformMatrixArray([A, B, C, D])` is equivalent to `A(B(C(D)))`
|
|
*
|
|
* @param matrices an array of matrices
|
|
* @param [is2x2] flag to multiply matrices as 2x2 matrices
|
|
* @returns the multiplication product
|
|
*/
|
|
const multiplyTransformMatrixArray = (matrices, is2x2) => matrices.reduceRight((product, curr) => curr && product ? multiplyTransformMatrices(curr, product, is2x2) : curr || product, undefined) || iMatrix.concat();
|
|
const calcPlaneRotation = _ref => {
|
|
let [a, b] = _ref;
|
|
return Math.atan2(b, a);
|
|
};
|
|
|
|
/**
|
|
* Decomposes standard 2x3 matrix into transform components
|
|
* @param {TMat2D} a transformMatrix
|
|
* @return {Object} Components of transform
|
|
*/
|
|
const qrDecompose = a => {
|
|
const angle = calcPlaneRotation(a),
|
|
denom = Math.pow(a[0], 2) + Math.pow(a[1], 2),
|
|
scaleX = Math.sqrt(denom),
|
|
scaleY = (a[0] * a[3] - a[2] * a[1]) / scaleX,
|
|
skewX = Math.atan2(a[0] * a[2] + a[1] * a[3], denom);
|
|
return {
|
|
angle: radiansToDegrees(angle),
|
|
scaleX,
|
|
scaleY,
|
|
skewX: radiansToDegrees(skewX),
|
|
skewY: 0,
|
|
translateX: a[4] || 0,
|
|
translateY: a[5] || 0
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Generate a translation matrix
|
|
*
|
|
* A translation matrix in the form of
|
|
* [ 1 0 x ]
|
|
* [ 0 1 y ]
|
|
* [ 0 0 1 ]
|
|
*
|
|
* See @link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform#translate for more details
|
|
*
|
|
* @param {number} x translation on X axis
|
|
* @param {number} [y] translation on Y axis
|
|
* @returns {TMat2D} matrix
|
|
*/
|
|
const createTranslateMatrix = function (x) {
|
|
let y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
|
|
return [1, 0, 0, 1, x, y];
|
|
};
|
|
|
|
/**
|
|
* Generate a rotation matrix around around a point (x,y), defaulting to (0,0)
|
|
*
|
|
* A matrix in the form of
|
|
* [cos(a) -sin(a) -x*cos(a)+y*sin(a)+x]
|
|
* [sin(a) cos(a) -x*sin(a)-y*cos(a)+y]
|
|
* [0 0 1 ]
|
|
*
|
|
*
|
|
* @param {TDegree} angle rotation in degrees
|
|
* @param {XY} [pivotPoint] pivot point to rotate around
|
|
* @returns {TMat2D} matrix
|
|
*/
|
|
function createRotateMatrix() {
|
|
let {
|
|
angle = 0
|
|
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
let {
|
|
x = 0,
|
|
y = 0
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const angleRadiant = degreesToRadians(angle),
|
|
cosValue = cos(angleRadiant),
|
|
sinValue = sin(angleRadiant);
|
|
return [cosValue, sinValue, -sinValue, cosValue, x ? x - (cosValue * x - sinValue * y) : 0, y ? y - (sinValue * x + cosValue * y) : 0];
|
|
}
|
|
|
|
/**
|
|
* Generate a scale matrix around the point (0,0)
|
|
*
|
|
* A matrix in the form of
|
|
* [x 0 0]
|
|
* [0 y 0]
|
|
* [0 0 1]
|
|
*
|
|
* @link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform#scale
|
|
*
|
|
* @param {number} x scale on X axis
|
|
* @param {number} [y] scale on Y axis
|
|
* @returns {TMat2D} matrix
|
|
*/
|
|
const createScaleMatrix = function (x) {
|
|
let y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : x;
|
|
return [x, 0, 0, y, 0, 0];
|
|
};
|
|
const angleToSkew = angle => Math.tan(degreesToRadians(angle));
|
|
|
|
/**
|
|
* Generate a skew matrix for the X axis
|
|
*
|
|
* A matrix in the form of
|
|
* [1 x 0]
|
|
* [0 1 0]
|
|
* [0 0 1]
|
|
*
|
|
* @link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform#skewx
|
|
*
|
|
* @param {TDegree} skewValue translation on X axis
|
|
* @returns {TMat2D} matrix
|
|
*/
|
|
const createSkewXMatrix = skewValue => [1, 0, angleToSkew(skewValue), 1, 0, 0];
|
|
|
|
/**
|
|
* Generate a skew matrix for the Y axis
|
|
*
|
|
* A matrix in the form of
|
|
* [1 0 0]
|
|
* [y 1 0]
|
|
* [0 0 1]
|
|
*
|
|
* @link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform#skewy
|
|
*
|
|
* @param {TDegree} skewValue translation on Y axis
|
|
* @returns {TMat2D} matrix
|
|
*/
|
|
const createSkewYMatrix = skewValue => [1, angleToSkew(skewValue), 0, 1, 0, 0];
|
|
|
|
/**
|
|
* Returns a transform matrix starting from an object of the same kind of
|
|
* the one returned from qrDecompose, useful also if you want to calculate some
|
|
* transformations from an object that is not enlived yet.
|
|
* is called DimensionsTransformMatrix because those properties are the one that influence
|
|
* the size of the resulting box of the object.
|
|
* @param {Object} options
|
|
* @param {Number} [options.scaleX]
|
|
* @param {Number} [options.scaleY]
|
|
* @param {Boolean} [options.flipX]
|
|
* @param {Boolean} [options.flipY]
|
|
* @param {Number} [options.skewX]
|
|
* @param {Number} [options.skewY]
|
|
* @return {Number[]} transform matrix
|
|
*/
|
|
const calcDimensionsMatrix = _ref2 => {
|
|
let {
|
|
scaleX = 1,
|
|
scaleY = 1,
|
|
flipX = false,
|
|
flipY = false,
|
|
skewX = 0,
|
|
skewY = 0
|
|
} = _ref2;
|
|
let matrix = createScaleMatrix(flipX ? -scaleX : scaleX, flipY ? -scaleY : scaleY);
|
|
if (skewX) {
|
|
matrix = multiplyTransformMatrices(matrix, createSkewXMatrix(skewX), true);
|
|
}
|
|
if (skewY) {
|
|
matrix = multiplyTransformMatrices(matrix, createSkewYMatrix(skewY), true);
|
|
}
|
|
return matrix;
|
|
};
|
|
|
|
/**
|
|
* Returns a transform matrix starting from an object of the same kind of
|
|
* the one returned from qrDecompose, useful also if you want to calculate some
|
|
* transformations from an object that is not enlived yet
|
|
* Before changing this function look at: src/benchmarks/calcTransformMatrix.mjs
|
|
* @param {Object} options
|
|
* @param {Number} [options.angle]
|
|
* @param {Number} [options.scaleX]
|
|
* @param {Number} [options.scaleY]
|
|
* @param {Boolean} [options.flipX]
|
|
* @param {Boolean} [options.flipY]
|
|
* @param {Number} [options.skewX]
|
|
* @param {Number} [options.skewY]
|
|
* @param {Number} [options.translateX]
|
|
* @param {Number} [options.translateY]
|
|
* @return {Number[]} transform matrix
|
|
*/
|
|
const composeMatrix = options => {
|
|
const {
|
|
translateX = 0,
|
|
translateY = 0,
|
|
angle = 0
|
|
} = options;
|
|
let matrix = createTranslateMatrix(translateX, translateY);
|
|
if (angle) {
|
|
matrix = multiplyTransformMatrices(matrix, createRotateMatrix({
|
|
angle
|
|
}));
|
|
}
|
|
const scaleMatrix = calcDimensionsMatrix(options);
|
|
if (!isIdentityMatrix(scaleMatrix)) {
|
|
matrix = multiplyTransformMatrices(matrix, scaleMatrix);
|
|
}
|
|
return matrix;
|
|
};
|
|
|
|
/**
|
|
* Loads image element from given url and resolve it, or catch.
|
|
* @param {String} url URL representing an image
|
|
* @param {LoadImageOptions} [options] image loading options
|
|
* @returns {Promise<HTMLImageElement>} the loaded image.
|
|
*/
|
|
const loadImage = function (url) {
|
|
let {
|
|
signal,
|
|
crossOrigin = null
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
return new Promise(function (resolve, reject) {
|
|
if (signal && signal.aborted) {
|
|
return reject(new SignalAbortedError('loadImage'));
|
|
}
|
|
const img = createImage();
|
|
let abort;
|
|
if (signal) {
|
|
abort = function (err) {
|
|
img.src = '';
|
|
reject(err);
|
|
};
|
|
signal.addEventListener('abort', abort, {
|
|
once: true
|
|
});
|
|
}
|
|
const done = function () {
|
|
img.onload = img.onerror = null;
|
|
abort && (signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', abort));
|
|
resolve(img);
|
|
};
|
|
if (!url) {
|
|
done();
|
|
return;
|
|
}
|
|
img.onload = done;
|
|
img.onerror = function () {
|
|
abort && (signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', abort));
|
|
reject(new FabricError("Error loading ".concat(img.src)));
|
|
};
|
|
crossOrigin && (img.crossOrigin = crossOrigin);
|
|
img.src = url;
|
|
});
|
|
};
|
|
/**
|
|
* @TODO type this correctly.
|
|
* Creates corresponding fabric instances from their object representations
|
|
* @param {Object[]} objects Objects to enliven
|
|
* @param {EnlivenObjectOptions} [options]
|
|
* @param {(serializedObj: object, instance: FabricObject) => any} [options.reviver] Method for further parsing of object elements,
|
|
* called after each fabric object created.
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @returns {Promise<FabricObject[]>}
|
|
*/
|
|
const enlivenObjects = function (objects) {
|
|
let {
|
|
signal,
|
|
reviver = noop
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
return new Promise((resolve, reject) => {
|
|
const instances = [];
|
|
signal && signal.addEventListener('abort', reject, {
|
|
once: true
|
|
});
|
|
Promise.all(objects.map(obj => classRegistry.getClass(obj.type).fromObject(obj, {
|
|
signal
|
|
}).then(fabricInstance => {
|
|
reviver(obj, fabricInstance);
|
|
instances.push(fabricInstance);
|
|
return fabricInstance;
|
|
}))).then(resolve).catch(error => {
|
|
// cleanup
|
|
instances.forEach(instance => {
|
|
instance.dispose && instance.dispose();
|
|
});
|
|
reject(error);
|
|
}).finally(() => {
|
|
signal && signal.removeEventListener('abort', reject);
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Creates corresponding fabric instances residing in an object, e.g. `clipPath`
|
|
* @param {Object} object with properties to enlive ( fill, stroke, clipPath, path )
|
|
* @param {object} [options]
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @returns {Promise<Record<string, FabricObject | TFiller | null>>} the input object with enlived values
|
|
*/
|
|
const enlivenObjectEnlivables = function (serializedObject) {
|
|
let {
|
|
signal
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
return new Promise((resolve, reject) => {
|
|
const instances = [];
|
|
signal && signal.addEventListener('abort', reject, {
|
|
once: true
|
|
});
|
|
// enlive every possible property
|
|
const promises = Object.values(serializedObject).map(value => {
|
|
if (!value) {
|
|
return value;
|
|
}
|
|
/**
|
|
* clipPath or shadow or gradient or text on a path or a pattern,
|
|
* or the backgroundImage or overlayImage of canvas
|
|
* If we have a type and there is a classe registered for it, we enlive it.
|
|
* If there is no class registered for it we return the value as is
|
|
* */
|
|
if (value.type && classRegistry.has(value.type)) {
|
|
return enlivenObjects([value], {
|
|
signal
|
|
}).then(_ref => {
|
|
let [enlived] = _ref;
|
|
instances.push(enlived);
|
|
return enlived;
|
|
});
|
|
}
|
|
return value;
|
|
});
|
|
const keys = Object.keys(serializedObject);
|
|
Promise.all(promises).then(enlived => {
|
|
return enlived.reduce((acc, instance, index) => {
|
|
acc[keys[index]] = instance;
|
|
return acc;
|
|
}, {});
|
|
}).then(resolve).catch(error => {
|
|
// cleanup
|
|
instances.forEach(instance => {
|
|
instance.dispose && instance.dispose();
|
|
});
|
|
reject(error);
|
|
}).finally(() => {
|
|
signal && signal.removeEventListener('abort', reject);
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Populates an object with properties of another object
|
|
* @param {Object} source Source object
|
|
* @param {string[]} properties Properties names to include
|
|
* @returns object populated with the picked keys
|
|
*/
|
|
const pick = function (source) {
|
|
let keys = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];
|
|
return keys.reduce((o, key) => {
|
|
if (key in source) {
|
|
o[key] = source[key];
|
|
}
|
|
return o;
|
|
}, {});
|
|
};
|
|
const pickBy = (source, predicate) => {
|
|
return Object.keys(source).reduce((o, key) => {
|
|
if (predicate(source[key], key, source)) {
|
|
o[key] = source[key];
|
|
}
|
|
return o;
|
|
}, {});
|
|
};
|
|
|
|
/**
|
|
* Map of the 148 color names with HEX code
|
|
* @see: https://www.w3.org/TR/css3-color/#svg-color
|
|
*/
|
|
const ColorNameMap = {
|
|
aliceblue: '#F0F8FF',
|
|
antiquewhite: '#FAEBD7',
|
|
aqua: '#0FF',
|
|
aquamarine: '#7FFFD4',
|
|
azure: '#F0FFFF',
|
|
beige: '#F5F5DC',
|
|
bisque: '#FFE4C4',
|
|
black: '#000',
|
|
blanchedalmond: '#FFEBCD',
|
|
blue: '#00F',
|
|
blueviolet: '#8A2BE2',
|
|
brown: '#A52A2A',
|
|
burlywood: '#DEB887',
|
|
cadetblue: '#5F9EA0',
|
|
chartreuse: '#7FFF00',
|
|
chocolate: '#D2691E',
|
|
coral: '#FF7F50',
|
|
cornflowerblue: '#6495ED',
|
|
cornsilk: '#FFF8DC',
|
|
crimson: '#DC143C',
|
|
cyan: '#0FF',
|
|
darkblue: '#00008B',
|
|
darkcyan: '#008B8B',
|
|
darkgoldenrod: '#B8860B',
|
|
darkgray: '#A9A9A9',
|
|
darkgrey: '#A9A9A9',
|
|
darkgreen: '#006400',
|
|
darkkhaki: '#BDB76B',
|
|
darkmagenta: '#8B008B',
|
|
darkolivegreen: '#556B2F',
|
|
darkorange: '#FF8C00',
|
|
darkorchid: '#9932CC',
|
|
darkred: '#8B0000',
|
|
darksalmon: '#E9967A',
|
|
darkseagreen: '#8FBC8F',
|
|
darkslateblue: '#483D8B',
|
|
darkslategray: '#2F4F4F',
|
|
darkslategrey: '#2F4F4F',
|
|
darkturquoise: '#00CED1',
|
|
darkviolet: '#9400D3',
|
|
deeppink: '#FF1493',
|
|
deepskyblue: '#00BFFF',
|
|
dimgray: '#696969',
|
|
dimgrey: '#696969',
|
|
dodgerblue: '#1E90FF',
|
|
firebrick: '#B22222',
|
|
floralwhite: '#FFFAF0',
|
|
forestgreen: '#228B22',
|
|
fuchsia: '#F0F',
|
|
gainsboro: '#DCDCDC',
|
|
ghostwhite: '#F8F8FF',
|
|
gold: '#FFD700',
|
|
goldenrod: '#DAA520',
|
|
gray: '#808080',
|
|
grey: '#808080',
|
|
green: '#008000',
|
|
greenyellow: '#ADFF2F',
|
|
honeydew: '#F0FFF0',
|
|
hotpink: '#FF69B4',
|
|
indianred: '#CD5C5C',
|
|
indigo: '#4B0082',
|
|
ivory: '#FFFFF0',
|
|
khaki: '#F0E68C',
|
|
lavender: '#E6E6FA',
|
|
lavenderblush: '#FFF0F5',
|
|
lawngreen: '#7CFC00',
|
|
lemonchiffon: '#FFFACD',
|
|
lightblue: '#ADD8E6',
|
|
lightcoral: '#F08080',
|
|
lightcyan: '#E0FFFF',
|
|
lightgoldenrodyellow: '#FAFAD2',
|
|
lightgray: '#D3D3D3',
|
|
lightgrey: '#D3D3D3',
|
|
lightgreen: '#90EE90',
|
|
lightpink: '#FFB6C1',
|
|
lightsalmon: '#FFA07A',
|
|
lightseagreen: '#20B2AA',
|
|
lightskyblue: '#87CEFA',
|
|
lightslategray: '#789',
|
|
lightslategrey: '#789',
|
|
lightsteelblue: '#B0C4DE',
|
|
lightyellow: '#FFFFE0',
|
|
lime: '#0F0',
|
|
limegreen: '#32CD32',
|
|
linen: '#FAF0E6',
|
|
magenta: '#F0F',
|
|
maroon: '#800000',
|
|
mediumaquamarine: '#66CDAA',
|
|
mediumblue: '#0000CD',
|
|
mediumorchid: '#BA55D3',
|
|
mediumpurple: '#9370DB',
|
|
mediumseagreen: '#3CB371',
|
|
mediumslateblue: '#7B68EE',
|
|
mediumspringgreen: '#00FA9A',
|
|
mediumturquoise: '#48D1CC',
|
|
mediumvioletred: '#C71585',
|
|
midnightblue: '#191970',
|
|
mintcream: '#F5FFFA',
|
|
mistyrose: '#FFE4E1',
|
|
moccasin: '#FFE4B5',
|
|
navajowhite: '#FFDEAD',
|
|
navy: '#000080',
|
|
oldlace: '#FDF5E6',
|
|
olive: '#808000',
|
|
olivedrab: '#6B8E23',
|
|
orange: '#FFA500',
|
|
orangered: '#FF4500',
|
|
orchid: '#DA70D6',
|
|
palegoldenrod: '#EEE8AA',
|
|
palegreen: '#98FB98',
|
|
paleturquoise: '#AFEEEE',
|
|
palevioletred: '#DB7093',
|
|
papayawhip: '#FFEFD5',
|
|
peachpuff: '#FFDAB9',
|
|
peru: '#CD853F',
|
|
pink: '#FFC0CB',
|
|
plum: '#DDA0DD',
|
|
powderblue: '#B0E0E6',
|
|
purple: '#800080',
|
|
rebeccapurple: '#639',
|
|
red: '#F00',
|
|
rosybrown: '#BC8F8F',
|
|
royalblue: '#4169E1',
|
|
saddlebrown: '#8B4513',
|
|
salmon: '#FA8072',
|
|
sandybrown: '#F4A460',
|
|
seagreen: '#2E8B57',
|
|
seashell: '#FFF5EE',
|
|
sienna: '#A0522D',
|
|
silver: '#C0C0C0',
|
|
skyblue: '#87CEEB',
|
|
slateblue: '#6A5ACD',
|
|
slategray: '#708090',
|
|
slategrey: '#708090',
|
|
snow: '#FFFAFA',
|
|
springgreen: '#00FF7F',
|
|
steelblue: '#4682B4',
|
|
tan: '#D2B48C',
|
|
teal: '#008080',
|
|
thistle: '#D8BFD8',
|
|
tomato: '#FF6347',
|
|
turquoise: '#40E0D0',
|
|
violet: '#EE82EE',
|
|
wheat: '#F5DEB3',
|
|
white: '#FFF',
|
|
whitesmoke: '#F5F5F5',
|
|
yellow: '#FF0',
|
|
yellowgreen: '#9ACD32'
|
|
};
|
|
|
|
/**
|
|
* Regex matching color in RGB or RGBA formats (ex: `rgb(0, 0, 0)`, `rgba(255, 100, 10, 0.5)`, `rgba( 255 , 100 , 10 , 0.5 )`, `rgb(1,1,1)`, `rgba(100%, 60%, 10%, 0.5)`)
|
|
* Also matching rgba(r g b / a) as per new specs
|
|
* https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb
|
|
* Formal syntax at the time of writing:
|
|
* <rgb()> =
|
|
* rgb( [ <percentage> | none ]{3} [ / [ <alpha-value> | none ] ]? ) |
|
|
* rgb( [ <number> | none ]{3} [ / [ <alpha-value> | none ] ]? )
|
|
* <alpha-value> = <number> | <percentage>
|
|
*
|
|
* For learners this is how you can read this regex
|
|
* Regular expression for matching an rgba or rgb CSS color value
|
|
*
|
|
* /^ # Beginning of the string
|
|
* rgba? # "rgb" or "rgba"
|
|
* \(\s* # Opening parenthesis and optional whitespace
|
|
* (\d{0,3} # 0 to three digits R channel
|
|
* (?:\.\d+)? # Optional decimal with one or more digits
|
|
* ) # End of capturing group for the first color component
|
|
* %? # Optional percent sign after the first color component
|
|
* \s* # Optional whitespace
|
|
* [\s|,] # Separator between color components can be a space or comma
|
|
* \s* # Optional whitespace
|
|
* (\d{0,3} # 0 to three digits G channel
|
|
* (?:\.\d+)? # Optional decimal with one or more digits
|
|
* ) # End of capturing group for the second color component
|
|
* %? # Optional percent sign after the second color component
|
|
* \s* # Optional whitespace
|
|
* [\s|,] # Separator between color components can be a space or comma
|
|
* \s* # Optional whitespace
|
|
* (\d{0,3} # 0 to three digits B channel
|
|
* (?:\.\d+)? # Optional decimal with one or more digits
|
|
* ) # End of capturing group for the third color component
|
|
* %? # Optional percent sign after the third color component
|
|
* \s* # Optional whitespace
|
|
* (?: # Beginning of non-capturing group for alpha value
|
|
* \s* # Optional whitespace
|
|
* [,/] # Comma or slash separator for alpha value
|
|
* \s* # Optional whitespace
|
|
* (\d{0,3} # Zero to three digits
|
|
* (?:\.\d+)? # Optional decimal with one or more digits
|
|
* ) # End of capturing group for alpha value
|
|
* %? # Optional percent sign after alpha value
|
|
* \s* # Optional whitespace
|
|
* )? # End of non-capturing group for alpha value (optional)
|
|
* \) # Closing parenthesis
|
|
* $ # End of the string
|
|
*
|
|
* The alpha channel can be in the format 0.4 .7 or 1 or 73%
|
|
*
|
|
* WARNING this regex doesn't fail on off spec colors. it matches everything that could be a color.
|
|
* So the spec does not allow for `rgba(30 , 45% 35, 49%)` but this will work anyways for us
|
|
*/
|
|
const reRGBa = () => /^rgba?\(\s*(\d{0,3}(?:\.\d+)?%?)\s*[\s|,]\s*(\d{0,3}(?:\.\d+)?%?)\s*[\s|,]\s*(\d{0,3}(?:\.\d+)?%?)\s*(?:\s*[,/]\s*(\d{0,3}(?:\.\d+)?%?)\s*)?\)$/i;
|
|
|
|
/**
|
|
* Regex matching color in HSL or HSLA formats (ex: hsl(0, 0, 0), rgba(255, 100, 10, 0.5), rgba( 255 , 100 , 10 , 0.5 ), rgb(1,1,1), rgba(100%, 60%, 10%, 0.5))
|
|
* Also matching rgba(r g b / a) as per new specs
|
|
* https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl
|
|
* Formal syntax at the time of writing:
|
|
* <hsl()> =
|
|
* hsl( [ <hue> | none ] [ <percentage> | none ] [ <percentage> | none ] [ / [ <alpha-value> | none ] ]? )
|
|
*
|
|
* <hue> =
|
|
* <number> |
|
|
* <angle>
|
|
*
|
|
* <alpha-value> =
|
|
* <number> |
|
|
* <percentage>
|
|
*
|
|
* For learners this is how you can read this regex
|
|
* Regular expression for matching an hsla or hsl CSS color value
|
|
*
|
|
* /^hsla?\( // Matches the beginning of the string and the opening parenthesis of "hsl" or "hsla"
|
|
* \s* // Matches any whitespace characters (space, tab, etc.) zero or more times
|
|
* (\d{0,3} // Hue: 0 to three digits - start capture in a group
|
|
* (?:\.\d+)? // Hue: Optional (non capture group) decimal with one or more digits.
|
|
* (?:deg|turn|rad)? // Hue: Optionally include suffix deg or turn or rad
|
|
* ) // Hue: End capture group
|
|
* \s* // Matches any whitespace characters zero or more times
|
|
* [\s|,] // Matches a space, tab or comma
|
|
* \s* // Matches any whitespace characters zero or more times
|
|
* (\d{0,3} // Saturation: 0 to three digits - start capture in a group
|
|
* (?:\.\d+)? // Saturation: Optional decimal with one or more digits in a non-capturing group
|
|
* %?) // Saturation: match optional % character and end capture group
|
|
* \s* // Matches any whitespace characters zero or more times
|
|
* [\s|,] // Matches a space, tab or comma
|
|
* \s* // Matches any whitespace characters zero or more times
|
|
* (\d{0,3} // Lightness: 0 to three digits - start capture in a group
|
|
* (?:\.\d+)? // Lightness: Optional decimal with one or more digits in a non-capturing group
|
|
* %?) // Lightness: match % character and end capture group
|
|
* \s* // Matches any whitespace characters zero or more times
|
|
* (?: // Alpha: Begins a non-capturing group for the alpha value
|
|
* \s* // Matches any whitespace characters zero or more times
|
|
* [,/] // Matches a comma or forward slash
|
|
* \s* // Matches any whitespace characters zero or more times
|
|
* (\d*(?:\.\d+)?%?) // Matches zero or more digits, optionally followed by a decimal point and one or more digits, followed by an optional percentage sign and captures it in a group
|
|
* \s* // Matches any whitespace characters zero or more times
|
|
* )? // Makes the alpha value group optional
|
|
* \) // Matches the closing parenthesis
|
|
* $/i // Matches the end of the string and sets the regular expression to case-insensitive mode
|
|
*
|
|
* WARNING this regex doesn't fail on off spec colors. It matches everything that could be a color.
|
|
* So the spec does not allow `hsl(30 , 45% 35, 49%)` but this will work anyways for us.
|
|
*/
|
|
const reHSLa = () => /^hsla?\(\s*([+-]?\d{0,3}(?:\.\d+)?(?:deg|turn|rad)?)\s*[\s|,]\s*(\d{0,3}(?:\.\d+)?%?)\s*[\s|,]\s*(\d{0,3}(?:\.\d+)?%?)\s*(?:\s*[,/]\s*(\d*(?:\.\d+)?%?)\s*)?\)$/i;
|
|
|
|
/**
|
|
* Regex matching color in HEX format (ex: #FF5544CC, #FF5555, 010155, aff)
|
|
*/
|
|
const reHex = () => /^#?(([0-9a-f]){3,4}|([0-9a-f]{2}){3,4})$/i;
|
|
|
|
/**
|
|
* @param {Number} p
|
|
* @param {Number} q
|
|
* @param {Number} t
|
|
* @return {Number}
|
|
*/
|
|
const hue2rgb = (p, q, t) => {
|
|
if (t < 0) {
|
|
t += 1;
|
|
}
|
|
if (t > 1) {
|
|
t -= 1;
|
|
}
|
|
if (t < 1 / 6) {
|
|
return p + (q - p) * 6 * t;
|
|
}
|
|
if (t < 1 / 2) {
|
|
return q;
|
|
}
|
|
if (t < 2 / 3) {
|
|
return p + (q - p) * (2 / 3 - t) * 6;
|
|
}
|
|
return p;
|
|
};
|
|
|
|
/**
|
|
* Adapted from {@link https://gist.github.com/mjackson/5311256 https://gist.github.com/mjackson}
|
|
* @param {Number} r Red color value
|
|
* @param {Number} g Green color value
|
|
* @param {Number} b Blue color value
|
|
* @param {Number} a Alpha color value pass through
|
|
* @return {TRGBColorSource} Hsl color
|
|
*/
|
|
const rgb2Hsl = (r, g, b, a) => {
|
|
r /= 255;
|
|
g /= 255;
|
|
b /= 255;
|
|
const maxValue = Math.max(r, g, b),
|
|
minValue = Math.min(r, g, b);
|
|
let h, s;
|
|
const l = (maxValue + minValue) / 2;
|
|
if (maxValue === minValue) {
|
|
h = s = 0; // achromatic
|
|
} else {
|
|
const d = maxValue - minValue;
|
|
s = l > 0.5 ? d / (2 - maxValue - minValue) : d / (maxValue + minValue);
|
|
switch (maxValue) {
|
|
case r:
|
|
h = (g - b) / d + (g < b ? 6 : 0);
|
|
break;
|
|
case g:
|
|
h = (b - r) / d + 2;
|
|
break;
|
|
case b:
|
|
h = (r - g) / d + 4;
|
|
break;
|
|
}
|
|
h /= 6;
|
|
}
|
|
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100), a];
|
|
};
|
|
const fromAlphaToFloat = function () {
|
|
let value = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '1';
|
|
return parseFloat(value) / (value.endsWith('%') ? 100 : 1);
|
|
};
|
|
|
|
/**
|
|
* Convert a value in the inclusive range [0, 255] to hex
|
|
*/
|
|
const hexify = value => Math.min(Math.round(value), 255).toString(16).toUpperCase().padStart(2, '0');
|
|
|
|
/**
|
|
* Calculate the grey average value for rgb and pass through alpha
|
|
*/
|
|
const greyAverage = _ref => {
|
|
let [r, g, b, a = 1] = _ref;
|
|
const avg = Math.round(r * 0.3 + g * 0.59 + b * 0.11);
|
|
return [avg, avg, avg, a];
|
|
};
|
|
|
|
/**
|
|
* @class Color common color operations
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-2/#colors colors}
|
|
*/
|
|
class Color {
|
|
/**
|
|
*
|
|
* @param {string} [color] optional in hex or rgb(a) or hsl format or from known color list
|
|
*/
|
|
constructor(color) {
|
|
_defineProperty(this, "isUnrecognised", false);
|
|
if (!color) {
|
|
// we default to black as canvas does
|
|
this.setSource([0, 0, 0, 1]);
|
|
} else if (color instanceof Color) {
|
|
this.setSource([...color._source]);
|
|
} else if (Array.isArray(color)) {
|
|
const [r, g, b, a = 1] = color;
|
|
this.setSource([r, g, b, a]);
|
|
} else {
|
|
this.setSource(this._tryParsingColor(color));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {string} [color] Color value to parse
|
|
* @returns {TRGBAColorSource}
|
|
*/
|
|
_tryParsingColor(color) {
|
|
if (color in ColorNameMap) {
|
|
color = ColorNameMap[color];
|
|
}
|
|
return color === 'transparent' ? [255, 255, 255, 0] : Color.sourceFromHex(color) || Color.sourceFromRgb(color) || Color.sourceFromHsl(color) ||
|
|
// color is not recognized
|
|
// we default to black as canvas does
|
|
// eslint-disable-next-line no-constant-binary-expression
|
|
(this.isUnrecognised = true) && [0, 0, 0, 1];
|
|
}
|
|
|
|
/**
|
|
* Returns source of this color (where source is an array representation; ex: [200, 200, 100, 1])
|
|
* @return {TRGBAColorSource}
|
|
*/
|
|
getSource() {
|
|
return this._source;
|
|
}
|
|
|
|
/**
|
|
* Sets source of this color (where source is an array representation; ex: [200, 200, 100, 1])
|
|
* @param {TRGBAColorSource} source
|
|
*/
|
|
setSource(source) {
|
|
this._source = source;
|
|
}
|
|
|
|
/**
|
|
* Returns color representation in RGB format
|
|
* @return {String} ex: rgb(0-255,0-255,0-255)
|
|
*/
|
|
toRgb() {
|
|
const [r, g, b] = this.getSource();
|
|
return "rgb(".concat(r, ",").concat(g, ",").concat(b, ")");
|
|
}
|
|
|
|
/**
|
|
* Returns color representation in RGBA format
|
|
* @return {String} ex: rgba(0-255,0-255,0-255,0-1)
|
|
*/
|
|
toRgba() {
|
|
return "rgba(".concat(this.getSource().join(','), ")");
|
|
}
|
|
|
|
/**
|
|
* Returns color representation in HSL format
|
|
* @return {String} ex: hsl(0-360,0%-100%,0%-100%)
|
|
*/
|
|
toHsl() {
|
|
const [h, s, l] = rgb2Hsl(...this.getSource());
|
|
return "hsl(".concat(h, ",").concat(s, "%,").concat(l, "%)");
|
|
}
|
|
|
|
/**
|
|
* Returns color representation in HSLA format
|
|
* @return {String} ex: hsla(0-360,0%-100%,0%-100%,0-1)
|
|
*/
|
|
toHsla() {
|
|
const [h, s, l, a] = rgb2Hsl(...this.getSource());
|
|
return "hsla(".concat(h, ",").concat(s, "%,").concat(l, "%,").concat(a, ")");
|
|
}
|
|
|
|
/**
|
|
* Returns color representation in HEX format
|
|
* @return {String} ex: FF5555
|
|
*/
|
|
toHex() {
|
|
const fullHex = this.toHexa();
|
|
return fullHex.slice(0, 6);
|
|
}
|
|
|
|
/**
|
|
* Returns color representation in HEXA format
|
|
* @return {String} ex: FF5555CC
|
|
*/
|
|
toHexa() {
|
|
const [r, g, b, a] = this.getSource();
|
|
return "".concat(hexify(r)).concat(hexify(g)).concat(hexify(b)).concat(hexify(Math.round(a * 255)));
|
|
}
|
|
|
|
/**
|
|
* Gets value of alpha channel for this color
|
|
* @return {Number} 0-1
|
|
*/
|
|
getAlpha() {
|
|
return this.getSource()[3];
|
|
}
|
|
|
|
/**
|
|
* Sets value of alpha channel for this color
|
|
* @param {Number} alpha Alpha value 0-1
|
|
* @return {Color} thisArg
|
|
*/
|
|
setAlpha(alpha) {
|
|
this._source[3] = alpha;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Transforms color to its grayscale representation
|
|
* @return {Color} thisArg
|
|
*/
|
|
toGrayscale() {
|
|
this.setSource(greyAverage(this.getSource()));
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Transforms color to its black and white representation
|
|
* @param {Number} threshold
|
|
* @return {Color} thisArg
|
|
*/
|
|
toBlackWhite(threshold) {
|
|
const [average,,, a] = greyAverage(this.getSource()),
|
|
bOrW = average < (threshold || 127) ? 0 : 255;
|
|
this.setSource([bOrW, bOrW, bOrW, a]);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Overlays color with another color
|
|
* @param {String|Color} otherColor
|
|
* @return {Color} thisArg
|
|
*/
|
|
overlayWith(otherColor) {
|
|
if (!(otherColor instanceof Color)) {
|
|
otherColor = new Color(otherColor);
|
|
}
|
|
const source = this.getSource(),
|
|
otherAlpha = 0.5,
|
|
otherSource = otherColor.getSource(),
|
|
[R, G, B] = source.map((value, index) => Math.round(value * (1 - otherAlpha) + otherSource[index] * otherAlpha));
|
|
this.setSource([R, G, B, source[3]]);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Returns new color object, when given a color in RGB format
|
|
* @memberOf Color
|
|
* @param {String} color Color value ex: rgb(0-255,0-255,0-255)
|
|
* @return {Color}
|
|
*/
|
|
static fromRgb(color) {
|
|
return Color.fromRgba(color);
|
|
}
|
|
|
|
/**
|
|
* Returns new color object, when given a color in RGBA format
|
|
* @static
|
|
* @function
|
|
* @memberOf Color
|
|
* @param {String} color
|
|
* @return {Color}
|
|
*/
|
|
static fromRgba(color) {
|
|
return new Color(Color.sourceFromRgb(color));
|
|
}
|
|
|
|
/**
|
|
* Returns array representation (ex: [100, 100, 200, 1]) of a color that's in RGB or RGBA format
|
|
* @memberOf Color
|
|
* @param {String} color Color value ex: rgb(0-255,0-255,0-255), rgb(0%-100%,0%-100%,0%-100%)
|
|
* @return {TRGBAColorSource | undefined} source
|
|
*/
|
|
static sourceFromRgb(color) {
|
|
const match = color.match(reRGBa());
|
|
if (match) {
|
|
const [r, g, b] = match.slice(1, 4).map(value => {
|
|
const parsedValue = parseFloat(value);
|
|
return value.endsWith('%') ? Math.round(parsedValue * 2.55) : parsedValue;
|
|
});
|
|
return [r, g, b, fromAlphaToFloat(match[4])];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns new color object, when given a color in HSL format
|
|
* @param {String} color Color value ex: hsl(0-260,0%-100%,0%-100%)
|
|
* @memberOf Color
|
|
* @return {Color}
|
|
*/
|
|
static fromHsl(color) {
|
|
return Color.fromHsla(color);
|
|
}
|
|
|
|
/**
|
|
* Returns new color object, when given a color in HSLA format
|
|
* @static
|
|
* @function
|
|
* @memberOf Color
|
|
* @param {String} color
|
|
* @return {Color}
|
|
*/
|
|
static fromHsla(color) {
|
|
return new Color(Color.sourceFromHsl(color));
|
|
}
|
|
|
|
/**
|
|
* Returns array representation (ex: [100, 100, 200, 1]) of a color that's in HSL or HSLA format.
|
|
* Adapted from <a href="https://rawgithub.com/mjijackson/mjijackson.github.com/master/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript.html">https://github.com/mjijackson</a>
|
|
* @memberOf Color
|
|
* @param {String} color Color value ex: hsl(0-360,0%-100%,0%-100%) or hsla(0-360,0%-100%,0%-100%, 0-1)
|
|
* @return {TRGBAColorSource | undefined} source
|
|
* @see http://http://www.w3.org/TR/css3-color/#hsl-color
|
|
*/
|
|
static sourceFromHsl(color) {
|
|
const match = color.match(reHSLa());
|
|
if (!match) {
|
|
return;
|
|
}
|
|
const match1degrees = Color.parseAngletoDegrees(match[1]);
|
|
const h = (match1degrees % 360 + 360) % 360 / 360,
|
|
s = parseFloat(match[2]) / 100,
|
|
l = parseFloat(match[3]) / 100;
|
|
let r, g, b;
|
|
if (s === 0) {
|
|
r = g = b = l;
|
|
} else {
|
|
const q = l <= 0.5 ? l * (s + 1) : l + s - l * s,
|
|
p = l * 2 - q;
|
|
r = hue2rgb(p, q, h + 1 / 3);
|
|
g = hue2rgb(p, q, h);
|
|
b = hue2rgb(p, q, h - 1 / 3);
|
|
}
|
|
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), fromAlphaToFloat(match[4])];
|
|
}
|
|
|
|
/**
|
|
* Returns new color object, when given a color in HEX format
|
|
* @static
|
|
* @memberOf Color
|
|
* @param {String} color Color value ex: FF5555
|
|
* @return {Color}
|
|
*/
|
|
static fromHex(color) {
|
|
return new Color(Color.sourceFromHex(color));
|
|
}
|
|
|
|
/**
|
|
* Returns array representation (ex: [100, 100, 200, 1]) of a color that's in HEX format
|
|
* @static
|
|
* @memberOf Color
|
|
* @param {String} color ex: FF5555 or FF5544CC (RGBa)
|
|
* @return {TRGBAColorSource | undefined} source
|
|
*/
|
|
static sourceFromHex(color) {
|
|
if (color.match(reHex())) {
|
|
const value = color.slice(color.indexOf('#') + 1),
|
|
isShortNotation = value.length <= 4;
|
|
let expandedValue;
|
|
if (isShortNotation) {
|
|
expandedValue = value.split('').map(hex => hex + hex);
|
|
} else {
|
|
expandedValue = value.match(/.{2}/g);
|
|
}
|
|
const [r, g, b, a = 255] = expandedValue.map(hexCouple => parseInt(hexCouple, 16));
|
|
return [r, g, b, a / 255];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts a string that could be any angle notation (50deg, 0.5turn, 2rad)
|
|
* into degrees without the 'deg' suffix
|
|
* @static
|
|
* @memberOf Color
|
|
* @param {String} value ex: 0deg, 0.5turn, 2rad
|
|
* @return {Number} number in degrees or NaN if inputs are invalid
|
|
*/
|
|
static parseAngletoDegrees(value) {
|
|
const lowercase = value.toLowerCase();
|
|
const numeric = parseFloat(lowercase);
|
|
if (lowercase.includes('rad')) {
|
|
return radiansToDegrees(numeric);
|
|
}
|
|
if (lowercase.includes('turn')) {
|
|
return numeric * 360;
|
|
}
|
|
|
|
// Value is probably just a number already in degrees eg '50'
|
|
return numeric;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A wrapper around Number#toFixed, which contrary to native method returns number, not string.
|
|
* @param {number|string} number number to operate on
|
|
* @param {number} fractionDigits number of fraction digits to "leave"
|
|
* @return {number}
|
|
*/
|
|
const toFixed = (number, fractionDigits) => parseFloat(Number(number).toFixed(fractionDigits));
|
|
|
|
/**
|
|
* Returns array of attributes for given svg that fabric parses
|
|
* @param {SVGElementName} type Type of svg element (eg. 'circle')
|
|
* @return {Array} string names of supported attributes
|
|
*/
|
|
const getSvgAttributes = type => {
|
|
const commonAttributes = ['instantiated_by_use', 'style', 'id', 'class'];
|
|
switch (type) {
|
|
case 'linearGradient':
|
|
return commonAttributes.concat(['x1', 'y1', 'x2', 'y2', 'gradientUnits', 'gradientTransform']);
|
|
case 'radialGradient':
|
|
return commonAttributes.concat(['gradientUnits', 'gradientTransform', 'cx', 'cy', 'r', 'fx', 'fy', 'fr']);
|
|
case 'stop':
|
|
return commonAttributes.concat(['offset', 'stop-color', 'stop-opacity']);
|
|
}
|
|
return commonAttributes;
|
|
};
|
|
|
|
/**
|
|
* Converts from attribute value to pixel value if applicable.
|
|
* Returns converted pixels or original value not converted.
|
|
* @param {string} value number to operate on
|
|
* @param {number} fontSize
|
|
* @return {number}
|
|
*/
|
|
const parseUnit = function (value) {
|
|
let fontSize = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : DEFAULT_SVG_FONT_SIZE;
|
|
const unit = /\D{0,2}$/.exec(value),
|
|
number = parseFloat(value);
|
|
const dpi = config.DPI;
|
|
switch (unit === null || unit === void 0 ? void 0 : unit[0]) {
|
|
case 'mm':
|
|
return number * dpi / 25.4;
|
|
case 'cm':
|
|
return number * dpi / 2.54;
|
|
case 'in':
|
|
return number * dpi;
|
|
case 'pt':
|
|
return number * dpi / 72;
|
|
// or * 4 / 3
|
|
|
|
case 'pc':
|
|
return number * dpi / 72 * 12;
|
|
// or * 16
|
|
|
|
case 'em':
|
|
return number * fontSize;
|
|
default:
|
|
return number;
|
|
}
|
|
};
|
|
// align can be either none or undefined or a combination of mid/max
|
|
const parseAlign = align => {
|
|
//divide align in alignX and alignY
|
|
if (align && align !== NONE) {
|
|
return [align.slice(1, 4), align.slice(5, 8)];
|
|
} else if (align === NONE) {
|
|
return [align, align];
|
|
}
|
|
return ['Mid', 'Mid'];
|
|
};
|
|
|
|
/**
|
|
* Parse preserveAspectRatio attribute from element
|
|
* https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio
|
|
* @param {string} attribute to be parsed
|
|
* @return {Object} an object containing align and meetOrSlice attribute
|
|
*/
|
|
const parsePreserveAspectRatioAttribute = attribute => {
|
|
const [firstPart, secondPart] = attribute.trim().split(' ');
|
|
const [alignX, alignY] = parseAlign(firstPart);
|
|
return {
|
|
meetOrSlice: secondPart || 'meet',
|
|
alignX,
|
|
alignY
|
|
};
|
|
};
|
|
|
|
/**
|
|
* given an array of 6 number returns something like `"matrix(...numbers)"`
|
|
* @param {TMat2D} transform an array with 6 numbers
|
|
* @return {String} transform matrix for svg
|
|
*/
|
|
const matrixToSVG = transform => 'matrix(' + transform.map(value => toFixed(value, config.NUM_FRACTION_DIGITS)).join(' ') + ')';
|
|
|
|
/**
|
|
* Adobe Illustrator (at least CS5) is unable to render rgba()-based fill values
|
|
* we work around it by "moving" alpha channel into opacity attribute and setting fill's alpha to 1
|
|
* @param prop
|
|
* @param value
|
|
* @param {boolean} inlineStyle The default is inline style, the separator used is ":", The other is "="
|
|
* @returns
|
|
*/
|
|
const colorPropToSVG = function (prop, value) {
|
|
let inlineStyle = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true;
|
|
let colorValue;
|
|
let opacityValue;
|
|
if (!value) {
|
|
colorValue = 'none';
|
|
} else if (value.toLive) {
|
|
colorValue = "url(#SVGID_".concat(value.id, ")");
|
|
} else {
|
|
const color = new Color(value),
|
|
opacity = color.getAlpha();
|
|
colorValue = color.toRgb();
|
|
if (opacity !== 1) {
|
|
opacityValue = opacity.toString();
|
|
}
|
|
}
|
|
if (inlineStyle) {
|
|
return "".concat(prop, ": ").concat(colorValue, "; ").concat(opacityValue ? "".concat(prop, "-opacity: ").concat(opacityValue, "; ") : '');
|
|
} else {
|
|
return "".concat(prop, "=\"").concat(colorValue, "\" ").concat(opacityValue ? "".concat(prop, "-opacity=\"").concat(opacityValue, "\" ") : '');
|
|
}
|
|
};
|
|
const createSVGRect = function (color, _ref) {
|
|
let {
|
|
left,
|
|
top,
|
|
width,
|
|
height
|
|
} = _ref;
|
|
let precision = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : config.NUM_FRACTION_DIGITS;
|
|
const svgColor = colorPropToSVG(FILL, color, false);
|
|
const [x, y, w, h] = [left, top, width, height].map(value => toFixed(value, precision));
|
|
return "<rect ".concat(svgColor, " x=\"").concat(x, "\" y=\"").concat(y, "\" width=\"").concat(w, "\" height=\"").concat(h, "\"></rect>");
|
|
};
|
|
|
|
const isFiller = filler => {
|
|
return !!filler && filler.toLive !== undefined;
|
|
};
|
|
const isSerializableFiller = filler => {
|
|
return !!filler && typeof filler.toObject === 'function';
|
|
};
|
|
const isPattern = filler => {
|
|
return !!filler && filler.offsetX !== undefined && 'source' in filler;
|
|
};
|
|
const isTextObject = fabricObject => {
|
|
return !!fabricObject && typeof fabricObject._renderText === 'function';
|
|
};
|
|
const isPath = fabricObject => {
|
|
// we could use instanceof but that would mean pulling in Text code for a simple check
|
|
// @todo discuss what to do and how to do
|
|
return !!fabricObject && typeof fabricObject._renderPathCommands === 'function';
|
|
};
|
|
const isActiveSelection = fabricObject => !!fabricObject && 'multiSelectionStacking' in fabricObject;
|
|
|
|
/**
|
|
* Returns element scroll offsets
|
|
* @param {HTMLElement} element Element to operate on
|
|
* @return {Object} Object with left/top values
|
|
*/
|
|
function getScrollLeftTop(element) {
|
|
const doc = element && getDocumentFromElement(element);
|
|
let left = 0,
|
|
top = 0;
|
|
if (!element || !doc) {
|
|
return {
|
|
left,
|
|
top
|
|
};
|
|
}
|
|
let elementLoop = element;
|
|
const docElement = doc.documentElement,
|
|
body = doc.body || {
|
|
scrollLeft: 0,
|
|
scrollTop: 0
|
|
};
|
|
// While loop checks (and then sets element to) .parentNode OR .host
|
|
// to account for ShadowDOM. We still want to traverse up out of ShadowDOM,
|
|
// but the .parentNode of a root ShadowDOM node will always be null, instead
|
|
// it should be accessed through .host. See http://stackoverflow.com/a/24765528/4383938
|
|
while (elementLoop && (elementLoop.parentNode || elementLoop.host)) {
|
|
elementLoop = elementLoop.parentNode || elementLoop.host;
|
|
if (elementLoop === doc) {
|
|
left = body.scrollLeft || docElement.scrollLeft || 0;
|
|
top = body.scrollTop || docElement.scrollTop || 0;
|
|
} else {
|
|
left += elementLoop.scrollLeft || 0;
|
|
top += elementLoop.scrollTop || 0;
|
|
}
|
|
if (elementLoop.nodeType === 1 && elementLoop.style.position === 'fixed') {
|
|
break;
|
|
}
|
|
}
|
|
return {
|
|
left,
|
|
top
|
|
};
|
|
}
|
|
const getDocumentFromElement = el => el.ownerDocument || null;
|
|
const getWindowFromElement = el => {
|
|
var _el$ownerDocument;
|
|
return ((_el$ownerDocument = el.ownerDocument) === null || _el$ownerDocument === void 0 ? void 0 : _el$ownerDocument.defaultView) || null;
|
|
};
|
|
|
|
const setCanvasDimensions = function (el, ctx, _ref) {
|
|
let {
|
|
width,
|
|
height
|
|
} = _ref;
|
|
let retinaScaling = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 1;
|
|
el.width = width;
|
|
el.height = height;
|
|
if (retinaScaling > 1) {
|
|
el.setAttribute('width', (width * retinaScaling).toString());
|
|
el.setAttribute('height', (height * retinaScaling).toString());
|
|
ctx.scale(retinaScaling, retinaScaling);
|
|
}
|
|
};
|
|
const setCSSDimensions = (el, _ref2) => {
|
|
let {
|
|
width,
|
|
height
|
|
} = _ref2;
|
|
width && (el.style.width = typeof width === 'number' ? "".concat(width, "px") : width);
|
|
height && (el.style.height = typeof height === 'number' ? "".concat(height, "px") : height);
|
|
};
|
|
|
|
/**
|
|
* Returns offset for a given element
|
|
* @param {HTMLElement} element Element to get offset for
|
|
* @return {Object} Object with "left" and "top" properties
|
|
*/
|
|
function getElementOffset(element) {
|
|
var _getWindowFromElement;
|
|
const doc = element && getDocumentFromElement(element),
|
|
offset = {
|
|
left: 0,
|
|
top: 0
|
|
};
|
|
if (!doc) {
|
|
return offset;
|
|
}
|
|
const elemStyle = ((_getWindowFromElement = getWindowFromElement(element)) === null || _getWindowFromElement === void 0 ? void 0 : _getWindowFromElement.getComputedStyle(element, null)) || {};
|
|
offset.left += parseInt(elemStyle.borderLeftWidth, 10) || 0;
|
|
offset.top += parseInt(elemStyle.borderTopWidth, 10) || 0;
|
|
offset.left += parseInt(elemStyle.paddingLeft, 10) || 0;
|
|
offset.top += parseInt(elemStyle.paddingTop, 10) || 0;
|
|
let box = {
|
|
left: 0,
|
|
top: 0
|
|
};
|
|
const docElem = doc.documentElement;
|
|
if (typeof element.getBoundingClientRect !== 'undefined') {
|
|
box = element.getBoundingClientRect();
|
|
}
|
|
const scrollLeftTop = getScrollLeftTop(element);
|
|
return {
|
|
left: box.left + scrollLeftTop.left - (docElem.clientLeft || 0) + offset.left,
|
|
top: box.top + scrollLeftTop.top - (docElem.clientTop || 0) + offset.top
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Makes element unselectable
|
|
* @param {HTMLElement} element Element to make unselectable
|
|
* @return {HTMLElement} Element that was passed in
|
|
*/
|
|
function makeElementUnselectable(element) {
|
|
if (typeof element.onselectstart !== 'undefined') {
|
|
element.onselectstart = () => false;
|
|
}
|
|
element.style.userSelect = NONE;
|
|
return element;
|
|
}
|
|
|
|
class StaticCanvasDOMManager {
|
|
constructor(arg0) {
|
|
/**
|
|
* Keeps a copy of the canvas style before setting retina scaling and other potions
|
|
* in order to return it to original state on dispose
|
|
* @type string
|
|
*/
|
|
_defineProperty(this, "_originalCanvasStyle", void 0);
|
|
_defineProperty(this, "lower", void 0);
|
|
const el = this.createLowerCanvas(arg0);
|
|
this.lower = {
|
|
el,
|
|
ctx: el.getContext('2d')
|
|
};
|
|
}
|
|
createLowerCanvas(arg0) {
|
|
// canvasEl === 'HTMLCanvasElement' does not work on jsdom/node
|
|
const el = isHTMLCanvas(arg0) ? arg0 : arg0 && getFabricDocument().getElementById(arg0) || createCanvasElement();
|
|
if (el.hasAttribute('data-fabric')) {
|
|
throw new FabricError('Trying to initialize a canvas that has already been initialized. Did you forget to dispose the canvas?');
|
|
}
|
|
this._originalCanvasStyle = el.style.cssText;
|
|
el.setAttribute('data-fabric', 'main');
|
|
el.classList.add('lower-canvas');
|
|
return el;
|
|
}
|
|
cleanupDOM(_ref) {
|
|
let {
|
|
width,
|
|
height
|
|
} = _ref;
|
|
const {
|
|
el
|
|
} = this.lower;
|
|
// restore canvas style and attributes
|
|
el.classList.remove('lower-canvas');
|
|
el.removeAttribute('data-fabric');
|
|
// restore canvas size to original size in case retina scaling was applied
|
|
el.setAttribute('width', "".concat(width));
|
|
el.setAttribute('height', "".concat(height));
|
|
el.style.cssText = this._originalCanvasStyle || '';
|
|
this._originalCanvasStyle = undefined;
|
|
}
|
|
setDimensions(size, retinaScaling) {
|
|
const {
|
|
el,
|
|
ctx
|
|
} = this.lower;
|
|
setCanvasDimensions(el, ctx, size, retinaScaling);
|
|
}
|
|
setCSSDimensions(size) {
|
|
setCSSDimensions(this.lower.el, size);
|
|
}
|
|
|
|
/**
|
|
* Calculates canvas element offset relative to the document
|
|
*/
|
|
calcOffset() {
|
|
return getElementOffset(this.lower.el);
|
|
}
|
|
dispose() {
|
|
getEnv().dispose(this.lower.el);
|
|
// @ts-expect-error disposing
|
|
delete this.lower;
|
|
}
|
|
}
|
|
|
|
const staticCanvasDefaults = {
|
|
backgroundVpt: true,
|
|
backgroundColor: '',
|
|
overlayVpt: true,
|
|
overlayColor: '',
|
|
includeDefaultValues: true,
|
|
svgViewportTransformation: true,
|
|
renderOnAddRemove: true,
|
|
skipOffscreen: true,
|
|
enableRetinaScaling: true,
|
|
imageSmoothingEnabled: true,
|
|
/**
|
|
* @todo move to Canvas
|
|
*/
|
|
controlsAboveOverlay: false,
|
|
/**
|
|
* @todo move to Canvas
|
|
*/
|
|
allowTouchScrolling: false,
|
|
viewportTransform: [...iMatrix]
|
|
};
|
|
|
|
/**
|
|
* Having both options in TCanvasSizeOptions set to true transform the call in a calcOffset
|
|
* Better try to restrict with types to avoid confusion.
|
|
*/
|
|
|
|
/**
|
|
* Static canvas class
|
|
* @see {@link http://fabricjs.com/static_canvas|StaticCanvas demo}
|
|
* @fires before:render
|
|
* @fires after:render
|
|
* @fires canvas:cleared
|
|
* @fires object:added
|
|
* @fires object:removed
|
|
*/
|
|
// TODO: fix `EventSpec` inheritance https://github.com/microsoft/TypeScript/issues/26154#issuecomment-1366616260
|
|
class StaticCanvas extends createCollectionMixin(CommonMethods) {
|
|
// background
|
|
|
|
// overlay
|
|
|
|
// rendering config
|
|
|
|
/**
|
|
* @todo move to Canvas
|
|
*/
|
|
|
|
/**
|
|
* @todo move to Canvas
|
|
*/
|
|
|
|
/**
|
|
* The viewport bounding box in scene plane coordinates, see {@link calcViewportBoundaries}
|
|
*/
|
|
|
|
/**
|
|
* A reference to the canvas actual HTMLCanvasElement.
|
|
* Can be use to read the raw pixels, but never write or manipulate
|
|
* @type HTMLCanvasElement
|
|
*/
|
|
get lowerCanvasEl() {
|
|
var _this$elements$lower;
|
|
return (_this$elements$lower = this.elements.lower) === null || _this$elements$lower === void 0 ? void 0 : _this$elements$lower.el;
|
|
}
|
|
get contextContainer() {
|
|
var _this$elements$lower2;
|
|
return (_this$elements$lower2 = this.elements.lower) === null || _this$elements$lower2 === void 0 ? void 0 : _this$elements$lower2.ctx;
|
|
}
|
|
|
|
/**
|
|
* If true the Canvas is in the process or has been disposed/destroyed.
|
|
* No more rendering operation will be executed on this canvas.
|
|
* @type boolean
|
|
*/
|
|
|
|
/**
|
|
* Started the process of disposing but not done yet.
|
|
* WIll likely complete the render cycle already scheduled but stopping adding more.
|
|
* @type boolean
|
|
*/
|
|
|
|
/**
|
|
* When true control drawing is skipped.
|
|
* This boolean is used to avoid toDataURL to export controls.
|
|
* Usage of this boolean to build up other flows and features is not supported
|
|
* @type Boolean
|
|
* @default false
|
|
*/
|
|
|
|
// reference to
|
|
|
|
static getDefaults() {
|
|
return StaticCanvas.ownDefaults;
|
|
}
|
|
constructor(el) {
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
super();
|
|
Object.assign(this, this.constructor.getDefaults());
|
|
this.set(options);
|
|
this.initElements(el);
|
|
this._setDimensionsImpl({
|
|
width: this.width || this.elements.lower.el.width || 0,
|
|
height: this.height || this.elements.lower.el.height || 0
|
|
});
|
|
this.skipControlsDrawing = false;
|
|
this.viewportTransform = [...this.viewportTransform];
|
|
this.calcViewportBoundaries();
|
|
}
|
|
initElements(el) {
|
|
this.elements = new StaticCanvasDOMManager(el);
|
|
}
|
|
add() {
|
|
const size = super.add(...arguments);
|
|
arguments.length > 0 && this.renderOnAddRemove && this.requestRenderAll();
|
|
return size;
|
|
}
|
|
insertAt(index) {
|
|
for (var _len = arguments.length, objects = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
|
|
objects[_key - 1] = arguments[_key];
|
|
}
|
|
const size = super.insertAt(index, ...objects);
|
|
objects.length > 0 && this.renderOnAddRemove && this.requestRenderAll();
|
|
return size;
|
|
}
|
|
remove() {
|
|
const removed = super.remove(...arguments);
|
|
removed.length > 0 && this.renderOnAddRemove && this.requestRenderAll();
|
|
return removed;
|
|
}
|
|
_onObjectAdded(obj) {
|
|
if (obj.canvas && obj.canvas !== this) {
|
|
log('warn', 'Canvas is trying to add an object that belongs to a different canvas.\n' + 'Resulting to default behavior: removing object from previous canvas and adding to new canvas');
|
|
obj.canvas.remove(obj);
|
|
}
|
|
obj._set('canvas', this);
|
|
obj.setCoords();
|
|
this.fire('object:added', {
|
|
target: obj
|
|
});
|
|
obj.fire('added', {
|
|
target: this
|
|
});
|
|
}
|
|
_onObjectRemoved(obj) {
|
|
obj._set('canvas', undefined);
|
|
this.fire('object:removed', {
|
|
target: obj
|
|
});
|
|
obj.fire('removed', {
|
|
target: this
|
|
});
|
|
}
|
|
_onStackOrderChanged() {
|
|
this.renderOnAddRemove && this.requestRenderAll();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @see https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/HTML-canvas-guide/SettingUptheCanvas/SettingUptheCanvas.html
|
|
* @return {Number} retinaScaling if applied, otherwise 1;
|
|
*/
|
|
getRetinaScaling() {
|
|
return this.enableRetinaScaling ? getDevicePixelRatio() : 1;
|
|
}
|
|
|
|
/**
|
|
* Calculates canvas element offset relative to the document
|
|
* This method is also attached as "resize" event handler of window
|
|
*/
|
|
calcOffset() {
|
|
return this._offset = this.elements.calcOffset();
|
|
}
|
|
|
|
/**
|
|
* Returns canvas width (in px)
|
|
* @return {Number}
|
|
*/
|
|
getWidth() {
|
|
return this.width;
|
|
}
|
|
|
|
/**
|
|
* Returns canvas height (in px)
|
|
* @return {Number}
|
|
*/
|
|
getHeight() {
|
|
return this.height;
|
|
}
|
|
|
|
/**
|
|
* Sets width of this canvas instance
|
|
* @param {Number|String} value Value to set width to
|
|
* @param {Object} [options] Options object
|
|
* @param {Boolean} [options.backstoreOnly=false] Set the given dimensions only as canvas backstore dimensions
|
|
* @param {Boolean} [options.cssOnly=false] Set the given dimensions only as css dimensions
|
|
* @deprecated will be removed in 7.0
|
|
*/
|
|
|
|
setWidth(value, options) {
|
|
return this.setDimensions({
|
|
width: value
|
|
}, options);
|
|
}
|
|
|
|
/**s
|
|
* Sets height of this canvas instance
|
|
* @param {Number|String} value Value to set height to
|
|
* @param {Object} [options] Options object
|
|
* @param {Boolean} [options.backstoreOnly=false] Set the given dimensions only as canvas backstore dimensions
|
|
* @param {Boolean} [options.cssOnly=false] Set the given dimensions only as css dimensions
|
|
* @deprecated will be removed in 7.0
|
|
*/
|
|
|
|
setHeight(value, options) {
|
|
return this.setDimensions({
|
|
height: value
|
|
}, options);
|
|
}
|
|
|
|
/**
|
|
* Internal use only
|
|
* @protected
|
|
*/
|
|
_setDimensionsImpl(dimensions) {
|
|
let {
|
|
cssOnly = false,
|
|
backstoreOnly = false
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
if (!cssOnly) {
|
|
const size = _objectSpread2({
|
|
width: this.width,
|
|
height: this.height
|
|
}, dimensions);
|
|
this.elements.setDimensions(size, this.getRetinaScaling());
|
|
this.hasLostContext = true;
|
|
this.width = size.width;
|
|
this.height = size.height;
|
|
}
|
|
if (!backstoreOnly) {
|
|
this.elements.setCSSDimensions(dimensions);
|
|
}
|
|
this.calcOffset();
|
|
}
|
|
|
|
/**
|
|
* Sets dimensions (width, height) of this canvas instance. when options.cssOnly flag active you should also supply the unit of measure (px/%/em)
|
|
* @param {Object} dimensions Object with width/height properties
|
|
* @param {Number|String} [dimensions.width] Width of canvas element
|
|
* @param {Number|String} [dimensions.height] Height of canvas element
|
|
* @param {Object} [options] Options object
|
|
* @param {Boolean} [options.backstoreOnly=false] Set the given dimensions only as canvas backstore dimensions
|
|
* @param {Boolean} [options.cssOnly=false] Set the given dimensions only as css dimensions
|
|
*/
|
|
|
|
setDimensions(dimensions, options) {
|
|
this._setDimensionsImpl(dimensions, options);
|
|
if (!options || !options.cssOnly) {
|
|
this.requestRenderAll();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns canvas zoom level
|
|
* @return {Number}
|
|
*/
|
|
getZoom() {
|
|
return this.viewportTransform[0];
|
|
}
|
|
|
|
/**
|
|
* Sets viewport transformation of this canvas instance
|
|
* @param {Array} vpt a Canvas 2D API transform matrix
|
|
*/
|
|
setViewportTransform(vpt) {
|
|
this.viewportTransform = vpt;
|
|
this.calcViewportBoundaries();
|
|
this.renderOnAddRemove && this.requestRenderAll();
|
|
}
|
|
|
|
/**
|
|
* Sets zoom level of this canvas instance, the zoom centered around point
|
|
* meaning that following zoom to point with the same point will have the visual
|
|
* effect of the zoom originating from that point. The point won't move.
|
|
* It has nothing to do with canvas center or visual center of the viewport.
|
|
* @param {Point} point to zoom with respect to
|
|
* @param {Number} value to set zoom to, less than 1 zooms out
|
|
*/
|
|
zoomToPoint(point, value) {
|
|
// TODO: just change the scale, preserve other transformations
|
|
const before = point,
|
|
vpt = [...this.viewportTransform];
|
|
const newPoint = transformPoint(point, invertTransform(vpt));
|
|
vpt[0] = value;
|
|
vpt[3] = value;
|
|
const after = transformPoint(newPoint, vpt);
|
|
vpt[4] += before.x - after.x;
|
|
vpt[5] += before.y - after.y;
|
|
this.setViewportTransform(vpt);
|
|
}
|
|
|
|
/**
|
|
* Sets zoom level of this canvas instance
|
|
* @param {Number} value to set zoom to, less than 1 zooms out
|
|
*/
|
|
setZoom(value) {
|
|
this.zoomToPoint(new Point(0, 0), value);
|
|
}
|
|
|
|
/**
|
|
* Pan viewport so as to place point at top left corner of canvas
|
|
* @param {Point} point to move to
|
|
*/
|
|
absolutePan(point) {
|
|
const vpt = [...this.viewportTransform];
|
|
vpt[4] = -point.x;
|
|
vpt[5] = -point.y;
|
|
return this.setViewportTransform(vpt);
|
|
}
|
|
|
|
/**
|
|
* Pans viewpoint relatively
|
|
* @param {Point} point (position vector) to move by
|
|
*/
|
|
relativePan(point) {
|
|
return this.absolutePan(new Point(-point.x - this.viewportTransform[4], -point.y - this.viewportTransform[5]));
|
|
}
|
|
|
|
/**
|
|
* Returns <canvas> element corresponding to this instance
|
|
* @return {HTMLCanvasElement}
|
|
*/
|
|
getElement() {
|
|
return this.elements.lower.el;
|
|
}
|
|
|
|
/**
|
|
* Clears specified context of canvas element
|
|
* @param {CanvasRenderingContext2D} ctx Context to clear
|
|
*/
|
|
clearContext(ctx) {
|
|
ctx.clearRect(0, 0, this.width, this.height);
|
|
}
|
|
|
|
/**
|
|
* Returns context of canvas where objects are drawn
|
|
* @return {CanvasRenderingContext2D}
|
|
*/
|
|
getContext() {
|
|
return this.elements.lower.ctx;
|
|
}
|
|
|
|
/**
|
|
* Clears all contexts (background, main, top) of an instance
|
|
*/
|
|
clear() {
|
|
this.remove(...this.getObjects());
|
|
this.backgroundImage = undefined;
|
|
this.overlayImage = undefined;
|
|
this.backgroundColor = '';
|
|
this.overlayColor = '';
|
|
this.clearContext(this.getContext());
|
|
this.fire('canvas:cleared');
|
|
this.renderOnAddRemove && this.requestRenderAll();
|
|
}
|
|
|
|
/**
|
|
* Renders the canvas
|
|
*/
|
|
renderAll() {
|
|
this.cancelRequestedRender();
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
this.renderCanvas(this.getContext(), this._objects);
|
|
}
|
|
|
|
/**
|
|
* Function created to be instance bound at initialization
|
|
* used in requestAnimationFrame rendering
|
|
* Let the fabricJS call it. If you call it manually you could have more
|
|
* animationFrame stacking on to of each other
|
|
* for an imperative rendering, use canvas.renderAll
|
|
* @private
|
|
*/
|
|
renderAndReset() {
|
|
this.nextRenderHandle = 0;
|
|
this.renderAll();
|
|
}
|
|
|
|
/**
|
|
* Append a renderAll request to next animation frame.
|
|
* unless one is already in progress, in that case nothing is done
|
|
* a boolean flag will avoid appending more.
|
|
*/
|
|
requestRenderAll() {
|
|
if (!this.nextRenderHandle && !this.disposed && !this.destroyed) {
|
|
this.nextRenderHandle = requestAnimFrame(() => this.renderAndReset());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate the position of the 4 corner of canvas with current viewportTransform.
|
|
* helps to determinate when an object is in the current rendering viewport
|
|
*/
|
|
calcViewportBoundaries() {
|
|
const width = this.width,
|
|
height = this.height,
|
|
iVpt = invertTransform(this.viewportTransform),
|
|
a = transformPoint({
|
|
x: 0,
|
|
y: 0
|
|
}, iVpt),
|
|
b = transformPoint({
|
|
x: width,
|
|
y: height
|
|
}, iVpt),
|
|
// we don't support vpt flipping
|
|
// but the code is robust enough to mostly work with flipping
|
|
min = a.min(b),
|
|
max = a.max(b);
|
|
return this.vptCoords = {
|
|
tl: min,
|
|
tr: new Point(max.x, min.y),
|
|
bl: new Point(min.x, max.y),
|
|
br: max
|
|
};
|
|
}
|
|
cancelRequestedRender() {
|
|
if (this.nextRenderHandle) {
|
|
cancelAnimFrame(this.nextRenderHandle);
|
|
this.nextRenderHandle = 0;
|
|
}
|
|
}
|
|
drawControls(_ctx) {
|
|
// Static canvas has no controls
|
|
}
|
|
|
|
/**
|
|
* Renders background, objects, overlay and controls.
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {Array} objects to render
|
|
*/
|
|
renderCanvas(ctx, objects) {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
const v = this.viewportTransform,
|
|
path = this.clipPath;
|
|
this.calcViewportBoundaries();
|
|
this.clearContext(ctx);
|
|
ctx.imageSmoothingEnabled = this.imageSmoothingEnabled;
|
|
// @ts-expect-error node-canvas stuff
|
|
ctx.patternQuality = 'best';
|
|
this.fire('before:render', {
|
|
ctx
|
|
});
|
|
this._renderBackground(ctx);
|
|
ctx.save();
|
|
//apply viewport transform once for all rendering process
|
|
ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]);
|
|
this._renderObjects(ctx, objects);
|
|
ctx.restore();
|
|
if (!this.controlsAboveOverlay && !this.skipControlsDrawing) {
|
|
this.drawControls(ctx);
|
|
}
|
|
if (path) {
|
|
path._set('canvas', this);
|
|
// needed to setup a couple of variables
|
|
path.shouldCache();
|
|
path._transformDone = true;
|
|
path.renderCache({
|
|
forClipping: true
|
|
});
|
|
this.drawClipPathOnCanvas(ctx, path);
|
|
}
|
|
this._renderOverlay(ctx);
|
|
if (this.controlsAboveOverlay && !this.skipControlsDrawing) {
|
|
this.drawControls(ctx);
|
|
}
|
|
this.fire('after:render', {
|
|
ctx
|
|
});
|
|
if (this.__cleanupTask) {
|
|
this.__cleanupTask();
|
|
this.__cleanupTask = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Paint the cached clipPath on the lowerCanvasEl
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
drawClipPathOnCanvas(ctx, clipPath) {
|
|
const v = this.viewportTransform;
|
|
ctx.save();
|
|
ctx.transform(...v);
|
|
// DEBUG: uncomment this line, comment the following
|
|
// ctx.globalAlpha = 0.4;
|
|
ctx.globalCompositeOperation = 'destination-in';
|
|
clipPath.transform(ctx);
|
|
ctx.scale(1 / clipPath.zoomX, 1 / clipPath.zoomY);
|
|
ctx.drawImage(clipPath._cacheCanvas, -clipPath.cacheTranslationX, -clipPath.cacheTranslationY);
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {Array} objects to render
|
|
*/
|
|
_renderObjects(ctx, objects) {
|
|
for (let i = 0, len = objects.length; i < len; ++i) {
|
|
objects[i] && objects[i].render(ctx);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {string} property 'background' or 'overlay'
|
|
*/
|
|
_renderBackgroundOrOverlay(ctx, property) {
|
|
const fill = this["".concat(property, "Color")],
|
|
object = this["".concat(property, "Image")],
|
|
v = this.viewportTransform,
|
|
needsVpt = this["".concat(property, "Vpt")];
|
|
if (!fill && !object) {
|
|
return;
|
|
}
|
|
const isAFiller = isFiller(fill);
|
|
if (fill) {
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(this.width, 0);
|
|
ctx.lineTo(this.width, this.height);
|
|
ctx.lineTo(0, this.height);
|
|
ctx.closePath();
|
|
ctx.fillStyle = isAFiller ? fill.toLive(ctx /* this */) : fill;
|
|
if (needsVpt) {
|
|
ctx.transform(...v);
|
|
}
|
|
if (isAFiller) {
|
|
ctx.transform(1, 0, 0, 1, fill.offsetX || 0, fill.offsetY || 0);
|
|
const m = fill.gradientTransform || fill.patternTransform;
|
|
m && ctx.transform(...m);
|
|
}
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
if (object) {
|
|
ctx.save();
|
|
const {
|
|
skipOffscreen
|
|
} = this;
|
|
// if the object doesn't move with the viewport,
|
|
// the offscreen concept does not apply;
|
|
this.skipOffscreen = needsVpt;
|
|
if (needsVpt) {
|
|
ctx.transform(...v);
|
|
}
|
|
object.render(ctx);
|
|
this.skipOffscreen = skipOffscreen;
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderBackground(ctx) {
|
|
this._renderBackgroundOrOverlay(ctx, 'background');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderOverlay(ctx) {
|
|
this._renderBackgroundOrOverlay(ctx, 'overlay');
|
|
}
|
|
|
|
/**
|
|
* Returns coordinates of a center of canvas.
|
|
* Returned value is an object with top and left properties
|
|
* @return {Object} object with "top" and "left" number values
|
|
* @deprecated migrate to `getCenterPoint`
|
|
*/
|
|
getCenter() {
|
|
return {
|
|
top: this.height / 2,
|
|
left: this.width / 2
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns coordinates of a center of canvas.
|
|
* @return {Point}
|
|
*/
|
|
getCenterPoint() {
|
|
return new Point(this.width / 2, this.height / 2);
|
|
}
|
|
|
|
/**
|
|
* Centers object horizontally in the canvas
|
|
*/
|
|
centerObjectH(object) {
|
|
return this._centerObject(object, new Point(this.getCenterPoint().x, object.getCenterPoint().y));
|
|
}
|
|
|
|
/**
|
|
* Centers object vertically in the canvas
|
|
* @param {FabricObject} object Object to center vertically
|
|
*/
|
|
centerObjectV(object) {
|
|
return this._centerObject(object, new Point(object.getCenterPoint().x, this.getCenterPoint().y));
|
|
}
|
|
|
|
/**
|
|
* Centers object vertically and horizontally in the canvas
|
|
* @param {FabricObject} object Object to center vertically and horizontally
|
|
*/
|
|
centerObject(object) {
|
|
return this._centerObject(object, this.getCenterPoint());
|
|
}
|
|
|
|
/**
|
|
* Centers object vertically and horizontally in the viewport
|
|
* @param {FabricObject} object Object to center vertically and horizontally
|
|
*/
|
|
viewportCenterObject(object) {
|
|
return this._centerObject(object, this.getVpCenter());
|
|
}
|
|
|
|
/**
|
|
* Centers object horizontally in the viewport, object.top is unchanged
|
|
* @param {FabricObject} object Object to center vertically and horizontally
|
|
*/
|
|
viewportCenterObjectH(object) {
|
|
return this._centerObject(object, new Point(this.getVpCenter().x, object.getCenterPoint().y));
|
|
}
|
|
|
|
/**
|
|
* Centers object Vertically in the viewport, object.top is unchanged
|
|
* @param {FabricObject} object Object to center vertically and horizontally
|
|
*/
|
|
viewportCenterObjectV(object) {
|
|
return this._centerObject(object, new Point(object.getCenterPoint().x, this.getVpCenter().y));
|
|
}
|
|
|
|
/**
|
|
* Calculate the point in canvas that correspond to the center of actual viewport.
|
|
* @return {Point} vpCenter, viewport center
|
|
*/
|
|
getVpCenter() {
|
|
return transformPoint(this.getCenterPoint(), invertTransform(this.viewportTransform));
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {FabricObject} object Object to center
|
|
* @param {Point} center Center point
|
|
*/
|
|
_centerObject(object, center) {
|
|
object.setXY(center, CENTER, CENTER);
|
|
object.setCoords();
|
|
this.renderOnAddRemove && this.requestRenderAll();
|
|
}
|
|
|
|
/**
|
|
* Returns dataless JSON representation of canvas
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {String} json string
|
|
*/
|
|
toDatalessJSON(propertiesToInclude) {
|
|
return this.toDatalessObject(propertiesToInclude);
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of canvas
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toObject(propertiesToInclude) {
|
|
return this._toObjectMethod('toObject', propertiesToInclude);
|
|
}
|
|
|
|
/**
|
|
* Returns Object representation of canvas
|
|
* this alias is provided because if you call JSON.stringify on an instance,
|
|
* the toJSON object will be invoked if it exists.
|
|
* Having a toJSON method means you can do JSON.stringify(myCanvas)
|
|
* @return {Object} JSON compatible object
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-3#serialization}
|
|
* @see {@link http://jsfiddle.net/fabricjs/pec86/|jsFiddle demo}
|
|
* @example <caption>JSON without additional properties</caption>
|
|
* var json = canvas.toJSON();
|
|
* @example <caption>JSON with additional properties included</caption>
|
|
* var json = canvas.toJSON(['lockMovementX', 'lockMovementY', 'lockRotation', 'lockScalingX', 'lockScalingY']);
|
|
* @example <caption>JSON without default values</caption>
|
|
* var json = canvas.toJSON();
|
|
*/
|
|
toJSON() {
|
|
return this.toObject();
|
|
}
|
|
|
|
/**
|
|
* Returns dataless object representation of canvas
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toDatalessObject(propertiesToInclude) {
|
|
return this._toObjectMethod('toDatalessObject', propertiesToInclude);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_toObjectMethod(methodName, propertiesToInclude) {
|
|
const clipPath = this.clipPath;
|
|
const clipPathData = clipPath && !clipPath.excludeFromExport ? this._toObject(clipPath, methodName, propertiesToInclude) : null;
|
|
return _objectSpread2(_objectSpread2(_objectSpread2({
|
|
version: VERSION
|
|
}, pick(this, propertiesToInclude)), {}, {
|
|
objects: this._objects.filter(object => !object.excludeFromExport).map(instance => this._toObject(instance, methodName, propertiesToInclude))
|
|
}, this.__serializeBgOverlay(methodName, propertiesToInclude)), clipPathData ? {
|
|
clipPath: clipPathData
|
|
} : null);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_toObject(instance, methodName, propertiesToInclude) {
|
|
let originalValue;
|
|
if (!this.includeDefaultValues) {
|
|
originalValue = instance.includeDefaultValues;
|
|
instance.includeDefaultValues = false;
|
|
}
|
|
const object = instance[methodName](propertiesToInclude);
|
|
if (!this.includeDefaultValues) {
|
|
instance.includeDefaultValues = !!originalValue;
|
|
}
|
|
return object;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
__serializeBgOverlay(methodName, propertiesToInclude) {
|
|
const data = {},
|
|
bgImage = this.backgroundImage,
|
|
overlayImage = this.overlayImage,
|
|
bgColor = this.backgroundColor,
|
|
overlayColor = this.overlayColor;
|
|
if (isFiller(bgColor)) {
|
|
if (!bgColor.excludeFromExport) {
|
|
data.background = bgColor.toObject(propertiesToInclude);
|
|
}
|
|
} else if (bgColor) {
|
|
data.background = bgColor;
|
|
}
|
|
if (isFiller(overlayColor)) {
|
|
if (!overlayColor.excludeFromExport) {
|
|
data.overlay = overlayColor.toObject(propertiesToInclude);
|
|
}
|
|
} else if (overlayColor) {
|
|
data.overlay = overlayColor;
|
|
}
|
|
if (bgImage && !bgImage.excludeFromExport) {
|
|
data.backgroundImage = this._toObject(bgImage, methodName, propertiesToInclude);
|
|
}
|
|
if (overlayImage && !overlayImage.excludeFromExport) {
|
|
data.overlayImage = this._toObject(overlayImage, methodName, propertiesToInclude);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/* _TO_SVG_START_ */
|
|
|
|
/**
|
|
* Returns SVG representation of canvas
|
|
* @function
|
|
* @param {Object} [options] Options object for SVG output
|
|
* @param {Boolean} [options.suppressPreamble=false] If true xml tag is not included
|
|
* @param {Object} [options.viewBox] SVG viewbox object
|
|
* @param {Number} [options.viewBox.x] x-coordinate of viewbox
|
|
* @param {Number} [options.viewBox.y] y-coordinate of viewbox
|
|
* @param {Number} [options.viewBox.width] Width of viewbox
|
|
* @param {Number} [options.viewBox.height] Height of viewbox
|
|
* @param {String} [options.encoding=UTF-8] Encoding of SVG output
|
|
* @param {String} [options.width] desired width of svg with or without units
|
|
* @param {String} [options.height] desired height of svg with or without units
|
|
* @param {Function} [reviver] Method for further parsing of svg elements, called after each fabric object converted into svg representation.
|
|
* @return {String} SVG string
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-3#serialization}
|
|
* @see {@link http://jsfiddle.net/fabricjs/jQ3ZZ/|jsFiddle demo}
|
|
* @example <caption>Normal SVG output</caption>
|
|
* var svg = canvas.toSVG();
|
|
* @example <caption>SVG output without preamble (without <?xml ../>)</caption>
|
|
* var svg = canvas.toSVG({suppressPreamble: true});
|
|
* @example <caption>SVG output with viewBox attribute</caption>
|
|
* var svg = canvas.toSVG({
|
|
* viewBox: {
|
|
* x: 100,
|
|
* y: 100,
|
|
* width: 200,
|
|
* height: 300
|
|
* }
|
|
* });
|
|
* @example <caption>SVG output with different encoding (default: UTF-8)</caption>
|
|
* var svg = canvas.toSVG({encoding: 'ISO-8859-1'});
|
|
* @example <caption>Modify SVG output with reviver function</caption>
|
|
* var svg = canvas.toSVG(null, function(svg) {
|
|
* return svg.replace('stroke-dasharray: ; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; ', '');
|
|
* });
|
|
*/
|
|
toSVG() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
let reviver = arguments.length > 1 ? arguments[1] : undefined;
|
|
options.reviver = reviver;
|
|
const markup = [];
|
|
this._setSVGPreamble(markup, options);
|
|
this._setSVGHeader(markup, options);
|
|
if (this.clipPath) {
|
|
markup.push("<g clip-path=\"url(#".concat(this.clipPath.clipPathId, ")\" >\n"));
|
|
}
|
|
this._setSVGBgOverlayColor(markup, 'background');
|
|
this._setSVGBgOverlayImage(markup, 'backgroundImage', reviver);
|
|
this._setSVGObjects(markup, reviver);
|
|
if (this.clipPath) {
|
|
markup.push('</g>\n');
|
|
}
|
|
this._setSVGBgOverlayColor(markup, 'overlay');
|
|
this._setSVGBgOverlayImage(markup, 'overlayImage', reviver);
|
|
markup.push('</svg>');
|
|
return markup.join('');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_setSVGPreamble(markup, options) {
|
|
if (options.suppressPreamble) {
|
|
return;
|
|
}
|
|
markup.push('<?xml version="1.0" encoding="', options.encoding || 'UTF-8', '" standalone="no" ?>\n', '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ', '"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_setSVGHeader(markup, options) {
|
|
const width = options.width || "".concat(this.width),
|
|
height = options.height || "".concat(this.height),
|
|
NUM_FRACTION_DIGITS = config.NUM_FRACTION_DIGITS,
|
|
optViewBox = options.viewBox;
|
|
let viewBox;
|
|
if (optViewBox) {
|
|
viewBox = "viewBox=\"".concat(optViewBox.x, " ").concat(optViewBox.y, " ").concat(optViewBox.width, " ").concat(optViewBox.height, "\" ");
|
|
} else if (this.svgViewportTransformation) {
|
|
const vpt = this.viewportTransform;
|
|
viewBox = "viewBox=\"".concat(toFixed(-vpt[4] / vpt[0], NUM_FRACTION_DIGITS), " ").concat(toFixed(-vpt[5] / vpt[3], NUM_FRACTION_DIGITS), " ").concat(toFixed(this.width / vpt[0], NUM_FRACTION_DIGITS), " ").concat(toFixed(this.height / vpt[3], NUM_FRACTION_DIGITS), "\" ");
|
|
} else {
|
|
viewBox = "viewBox=\"0 0 ".concat(this.width, " ").concat(this.height, "\" ");
|
|
}
|
|
markup.push('<svg ', 'xmlns="http://www.w3.org/2000/svg" ', 'xmlns:xlink="http://www.w3.org/1999/xlink" ', 'version="1.1" ', 'width="', width, '" ', 'height="', height, '" ', viewBox, 'xml:space="preserve">\n', '<desc>Created with Fabric.js ', VERSION, '</desc>\n', '<defs>\n', this.createSVGFontFacesMarkup(), this.createSVGRefElementsMarkup(), this.createSVGClipPathMarkup(options), '</defs>\n');
|
|
}
|
|
createSVGClipPathMarkup(options) {
|
|
const clipPath = this.clipPath;
|
|
if (clipPath) {
|
|
clipPath.clipPathId = "CLIPPATH_".concat(uid());
|
|
return "<clipPath id=\"".concat(clipPath.clipPathId, "\" >\n").concat(clipPath.toClipPathSVG(options.reviver), "</clipPath>\n");
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Creates markup containing SVG referenced elements like patterns, gradients etc.
|
|
* @return {String}
|
|
*/
|
|
createSVGRefElementsMarkup() {
|
|
return ['background', 'overlay'].map(prop => {
|
|
const fill = this["".concat(prop, "Color")];
|
|
if (isFiller(fill)) {
|
|
const shouldTransform = this["".concat(prop, "Vpt")],
|
|
vpt = this.viewportTransform,
|
|
object = {
|
|
// otherwise circular dependency
|
|
isType: () => false,
|
|
width: this.width / (shouldTransform ? vpt[0] : 1),
|
|
height: this.height / (shouldTransform ? vpt[3] : 1)
|
|
};
|
|
return fill.toSVG(object, {
|
|
additionalTransform: shouldTransform ? matrixToSVG(vpt) : ''
|
|
});
|
|
}
|
|
}).join('');
|
|
}
|
|
|
|
/**
|
|
* Creates markup containing SVG font faces,
|
|
* font URLs for font faces must be collected by developers
|
|
* and are not extracted from the DOM by fabricjs
|
|
* @param {Array} objects Array of fabric objects
|
|
* @return {String}
|
|
*/
|
|
createSVGFontFacesMarkup() {
|
|
const objects = [],
|
|
fontList = {},
|
|
fontPaths = config.fontPaths;
|
|
this._objects.forEach(function add(object) {
|
|
objects.push(object);
|
|
if (isCollection(object)) {
|
|
object._objects.forEach(add);
|
|
}
|
|
});
|
|
objects.forEach(obj => {
|
|
if (!isTextObject(obj)) {
|
|
return;
|
|
}
|
|
const {
|
|
styles,
|
|
fontFamily
|
|
} = obj;
|
|
if (fontList[fontFamily] || !fontPaths[fontFamily]) {
|
|
return;
|
|
}
|
|
fontList[fontFamily] = true;
|
|
if (!styles) {
|
|
return;
|
|
}
|
|
Object.values(styles).forEach(styleRow => {
|
|
Object.values(styleRow).forEach(_ref => {
|
|
let {
|
|
fontFamily = ''
|
|
} = _ref;
|
|
if (!fontList[fontFamily] && fontPaths[fontFamily]) {
|
|
fontList[fontFamily] = true;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
const fontListMarkup = Object.keys(fontList).map(fontFamily => "\t\t@font-face {\n\t\t\tfont-family: '".concat(fontFamily, "';\n\t\t\tsrc: url('").concat(fontPaths[fontFamily], "');\n\t\t}\n")).join('');
|
|
if (fontListMarkup) {
|
|
return "\t<style type=\"text/css\"><![CDATA[\n".concat(fontListMarkup, "]]></style>\n");
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_setSVGObjects(markup, reviver) {
|
|
this.forEachObject(fabricObject => {
|
|
if (fabricObject.excludeFromExport) {
|
|
return;
|
|
}
|
|
this._setSVGObject(markup, fabricObject, reviver);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* This is its own function because the Canvas ( non static ) requires extra code here
|
|
* @private
|
|
*/
|
|
_setSVGObject(markup, instance, reviver) {
|
|
markup.push(instance.toSVG(reviver));
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_setSVGBgOverlayImage(markup, property, reviver) {
|
|
const bgOrOverlay = this[property];
|
|
if (bgOrOverlay && !bgOrOverlay.excludeFromExport && bgOrOverlay.toSVG) {
|
|
markup.push(bgOrOverlay.toSVG(reviver));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @TODO this seems to handle patterns but fail at gradients.
|
|
* @private
|
|
*/
|
|
_setSVGBgOverlayColor(markup, property) {
|
|
const filler = this["".concat(property, "Color")];
|
|
if (!filler) {
|
|
return;
|
|
}
|
|
if (isFiller(filler)) {
|
|
const repeat = filler.repeat || '',
|
|
finalWidth = this.width,
|
|
finalHeight = this.height,
|
|
shouldInvert = this["".concat(property, "Vpt")],
|
|
additionalTransform = shouldInvert ? matrixToSVG(invertTransform(this.viewportTransform)) : '';
|
|
markup.push("<rect transform=\"".concat(additionalTransform, " translate(").concat(finalWidth / 2, ",").concat(finalHeight / 2, ")\" x=\"").concat(filler.offsetX - finalWidth / 2, "\" y=\"").concat(filler.offsetY - finalHeight / 2, "\" width=\"").concat((repeat === 'repeat-y' || repeat === 'no-repeat') && isPattern(filler) ? filler.source.width : finalWidth, "\" height=\"").concat((repeat === 'repeat-x' || repeat === 'no-repeat') && isPattern(filler) ? filler.source.height : finalHeight, "\" fill=\"url(#SVGID_").concat(filler.id, ")\"></rect>\n"));
|
|
} else {
|
|
markup.push('<rect x="0" y="0" width="100%" height="100%" ', 'fill="', filler, '"', '></rect>\n');
|
|
}
|
|
}
|
|
/* _TO_SVG_END_ */
|
|
|
|
/**
|
|
* Populates canvas with data from the specified JSON.
|
|
* JSON format must conform to the one of {@link fabric.Canvas#toJSON}
|
|
*
|
|
* **IMPORTANT**: It is recommended to abort loading tasks before calling this method to prevent race conditions and unnecessary networking
|
|
*
|
|
* @param {String|Object} json JSON string or object
|
|
* @param {Function} [reviver] Method for further parsing of JSON elements, called after each fabric object created.
|
|
* @param {Object} [options] options
|
|
* @param {AbortSignal} [options.signal] see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @return {Promise<Canvas | StaticCanvas>} instance
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-3#deserialization}
|
|
* @see {@link http://jsfiddle.net/fabricjs/fmgXt/|jsFiddle demo}
|
|
* @example <caption>loadFromJSON</caption>
|
|
* canvas.loadFromJSON(json).then((canvas) => canvas.requestRenderAll());
|
|
* @example <caption>loadFromJSON with reviver</caption>
|
|
* canvas.loadFromJSON(json, function(o, object) {
|
|
* // `o` = json object
|
|
* // `object` = fabric.Object instance
|
|
* // ... do some stuff ...
|
|
* }).then((canvas) => {
|
|
* ... canvas is restored, add your code.
|
|
* });
|
|
*
|
|
*/
|
|
loadFromJSON(json, reviver) {
|
|
let {
|
|
signal
|
|
} = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
|
|
if (!json) {
|
|
return Promise.reject(new FabricError('`json` is undefined'));
|
|
}
|
|
|
|
// parse json if it wasn't already
|
|
const serialized = typeof json === 'string' ? JSON.parse(json) : json;
|
|
const {
|
|
objects = [],
|
|
backgroundImage,
|
|
background,
|
|
overlayImage,
|
|
overlay,
|
|
clipPath
|
|
} = serialized;
|
|
const renderOnAddRemove = this.renderOnAddRemove;
|
|
this.renderOnAddRemove = false;
|
|
return Promise.all([enlivenObjects(objects, {
|
|
reviver,
|
|
signal
|
|
}), enlivenObjectEnlivables({
|
|
backgroundImage,
|
|
backgroundColor: background,
|
|
overlayImage,
|
|
overlayColor: overlay,
|
|
clipPath
|
|
}, {
|
|
signal
|
|
})]).then(_ref2 => {
|
|
let [enlived, enlivedMap] = _ref2;
|
|
this.clear();
|
|
this.add(...enlived);
|
|
this.set(serialized);
|
|
this.set(enlivedMap);
|
|
this.renderOnAddRemove = renderOnAddRemove;
|
|
return this;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clones canvas instance
|
|
* @param {string[]} [properties] Array of properties to include in the cloned canvas and children
|
|
*/
|
|
clone(properties) {
|
|
const data = this.toObject(properties);
|
|
const canvas = this.cloneWithoutData();
|
|
return canvas.loadFromJSON(data);
|
|
}
|
|
|
|
/**
|
|
* Clones canvas instance without cloning existing data.
|
|
* This essentially copies canvas dimensions since loadFromJSON does not affect canvas size.
|
|
*/
|
|
cloneWithoutData() {
|
|
const el = createCanvasElement();
|
|
el.width = this.width;
|
|
el.height = this.height;
|
|
return new this.constructor(el);
|
|
}
|
|
|
|
/**
|
|
* Exports canvas element to a dataurl image. Note that when multiplier is used, cropping is scaled appropriately
|
|
* @param {Object} [options] Options object
|
|
* @param {String} [options.format=png] The format of the output image. Either "jpeg" or "png"
|
|
* @param {Number} [options.quality=1] Quality level (0..1). Only used for jpeg.
|
|
* @param {Number} [options.multiplier=1] Multiplier to scale by, to have consistent
|
|
* @param {Number} [options.left] Cropping left offset. Introduced in v1.2.14
|
|
* @param {Number} [options.top] Cropping top offset. Introduced in v1.2.14
|
|
* @param {Number} [options.width] Cropping width. Introduced in v1.2.14
|
|
* @param {Number} [options.height] Cropping height. Introduced in v1.2.14
|
|
* @param {Boolean} [options.enableRetinaScaling] Enable retina scaling for clone image. Introduce in 2.0.0
|
|
* @param {(object: fabric.Object) => boolean} [options.filter] Function to filter objects.
|
|
* @return {String} Returns a data: URL containing a representation of the object in the format specified by options.format
|
|
* @see {@link https://jsfiddle.net/xsjua1rd/ demo}
|
|
* @example <caption>Generate jpeg dataURL with lower quality</caption>
|
|
* var dataURL = canvas.toDataURL({
|
|
* format: 'jpeg',
|
|
* quality: 0.8
|
|
* });
|
|
* @example <caption>Generate cropped png dataURL (clipping of canvas)</caption>
|
|
* var dataURL = canvas.toDataURL({
|
|
* format: 'png',
|
|
* left: 100,
|
|
* top: 100,
|
|
* width: 200,
|
|
* height: 200
|
|
* });
|
|
* @example <caption>Generate double scaled png dataURL</caption>
|
|
* var dataURL = canvas.toDataURL({
|
|
* format: 'png',
|
|
* multiplier: 2
|
|
* });
|
|
* @example <caption>Generate dataURL with objects that overlap a specified object</caption>
|
|
* var myObject;
|
|
* var dataURL = canvas.toDataURL({
|
|
* filter: (object) => object.isContainedWithinObject(myObject) || object.intersectsWithObject(myObject)
|
|
* });
|
|
*/
|
|
toDataURL() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
const {
|
|
format = 'png',
|
|
quality = 1,
|
|
multiplier = 1,
|
|
enableRetinaScaling = false
|
|
} = options;
|
|
const finalMultiplier = multiplier * (enableRetinaScaling ? this.getRetinaScaling() : 1);
|
|
return toDataURL(this.toCanvasElement(finalMultiplier, options), format, quality);
|
|
}
|
|
|
|
/**
|
|
* Create a new HTMLCanvas element painted with the current canvas content.
|
|
* No need to resize the actual one or repaint it.
|
|
* Will transfer object ownership to a new canvas, paint it, and set everything back.
|
|
* This is an intermediary step used to get to a dataUrl but also it is useful to
|
|
* create quick image copies of a canvas without passing for the dataUrl string
|
|
* @param {Number} [multiplier] a zoom factor.
|
|
* @param {Object} [options] Cropping informations
|
|
* @param {Number} [options.left] Cropping left offset.
|
|
* @param {Number} [options.top] Cropping top offset.
|
|
* @param {Number} [options.width] Cropping width.
|
|
* @param {Number} [options.height] Cropping height.
|
|
* @param {(object: fabric.Object) => boolean} [options.filter] Function to filter objects.
|
|
*/
|
|
toCanvasElement() {
|
|
let multiplier = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
|
|
let {
|
|
width,
|
|
height,
|
|
left,
|
|
top,
|
|
filter
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const scaledWidth = (width || this.width) * multiplier,
|
|
scaledHeight = (height || this.height) * multiplier,
|
|
zoom = this.getZoom(),
|
|
originalWidth = this.width,
|
|
originalHeight = this.height,
|
|
originalSkipControlsDrawing = this.skipControlsDrawing,
|
|
newZoom = zoom * multiplier,
|
|
vp = this.viewportTransform,
|
|
translateX = (vp[4] - (left || 0)) * multiplier,
|
|
translateY = (vp[5] - (top || 0)) * multiplier,
|
|
newVp = [newZoom, 0, 0, newZoom, translateX, translateY],
|
|
originalRetina = this.enableRetinaScaling,
|
|
canvasEl = createCanvasElement(),
|
|
objectsToRender = filter ? this._objects.filter(obj => filter(obj)) : this._objects;
|
|
canvasEl.width = scaledWidth;
|
|
canvasEl.height = scaledHeight;
|
|
this.enableRetinaScaling = false;
|
|
this.viewportTransform = newVp;
|
|
this.width = scaledWidth;
|
|
this.height = scaledHeight;
|
|
this.skipControlsDrawing = true;
|
|
this.calcViewportBoundaries();
|
|
this.renderCanvas(canvasEl.getContext('2d'), objectsToRender);
|
|
this.viewportTransform = vp;
|
|
this.width = originalWidth;
|
|
this.height = originalHeight;
|
|
this.calcViewportBoundaries();
|
|
this.enableRetinaScaling = originalRetina;
|
|
this.skipControlsDrawing = originalSkipControlsDrawing;
|
|
return canvasEl;
|
|
}
|
|
|
|
/**
|
|
* Waits until rendering has settled to destroy the canvas
|
|
* @returns {Promise<boolean>} a promise resolving to `true` once the canvas has been destroyed or to `false` if the canvas has was already destroyed
|
|
* @throws if aborted by a consequent call
|
|
*/
|
|
dispose() {
|
|
!this.disposed && this.elements.cleanupDOM({
|
|
width: this.width,
|
|
height: this.height
|
|
});
|
|
runningAnimations.cancelByCanvas(this);
|
|
this.disposed = true;
|
|
return new Promise((resolve, reject) => {
|
|
const task = () => {
|
|
this.destroy();
|
|
resolve(true);
|
|
};
|
|
task.kill = reject;
|
|
if (this.__cleanupTask) {
|
|
this.__cleanupTask.kill('aborted');
|
|
}
|
|
if (this.destroyed) {
|
|
resolve(false);
|
|
} else if (this.nextRenderHandle) {
|
|
this.__cleanupTask = task;
|
|
} else {
|
|
task();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clears the canvas element, disposes objects and frees resources.
|
|
*
|
|
* Invoked as part of the **async** operation of {@link dispose}.
|
|
*
|
|
* **CAUTION**:
|
|
*
|
|
* This method is **UNSAFE**.
|
|
* You may encounter a race condition using it if there's a requested render.
|
|
* Call this method only if you are sure rendering has settled.
|
|
* Consider using {@link dispose} as it is **SAFE**
|
|
*
|
|
* @private
|
|
*/
|
|
destroy() {
|
|
this.destroyed = true;
|
|
this.cancelRequestedRender();
|
|
this.forEachObject(object => object.dispose());
|
|
this._objects = [];
|
|
if (this.backgroundImage) {
|
|
this.backgroundImage.dispose();
|
|
}
|
|
this.backgroundImage = undefined;
|
|
if (this.overlayImage) {
|
|
this.overlayImage.dispose();
|
|
}
|
|
this.overlayImage = undefined;
|
|
this.elements.dispose();
|
|
}
|
|
|
|
/**
|
|
* Returns a string representation of an instance
|
|
* @return {String} string representation of an instance
|
|
*/
|
|
toString() {
|
|
return "#<Canvas (".concat(this.complexity(), "): { objects: ").concat(this._objects.length, " }>");
|
|
}
|
|
}
|
|
_defineProperty(StaticCanvas, "ownDefaults", staticCanvasDefaults);
|
|
|
|
const touchEvents = ['touchstart', 'touchmove', 'touchend'];
|
|
function getTouchInfo(event) {
|
|
const touchProp = event.changedTouches;
|
|
if (touchProp && touchProp[0]) {
|
|
return touchProp[0];
|
|
}
|
|
return event;
|
|
}
|
|
const getPointer = event => {
|
|
const element = event.target,
|
|
scroll = getScrollLeftTop(element),
|
|
_evt = getTouchInfo(event);
|
|
return new Point(_evt.clientX + scroll.left, _evt.clientY + scroll.top);
|
|
};
|
|
const isTouchEvent = event => touchEvents.includes(event.type) || event.pointerType === 'touch';
|
|
const stopEvent = e => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
/**
|
|
* Calculates bounding box (left, top, width, height) from given `points`
|
|
* @param {XY[]} points
|
|
* @return {Object} Object with left, top, width, height properties
|
|
*/
|
|
const makeBoundingBoxFromPoints = points => {
|
|
let left = 0,
|
|
top = 0,
|
|
width = 0,
|
|
height = 0;
|
|
for (let i = 0, len = points.length; i < len; i++) {
|
|
const {
|
|
x,
|
|
y
|
|
} = points[i];
|
|
if (x > width || !i) width = x;
|
|
if (x < left || !i) left = x;
|
|
if (y > height || !i) height = y;
|
|
if (y < top || !i) top = y;
|
|
}
|
|
return {
|
|
left,
|
|
top,
|
|
width: width - left,
|
|
height: height - top
|
|
};
|
|
};
|
|
|
|
const _excluded$i = ["translateX", "translateY", "scaleX", "scaleY"];
|
|
|
|
/**
|
|
* given an object and a transform, apply the inverse transform to the object,
|
|
* this is equivalent to remove from that object that transformation, so that
|
|
* added in a space with the removed transform, the object will be the same as before.
|
|
* Removing from an object a transform that scale by 2 is like scaling it by 1/2.
|
|
* Removing from an object a transform that rotate by 30deg is like rotating by 30deg
|
|
* in the opposite direction.
|
|
* This util is used to add objects inside transformed groups or nested groups.
|
|
* @param {FabricObject} object the object you want to transform
|
|
* @param {TMat2D} transform the destination transform
|
|
*/
|
|
const removeTransformFromObject = (object, transform) => {
|
|
const inverted = invertTransform(transform),
|
|
finalTransform = multiplyTransformMatrices(inverted, object.calcOwnMatrix());
|
|
applyTransformToObject(object, finalTransform);
|
|
};
|
|
|
|
/**
|
|
* given an object and a transform, apply the transform to the object.
|
|
* this is equivalent to change the space where the object is drawn.
|
|
* Adding to an object a transform that scale by 2 is like scaling it by 2.
|
|
* This is used when removing an object from an active selection for example.
|
|
* @param {FabricObject} object the object you want to transform
|
|
* @param {Array} transform the destination transform
|
|
*/
|
|
const addTransformToObject = (object, transform) => applyTransformToObject(object, multiplyTransformMatrices(transform, object.calcOwnMatrix()));
|
|
|
|
/**
|
|
* discard an object transform state and apply the one from the matrix.
|
|
* @param {FabricObject} object the object you want to transform
|
|
* @param {Array} transform the destination transform
|
|
*/
|
|
const applyTransformToObject = (object, transform) => {
|
|
const _qrDecompose = qrDecompose(transform),
|
|
{
|
|
translateX,
|
|
translateY,
|
|
scaleX,
|
|
scaleY
|
|
} = _qrDecompose,
|
|
otherOptions = _objectWithoutProperties(_qrDecompose, _excluded$i),
|
|
center = new Point(translateX, translateY);
|
|
object.flipX = false;
|
|
object.flipY = false;
|
|
Object.assign(object, otherOptions);
|
|
object.set({
|
|
scaleX,
|
|
scaleY
|
|
});
|
|
object.setPositionByOrigin(center, CENTER, CENTER);
|
|
};
|
|
/**
|
|
* reset an object transform state to neutral. Top and left are not accounted for
|
|
* @param {FabricObject} target object to transform
|
|
*/
|
|
const resetObjectTransform = target => {
|
|
target.scaleX = 1;
|
|
target.scaleY = 1;
|
|
target.skewX = 0;
|
|
target.skewY = 0;
|
|
target.flipX = false;
|
|
target.flipY = false;
|
|
target.rotate(0);
|
|
};
|
|
|
|
/**
|
|
* Extract Object transform values
|
|
* @param {FabricObject} target object to read from
|
|
* @return {Object} Components of transform
|
|
*/
|
|
const saveObjectTransform = target => ({
|
|
scaleX: target.scaleX,
|
|
scaleY: target.scaleY,
|
|
skewX: target.skewX,
|
|
skewY: target.skewY,
|
|
angle: target.angle,
|
|
left: target.left,
|
|
flipX: target.flipX,
|
|
flipY: target.flipY,
|
|
top: target.top
|
|
});
|
|
|
|
/**
|
|
* given a width and height, return the size of the bounding box
|
|
* that can contains the box with width/height with applied transform.
|
|
* Use to calculate the boxes around objects for controls.
|
|
* @param {Number} width
|
|
* @param {Number} height
|
|
* @param {TMat2D} t
|
|
* @returns {Point} size
|
|
*/
|
|
const sizeAfterTransform = (width, height, t) => {
|
|
const dimX = width / 2,
|
|
dimY = height / 2,
|
|
points = [new Point(-dimX, -dimY), new Point(dimX, -dimY), new Point(-dimX, dimY), new Point(dimX, dimY)].map(p => p.transform(t)),
|
|
bbox = makeBoundingBoxFromPoints(points);
|
|
return new Point(bbox.width, bbox.height);
|
|
};
|
|
|
|
/**
|
|
* We are actually looking for the transformation from the destination plane to the source plane (change of basis matrix)\
|
|
* The object will exist on the destination plane and we want it to seem unchanged by it so we invert the destination matrix (`to`) and then apply the source matrix (`from`)
|
|
* @param [from]
|
|
* @param [to]
|
|
* @returns
|
|
*/
|
|
const calcPlaneChangeMatrix = function () {
|
|
let from = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : iMatrix;
|
|
let to = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : iMatrix;
|
|
return multiplyTransformMatrices(invertTransform(to), from);
|
|
};
|
|
|
|
/**
|
|
* Sends a point from the source coordinate plane to the destination coordinate plane.\
|
|
* From the canvas/viewer's perspective the point remains unchanged.
|
|
*
|
|
* @example <caption>Send point from canvas plane to group plane</caption>
|
|
* var obj = new Rect({ left: 20, top: 20, width: 60, height: 60, strokeWidth: 0 });
|
|
* var group = new Group([obj], { strokeWidth: 0 });
|
|
* var sentPoint1 = sendPointToPlane(new Point(50, 50), undefined, group.calcTransformMatrix());
|
|
* var sentPoint2 = sendPointToPlane(new Point(50, 50), iMatrix, group.calcTransformMatrix());
|
|
* console.log(sentPoint1, sentPoint2) // both points print (0,0) which is the center of group
|
|
*
|
|
* @param {Point} point
|
|
* @param {TMat2D} [from] plane matrix containing object. Passing `undefined` is equivalent to passing the identity matrix, which means `point` exists in the canvas coordinate plane.
|
|
* @param {TMat2D} [to] destination plane matrix to contain object. Passing `undefined` means `point` should be sent to the canvas coordinate plane.
|
|
* @returns {Point} transformed point
|
|
*/
|
|
const sendPointToPlane = function (point) {
|
|
let from = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : iMatrix;
|
|
let to = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : iMatrix;
|
|
return point.transform(calcPlaneChangeMatrix(from, to));
|
|
};
|
|
|
|
/**
|
|
* See {@link sendPointToPlane}
|
|
*/
|
|
const sendVectorToPlane = function (point) {
|
|
let from = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : iMatrix;
|
|
let to = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : iMatrix;
|
|
return point.transform(calcPlaneChangeMatrix(from, to), true);
|
|
};
|
|
|
|
/**
|
|
*
|
|
* A util that abstracts applying transform to objects.\
|
|
* Sends `object` to the destination coordinate plane by applying the relevant transformations.\
|
|
* Changes the space/plane where `object` is drawn.\
|
|
* From the canvas/viewer's perspective `object` remains unchanged.
|
|
*
|
|
* @example <caption>Move clip path from one object to another while preserving it's appearance as viewed by canvas/viewer</caption>
|
|
* let obj, obj2;
|
|
* let clipPath = new Circle({ radius: 50 });
|
|
* obj.clipPath = clipPath;
|
|
* // render
|
|
* sendObjectToPlane(clipPath, obj.calcTransformMatrix(), obj2.calcTransformMatrix());
|
|
* obj.clipPath = undefined;
|
|
* obj2.clipPath = clipPath;
|
|
* // render, clipPath now clips obj2 but seems unchanged from the eyes of the viewer
|
|
*
|
|
* @example <caption>Clip an object's clip path with an existing object</caption>
|
|
* let obj, existingObj;
|
|
* let clipPath = new Circle({ radius: 50 });
|
|
* obj.clipPath = clipPath;
|
|
* let transformTo = multiplyTransformMatrices(obj.calcTransformMatrix(), clipPath.calcTransformMatrix());
|
|
* sendObjectToPlane(existingObj, existingObj.group?.calcTransformMatrix(), transformTo);
|
|
* clipPath.clipPath = existingObj;
|
|
*
|
|
* @param {FabricObject} object
|
|
* @param {Matrix} [from] plane matrix containing object. Passing `undefined` is equivalent to passing the identity matrix, which means `object` is a direct child of canvas.
|
|
* @param {Matrix} [to] destination plane matrix to contain object. Passing `undefined` means `object` should be sent to the canvas coordinate plane.
|
|
* @returns {Matrix} the transform matrix that was applied to `object`
|
|
*/
|
|
const sendObjectToPlane = (object, from, to) => {
|
|
const t = calcPlaneChangeMatrix(from, to);
|
|
applyTransformToObject(object, multiplyTransformMatrices(t, object.calcOwnMatrix()));
|
|
return t;
|
|
};
|
|
|
|
const fireEvent = (eventName, options) => {
|
|
var _target$canvas;
|
|
const {
|
|
transform: {
|
|
target
|
|
}
|
|
} = options;
|
|
(_target$canvas = target.canvas) === null || _target$canvas === void 0 || _target$canvas.fire("object:".concat(eventName), _objectSpread2(_objectSpread2({}, options), {}, {
|
|
target
|
|
}));
|
|
target.fire(eventName, options);
|
|
};
|
|
|
|
const originOffset = {
|
|
left: -0.5,
|
|
top: -0.5,
|
|
center: 0,
|
|
bottom: 0.5,
|
|
right: 0.5
|
|
};
|
|
/**
|
|
* Resolves origin value relative to center
|
|
* @private
|
|
* @param {TOriginX | TOriginY} originValue originX / originY
|
|
* @returns number
|
|
*/
|
|
|
|
const resolveOrigin = originValue => typeof originValue === 'string' ? originOffset[originValue] : originValue - 0.5;
|
|
|
|
const NOT_ALLOWED_CURSOR = 'not-allowed';
|
|
|
|
/**
|
|
* @param {Boolean} alreadySelected true if target is already selected
|
|
* @param {String} corner a string representing the corner ml, mr, tl ...
|
|
* @param {Event} e Event object
|
|
* @param {FabricObject} [target] inserted back to help overriding. Unused
|
|
*/
|
|
const getActionFromCorner = (alreadySelected, corner, e, target) => {
|
|
if (!corner || !alreadySelected) {
|
|
return 'drag';
|
|
}
|
|
const control = target.controls[corner];
|
|
return control.getActionName(e, control, target);
|
|
};
|
|
|
|
/**
|
|
* Checks if transform is centered
|
|
* @param {Object} transform transform data
|
|
* @return {Boolean} true if transform is centered
|
|
*/
|
|
function isTransformCentered(transform) {
|
|
return resolveOrigin(transform.originX) === resolveOrigin(CENTER) && resolveOrigin(transform.originY) === resolveOrigin(CENTER);
|
|
}
|
|
function invertOrigin(origin) {
|
|
return -resolveOrigin(origin) + 0.5;
|
|
}
|
|
const isLocked = (target, lockingKey) => target[lockingKey];
|
|
const commonEventInfo = (eventData, transform, x, y) => {
|
|
return {
|
|
e: eventData,
|
|
transform,
|
|
pointer: new Point(x, y)
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Combine control position and object angle to find the control direction compared
|
|
* to the object center.
|
|
* @param {FabricObject} fabricObject the fabric object for which we are rendering controls
|
|
* @param {Control} control the control class
|
|
* @return {Number} 0 - 7 a quadrant number
|
|
*/
|
|
function findCornerQuadrant(fabricObject, control) {
|
|
// angle is relative to canvas plane
|
|
const angle = fabricObject.getTotalAngle(),
|
|
cornerAngle = angle + radiansToDegrees(Math.atan2(control.y, control.x)) + 360;
|
|
return Math.round(cornerAngle % 360 / 45);
|
|
}
|
|
|
|
/**
|
|
* @returns the normalized point (rotated relative to center) in local coordinates
|
|
*/
|
|
function normalizePoint(target, point, originX, originY) {
|
|
const center = target.getRelativeCenterPoint(),
|
|
p = typeof originX !== 'undefined' && typeof originY !== 'undefined' ? target.translateToGivenOrigin(center, CENTER, CENTER, originX, originY) : new Point(target.left, target.top),
|
|
p2 = target.angle ? point.rotate(-degreesToRadians(target.angle), center) : point;
|
|
return p2.subtract(p);
|
|
}
|
|
|
|
/**
|
|
* Transforms a point to the offset from the given origin
|
|
* @param {Object} transform
|
|
* @param {String} originX
|
|
* @param {String} originY
|
|
* @param {number} x
|
|
* @param {number} y
|
|
* @return {Fabric.Point} the normalized point
|
|
*/
|
|
function getLocalPoint(_ref, originX, originY, x, y) {
|
|
var _target$canvas;
|
|
let {
|
|
target,
|
|
corner
|
|
} = _ref;
|
|
const control = target.controls[corner],
|
|
zoom = ((_target$canvas = target.canvas) === null || _target$canvas === void 0 ? void 0 : _target$canvas.getZoom()) || 1,
|
|
padding = target.padding / zoom,
|
|
localPoint = normalizePoint(target, new Point(x, y), originX, originY);
|
|
if (localPoint.x >= padding) {
|
|
localPoint.x -= padding;
|
|
}
|
|
if (localPoint.x <= -padding) {
|
|
localPoint.x += padding;
|
|
}
|
|
if (localPoint.y >= padding) {
|
|
localPoint.y -= padding;
|
|
}
|
|
if (localPoint.y <= padding) {
|
|
localPoint.y += padding;
|
|
}
|
|
localPoint.x -= control.offsetX;
|
|
localPoint.y -= control.offsetY;
|
|
return localPoint;
|
|
}
|
|
|
|
/**
|
|
* Action handler
|
|
* @private
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if the translation occurred
|
|
*/
|
|
const dragHandler = (eventData, transform, x, y) => {
|
|
const {
|
|
target,
|
|
offsetX,
|
|
offsetY
|
|
} = transform,
|
|
newLeft = x - offsetX,
|
|
newTop = y - offsetY,
|
|
moveX = !isLocked(target, 'lockMovementX') && target.left !== newLeft,
|
|
moveY = !isLocked(target, 'lockMovementY') && target.top !== newTop;
|
|
moveX && target.set(LEFT, newLeft);
|
|
moveY && target.set(TOP, newTop);
|
|
if (moveX || moveY) {
|
|
fireEvent(MOVING, commonEventInfo(eventData, transform, x, y));
|
|
}
|
|
return moveX || moveY;
|
|
};
|
|
|
|
class FabricObjectSVGExportMixin {
|
|
/**
|
|
* When an object is being exported as SVG as a clippath, a reference inside the SVG is needed.
|
|
* This reference is a UID in the fabric namespace and is temporary stored here.
|
|
* @type {String}
|
|
*/
|
|
|
|
/**
|
|
* Returns styles-string for svg-export
|
|
* @param {Boolean} skipShadow a boolean to skip shadow filter output
|
|
* @return {String}
|
|
*/
|
|
getSvgStyles(skipShadow) {
|
|
const fillRule = this.fillRule ? this.fillRule : 'nonzero',
|
|
strokeWidth = this.strokeWidth ? this.strokeWidth : '0',
|
|
strokeDashArray = this.strokeDashArray ? this.strokeDashArray.join(' ') : NONE,
|
|
strokeDashOffset = this.strokeDashOffset ? this.strokeDashOffset : '0',
|
|
strokeLineCap = this.strokeLineCap ? this.strokeLineCap : 'butt',
|
|
strokeLineJoin = this.strokeLineJoin ? this.strokeLineJoin : 'miter',
|
|
strokeMiterLimit = this.strokeMiterLimit ? this.strokeMiterLimit : '4',
|
|
opacity = typeof this.opacity !== 'undefined' ? this.opacity : '1',
|
|
visibility = this.visible ? '' : ' visibility: hidden;',
|
|
filter = skipShadow ? '' : this.getSvgFilter(),
|
|
fill = colorPropToSVG(FILL, this.fill),
|
|
stroke = colorPropToSVG(STROKE, this.stroke);
|
|
return [stroke, 'stroke-width: ', strokeWidth, '; ', 'stroke-dasharray: ', strokeDashArray, '; ', 'stroke-linecap: ', strokeLineCap, '; ', 'stroke-dashoffset: ', strokeDashOffset, '; ', 'stroke-linejoin: ', strokeLineJoin, '; ', 'stroke-miterlimit: ', strokeMiterLimit, '; ', fill, 'fill-rule: ', fillRule, '; ', 'opacity: ', opacity, ';', filter, visibility].join('');
|
|
}
|
|
|
|
/**
|
|
* Returns filter for svg shadow
|
|
* @return {String}
|
|
*/
|
|
getSvgFilter() {
|
|
return this.shadow ? "filter: url(#SVGID_".concat(this.shadow.id, ");") : '';
|
|
}
|
|
|
|
/**
|
|
* Returns id attribute for svg output
|
|
* @return {String}
|
|
*/
|
|
getSvgCommons() {
|
|
return [this.id ? "id=\"".concat(this.id, "\" ") : '', this.clipPath ? "clip-path=\"url(#".concat(this.clipPath.clipPathId, ")\" ") : ''].join('');
|
|
}
|
|
|
|
/**
|
|
* Returns transform-string for svg-export
|
|
* @param {Boolean} use the full transform or the single object one.
|
|
* @return {String}
|
|
*/
|
|
getSvgTransform(full) {
|
|
let additionalTransform = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : '';
|
|
const transform = full ? this.calcTransformMatrix() : this.calcOwnMatrix(),
|
|
svgTransform = "transform=\"".concat(matrixToSVG(transform));
|
|
return "".concat(svgTransform).concat(additionalTransform, "\" ");
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* This function is implemented in each subclass
|
|
* This is just because typescript otherwise cryies all the time
|
|
* @return {Array} an array of strings with the specific svg representation
|
|
* of the instance
|
|
*/
|
|
_toSVG(_reviver) {
|
|
return [''];
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @param {TSVGReviver} [reviver] Method for further parsing of svg representation.
|
|
* @return {String} svg representation of an instance
|
|
*/
|
|
toSVG(reviver) {
|
|
return this._createBaseSVGMarkup(this._toSVG(reviver), {
|
|
reviver
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns svg clipPath representation of an instance
|
|
* @param {TSVGReviver} [reviver] Method for further parsing of svg representation.
|
|
* @return {String} svg representation of an instance
|
|
*/
|
|
toClipPathSVG(reviver) {
|
|
return '\t' + this._createBaseClipPathSVGMarkup(this._toSVG(reviver), {
|
|
reviver
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_createBaseClipPathSVGMarkup(objectMarkup) {
|
|
let {
|
|
reviver,
|
|
additionalTransform = ''
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const commonPieces = [this.getSvgTransform(true, additionalTransform), this.getSvgCommons()].join(''),
|
|
// insert commons in the markup, style and svgCommons
|
|
index = objectMarkup.indexOf('COMMON_PARTS');
|
|
objectMarkup[index] = commonPieces;
|
|
return reviver ? reviver(objectMarkup.join('')) : objectMarkup.join('');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_createBaseSVGMarkup(objectMarkup) {
|
|
let {
|
|
noStyle,
|
|
reviver,
|
|
withShadow,
|
|
additionalTransform
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const styleInfo = noStyle ? '' : "style=\"".concat(this.getSvgStyles(), "\" "),
|
|
shadowInfo = withShadow ? "style=\"".concat(this.getSvgFilter(), "\" ") : '',
|
|
clipPath = this.clipPath,
|
|
vectorEffect = this.strokeUniform ? 'vector-effect="non-scaling-stroke" ' : '',
|
|
absoluteClipPath = clipPath && clipPath.absolutePositioned,
|
|
stroke = this.stroke,
|
|
fill = this.fill,
|
|
shadow = this.shadow,
|
|
markup = [],
|
|
// insert commons in the markup, style and svgCommons
|
|
index = objectMarkup.indexOf('COMMON_PARTS');
|
|
let clipPathMarkup;
|
|
if (clipPath) {
|
|
clipPath.clipPathId = "CLIPPATH_".concat(uid());
|
|
clipPathMarkup = "<clipPath id=\"".concat(clipPath.clipPathId, "\" >\n").concat(clipPath.toClipPathSVG(reviver), "</clipPath>\n");
|
|
}
|
|
if (absoluteClipPath) {
|
|
markup.push('<g ', shadowInfo, this.getSvgCommons(), ' >\n');
|
|
}
|
|
markup.push('<g ', this.getSvgTransform(false), !absoluteClipPath ? shadowInfo + this.getSvgCommons() : '', ' >\n');
|
|
const commonPieces = [styleInfo, vectorEffect, noStyle ? '' : this.addPaintOrder(), ' ', additionalTransform ? "transform=\"".concat(additionalTransform, "\" ") : ''].join('');
|
|
objectMarkup[index] = commonPieces;
|
|
if (isFiller(fill)) {
|
|
markup.push(fill.toSVG(this));
|
|
}
|
|
if (isFiller(stroke)) {
|
|
markup.push(stroke.toSVG(this));
|
|
}
|
|
if (shadow) {
|
|
markup.push(shadow.toSVG(this));
|
|
}
|
|
if (clipPath) {
|
|
markup.push(clipPathMarkup);
|
|
}
|
|
markup.push(objectMarkup.join(''));
|
|
markup.push('</g>\n');
|
|
absoluteClipPath && markup.push('</g>\n');
|
|
return reviver ? reviver(markup.join('')) : markup.join('');
|
|
}
|
|
addPaintOrder() {
|
|
return this.paintFirst !== FILL ? " paint-order=\"".concat(this.paintFirst, "\" ") : '';
|
|
}
|
|
}
|
|
|
|
function getSvgRegex(arr) {
|
|
return new RegExp('^(' + arr.join('|') + ')\\b', 'i');
|
|
}
|
|
|
|
var _templateObject$1;
|
|
const reNum = String.raw(_templateObject$1 || (_templateObject$1 = _taggedTemplateLiteral(["(?:[-+]?(?:d*.d+|d+.?)(?:[eE][-+]?d+)?)"], ["(?:[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?)"])));
|
|
const svgNS = 'http://www.w3.org/2000/svg';
|
|
const reFontDeclaration = new RegExp('(normal|italic)?\\s*(normal|small-caps)?\\s*' + '(normal|bold|bolder|lighter|100|200|300|400|500|600|700|800|900)?\\s*(' + reNum + '(?:px|cm|mm|em|pt|pc|in)*)(?:\\/(normal|' + reNum + '))?\\s+(.*)');
|
|
const svgValidTagNames = ['path', 'circle', 'polygon', 'polyline', 'ellipse', 'rect', 'line', 'image', 'text'],
|
|
svgViewBoxElements = ['symbol', 'image', 'marker', 'pattern', 'view', 'svg'],
|
|
svgInvalidAncestors = ['pattern', 'defs', 'symbol', 'metadata', 'clipPath', 'mask', 'desc'],
|
|
svgValidParents = ['symbol', 'g', 'a', 'svg', 'clipPath', 'defs'],
|
|
attributesMap = {
|
|
cx: LEFT,
|
|
x: LEFT,
|
|
r: 'radius',
|
|
cy: TOP,
|
|
y: TOP,
|
|
display: 'visible',
|
|
visibility: 'visible',
|
|
transform: 'transformMatrix',
|
|
'fill-opacity': 'fillOpacity',
|
|
'fill-rule': 'fillRule',
|
|
'font-family': 'fontFamily',
|
|
'font-size': 'fontSize',
|
|
'font-style': 'fontStyle',
|
|
'font-weight': 'fontWeight',
|
|
'letter-spacing': 'charSpacing',
|
|
'paint-order': 'paintFirst',
|
|
'stroke-dasharray': 'strokeDashArray',
|
|
'stroke-dashoffset': 'strokeDashOffset',
|
|
'stroke-linecap': 'strokeLineCap',
|
|
'stroke-linejoin': 'strokeLineJoin',
|
|
'stroke-miterlimit': 'strokeMiterLimit',
|
|
'stroke-opacity': 'strokeOpacity',
|
|
'stroke-width': 'strokeWidth',
|
|
'text-decoration': 'textDecoration',
|
|
'text-anchor': 'textAnchor',
|
|
opacity: 'opacity',
|
|
'clip-path': 'clipPath',
|
|
'clip-rule': 'clipRule',
|
|
'vector-effect': 'strokeUniform',
|
|
'image-rendering': 'imageSmoothing'
|
|
},
|
|
fSize = 'font-size',
|
|
cPath = 'clip-path';
|
|
const svgValidTagNamesRegEx = getSvgRegex(svgValidTagNames);
|
|
const svgViewBoxElementsRegEx = getSvgRegex(svgViewBoxElements);
|
|
const svgValidParentsRegEx = getSvgRegex(svgValidParents);
|
|
|
|
// http://www.w3.org/TR/SVG/coords.html#ViewBoxAttribute
|
|
// matches, e.g.: +14.56e-12, etc.
|
|
const reViewBoxAttrValue = new RegExp('^' + '\\s*(' + reNum + '+)\\s*,?' + '\\s*(' + reNum + '+)\\s*,?' + '\\s*(' + reNum + '+)\\s*,?' + '\\s*(' + reNum + '+)\\s*' + '$');
|
|
|
|
const unitVectorX = new Point(1, 0);
|
|
const zero = new Point();
|
|
|
|
/**
|
|
* Rotates `vector` with `radians`
|
|
* @param {Point} vector The vector to rotate (x and y)
|
|
* @param {Number} radians The radians of the angle for the rotation
|
|
* @return {Point} The new rotated point
|
|
*/
|
|
const rotateVector = (vector, radians) => vector.rotate(radians);
|
|
|
|
/**
|
|
* Creates a vector from points represented as a point
|
|
*
|
|
* @param {Point} from
|
|
* @param {Point} to
|
|
* @returns {Point} vector
|
|
*/
|
|
const createVector = (from, to) => new Point(to).subtract(from);
|
|
|
|
/**
|
|
* return the magnitude of a vector
|
|
* @return {number}
|
|
*/
|
|
const magnitude = point => point.distanceFrom(zero);
|
|
|
|
/**
|
|
* Calculates the angle between 2 vectors
|
|
* @param {Point} a
|
|
* @param {Point} b
|
|
* @returns the angle in radians from `a` to `b`
|
|
*/
|
|
const calcAngleBetweenVectors = (a, b) => Math.atan2(crossProduct(a, b), dotProduct(a, b));
|
|
|
|
/**
|
|
* Calculates the angle between the x axis and the vector
|
|
* @param {Point} v
|
|
* @returns the angle in radians of `v`
|
|
*/
|
|
const calcVectorRotation = v => calcAngleBetweenVectors(unitVectorX, v);
|
|
|
|
/**
|
|
* @param {Point} v
|
|
* @returns {Point} vector representing the unit vector pointing to the direction of `v`
|
|
*/
|
|
const getUnitVector = v => v.eq(zero) ? v : v.scalarDivide(magnitude(v));
|
|
|
|
/**
|
|
* @param {Point} v
|
|
* @param {Boolean} [counterClockwise] the direction of the orthogonal vector, defaults to `true`
|
|
* @returns {Point} the unit orthogonal vector
|
|
*/
|
|
const getOrthonormalVector = function (v) {
|
|
let counterClockwise = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
|
|
return getUnitVector(new Point(-v.y, v.x).scalarMultiply(counterClockwise ? 1 : -1));
|
|
};
|
|
|
|
/**
|
|
* Cross product of two vectors in 2D
|
|
* @param {Point} a
|
|
* @param {Point} b
|
|
* @returns {number} the magnitude of Z vector
|
|
*/
|
|
const crossProduct = (a, b) => a.x * b.y - a.y * b.x;
|
|
|
|
/**
|
|
* Dot product of two vectors in 2D
|
|
* @param {Point} a
|
|
* @param {Point} b
|
|
* @returns {number}
|
|
*/
|
|
const dotProduct = (a, b) => a.x * b.x + a.y * b.y;
|
|
|
|
/**
|
|
* Checks if the vector is between two others. It is considered
|
|
* to be inside when the vector to be tested is between the
|
|
* initial vector and the final vector (included) in a counterclockwise direction.
|
|
* @param {Point} t vector to be tested
|
|
* @param {Point} a initial vector
|
|
* @param {Point} b final vector
|
|
* @returns {boolean} true if the vector is among the others
|
|
*/
|
|
const isBetweenVectors = (t, a, b) => {
|
|
if (t.eq(a) || t.eq(b)) return true;
|
|
const AxB = crossProduct(a, b),
|
|
AxT = crossProduct(a, t),
|
|
BxT = crossProduct(b, t);
|
|
return AxB >= 0 ? AxT >= 0 && BxT <= 0 : !(AxT <= 0 && BxT >= 0);
|
|
};
|
|
|
|
/**
|
|
* Regex matching shadow offsetX, offsetY and blur (ex: "2px 2px 10px rgba(0,0,0,0.2)", "rgb(0,255,0) 2px 2px")
|
|
* - (?:\s|^): This part captures either a whitespace character (\s) or the beginning of a line (^). It's non-capturing (due to (?:...)), meaning it doesn't create a capturing group.
|
|
* - (-?\d+(?:\.\d*)?(?:px)?(?:\s?|$))?: This captures the first component of the shadow, which is the horizontal offset. Breaking it down:
|
|
* - (-?\d+): Captures an optional minus sign followed by one or more digits (integer part of the number).
|
|
* - (?:\.\d*)?: Optionally captures a decimal point followed by zero or more digits (decimal part of the number).
|
|
* - (?:px)?: Optionally captures the "px" unit.
|
|
* - (?:\s?|$): Captures either an optional whitespace or the end of the line. This whole part is wrapped in a non-capturing group and marked as optional with ?.
|
|
* - (-?\d+(?:\.\d*)?(?:px)?(?:\s?|$))?: Similar to the previous step, this captures the vertical offset.
|
|
|
|
(\d+(?:\.\d*)?(?:px)?)?: This captures the blur radius. It's similar to the horizontal offset but without the optional minus sign.
|
|
|
|
(?:\s+(-?\d+(?:\.\d*)?(?:px)?(?:\s?|$))?){0,1}: This captures an optional part for the color. It allows for whitespace followed by a component with an optional minus sign, digits, decimal point, and "px" unit.
|
|
|
|
(?:$|\s): This captures either the end of the line or a whitespace character. It ensures that the match ends either at the end of the string or with a whitespace character.
|
|
*/
|
|
// eslint-disable-next-line max-len
|
|
|
|
const shadowOffsetRegex = '(-?\\d+(?:\\.\\d*)?(?:px)?(?:\\s?|$))?';
|
|
const reOffsetsAndBlur = new RegExp('(?:\\s|^)' + shadowOffsetRegex + shadowOffsetRegex + '(' + reNum + '?(?:px)?)?(?:\\s?|$)(?:$|\\s)');
|
|
const shadowDefaultValues = {
|
|
color: 'rgb(0,0,0)',
|
|
blur: 0,
|
|
offsetX: 0,
|
|
offsetY: 0,
|
|
affectStroke: false,
|
|
includeDefaultValues: true,
|
|
nonScaling: false
|
|
};
|
|
class Shadow {
|
|
/**
|
|
* @see {@link http://fabricjs.com/shadows|Shadow demo}
|
|
* @param {Object|String} [options] Options object with any of color, blur, offsetX, offsetY properties or string (e.g. "rgba(0,0,0,0.2) 2px 2px 10px")
|
|
*/
|
|
|
|
constructor(arg0) {
|
|
const options = typeof arg0 === 'string' ? Shadow.parseShadow(arg0) : arg0;
|
|
Object.assign(this, Shadow.ownDefaults, options);
|
|
this.id = uid();
|
|
}
|
|
|
|
/**
|
|
* @param {String} value Shadow value to parse
|
|
* @return {Object} Shadow object with color, offsetX, offsetY and blur
|
|
*/
|
|
static parseShadow(value) {
|
|
const shadowStr = value.trim(),
|
|
[, offsetX = 0, offsetY = 0, blur = 0] = (reOffsetsAndBlur.exec(shadowStr) || []).map(value => parseFloat(value) || 0),
|
|
color = (shadowStr.replace(reOffsetsAndBlur, '') || 'rgb(0,0,0)').trim();
|
|
return {
|
|
color,
|
|
offsetX,
|
|
offsetY,
|
|
blur
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns a string representation of an instance
|
|
* @see http://www.w3.org/TR/css-text-decor-3/#text-shadow
|
|
* @return {String} Returns CSS3 text-shadow declaration
|
|
*/
|
|
toString() {
|
|
return [this.offsetX, this.offsetY, this.blur, this.color].join('px ');
|
|
}
|
|
|
|
/**
|
|
* Returns SVG representation of a shadow
|
|
* @param {FabricObject} object
|
|
* @return {String} SVG representation of a shadow
|
|
*/
|
|
toSVG(object) {
|
|
const offset = rotateVector(new Point(this.offsetX, this.offsetY), degreesToRadians(-object.angle)),
|
|
BLUR_BOX = 20,
|
|
color = new Color(this.color);
|
|
let fBoxX = 40,
|
|
fBoxY = 40;
|
|
if (object.width && object.height) {
|
|
//http://www.w3.org/TR/SVG/filters.html#FilterEffectsRegion
|
|
// we add some extra space to filter box to contain the blur ( 20 )
|
|
fBoxX = toFixed((Math.abs(offset.x) + this.blur) / object.width, config.NUM_FRACTION_DIGITS) * 100 + BLUR_BOX;
|
|
fBoxY = toFixed((Math.abs(offset.y) + this.blur) / object.height, config.NUM_FRACTION_DIGITS) * 100 + BLUR_BOX;
|
|
}
|
|
if (object.flipX) {
|
|
offset.x *= -1;
|
|
}
|
|
if (object.flipY) {
|
|
offset.y *= -1;
|
|
}
|
|
return "<filter id=\"SVGID_".concat(this.id, "\" y=\"-").concat(fBoxY, "%\" height=\"").concat(100 + 2 * fBoxY, "%\" x=\"-").concat(fBoxX, "%\" width=\"").concat(100 + 2 * fBoxX, "%\" >\n\t<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"").concat(toFixed(this.blur ? this.blur / 2 : 0, config.NUM_FRACTION_DIGITS), "\"></feGaussianBlur>\n\t<feOffset dx=\"").concat(toFixed(offset.x, config.NUM_FRACTION_DIGITS), "\" dy=\"").concat(toFixed(offset.y, config.NUM_FRACTION_DIGITS), "\" result=\"oBlur\" ></feOffset>\n\t<feFlood flood-color=\"").concat(color.toRgb(), "\" flood-opacity=\"").concat(color.getAlpha(), "\"/>\n\t<feComposite in2=\"oBlur\" operator=\"in\" />\n\t<feMerge>\n\t\t<feMergeNode></feMergeNode>\n\t\t<feMergeNode in=\"SourceGraphic\"></feMergeNode>\n\t</feMerge>\n</filter>\n");
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of a shadow
|
|
* @return {Object} Object representation of a shadow instance
|
|
*/
|
|
toObject() {
|
|
const data = {
|
|
color: this.color,
|
|
blur: this.blur,
|
|
offsetX: this.offsetX,
|
|
offsetY: this.offsetY,
|
|
affectStroke: this.affectStroke,
|
|
nonScaling: this.nonScaling,
|
|
type: this.constructor.type
|
|
};
|
|
const defaults = Shadow.ownDefaults;
|
|
return !this.includeDefaultValues ? pickBy(data, (value, key) => value !== defaults[key]) : data;
|
|
}
|
|
static async fromObject(options) {
|
|
return new this(options);
|
|
}
|
|
}
|
|
/**
|
|
* Shadow color
|
|
* @type String
|
|
* @default
|
|
*/
|
|
/**
|
|
* Shadow blur
|
|
* @type Number
|
|
*/
|
|
/**
|
|
* Shadow horizontal offset
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Shadow vertical offset
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Whether the shadow should affect stroke operations
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
/**
|
|
* Indicates whether toObject should include default values
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
/**
|
|
* When `false`, the shadow will scale with the object.
|
|
* When `true`, the shadow's offsetX, offsetY, and blur will not be affected by the object's scale.
|
|
* default to false
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
_defineProperty(Shadow, "ownDefaults", shadowDefaultValues);
|
|
_defineProperty(Shadow, "type", 'shadow');
|
|
classRegistry.setClass(Shadow, 'shadow');
|
|
|
|
const capValue = (min, value, max) => Math.max(min, Math.min(value, max));
|
|
|
|
const stateProperties = [TOP, LEFT, SCALE_X, SCALE_Y, 'flipX', 'flipY', 'originX', 'originY', 'angle', 'opacity', 'globalCompositeOperation', 'shadow', 'visible', SKEW_X, SKEW_Y];
|
|
const cacheProperties = [FILL, STROKE, 'strokeWidth', 'strokeDashArray', 'width', 'height', 'paintFirst', 'strokeUniform', 'strokeLineCap', 'strokeDashOffset', 'strokeLineJoin', 'strokeMiterLimit', 'backgroundColor', 'clipPath'];
|
|
const fabricObjectDefaultValues = {
|
|
// see composeMatrix() to see order of transforms. First defaults listed based on this
|
|
top: 0,
|
|
left: 0,
|
|
width: 0,
|
|
height: 0,
|
|
angle: 0,
|
|
flipX: false,
|
|
flipY: false,
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
minScaleLimit: 0,
|
|
skewX: 0,
|
|
skewY: 0,
|
|
originX: LEFT,
|
|
originY: TOP,
|
|
strokeWidth: 1,
|
|
strokeUniform: false,
|
|
padding: 0,
|
|
opacity: 1,
|
|
paintFirst: FILL,
|
|
fill: 'rgb(0,0,0)',
|
|
fillRule: 'nonzero',
|
|
stroke: null,
|
|
strokeDashArray: null,
|
|
strokeDashOffset: 0,
|
|
strokeLineCap: 'butt',
|
|
strokeLineJoin: 'miter',
|
|
strokeMiterLimit: 4,
|
|
globalCompositeOperation: 'source-over',
|
|
backgroundColor: '',
|
|
shadow: null,
|
|
visible: true,
|
|
includeDefaultValues: true,
|
|
excludeFromExport: false,
|
|
objectCaching: true,
|
|
clipPath: undefined,
|
|
inverted: false,
|
|
absolutePositioned: false,
|
|
centeredRotation: true,
|
|
centeredScaling: false,
|
|
dirty: true
|
|
};
|
|
const interactiveObjectDefaultValues = {
|
|
noScaleCache: true,
|
|
lockMovementX: false,
|
|
lockMovementY: false,
|
|
lockRotation: false,
|
|
lockScalingX: false,
|
|
lockScalingY: false,
|
|
lockSkewingX: false,
|
|
lockSkewingY: false,
|
|
lockScalingFlip: false,
|
|
cornerSize: 13,
|
|
touchCornerSize: 24,
|
|
transparentCorners: true,
|
|
cornerColor: 'rgb(178,204,255)',
|
|
cornerStrokeColor: '',
|
|
cornerStyle: 'rect',
|
|
cornerDashArray: null,
|
|
hasControls: true,
|
|
borderColor: 'rgb(178,204,255)',
|
|
borderDashArray: null,
|
|
borderOpacityWhenMoving: 0.4,
|
|
borderScaleFactor: 1,
|
|
hasBorders: true,
|
|
selectionBackgroundColor: '',
|
|
selectable: true,
|
|
evented: true,
|
|
perPixelTargetFind: false,
|
|
activeOn: 'down',
|
|
hoverCursor: null,
|
|
moveCursor: null
|
|
};
|
|
|
|
/**
|
|
* Easing functions
|
|
* @see {@link http://gizma.com/easing/ Easing Equations by Robert Penner}
|
|
*/
|
|
|
|
const normalize = (a, c, p, s) => {
|
|
if (a < Math.abs(c)) {
|
|
a = c;
|
|
s = p / 4;
|
|
} else {
|
|
//handle the 0/0 case:
|
|
if (c === 0 && a === 0) {
|
|
s = p / twoMathPi * Math.asin(1);
|
|
} else {
|
|
s = p / twoMathPi * Math.asin(c / a);
|
|
}
|
|
}
|
|
return {
|
|
a,
|
|
c,
|
|
p,
|
|
s
|
|
};
|
|
};
|
|
const elastic = (a, s, p, t, d) => a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * d - s) * twoMathPi / p);
|
|
|
|
/**
|
|
* Default sinusoidal easing
|
|
*/
|
|
const defaultEasing = (t, b, c, d) => -c * Math.cos(t / d * halfPI) + c + b;
|
|
|
|
/**
|
|
* Cubic easing in
|
|
*/
|
|
const easeInCubic = (t, b, c, d) => c * (t / d) ** 3 + b;
|
|
|
|
/**
|
|
* Cubic easing out
|
|
*/
|
|
const easeOutCubic = (t, b, c, d) => c * ((t / d - 1) ** 3 + 1) + b;
|
|
|
|
/**
|
|
* Cubic easing in and out
|
|
*/
|
|
const easeInOutCubic = (t, b, c, d) => {
|
|
t /= d / 2;
|
|
if (t < 1) {
|
|
return c / 2 * t ** 3 + b;
|
|
}
|
|
return c / 2 * ((t - 2) ** 3 + 2) + b;
|
|
};
|
|
|
|
/**
|
|
* Quartic easing in
|
|
*/
|
|
const easeInQuart = (t, b, c, d) => c * (t /= d) * t ** 3 + b;
|
|
|
|
/**
|
|
* Quartic easing out
|
|
*/
|
|
const easeOutQuart = (t, b, c, d) => -c * ((t = t / d - 1) * t ** 3 - 1) + b;
|
|
|
|
/**
|
|
* Quartic easing in and out
|
|
*/
|
|
const easeInOutQuart = (t, b, c, d) => {
|
|
t /= d / 2;
|
|
if (t < 1) {
|
|
return c / 2 * t ** 4 + b;
|
|
}
|
|
return -c / 2 * ((t -= 2) * t ** 3 - 2) + b;
|
|
};
|
|
|
|
/**
|
|
* Quintic easing in
|
|
*/
|
|
const easeInQuint = (t, b, c, d) => c * (t / d) ** 5 + b;
|
|
|
|
/**
|
|
* Quintic easing out
|
|
*/
|
|
const easeOutQuint = (t, b, c, d) => c * ((t / d - 1) ** 5 + 1) + b;
|
|
|
|
/**
|
|
* Quintic easing in and out
|
|
*/
|
|
const easeInOutQuint = (t, b, c, d) => {
|
|
t /= d / 2;
|
|
if (t < 1) {
|
|
return c / 2 * t ** 5 + b;
|
|
}
|
|
return c / 2 * ((t - 2) ** 5 + 2) + b;
|
|
};
|
|
|
|
/**
|
|
* Sinusoidal easing in
|
|
*/
|
|
const easeInSine = (t, b, c, d) => -c * Math.cos(t / d * halfPI) + c + b;
|
|
|
|
/**
|
|
* Sinusoidal easing out
|
|
*/
|
|
const easeOutSine = (t, b, c, d) => c * Math.sin(t / d * halfPI) + b;
|
|
|
|
/**
|
|
* Sinusoidal easing in and out
|
|
*/
|
|
const easeInOutSine = (t, b, c, d) => -c / 2 * (Math.cos(Math.PI * t / d) - 1) + b;
|
|
|
|
/**
|
|
* Exponential easing in
|
|
*/
|
|
const easeInExpo = (t, b, c, d) => t === 0 ? b : c * 2 ** (10 * (t / d - 1)) + b;
|
|
|
|
/**
|
|
* Exponential easing out
|
|
*/
|
|
const easeOutExpo = (t, b, c, d) => t === d ? b + c : c * -(2 ** (-10 * t / d) + 1) + b;
|
|
|
|
/**
|
|
* Exponential easing in and out
|
|
*/
|
|
const easeInOutExpo = (t, b, c, d) => {
|
|
if (t === 0) {
|
|
return b;
|
|
}
|
|
if (t === d) {
|
|
return b + c;
|
|
}
|
|
t /= d / 2;
|
|
if (t < 1) {
|
|
return c / 2 * 2 ** (10 * (t - 1)) + b;
|
|
}
|
|
return c / 2 * -(2 ** (-10 * --t) + 2) + b;
|
|
};
|
|
|
|
/**
|
|
* Circular easing in
|
|
*/
|
|
const easeInCirc = (t, b, c, d) => -c * (Math.sqrt(1 - (t /= d) * t) - 1) + b;
|
|
|
|
/**
|
|
* Circular easing out
|
|
*/
|
|
const easeOutCirc = (t, b, c, d) => c * Math.sqrt(1 - (t = t / d - 1) * t) + b;
|
|
|
|
/**
|
|
* Circular easing in and out
|
|
*/
|
|
const easeInOutCirc = (t, b, c, d) => {
|
|
t /= d / 2;
|
|
if (t < 1) {
|
|
return -c / 2 * (Math.sqrt(1 - t ** 2) - 1) + b;
|
|
}
|
|
return c / 2 * (Math.sqrt(1 - (t -= 2) * t) + 1) + b;
|
|
};
|
|
|
|
/**
|
|
* Elastic easing in
|
|
*/
|
|
const easeInElastic = (t, b, c, d) => {
|
|
const s = 1.70158,
|
|
a = c;
|
|
let p = 0;
|
|
if (t === 0) {
|
|
return b;
|
|
}
|
|
t /= d;
|
|
if (t === 1) {
|
|
return b + c;
|
|
}
|
|
if (!p) {
|
|
p = d * 0.3;
|
|
}
|
|
const {
|
|
a: normA,
|
|
s: normS,
|
|
p: normP
|
|
} = normalize(a, c, p, s);
|
|
return -elastic(normA, normS, normP, t, d) + b;
|
|
};
|
|
|
|
/**
|
|
* Elastic easing out
|
|
*/
|
|
const easeOutElastic = (t, b, c, d) => {
|
|
const s = 1.70158,
|
|
a = c;
|
|
let p = 0;
|
|
if (t === 0) {
|
|
return b;
|
|
}
|
|
t /= d;
|
|
if (t === 1) {
|
|
return b + c;
|
|
}
|
|
if (!p) {
|
|
p = d * 0.3;
|
|
}
|
|
const {
|
|
a: normA,
|
|
s: normS,
|
|
p: normP,
|
|
c: normC
|
|
} = normalize(a, c, p, s);
|
|
return normA * 2 ** (-10 * t) * Math.sin((t * d - normS) * twoMathPi / normP) + normC + b;
|
|
};
|
|
|
|
/**
|
|
* Elastic easing in and out
|
|
*/
|
|
const easeInOutElastic = (t, b, c, d) => {
|
|
const s = 1.70158,
|
|
a = c;
|
|
let p = 0;
|
|
if (t === 0) {
|
|
return b;
|
|
}
|
|
t /= d / 2;
|
|
if (t === 2) {
|
|
return b + c;
|
|
}
|
|
if (!p) {
|
|
p = d * (0.3 * 1.5);
|
|
}
|
|
const {
|
|
a: normA,
|
|
s: normS,
|
|
p: normP,
|
|
c: normC
|
|
} = normalize(a, c, p, s);
|
|
if (t < 1) {
|
|
return -0.5 * elastic(normA, normS, normP, t, d) + b;
|
|
}
|
|
return normA * Math.pow(2, -10 * (t -= 1)) * Math.sin((t * d - normS) * twoMathPi / normP) * 0.5 + normC + b;
|
|
};
|
|
|
|
/**
|
|
* Backwards easing in
|
|
*/
|
|
const easeInBack = function (t, b, c, d) {
|
|
let s = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 1.70158;
|
|
return c * (t /= d) * t * ((s + 1) * t - s) + b;
|
|
};
|
|
|
|
/**
|
|
* Backwards easing out
|
|
*/
|
|
const easeOutBack = function (t, b, c, d) {
|
|
let s = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 1.70158;
|
|
return c * ((t = t / d - 1) * t * ((s + 1) * t + s) + 1) + b;
|
|
};
|
|
|
|
/**
|
|
* Backwards easing in and out
|
|
*/
|
|
const easeInOutBack = function (t, b, c, d) {
|
|
let s = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 1.70158;
|
|
t /= d / 2;
|
|
if (t < 1) {
|
|
return c / 2 * (t * t * (((s *= 1.525) + 1) * t - s)) + b;
|
|
}
|
|
return c / 2 * ((t -= 2) * t * (((s *= 1.525) + 1) * t + s) + 2) + b;
|
|
};
|
|
|
|
/**
|
|
* Bouncing easing out
|
|
*/
|
|
const easeOutBounce = (t, b, c, d) => {
|
|
if ((t /= d) < 1 / 2.75) {
|
|
return c * (7.5625 * t * t) + b;
|
|
} else if (t < 2 / 2.75) {
|
|
return c * (7.5625 * (t -= 1.5 / 2.75) * t + 0.75) + b;
|
|
} else if (t < 2.5 / 2.75) {
|
|
return c * (7.5625 * (t -= 2.25 / 2.75) * t + 0.9375) + b;
|
|
} else {
|
|
return c * (7.5625 * (t -= 2.625 / 2.75) * t + 0.984375) + b;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Bouncing easing in
|
|
*/
|
|
const easeInBounce = (t, b, c, d) => c - easeOutBounce(d - t, 0, c, d) + b;
|
|
|
|
/**
|
|
* Bouncing easing in and out
|
|
*/
|
|
const easeInOutBounce = (t, b, c, d) => t < d / 2 ? easeInBounce(t * 2, 0, c, d) * 0.5 + b : easeOutBounce(t * 2 - d, 0, c, d) * 0.5 + c * 0.5 + b;
|
|
|
|
/**
|
|
* Quadratic easing in
|
|
*/
|
|
const easeInQuad = (t, b, c, d) => c * (t /= d) * t + b;
|
|
|
|
/**
|
|
* Quadratic easing out
|
|
*/
|
|
const easeOutQuad = (t, b, c, d) => -c * (t /= d) * (t - 2) + b;
|
|
|
|
/**
|
|
* Quadratic easing in and out
|
|
*/
|
|
const easeInOutQuad = (t, b, c, d) => {
|
|
t /= d / 2;
|
|
if (t < 1) {
|
|
return c / 2 * t ** 2 + b;
|
|
}
|
|
return -c / 2 * (--t * (t - 2) - 1) + b;
|
|
};
|
|
|
|
var easing = /*#__PURE__*/Object.freeze({
|
|
__proto__: null,
|
|
defaultEasing: defaultEasing,
|
|
easeInBack: easeInBack,
|
|
easeInBounce: easeInBounce,
|
|
easeInCirc: easeInCirc,
|
|
easeInCubic: easeInCubic,
|
|
easeInElastic: easeInElastic,
|
|
easeInExpo: easeInExpo,
|
|
easeInOutBack: easeInOutBack,
|
|
easeInOutBounce: easeInOutBounce,
|
|
easeInOutCirc: easeInOutCirc,
|
|
easeInOutCubic: easeInOutCubic,
|
|
easeInOutElastic: easeInOutElastic,
|
|
easeInOutExpo: easeInOutExpo,
|
|
easeInOutQuad: easeInOutQuad,
|
|
easeInOutQuart: easeInOutQuart,
|
|
easeInOutQuint: easeInOutQuint,
|
|
easeInOutSine: easeInOutSine,
|
|
easeInQuad: easeInQuad,
|
|
easeInQuart: easeInQuart,
|
|
easeInQuint: easeInQuint,
|
|
easeInSine: easeInSine,
|
|
easeOutBack: easeOutBack,
|
|
easeOutBounce: easeOutBounce,
|
|
easeOutCirc: easeOutCirc,
|
|
easeOutCubic: easeOutCubic,
|
|
easeOutElastic: easeOutElastic,
|
|
easeOutExpo: easeOutExpo,
|
|
easeOutQuad: easeOutQuad,
|
|
easeOutQuart: easeOutQuart,
|
|
easeOutQuint: easeOutQuint,
|
|
easeOutSine: easeOutSine
|
|
});
|
|
|
|
const defaultAbort = () => false;
|
|
class AnimationBase {
|
|
/**
|
|
* Current value
|
|
*/
|
|
|
|
/**
|
|
* Animation start time ms
|
|
*/
|
|
|
|
constructor(_ref) {
|
|
let {
|
|
startValue,
|
|
byValue,
|
|
duration = 500,
|
|
delay = 0,
|
|
easing = defaultEasing,
|
|
onStart = noop,
|
|
onChange = noop,
|
|
onComplete = noop,
|
|
abort = defaultAbort,
|
|
target
|
|
} = _ref;
|
|
/**
|
|
* Used to register the animation to a target object
|
|
* so that it can be cancelled within the object context
|
|
*/
|
|
_defineProperty(this, "_state", 'pending');
|
|
/**
|
|
* Time %, or the ratio of `timeElapsed / duration`
|
|
* @see tick
|
|
*/
|
|
_defineProperty(this, "durationProgress", 0);
|
|
/**
|
|
* Value %, or the ratio of `(currentValue - startValue) / (endValue - startValue)`
|
|
*/
|
|
_defineProperty(this, "valueProgress", 0);
|
|
this.tick = this.tick.bind(this);
|
|
this.duration = duration;
|
|
this.delay = delay;
|
|
this.easing = easing;
|
|
this._onStart = onStart;
|
|
this._onChange = onChange;
|
|
this._onComplete = onComplete;
|
|
this._abort = abort;
|
|
this.target = target;
|
|
this.startValue = startValue;
|
|
this.byValue = byValue;
|
|
this.value = this.startValue;
|
|
this.endValue = Object.freeze(this.calculate(this.duration).value);
|
|
}
|
|
get state() {
|
|
return this._state;
|
|
}
|
|
isDone() {
|
|
return this._state === 'aborted' || this._state === 'completed';
|
|
}
|
|
|
|
/**
|
|
* Calculate the current value based on the easing parameters
|
|
* @param timeElapsed in ms
|
|
* @protected
|
|
*/
|
|
|
|
start() {
|
|
const firstTick = timestamp => {
|
|
if (this._state !== 'pending') return;
|
|
this.startTime = timestamp || +new Date();
|
|
this._state = 'running';
|
|
this._onStart();
|
|
this.tick(this.startTime);
|
|
};
|
|
this.register();
|
|
|
|
// setTimeout(cb, 0) will run cb on the next frame, causing a delay
|
|
// we don't want that
|
|
if (this.delay > 0) {
|
|
setTimeout(() => requestAnimFrame(firstTick), this.delay);
|
|
} else {
|
|
requestAnimFrame(firstTick);
|
|
}
|
|
}
|
|
tick(t) {
|
|
const durationMs = (t || +new Date()) - this.startTime;
|
|
const boundDurationMs = Math.min(durationMs, this.duration);
|
|
this.durationProgress = boundDurationMs / this.duration;
|
|
const {
|
|
value,
|
|
valueProgress
|
|
} = this.calculate(boundDurationMs);
|
|
this.value = Object.freeze(value);
|
|
this.valueProgress = valueProgress;
|
|
if (this._state === 'aborted') {
|
|
return;
|
|
} else if (this._abort(this.value, this.valueProgress, this.durationProgress)) {
|
|
this._state = 'aborted';
|
|
this.unregister();
|
|
} else if (durationMs >= this.duration) {
|
|
this.durationProgress = this.valueProgress = 1;
|
|
this._onChange(this.endValue, this.valueProgress, this.durationProgress);
|
|
this._state = 'completed';
|
|
this._onComplete(this.endValue, this.valueProgress, this.durationProgress);
|
|
this.unregister();
|
|
} else {
|
|
this._onChange(this.value, this.valueProgress, this.durationProgress);
|
|
requestAnimFrame(this.tick);
|
|
}
|
|
}
|
|
register() {
|
|
runningAnimations.push(this);
|
|
}
|
|
unregister() {
|
|
runningAnimations.remove(this);
|
|
}
|
|
abort() {
|
|
this._state = 'aborted';
|
|
this.unregister();
|
|
}
|
|
}
|
|
|
|
const _excluded$h = ["startValue", "endValue"];
|
|
class ValueAnimation extends AnimationBase {
|
|
constructor(_ref) {
|
|
let {
|
|
startValue = 0,
|
|
endValue = 100
|
|
} = _ref,
|
|
otherOptions = _objectWithoutProperties(_ref, _excluded$h);
|
|
super(_objectSpread2(_objectSpread2({}, otherOptions), {}, {
|
|
startValue,
|
|
byValue: endValue - startValue
|
|
}));
|
|
}
|
|
calculate(timeElapsed) {
|
|
const value = this.easing(timeElapsed, this.startValue, this.byValue, this.duration);
|
|
return {
|
|
value,
|
|
valueProgress: Math.abs((value - this.startValue) / this.byValue)
|
|
};
|
|
}
|
|
}
|
|
|
|
const _excluded$g = ["startValue", "endValue"];
|
|
class ArrayAnimation extends AnimationBase {
|
|
constructor(_ref) {
|
|
let {
|
|
startValue = [0],
|
|
endValue = [100]
|
|
} = _ref,
|
|
options = _objectWithoutProperties(_ref, _excluded$g);
|
|
super(_objectSpread2(_objectSpread2({}, options), {}, {
|
|
startValue,
|
|
byValue: endValue.map((value, i) => value - startValue[i])
|
|
}));
|
|
}
|
|
calculate(timeElapsed) {
|
|
const values = this.startValue.map((value, i) => this.easing(timeElapsed, value, this.byValue[i], this.duration, i));
|
|
return {
|
|
value: values,
|
|
valueProgress: Math.abs((values[0] - this.startValue[0]) / this.byValue[0])
|
|
};
|
|
}
|
|
}
|
|
|
|
const _excluded$f = ["startValue", "endValue", "easing", "onChange", "onComplete", "abort"];
|
|
const defaultColorEasing = (timeElapsed, startValue, byValue, duration) => {
|
|
const durationProgress = 1 - Math.cos(timeElapsed / duration * halfPI);
|
|
return startValue + byValue * durationProgress;
|
|
};
|
|
const wrapColorCallback = callback => callback && ((rgba, valueProgress, durationProgress) => callback(new Color(rgba).toRgba(), valueProgress, durationProgress));
|
|
class ColorAnimation extends AnimationBase {
|
|
constructor(_ref) {
|
|
let {
|
|
startValue,
|
|
endValue,
|
|
easing = defaultColorEasing,
|
|
onChange,
|
|
onComplete,
|
|
abort
|
|
} = _ref,
|
|
options = _objectWithoutProperties(_ref, _excluded$f);
|
|
const startColor = new Color(startValue).getSource();
|
|
const endColor = new Color(endValue).getSource();
|
|
super(_objectSpread2(_objectSpread2({}, options), {}, {
|
|
startValue: startColor,
|
|
byValue: endColor.map((value, i) => value - startColor[i]),
|
|
easing,
|
|
onChange: wrapColorCallback(onChange),
|
|
onComplete: wrapColorCallback(onComplete),
|
|
abort: wrapColorCallback(abort)
|
|
}));
|
|
}
|
|
calculate(timeElapsed) {
|
|
const [r, g, b, a] = this.startValue.map((value, i) => this.easing(timeElapsed, value, this.byValue[i], this.duration, i));
|
|
const value = [...[r, g, b].map(Math.round), capValue(0, a, 1)];
|
|
return {
|
|
value,
|
|
valueProgress:
|
|
// to correctly calculate the change ratio we must find a changed value
|
|
value.map((p, i) => this.byValue[i] !== 0 ? Math.abs((p - this.startValue[i]) / this.byValue[i]) : 0).find(p => p !== 0) || 0
|
|
};
|
|
}
|
|
}
|
|
|
|
const isArrayAnimation = options => {
|
|
return Array.isArray(options.startValue) || Array.isArray(options.endValue);
|
|
};
|
|
|
|
/**
|
|
* Changes value(s) from startValue to endValue within a certain period of time,
|
|
* invoking callbacks as the value(s) change.
|
|
*
|
|
* @example
|
|
* animate({
|
|
* startValue: 1,
|
|
* endValue: 0,
|
|
* onChange: (v) => {
|
|
* obj.set('opacity', v);
|
|
* // since we are running in a requested frame we should call `renderAll` and not `requestRenderAll`
|
|
* canvas.renderAll();
|
|
* }
|
|
* });
|
|
*
|
|
* @example Using lists:
|
|
* animate({
|
|
* startValue: [1, 2, 3],
|
|
* endValue: [2, 4, 6],
|
|
* onChange: ([x, y, zoom]) => {
|
|
* canvas.zoomToPoint(new Point(x, y), zoom);
|
|
* canvas.renderAll();
|
|
* }
|
|
* });
|
|
*
|
|
*/
|
|
|
|
function animate(options) {
|
|
const animation = isArrayAnimation(options) ? new ArrayAnimation(options) : new ValueAnimation(options);
|
|
animation.start();
|
|
return animation;
|
|
}
|
|
function animateColor(options) {
|
|
const animation = new ColorAnimation(options);
|
|
animation.start();
|
|
return animation;
|
|
}
|
|
|
|
/* Adaptation of work of Kevin Lindsey (kevin@kevlindev.com) */
|
|
|
|
class Intersection {
|
|
constructor(status) {
|
|
this.status = status;
|
|
this.points = [];
|
|
}
|
|
|
|
/**
|
|
* Used to verify if a point is alredy in the collection
|
|
* @param {Point} point
|
|
* @returns {boolean}
|
|
*/
|
|
includes(point) {
|
|
return this.points.some(p => p.eq(point));
|
|
}
|
|
|
|
/**
|
|
* Appends points of intersection
|
|
* @param {...Point[]} points
|
|
* @return {Intersection} thisArg
|
|
* @chainable
|
|
*/
|
|
append() {
|
|
for (var _len = arguments.length, points = new Array(_len), _key = 0; _key < _len; _key++) {
|
|
points[_key] = arguments[_key];
|
|
}
|
|
this.points = this.points.concat(points.filter(point => {
|
|
return !this.includes(point);
|
|
}));
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* check if point T is on the segment or line defined between A and B
|
|
*
|
|
* @param {Point} T the point we are checking for
|
|
* @param {Point} A one extremity of the segment
|
|
* @param {Point} B the other extremity of the segment
|
|
* @param [infinite] if true checks if `T` is on the line defined by `A` and `B`
|
|
* @returns true if `T` is contained
|
|
*/
|
|
static isPointContained(T, A, B) {
|
|
let infinite = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
|
|
if (A.eq(B)) {
|
|
// Edge case: the segment is a point, we check for coincidence,
|
|
// infinite param has no meaning because there are infinite lines to consider
|
|
return T.eq(A);
|
|
} else if (A.x === B.x) {
|
|
// Edge case: horizontal line.
|
|
// we first check if T.x has the same value, and then if T.y is contained between A.y and B.y
|
|
return T.x === A.x && (infinite || T.y >= Math.min(A.y, B.y) && T.y <= Math.max(A.y, B.y));
|
|
} else if (A.y === B.y) {
|
|
// Edge case: vertical line.
|
|
// we first check if T.y has the same value, and then if T.x is contained between A.x and B.x
|
|
return T.y === A.y && (infinite || T.x >= Math.min(A.x, B.x) && T.x <= Math.max(A.x, B.x));
|
|
} else {
|
|
// Generic case: sloped line.
|
|
// we check that AT has the same slope as AB
|
|
// for the segment case we need both the vectors to have the same direction and for AT to be lte AB in size
|
|
// for the infinite case we check the absolute value of the slope, since direction is meaningless
|
|
const AB = createVector(A, B);
|
|
const AT = createVector(A, T);
|
|
const s = AT.divide(AB);
|
|
return infinite ? Math.abs(s.x) === Math.abs(s.y) : s.x === s.y && s.x >= 0 && s.x <= 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Use the ray casting algorithm to determine if {@link point} is in the polygon defined by {@link points}
|
|
* @see https://en.wikipedia.org/wiki/Point_in_polygon
|
|
* @param point
|
|
* @param points polygon points
|
|
* @returns
|
|
*/
|
|
static isPointInPolygon(point, points) {
|
|
const other = new Point(point).setX(Math.min(point.x - 1, ...points.map(p => p.x)));
|
|
let hits = 0;
|
|
for (let index = 0; index < points.length; index++) {
|
|
const inter = this.intersectSegmentSegment(
|
|
// polygon side
|
|
points[index], points[(index + 1) % points.length],
|
|
// ray
|
|
point, other);
|
|
if (inter.includes(point)) {
|
|
// point is on the polygon side
|
|
return true;
|
|
}
|
|
hits += Number(inter.status === 'Intersection');
|
|
}
|
|
return hits % 2 === 1;
|
|
}
|
|
|
|
/**
|
|
* Checks if a line intersects another
|
|
* @see {@link https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection line intersection}
|
|
* @see {@link https://en.wikipedia.org/wiki/Cramer%27s_rule Cramer's rule}
|
|
* @static
|
|
* @param {Point} a1
|
|
* @param {Point} a2
|
|
* @param {Point} b1
|
|
* @param {Point} b2
|
|
* @param {boolean} [aInfinite=true] check segment intersection by passing `false`
|
|
* @param {boolean} [bInfinite=true] check segment intersection by passing `false`
|
|
* @return {Intersection}
|
|
*/
|
|
static intersectLineLine(a1, a2, b1, b2) {
|
|
let aInfinite = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : true;
|
|
let bInfinite = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : true;
|
|
const a2xa1x = a2.x - a1.x,
|
|
a2ya1y = a2.y - a1.y,
|
|
b2xb1x = b2.x - b1.x,
|
|
b2yb1y = b2.y - b1.y,
|
|
a1xb1x = a1.x - b1.x,
|
|
a1yb1y = a1.y - b1.y,
|
|
uaT = b2xb1x * a1yb1y - b2yb1y * a1xb1x,
|
|
ubT = a2xa1x * a1yb1y - a2ya1y * a1xb1x,
|
|
uB = b2yb1y * a2xa1x - b2xb1x * a2ya1y;
|
|
if (uB !== 0) {
|
|
const ua = uaT / uB,
|
|
ub = ubT / uB;
|
|
if ((aInfinite || 0 <= ua && ua <= 1) && (bInfinite || 0 <= ub && ub <= 1)) {
|
|
return new Intersection('Intersection').append(new Point(a1.x + ua * a2xa1x, a1.y + ua * a2ya1y));
|
|
} else {
|
|
return new Intersection();
|
|
}
|
|
} else {
|
|
if (uaT === 0 || ubT === 0) {
|
|
const segmentsCoincide = aInfinite || bInfinite || Intersection.isPointContained(a1, b1, b2) || Intersection.isPointContained(a2, b1, b2) || Intersection.isPointContained(b1, a1, a2) || Intersection.isPointContained(b2, a1, a2);
|
|
return new Intersection(segmentsCoincide ? 'Coincident' : undefined);
|
|
} else {
|
|
return new Intersection('Parallel');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a segment intersects a line
|
|
* @see {@link intersectLineLine} for line intersection
|
|
* @static
|
|
* @param {Point} s1 boundary point of segment
|
|
* @param {Point} s2 other boundary point of segment
|
|
* @param {Point} l1 point on line
|
|
* @param {Point} l2 other point on line
|
|
* @return {Intersection}
|
|
*/
|
|
static intersectSegmentLine(s1, s2, l1, l2) {
|
|
return Intersection.intersectLineLine(s1, s2, l1, l2, false, true);
|
|
}
|
|
|
|
/**
|
|
* Checks if a segment intersects another
|
|
* @see {@link intersectLineLine} for line intersection
|
|
* @static
|
|
* @param {Point} a1 boundary point of segment
|
|
* @param {Point} a2 other boundary point of segment
|
|
* @param {Point} b1 boundary point of segment
|
|
* @param {Point} b2 other boundary point of segment
|
|
* @return {Intersection}
|
|
*/
|
|
static intersectSegmentSegment(a1, a2, b1, b2) {
|
|
return Intersection.intersectLineLine(a1, a2, b1, b2, false, false);
|
|
}
|
|
|
|
/**
|
|
* Checks if line intersects polygon
|
|
*
|
|
* @todo account for stroke
|
|
*
|
|
* @static
|
|
* @see {@link intersectSegmentPolygon} for segment intersection
|
|
* @param {Point} a1 point on line
|
|
* @param {Point} a2 other point on line
|
|
* @param {Point[]} points polygon points
|
|
* @param {boolean} [infinite=true] check segment intersection by passing `false`
|
|
* @return {Intersection}
|
|
*/
|
|
static intersectLinePolygon(a1, a2, points) {
|
|
let infinite = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true;
|
|
const result = new Intersection();
|
|
const length = points.length;
|
|
for (let i = 0, b1, b2, inter; i < length; i++) {
|
|
b1 = points[i];
|
|
b2 = points[(i + 1) % length];
|
|
inter = Intersection.intersectLineLine(a1, a2, b1, b2, infinite, false);
|
|
if (inter.status === 'Coincident') {
|
|
return inter;
|
|
}
|
|
result.append(...inter.points);
|
|
}
|
|
if (result.points.length > 0) {
|
|
result.status = 'Intersection';
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Checks if segment intersects polygon
|
|
* @static
|
|
* @see {@link intersectLinePolygon} for line intersection
|
|
* @param {Point} a1 boundary point of segment
|
|
* @param {Point} a2 other boundary point of segment
|
|
* @param {Point[]} points polygon points
|
|
* @return {Intersection}
|
|
*/
|
|
static intersectSegmentPolygon(a1, a2, points) {
|
|
return Intersection.intersectLinePolygon(a1, a2, points, false);
|
|
}
|
|
|
|
/**
|
|
* Checks if polygon intersects another polygon
|
|
*
|
|
* @todo account for stroke
|
|
*
|
|
* @static
|
|
* @param {Point[]} points1
|
|
* @param {Point[]} points2
|
|
* @return {Intersection}
|
|
*/
|
|
static intersectPolygonPolygon(points1, points2) {
|
|
const result = new Intersection(),
|
|
length = points1.length;
|
|
const coincidences = [];
|
|
for (let i = 0; i < length; i++) {
|
|
const a1 = points1[i],
|
|
a2 = points1[(i + 1) % length],
|
|
inter = Intersection.intersectSegmentPolygon(a1, a2, points2);
|
|
if (inter.status === 'Coincident') {
|
|
coincidences.push(inter);
|
|
result.append(a1, a2);
|
|
} else {
|
|
result.append(...inter.points);
|
|
}
|
|
}
|
|
if (coincidences.length > 0 && coincidences.length === points1.length) {
|
|
return new Intersection('Coincident');
|
|
} else if (result.points.length > 0) {
|
|
result.status = 'Intersection';
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Checks if polygon intersects rectangle
|
|
* @static
|
|
* @see {@link intersectPolygonPolygon} for polygon intersection
|
|
* @param {Point[]} points polygon points
|
|
* @param {Point} r1 top left point of rect
|
|
* @param {Point} r2 bottom right point of rect
|
|
* @return {Intersection}
|
|
*/
|
|
static intersectPolygonRectangle(points, r1, r2) {
|
|
const min = r1.min(r2),
|
|
max = r1.max(r2),
|
|
topRight = new Point(max.x, min.y),
|
|
bottomLeft = new Point(min.x, max.y);
|
|
return Intersection.intersectPolygonPolygon(points, [min, topRight, max, bottomLeft]);
|
|
}
|
|
}
|
|
|
|
class ObjectGeometry extends CommonMethods {
|
|
// #region Geometry
|
|
|
|
/**
|
|
* Describe object's corner position in scene coordinates.
|
|
* The coordinates are derived from the following:
|
|
* left, top, width, height, scaleX, scaleY, skewX, skewY, angle, strokeWidth.
|
|
* The coordinates do not depend on viewport changes.
|
|
* The coordinates get updated with {@link setCoords}.
|
|
* You can calculate them without updating with {@link calcACoords()}
|
|
*/
|
|
|
|
/**
|
|
* storage cache for object transform matrix
|
|
*/
|
|
|
|
/**
|
|
* storage cache for object full transform matrix
|
|
*/
|
|
|
|
/**
|
|
* A Reference of the Canvas where the object is actually added
|
|
* @type StaticCanvas | Canvas;
|
|
* @default undefined
|
|
* @private
|
|
*/
|
|
|
|
/**
|
|
* @returns {number} x position according to object's {@link originX} property in canvas coordinate plane
|
|
*/
|
|
getX() {
|
|
return this.getXY().x;
|
|
}
|
|
|
|
/**
|
|
* @param {number} value x position according to object's {@link originX} property in canvas coordinate plane
|
|
*/
|
|
setX(value) {
|
|
this.setXY(this.getXY().setX(value));
|
|
}
|
|
|
|
/**
|
|
* @returns {number} y position according to object's {@link originY} property in canvas coordinate plane
|
|
*/
|
|
getY() {
|
|
return this.getXY().y;
|
|
}
|
|
|
|
/**
|
|
* @param {number} value y position according to object's {@link originY} property in canvas coordinate plane
|
|
*/
|
|
setY(value) {
|
|
this.setXY(this.getXY().setY(value));
|
|
}
|
|
|
|
/**
|
|
* @returns {number} x position according to object's {@link originX} property in parent's coordinate plane\
|
|
* if parent is canvas then this property is identical to {@link getX}
|
|
*/
|
|
getRelativeX() {
|
|
return this.left;
|
|
}
|
|
|
|
/**
|
|
* @param {number} value x position according to object's {@link originX} property in parent's coordinate plane\
|
|
* if parent is canvas then this method is identical to {@link setX}
|
|
*/
|
|
setRelativeX(value) {
|
|
this.left = value;
|
|
}
|
|
|
|
/**
|
|
* @returns {number} y position according to object's {@link originY} property in parent's coordinate plane\
|
|
* if parent is canvas then this property is identical to {@link getY}
|
|
*/
|
|
getRelativeY() {
|
|
return this.top;
|
|
}
|
|
|
|
/**
|
|
* @param {number} value y position according to object's {@link originY} property in parent's coordinate plane\
|
|
* if parent is canvas then this property is identical to {@link setY}
|
|
*/
|
|
setRelativeY(value) {
|
|
this.top = value;
|
|
}
|
|
|
|
/**
|
|
* @returns {Point} x position according to object's {@link originX} {@link originY} properties in canvas coordinate plane
|
|
*/
|
|
getXY() {
|
|
const relativePosition = this.getRelativeXY();
|
|
return this.group ? transformPoint(relativePosition, this.group.calcTransformMatrix()) : relativePosition;
|
|
}
|
|
|
|
/**
|
|
* Set an object position to a particular point, the point is intended in absolute ( canvas ) coordinate.
|
|
* You can specify {@link originX} and {@link originY} values,
|
|
* that otherwise are the object's current values.
|
|
* @example <caption>Set object's bottom left corner to point (5,5) on canvas</caption>
|
|
* object.setXY(new Point(5, 5), 'left', 'bottom').
|
|
* @param {Point} point position in scene coordinate plane
|
|
* @param {TOriginX} [originX] Horizontal origin: 'left', 'center' or 'right'
|
|
* @param {TOriginY} [originY] Vertical origin: 'top', 'center' or 'bottom'
|
|
*/
|
|
setXY(point, originX, originY) {
|
|
if (this.group) {
|
|
point = transformPoint(point, invertTransform(this.group.calcTransformMatrix()));
|
|
}
|
|
this.setRelativeXY(point, originX, originY);
|
|
}
|
|
|
|
/**
|
|
* @returns {Point} x,y position according to object's {@link originX} {@link originY} properties in parent's coordinate plane
|
|
*/
|
|
getRelativeXY() {
|
|
return new Point(this.left, this.top);
|
|
}
|
|
|
|
/**
|
|
* As {@link setXY}, but in current parent's coordinate plane (the current group if any or the canvas)
|
|
* @param {Point} point position according to object's {@link originX} {@link originY} properties in parent's coordinate plane
|
|
* @param {TOriginX} [originX] Horizontal origin: 'left', 'center' or 'right'
|
|
* @param {TOriginY} [originY] Vertical origin: 'top', 'center' or 'bottom'
|
|
*/
|
|
setRelativeXY(point) {
|
|
let originX = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : this.originX;
|
|
let originY = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : this.originY;
|
|
this.setPositionByOrigin(point, originX, originY);
|
|
}
|
|
|
|
/**
|
|
* @deprecated intermidiate method to be removed, do not use
|
|
*/
|
|
isStrokeAccountedForInDimensions() {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return {Point[]} [tl, tr, br, bl] in the scene plane
|
|
*/
|
|
getCoords() {
|
|
const {
|
|
tl,
|
|
tr,
|
|
br,
|
|
bl
|
|
} = this.aCoords || (this.aCoords = this.calcACoords());
|
|
const coords = [tl, tr, br, bl];
|
|
if (this.group) {
|
|
const t = this.group.calcTransformMatrix();
|
|
return coords.map(p => transformPoint(p, t));
|
|
}
|
|
return coords;
|
|
}
|
|
|
|
/**
|
|
* Checks if object intersects with the scene rect formed by {@link tl} and {@link br}
|
|
*/
|
|
intersectsWithRect(tl, br) {
|
|
const intersection = Intersection.intersectPolygonRectangle(this.getCoords(), tl, br);
|
|
return intersection.status === 'Intersection';
|
|
}
|
|
|
|
/**
|
|
* Checks if object intersects with another object
|
|
* @param {Object} other Object to test
|
|
* @return {Boolean} true if object intersects with another object
|
|
*/
|
|
intersectsWithObject(other) {
|
|
const intersection = Intersection.intersectPolygonPolygon(this.getCoords(), other.getCoords());
|
|
return intersection.status === 'Intersection' || intersection.status === 'Coincident' || other.isContainedWithinObject(this) || this.isContainedWithinObject(other);
|
|
}
|
|
|
|
/**
|
|
* Checks if object is fully contained within area of another object
|
|
* @param {Object} other Object to test
|
|
* @return {Boolean} true if object is fully contained within area of another object
|
|
*/
|
|
isContainedWithinObject(other) {
|
|
const points = this.getCoords();
|
|
return points.every(point => other.containsPoint(point));
|
|
}
|
|
|
|
/**
|
|
* Checks if object is fully contained within the scene rect formed by {@link tl} and {@link br}
|
|
*/
|
|
isContainedWithinRect(tl, br) {
|
|
const {
|
|
left,
|
|
top,
|
|
width,
|
|
height
|
|
} = this.getBoundingRect();
|
|
return left >= tl.x && left + width <= br.x && top >= tl.y && top + height <= br.y;
|
|
}
|
|
isOverlapping(other) {
|
|
return this.intersectsWithObject(other) || this.isContainedWithinObject(other) || other.isContainedWithinObject(this);
|
|
}
|
|
|
|
/**
|
|
* Checks if point is inside the object
|
|
* @param {Point} point Point to check against
|
|
* @return {Boolean} true if point is inside the object
|
|
*/
|
|
containsPoint(point) {
|
|
return Intersection.isPointInPolygon(point, this.getCoords());
|
|
}
|
|
|
|
/**
|
|
* Checks if object is contained within the canvas with current viewportTransform
|
|
* the check is done stopping at first point that appears on screen
|
|
* @return {Boolean} true if object is fully or partially contained within canvas
|
|
*/
|
|
isOnScreen() {
|
|
if (!this.canvas) {
|
|
return false;
|
|
}
|
|
const {
|
|
tl,
|
|
br
|
|
} = this.canvas.vptCoords;
|
|
const points = this.getCoords();
|
|
// if some point is on screen, the object is on screen.
|
|
if (points.some(point => point.x <= br.x && point.x >= tl.x && point.y <= br.y && point.y >= tl.y)) {
|
|
return true;
|
|
}
|
|
// no points on screen, check intersection with absolute coordinates
|
|
if (this.intersectsWithRect(tl, br)) {
|
|
return true;
|
|
}
|
|
// check if the object is so big that it contains the entire viewport
|
|
return this.containsPoint(tl.midPointFrom(br));
|
|
}
|
|
|
|
/**
|
|
* Checks if object is partially contained within the canvas with current viewportTransform
|
|
* @return {Boolean} true if object is partially contained within canvas
|
|
*/
|
|
isPartiallyOnScreen() {
|
|
if (!this.canvas) {
|
|
return false;
|
|
}
|
|
const {
|
|
tl,
|
|
br
|
|
} = this.canvas.vptCoords;
|
|
if (this.intersectsWithRect(tl, br)) {
|
|
return true;
|
|
}
|
|
const allPointsAreOutside = this.getCoords().every(point => (point.x >= br.x || point.x <= tl.x) && (point.y >= br.y || point.y <= tl.y));
|
|
// check if the object is so big that it contains the entire viewport
|
|
return allPointsAreOutside && this.containsPoint(tl.midPointFrom(br));
|
|
}
|
|
|
|
/**
|
|
* Returns coordinates of object's bounding rectangle (left, top, width, height)
|
|
* the box is intended as aligned to axis of canvas.
|
|
* @return {Object} Object with left, top, width, height properties
|
|
*/
|
|
getBoundingRect() {
|
|
return makeBoundingBoxFromPoints(this.getCoords());
|
|
}
|
|
|
|
/**
|
|
* Returns width of an object's bounding box counting transformations
|
|
* @todo shouldn't this account for group transform and return the actual size in canvas coordinate plane?
|
|
* @return {Number} width value
|
|
*/
|
|
getScaledWidth() {
|
|
return this._getTransformedDimensions().x;
|
|
}
|
|
|
|
/**
|
|
* Returns height of an object bounding box counting transformations
|
|
* @todo shouldn't this account for group transform and return the actual size in canvas coordinate plane?
|
|
* @return {Number} height value
|
|
*/
|
|
getScaledHeight() {
|
|
return this._getTransformedDimensions().y;
|
|
}
|
|
|
|
/**
|
|
* Scales an object (equally by x and y)
|
|
* @param {Number} value Scale factor
|
|
* @return {void}
|
|
*/
|
|
scale(value) {
|
|
this._set(SCALE_X, value);
|
|
this._set(SCALE_Y, value);
|
|
this.setCoords();
|
|
}
|
|
|
|
/**
|
|
* Scales an object to a given width, with respect to bounding box (scaling by x/y equally)
|
|
* @param {Number} value New width value
|
|
* @return {void}
|
|
*/
|
|
scaleToWidth(value) {
|
|
// adjust to bounding rect factor so that rotated shapes would fit as well
|
|
const boundingRectFactor = this.getBoundingRect().width / this.getScaledWidth();
|
|
return this.scale(value / this.width / boundingRectFactor);
|
|
}
|
|
|
|
/**
|
|
* Scales an object to a given height, with respect to bounding box (scaling by x/y equally)
|
|
* @param {Number} value New height value
|
|
* @return {void}
|
|
*/
|
|
scaleToHeight(value) {
|
|
// adjust to bounding rect factor so that rotated shapes would fit as well
|
|
const boundingRectFactor = this.getBoundingRect().height / this.getScaledHeight();
|
|
return this.scale(value / this.height / boundingRectFactor);
|
|
}
|
|
getCanvasRetinaScaling() {
|
|
var _this$canvas;
|
|
return ((_this$canvas = this.canvas) === null || _this$canvas === void 0 ? void 0 : _this$canvas.getRetinaScaling()) || 1;
|
|
}
|
|
|
|
/**
|
|
* Returns the object angle relative to canvas counting also the group property
|
|
* @returns {TDegree}
|
|
*/
|
|
getTotalAngle() {
|
|
return this.group ? radiansToDegrees(calcPlaneRotation(this.calcTransformMatrix())) : this.angle;
|
|
}
|
|
|
|
/**
|
|
* Retrieves viewportTransform from Object's canvas if available
|
|
* @return {TMat2D}
|
|
*/
|
|
getViewportTransform() {
|
|
var _this$canvas2;
|
|
return ((_this$canvas2 = this.canvas) === null || _this$canvas2 === void 0 ? void 0 : _this$canvas2.viewportTransform) || iMatrix.concat();
|
|
}
|
|
|
|
/**
|
|
* Calculates the coordinates of the 4 corner of the bbox, in absolute coordinates.
|
|
* those never change with zoom or viewport changes.
|
|
* @return {TCornerPoint}
|
|
*/
|
|
calcACoords() {
|
|
const rotateMatrix = createRotateMatrix({
|
|
angle: this.angle
|
|
}),
|
|
{
|
|
x,
|
|
y
|
|
} = this.getRelativeCenterPoint(),
|
|
tMatrix = createTranslateMatrix(x, y),
|
|
finalMatrix = multiplyTransformMatrices(tMatrix, rotateMatrix),
|
|
dim = this._getTransformedDimensions(),
|
|
w = dim.x / 2,
|
|
h = dim.y / 2;
|
|
return {
|
|
// corners
|
|
tl: transformPoint({
|
|
x: -w,
|
|
y: -h
|
|
}, finalMatrix),
|
|
tr: transformPoint({
|
|
x: w,
|
|
y: -h
|
|
}, finalMatrix),
|
|
bl: transformPoint({
|
|
x: -w,
|
|
y: h
|
|
}, finalMatrix),
|
|
br: transformPoint({
|
|
x: w,
|
|
y: h
|
|
}, finalMatrix)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sets corner and controls position coordinates based on current angle, width and height, left and top.
|
|
* aCoords are used to quickly find an object on the canvas.
|
|
* See {@link https://github.com/fabricjs/fabric.js/wiki/When-to-call-setCoords} and {@link http://fabricjs.com/fabric-gotchas}
|
|
*/
|
|
setCoords() {
|
|
this.aCoords = this.calcACoords();
|
|
}
|
|
transformMatrixKey() {
|
|
let skipGroup = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
|
|
let prefix = [];
|
|
if (!skipGroup && this.group) {
|
|
prefix = this.group.transformMatrixKey(skipGroup);
|
|
}
|
|
prefix.push(this.top, this.left, this.width, this.height, this.scaleX, this.scaleY, this.angle, this.strokeWidth, this.skewX, this.skewY, +this.flipX, +this.flipY, resolveOrigin(this.originX), resolveOrigin(this.originY));
|
|
return prefix;
|
|
}
|
|
|
|
/**
|
|
* calculate transform matrix that represents the current transformations from the
|
|
* object's properties.
|
|
* @param {Boolean} [skipGroup] return transform matrix for object not counting parent transformations
|
|
* There are some situation in which this is useful to avoid the fake rotation.
|
|
* @return {TMat2D} transform matrix for the object
|
|
*/
|
|
calcTransformMatrix() {
|
|
let skipGroup = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
|
|
let matrix = this.calcOwnMatrix();
|
|
if (skipGroup || !this.group) {
|
|
return matrix;
|
|
}
|
|
const key = this.transformMatrixKey(skipGroup),
|
|
cache = this.matrixCache;
|
|
if (cache && cache.key.every((x, i) => x === key[i])) {
|
|
return cache.value;
|
|
}
|
|
if (this.group) {
|
|
matrix = multiplyTransformMatrices(this.group.calcTransformMatrix(false), matrix);
|
|
}
|
|
this.matrixCache = {
|
|
key,
|
|
value: matrix
|
|
};
|
|
return matrix;
|
|
}
|
|
|
|
/**
|
|
* calculate transform matrix that represents the current transformations from the
|
|
* object's properties, this matrix does not include the group transformation
|
|
* @return {TMat2D} transform matrix for the object
|
|
*/
|
|
calcOwnMatrix() {
|
|
const key = this.transformMatrixKey(true),
|
|
cache = this.ownMatrixCache;
|
|
if (cache && cache.key === key) {
|
|
return cache.value;
|
|
}
|
|
const center = this.getRelativeCenterPoint(),
|
|
options = {
|
|
angle: this.angle,
|
|
translateX: center.x,
|
|
translateY: center.y,
|
|
scaleX: this.scaleX,
|
|
scaleY: this.scaleY,
|
|
skewX: this.skewX,
|
|
skewY: this.skewY,
|
|
flipX: this.flipX,
|
|
flipY: this.flipY
|
|
},
|
|
value = composeMatrix(options);
|
|
this.ownMatrixCache = {
|
|
key,
|
|
value
|
|
};
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Calculate object dimensions from its properties
|
|
* @private
|
|
* @returns {Point} dimensions
|
|
*/
|
|
_getNonTransformedDimensions() {
|
|
return new Point(this.width, this.height).scalarAdd(this.strokeWidth);
|
|
}
|
|
|
|
/**
|
|
* Calculate object dimensions for controls box, including padding and canvas zoom.
|
|
* and active selection
|
|
* @private
|
|
* @param {object} [options] transform options
|
|
* @returns {Point} dimensions
|
|
*/
|
|
_calculateCurrentDimensions(options) {
|
|
return this._getTransformedDimensions(options).transform(this.getViewportTransform(), true).scalarAdd(2 * this.padding);
|
|
}
|
|
|
|
// #region Origin
|
|
|
|
/**
|
|
* @deprecated please use 'center' as value in new projects
|
|
* */
|
|
|
|
/**
|
|
* @deprecated please use 'center' as value in new projects
|
|
* */
|
|
|
|
/**
|
|
* Object containing this object.
|
|
* can influence its size and position
|
|
*/
|
|
|
|
/**
|
|
* Calculate object bounding box dimensions from its properties scale, skew.
|
|
* This bounding box is aligned with object angle and not with canvas axis or screen.
|
|
* @param {Object} [options]
|
|
* @param {Number} [options.scaleX]
|
|
* @param {Number} [options.scaleY]
|
|
* @param {Number} [options.skewX]
|
|
* @param {Number} [options.skewY]
|
|
* @private
|
|
* @returns {Point} dimensions
|
|
*/
|
|
_getTransformedDimensions() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
const dimOptions = _objectSpread2({
|
|
// if scaleX or scaleY are negative numbers,
|
|
// this will return dimensions that are negative.
|
|
// and this will break assumptions around the codebase
|
|
scaleX: this.scaleX,
|
|
scaleY: this.scaleY,
|
|
skewX: this.skewX,
|
|
skewY: this.skewY,
|
|
width: this.width,
|
|
height: this.height,
|
|
strokeWidth: this.strokeWidth
|
|
}, options);
|
|
// stroke is applied before/after transformations are applied according to `strokeUniform`
|
|
const strokeWidth = dimOptions.strokeWidth;
|
|
let preScalingStrokeValue = strokeWidth,
|
|
postScalingStrokeValue = 0;
|
|
if (this.strokeUniform) {
|
|
preScalingStrokeValue = 0;
|
|
postScalingStrokeValue = strokeWidth;
|
|
}
|
|
const dimX = dimOptions.width + preScalingStrokeValue,
|
|
dimY = dimOptions.height + preScalingStrokeValue,
|
|
noSkew = dimOptions.skewX === 0 && dimOptions.skewY === 0;
|
|
let finalDimensions;
|
|
if (noSkew) {
|
|
finalDimensions = new Point(dimX * dimOptions.scaleX, dimY * dimOptions.scaleY);
|
|
} else {
|
|
finalDimensions = sizeAfterTransform(dimX, dimY, calcDimensionsMatrix(dimOptions));
|
|
}
|
|
return finalDimensions.scalarAdd(postScalingStrokeValue);
|
|
}
|
|
|
|
/**
|
|
* Translates the coordinates from a set of origin to another (based on the object's dimensions)
|
|
* @param {Point} point The point which corresponds to the originX and originY params
|
|
* @param {TOriginX} fromOriginX Horizontal origin: 'left', 'center' or 'right'
|
|
* @param {TOriginY} fromOriginY Vertical origin: 'top', 'center' or 'bottom'
|
|
* @param {TOriginX} toOriginX Horizontal origin: 'left', 'center' or 'right'
|
|
* @param {TOriginY} toOriginY Vertical origin: 'top', 'center' or 'bottom'
|
|
* @return {Point}
|
|
*/
|
|
translateToGivenOrigin(point, fromOriginX, fromOriginY, toOriginX, toOriginY) {
|
|
let x = point.x,
|
|
y = point.y;
|
|
const offsetX = resolveOrigin(toOriginX) - resolveOrigin(fromOriginX),
|
|
offsetY = resolveOrigin(toOriginY) - resolveOrigin(fromOriginY);
|
|
if (offsetX || offsetY) {
|
|
const dim = this._getTransformedDimensions();
|
|
x += offsetX * dim.x;
|
|
y += offsetY * dim.y;
|
|
}
|
|
return new Point(x, y);
|
|
}
|
|
|
|
/**
|
|
* Translates the coordinates from origin to center coordinates (based on the object's dimensions)
|
|
* @param {Point} point The point which corresponds to the originX and originY params
|
|
* @param {TOriginX} originX Horizontal origin: 'left', 'center' or 'right'
|
|
* @param {TOriginY} originY Vertical origin: 'top', 'center' or 'bottom'
|
|
* @return {Point}
|
|
*/
|
|
translateToCenterPoint(point, originX, originY) {
|
|
if (originX === CENTER && originY === CENTER) {
|
|
return point;
|
|
}
|
|
const p = this.translateToGivenOrigin(point, originX, originY, CENTER, CENTER);
|
|
if (this.angle) {
|
|
return p.rotate(degreesToRadians(this.angle), point);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
/**
|
|
* Translates the coordinates from center to origin coordinates (based on the object's dimensions)
|
|
* @param {Point} center The point which corresponds to center of the object
|
|
* @param {OriginX} originX Horizontal origin: 'left', 'center' or 'right'
|
|
* @param {OriginY} originY Vertical origin: 'top', 'center' or 'bottom'
|
|
* @return {Point}
|
|
*/
|
|
translateToOriginPoint(center, originX, originY) {
|
|
const p = this.translateToGivenOrigin(center, CENTER, CENTER, originX, originY);
|
|
if (this.angle) {
|
|
return p.rotate(degreesToRadians(this.angle), center);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
/**
|
|
* Returns the center coordinates of the object relative to canvas
|
|
* @return {Point}
|
|
*/
|
|
getCenterPoint() {
|
|
const relCenter = this.getRelativeCenterPoint();
|
|
return this.group ? transformPoint(relCenter, this.group.calcTransformMatrix()) : relCenter;
|
|
}
|
|
|
|
/**
|
|
* Returns the center coordinates of the object relative to it's parent
|
|
* @return {Point}
|
|
*/
|
|
getRelativeCenterPoint() {
|
|
return this.translateToCenterPoint(new Point(this.left, this.top), this.originX, this.originY);
|
|
}
|
|
|
|
/**
|
|
* Returns the position of the object as if it has a different origin.
|
|
* Take an object that has left, top set to 100, 100 with origin 'left', 'top'.
|
|
* Return the values of left top ( wrapped in a point ) that you would need to keep
|
|
* the same position if origin where different.
|
|
* Alternatively you can use this to also find which point in the parent plane is a specific origin
|
|
* ( where is the bottom right corner of my object? )
|
|
* @param {TOriginX} originX Horizontal origin: 'left', 'center' or 'right'
|
|
* @param {TOriginY} originY Vertical origin: 'top', 'center' or 'bottom'
|
|
* @return {Point}
|
|
*/
|
|
getPointByOrigin(originX, originY) {
|
|
return this.translateToOriginPoint(this.getRelativeCenterPoint(), originX, originY);
|
|
}
|
|
|
|
/**
|
|
* Sets the position of the object taking into consideration the object's origin
|
|
* @param {Point} pos The new position of the object
|
|
* @param {TOriginX} originX Horizontal origin: 'left', 'center' or 'right'
|
|
* @param {TOriginY} originY Vertical origin: 'top', 'center' or 'bottom'
|
|
* @return {void}
|
|
*/
|
|
setPositionByOrigin(pos, originX, originY) {
|
|
const center = this.translateToCenterPoint(pos, originX, originY),
|
|
position = this.translateToOriginPoint(center, this.originX, this.originY);
|
|
this.set({
|
|
left: position.x,
|
|
top: position.y
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_getLeftTopCoords() {
|
|
return this.translateToOriginPoint(this.getRelativeCenterPoint(), LEFT, TOP);
|
|
}
|
|
}
|
|
|
|
const _excluded$e = ["type"],
|
|
_excluded2$4 = ["extraParam"];
|
|
/**
|
|
* Root object class from which all 2d shape classes inherit from
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-1#objects}
|
|
*
|
|
* @fires added
|
|
* @fires removed
|
|
*
|
|
* @fires selected
|
|
* @fires deselected
|
|
*
|
|
* @fires rotating
|
|
* @fires scaling
|
|
* @fires moving
|
|
* @fires skewing
|
|
* @fires modified
|
|
*
|
|
* @fires mousedown
|
|
* @fires mouseup
|
|
* @fires mouseover
|
|
* @fires mouseout
|
|
* @fires mousewheel
|
|
* @fires mousedblclick
|
|
*
|
|
* @fires dragover
|
|
* @fires dragenter
|
|
* @fires dragleave
|
|
* @fires drop
|
|
*/
|
|
let FabricObject$1 = class FabricObject extends ObjectGeometry {
|
|
static getDefaults() {
|
|
return FabricObject.ownDefaults;
|
|
}
|
|
|
|
/**
|
|
* The class type.
|
|
* This is used for serialization and deserialization purposes and internally it can be used
|
|
* to identify classes.
|
|
* When we transform a class in a plain JS object we need a way to recognize which class it was,
|
|
* and the type is the way we do that. It has no other purposes and you should not give one.
|
|
* Hard to reach on instances and please do not use to drive instance's logic (this.constructor.type).
|
|
* To idenfity a class use instanceof class ( instanceof Rect ).
|
|
* We do not do that in fabricJS code because we want to try to have code splitting possible.
|
|
*/
|
|
|
|
/**
|
|
* Legacy identifier of the class. Prefer using utils like isType or instanceOf
|
|
* Will be removed in fabric 7 or 8.
|
|
* The setter exists to avoid type errors in old code and possibly current deserialization code.
|
|
* @TODO add sustainable warning message
|
|
* @type string
|
|
* @deprecated
|
|
*/
|
|
get type() {
|
|
const name = this.constructor.type;
|
|
if (name === 'FabricObject') {
|
|
return 'object';
|
|
}
|
|
return name.toLowerCase();
|
|
}
|
|
set type(value) {
|
|
log('warn', 'Setting type has no effect', value);
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor(options) {
|
|
super();
|
|
/**
|
|
* Quick access for the _cacheCanvas rendering context
|
|
* This is part of the objectCaching feature
|
|
* since 1.7.0
|
|
* @type boolean
|
|
* @default undefined
|
|
* @private
|
|
*/
|
|
_defineProperty(this, "_cacheContext", null);
|
|
Object.assign(this, FabricObject.ownDefaults);
|
|
this.setOptions(options);
|
|
}
|
|
|
|
/**
|
|
* Create a the canvas used to keep the cached copy of the object
|
|
* @private
|
|
*/
|
|
_createCacheCanvas() {
|
|
this._cacheCanvas = createCanvasElement();
|
|
this._cacheContext = this._cacheCanvas.getContext('2d');
|
|
this._updateCacheCanvas();
|
|
// if canvas gets created, is empty, so dirty.
|
|
this.dirty = true;
|
|
}
|
|
|
|
/**
|
|
* Limit the cache dimensions so that X * Y do not cross config.perfLimitSizeTotal
|
|
* and each side do not cross fabric.cacheSideLimit
|
|
* those numbers are configurable so that you can get as much detail as you want
|
|
* making bargain with performances.
|
|
* @param {Object} dims
|
|
* @param {Object} dims.width width of canvas
|
|
* @param {Object} dims.height height of canvas
|
|
* @param {Object} dims.zoomX zoomX zoom value to unscale the canvas before drawing cache
|
|
* @param {Object} dims.zoomY zoomY zoom value to unscale the canvas before drawing cache
|
|
* @return {Object}.width width of canvas
|
|
* @return {Object}.height height of canvas
|
|
* @return {Object}.zoomX zoomX zoom value to unscale the canvas before drawing cache
|
|
* @return {Object}.zoomY zoomY zoom value to unscale the canvas before drawing cache
|
|
*/
|
|
_limitCacheSize(dims) {
|
|
const width = dims.width,
|
|
height = dims.height,
|
|
max = config.maxCacheSideLimit,
|
|
min = config.minCacheSideLimit;
|
|
if (width <= max && height <= max && width * height <= config.perfLimitSizeTotal) {
|
|
if (width < min) {
|
|
dims.width = min;
|
|
}
|
|
if (height < min) {
|
|
dims.height = min;
|
|
}
|
|
return dims;
|
|
}
|
|
const ar = width / height,
|
|
[limX, limY] = cache.limitDimsByArea(ar),
|
|
x = capValue(min, limX, max),
|
|
y = capValue(min, limY, max);
|
|
if (width > x) {
|
|
dims.zoomX /= width / x;
|
|
dims.width = x;
|
|
dims.capped = true;
|
|
}
|
|
if (height > y) {
|
|
dims.zoomY /= height / y;
|
|
dims.height = y;
|
|
dims.capped = true;
|
|
}
|
|
return dims;
|
|
}
|
|
|
|
/**
|
|
* Return the dimension and the zoom level needed to create a cache canvas
|
|
* big enough to host the object to be cached.
|
|
* @private
|
|
* @return {Object}.x width of object to be cached
|
|
* @return {Object}.y height of object to be cached
|
|
* @return {Object}.width width of canvas
|
|
* @return {Object}.height height of canvas
|
|
* @return {Object}.zoomX zoomX zoom value to unscale the canvas before drawing cache
|
|
* @return {Object}.zoomY zoomY zoom value to unscale the canvas before drawing cache
|
|
*/
|
|
_getCacheCanvasDimensions() {
|
|
const objectScale = this.getTotalObjectScaling(),
|
|
// calculate dimensions without skewing
|
|
dim = this._getTransformedDimensions({
|
|
skewX: 0,
|
|
skewY: 0
|
|
}),
|
|
neededX = dim.x * objectScale.x / this.scaleX,
|
|
neededY = dim.y * objectScale.y / this.scaleY;
|
|
return {
|
|
// for sure this ALIASING_LIMIT is slightly creating problem
|
|
// in situation in which the cache canvas gets an upper limit
|
|
// also objectScale contains already scaleX and scaleY
|
|
width: neededX + ALIASING_LIMIT,
|
|
height: neededY + ALIASING_LIMIT,
|
|
zoomX: objectScale.x,
|
|
zoomY: objectScale.y,
|
|
x: neededX,
|
|
y: neededY
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update width and height of the canvas for cache
|
|
* returns true or false if canvas needed resize.
|
|
* @private
|
|
* @return {Boolean} true if the canvas has been resized
|
|
*/
|
|
_updateCacheCanvas() {
|
|
const canvas = this._cacheCanvas,
|
|
context = this._cacheContext,
|
|
dims = this._limitCacheSize(this._getCacheCanvasDimensions()),
|
|
minCacheSize = config.minCacheSideLimit,
|
|
width = dims.width,
|
|
height = dims.height,
|
|
zoomX = dims.zoomX,
|
|
zoomY = dims.zoomY,
|
|
dimensionsChanged = width !== canvas.width || height !== canvas.height,
|
|
zoomChanged = this.zoomX !== zoomX || this.zoomY !== zoomY;
|
|
if (!canvas || !context) {
|
|
return false;
|
|
}
|
|
let drawingWidth,
|
|
drawingHeight,
|
|
shouldRedraw = dimensionsChanged || zoomChanged,
|
|
additionalWidth = 0,
|
|
additionalHeight = 0,
|
|
shouldResizeCanvas = false;
|
|
if (dimensionsChanged) {
|
|
const canvasWidth = this._cacheCanvas.width,
|
|
canvasHeight = this._cacheCanvas.height,
|
|
sizeGrowing = width > canvasWidth || height > canvasHeight,
|
|
sizeShrinking = (width < canvasWidth * 0.9 || height < canvasHeight * 0.9) && canvasWidth > minCacheSize && canvasHeight > minCacheSize;
|
|
shouldResizeCanvas = sizeGrowing || sizeShrinking;
|
|
if (sizeGrowing && !dims.capped && (width > minCacheSize || height > minCacheSize)) {
|
|
additionalWidth = width * 0.1;
|
|
additionalHeight = height * 0.1;
|
|
}
|
|
}
|
|
if (isTextObject(this) && this.path) {
|
|
shouldRedraw = true;
|
|
shouldResizeCanvas = true;
|
|
// IMHO in those lines we are using zoomX and zoomY not the this version.
|
|
additionalWidth += this.getHeightOfLine(0) * this.zoomX;
|
|
additionalHeight += this.getHeightOfLine(0) * this.zoomY;
|
|
}
|
|
if (shouldRedraw) {
|
|
if (shouldResizeCanvas) {
|
|
canvas.width = Math.ceil(width + additionalWidth);
|
|
canvas.height = Math.ceil(height + additionalHeight);
|
|
} else {
|
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
drawingWidth = dims.x / 2;
|
|
drawingHeight = dims.y / 2;
|
|
this.cacheTranslationX = Math.round(canvas.width / 2 - drawingWidth) + drawingWidth;
|
|
this.cacheTranslationY = Math.round(canvas.height / 2 - drawingHeight) + drawingHeight;
|
|
context.translate(this.cacheTranslationX, this.cacheTranslationY);
|
|
context.scale(zoomX, zoomY);
|
|
this.zoomX = zoomX;
|
|
this.zoomY = zoomY;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Sets object's properties from options, for class constructor only.
|
|
* Needs to be overridden for different defaults.
|
|
* @protected
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
setOptions() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
this._setOptions(options);
|
|
}
|
|
|
|
/**
|
|
* Transforms context when rendering an object
|
|
* @param {CanvasRenderingContext2D} ctx Context
|
|
*/
|
|
transform(ctx) {
|
|
const needFullTransform = this.group && !this.group._transformDone || this.group && this.canvas && ctx === this.canvas.contextTop;
|
|
const m = this.calcTransformMatrix(!needFullTransform);
|
|
ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
|
}
|
|
|
|
/**
|
|
* Return the object scale factor counting also the group scaling
|
|
* @return {Point}
|
|
*/
|
|
getObjectScaling() {
|
|
// if the object is a top level one, on the canvas, we go for simple aritmetic
|
|
// otherwise the complex method with angles will return approximations and decimals
|
|
// and will likely kill the cache when not needed
|
|
// https://github.com/fabricjs/fabric.js/issues/7157
|
|
if (!this.group) {
|
|
return new Point(Math.abs(this.scaleX), Math.abs(this.scaleY));
|
|
}
|
|
// if we are inside a group total zoom calculation is complex, we defer to generic matrices
|
|
const options = qrDecompose(this.calcTransformMatrix());
|
|
return new Point(Math.abs(options.scaleX), Math.abs(options.scaleY));
|
|
}
|
|
|
|
/**
|
|
* Return the object scale factor counting also the group scaling, zoom and retina
|
|
* @return {Object} object with scaleX and scaleY properties
|
|
*/
|
|
getTotalObjectScaling() {
|
|
const scale = this.getObjectScaling();
|
|
if (this.canvas) {
|
|
const zoom = this.canvas.getZoom();
|
|
const retina = this.getCanvasRetinaScaling();
|
|
return scale.scalarMultiply(zoom * retina);
|
|
}
|
|
return scale;
|
|
}
|
|
|
|
/**
|
|
* Return the object opacity counting also the group property
|
|
* @return {Number}
|
|
*/
|
|
getObjectOpacity() {
|
|
let opacity = this.opacity;
|
|
if (this.group) {
|
|
opacity *= this.group.getObjectOpacity();
|
|
}
|
|
return opacity;
|
|
}
|
|
|
|
/**
|
|
* Makes sure the scale is valid and modifies it if necessary
|
|
* @todo: this is a control action issue, not a geometry one
|
|
* @private
|
|
* @param {Number} value, unconstrained
|
|
* @return {Number} constrained value;
|
|
*/
|
|
_constrainScale(value) {
|
|
if (Math.abs(value) < this.minScaleLimit) {
|
|
if (value < 0) {
|
|
return -this.minScaleLimit;
|
|
} else {
|
|
return this.minScaleLimit;
|
|
}
|
|
} else if (value === 0) {
|
|
return 0.0001;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Handles setting values on the instance and handling internal side effects
|
|
* @protected
|
|
* @param {String} key
|
|
* @param {*} value
|
|
*/
|
|
_set(key, value) {
|
|
if (key === SCALE_X || key === SCALE_Y) {
|
|
value = this._constrainScale(value);
|
|
}
|
|
if (key === SCALE_X && value < 0) {
|
|
this.flipX = !this.flipX;
|
|
value *= -1;
|
|
} else if (key === 'scaleY' && value < 0) {
|
|
this.flipY = !this.flipY;
|
|
value *= -1;
|
|
// i don't like this automatic initialization here
|
|
} else if (key === 'shadow' && value && !(value instanceof Shadow)) {
|
|
value = new Shadow(value);
|
|
}
|
|
const isChanged = this[key] !== value;
|
|
this[key] = value;
|
|
|
|
// invalidate caches
|
|
if (isChanged && this.constructor.cacheProperties.includes(key)) {
|
|
this.dirty = true;
|
|
}
|
|
// a dirty child makes the parent dirty.
|
|
// but a non dirty child does not make the parent not dirty.
|
|
// the parent could be dirty for some other reason.
|
|
this.parent && (this.dirty || isChanged && this.constructor.stateProperties.includes(key)) && this.parent._set('dirty', true);
|
|
return this;
|
|
}
|
|
|
|
/*
|
|
* @private
|
|
* return if the object would be visible in rendering
|
|
* @memberOf FabricObject.prototype
|
|
* @return {Boolean}
|
|
*/
|
|
isNotVisible() {
|
|
return this.opacity === 0 || !this.width && !this.height && this.strokeWidth === 0 || !this.visible;
|
|
}
|
|
|
|
/**
|
|
* Renders an object on a specified context
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
render(ctx) {
|
|
// do not render if width/height are zeros or object is not visible
|
|
if (this.isNotVisible()) {
|
|
return;
|
|
}
|
|
if (this.canvas && this.canvas.skipOffscreen && !this.group && !this.isOnScreen()) {
|
|
return;
|
|
}
|
|
ctx.save();
|
|
this._setupCompositeOperation(ctx);
|
|
this.drawSelectionBackground(ctx);
|
|
this.transform(ctx);
|
|
this._setOpacity(ctx);
|
|
this._setShadow(ctx);
|
|
if (this.shouldCache()) {
|
|
this.renderCache();
|
|
this.drawCacheOnCanvas(ctx);
|
|
} else {
|
|
this._removeCacheCanvas();
|
|
this.drawObject(ctx);
|
|
this.dirty = false;
|
|
}
|
|
ctx.restore();
|
|
}
|
|
drawSelectionBackground(_ctx) {
|
|
/* no op */
|
|
}
|
|
renderCache(options) {
|
|
options = options || {};
|
|
if (!this._cacheCanvas || !this._cacheContext) {
|
|
this._createCacheCanvas();
|
|
}
|
|
if (this.isCacheDirty() && this._cacheContext) {
|
|
this.drawObject(this._cacheContext, options.forClipping);
|
|
this.dirty = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove cacheCanvas and its dimensions from the objects
|
|
*/
|
|
_removeCacheCanvas() {
|
|
this._cacheCanvas = undefined;
|
|
this._cacheContext = null;
|
|
}
|
|
|
|
/**
|
|
* return true if the object will draw a stroke
|
|
* Does not consider text styles. This is just a shortcut used at rendering time
|
|
* We want it to be an approximation and be fast.
|
|
* wrote to avoid extra caching, it has to return true when stroke happens,
|
|
* can guess when it will not happen at 100% chance, does not matter if it misses
|
|
* some use case where the stroke is invisible.
|
|
* @since 3.0.0
|
|
* @returns Boolean
|
|
*/
|
|
hasStroke() {
|
|
return this.stroke && this.stroke !== 'transparent' && this.strokeWidth !== 0;
|
|
}
|
|
|
|
/**
|
|
* return true if the object will draw a fill
|
|
* Does not consider text styles. This is just a shortcut used at rendering time
|
|
* We want it to be an approximation and be fast.
|
|
* wrote to avoid extra caching, it has to return true when fill happens,
|
|
* can guess when it will not happen at 100% chance, does not matter if it misses
|
|
* some use case where the fill is invisible.
|
|
* @since 3.0.0
|
|
* @returns Boolean
|
|
*/
|
|
hasFill() {
|
|
return this.fill && this.fill !== 'transparent';
|
|
}
|
|
|
|
/**
|
|
* When set to `true`, force the object to have its own cache, even if it is inside a group
|
|
* it may be needed when your object behave in a particular way on the cache and always needs
|
|
* its own isolated canvas to render correctly.
|
|
* Created to be overridden
|
|
* since 1.7.12
|
|
* @returns Boolean
|
|
*/
|
|
needsItsOwnCache() {
|
|
if (this.paintFirst === STROKE && this.hasFill() && this.hasStroke() && !!this.shadow) {
|
|
return true;
|
|
}
|
|
if (this.clipPath) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Decide if the object should cache or not. Create its own cache level
|
|
* objectCaching is a global flag, wins over everything
|
|
* needsItsOwnCache should be used when the object drawing method requires
|
|
* a cache step. None of the fabric classes requires it.
|
|
* Generally you do not cache objects in groups because the group outside is cached.
|
|
* Read as: cache if is needed, or if the feature is enabled but we are not already caching.
|
|
* @return {Boolean}
|
|
*/
|
|
shouldCache() {
|
|
this.ownCaching = this.needsItsOwnCache() || this.objectCaching && (!this.parent || !this.parent.isOnACache());
|
|
return this.ownCaching;
|
|
}
|
|
|
|
/**
|
|
* Check if this object will cast a shadow with an offset.
|
|
* used by Group.shouldCache to know if child has a shadow recursively
|
|
* @return {Boolean}
|
|
* @deprecated
|
|
*/
|
|
willDrawShadow() {
|
|
return !!this.shadow && (this.shadow.offsetX !== 0 || this.shadow.offsetY !== 0);
|
|
}
|
|
|
|
/**
|
|
* Execute the drawing operation for an object clipPath
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {FabricObject} clipPath
|
|
*/
|
|
drawClipPathOnCache(ctx, clipPath) {
|
|
ctx.save();
|
|
// DEBUG: uncomment this line, comment the following
|
|
// ctx.globalAlpha = 0.4
|
|
if (clipPath.inverted) {
|
|
ctx.globalCompositeOperation = 'destination-out';
|
|
} else {
|
|
ctx.globalCompositeOperation = 'destination-in';
|
|
}
|
|
//ctx.scale(1 / 2, 1 / 2);
|
|
if (clipPath.absolutePositioned) {
|
|
const m = invertTransform(this.calcTransformMatrix());
|
|
ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
|
}
|
|
clipPath.transform(ctx);
|
|
ctx.scale(1 / clipPath.zoomX, 1 / clipPath.zoomY);
|
|
ctx.drawImage(clipPath._cacheCanvas, -clipPath.cacheTranslationX, -clipPath.cacheTranslationY);
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Execute the drawing operation for an object on a specified context
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {boolean} forClipping apply clipping styles
|
|
*/
|
|
drawObject(ctx, forClipping) {
|
|
const originalFill = this.fill,
|
|
originalStroke = this.stroke;
|
|
if (forClipping) {
|
|
this.fill = 'black';
|
|
this.stroke = '';
|
|
this._setClippingProperties(ctx);
|
|
} else {
|
|
this._renderBackground(ctx);
|
|
}
|
|
this._render(ctx);
|
|
this._drawClipPath(ctx, this.clipPath);
|
|
this.fill = originalFill;
|
|
this.stroke = originalStroke;
|
|
}
|
|
|
|
/**
|
|
* Prepare clipPath state and cache and draw it on instance's cache
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {FabricObject} clipPath
|
|
*/
|
|
_drawClipPath(ctx, clipPath) {
|
|
if (!clipPath) {
|
|
return;
|
|
}
|
|
// needed to setup a couple of variables
|
|
// path canvas gets overridden with this one.
|
|
// TODO find a better solution?
|
|
clipPath._set('canvas', this.canvas);
|
|
clipPath.shouldCache();
|
|
clipPath._transformDone = true;
|
|
clipPath.renderCache({
|
|
forClipping: true
|
|
});
|
|
this.drawClipPathOnCache(ctx, clipPath);
|
|
}
|
|
|
|
/**
|
|
* Paint the cached copy of the object on the target context.
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
drawCacheOnCanvas(ctx) {
|
|
ctx.scale(1 / this.zoomX, 1 / this.zoomY);
|
|
ctx.drawImage(this._cacheCanvas, -this.cacheTranslationX, -this.cacheTranslationY);
|
|
}
|
|
|
|
/**
|
|
* Check if cache is dirty
|
|
* @param {Boolean} skipCanvas skip canvas checks because this object is painted
|
|
* on parent canvas.
|
|
*/
|
|
isCacheDirty() {
|
|
let skipCanvas = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
|
|
if (this.isNotVisible()) {
|
|
return false;
|
|
}
|
|
const canvas = this._cacheCanvas;
|
|
const ctx = this._cacheContext;
|
|
if (canvas && ctx && !skipCanvas && this._updateCacheCanvas()) {
|
|
// in this case the context is already cleared.
|
|
return true;
|
|
} else {
|
|
if (this.dirty || this.clipPath && this.clipPath.absolutePositioned) {
|
|
if (canvas && ctx && !skipCanvas) {
|
|
ctx.save();
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.restore();
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Draws a background for the object big as its untransformed dimensions
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderBackground(ctx) {
|
|
if (!this.backgroundColor) {
|
|
return;
|
|
}
|
|
const dim = this._getNonTransformedDimensions();
|
|
ctx.fillStyle = this.backgroundColor;
|
|
ctx.fillRect(-dim.x / 2, -dim.y / 2, dim.x, dim.y);
|
|
// if there is background color no other shadows
|
|
// should be casted
|
|
this._removeShadow(ctx);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_setOpacity(ctx) {
|
|
if (this.group && !this.group._transformDone) {
|
|
ctx.globalAlpha = this.getObjectOpacity();
|
|
} else {
|
|
ctx.globalAlpha *= this.opacity;
|
|
}
|
|
}
|
|
_setStrokeStyles(ctx, decl) {
|
|
const stroke = decl.stroke;
|
|
if (stroke) {
|
|
ctx.lineWidth = decl.strokeWidth;
|
|
ctx.lineCap = decl.strokeLineCap;
|
|
ctx.lineDashOffset = decl.strokeDashOffset;
|
|
ctx.lineJoin = decl.strokeLineJoin;
|
|
ctx.miterLimit = decl.strokeMiterLimit;
|
|
if (isFiller(stroke)) {
|
|
if (stroke.gradientUnits === 'percentage' || stroke.gradientTransform || stroke.patternTransform) {
|
|
// need to transform gradient in a pattern.
|
|
// this is a slow process. If you are hitting this codepath, and the object
|
|
// is not using caching, you should consider switching it on.
|
|
// we need a canvas as big as the current object caching canvas.
|
|
this._applyPatternForTransformedGradient(ctx, stroke);
|
|
} else {
|
|
// is a simple gradient or pattern
|
|
ctx.strokeStyle = stroke.toLive(ctx);
|
|
this._applyPatternGradientTransform(ctx, stroke);
|
|
}
|
|
} else {
|
|
// is a color
|
|
ctx.strokeStyle = decl.stroke;
|
|
}
|
|
}
|
|
}
|
|
_setFillStyles(ctx, _ref) {
|
|
let {
|
|
fill
|
|
} = _ref;
|
|
if (fill) {
|
|
if (isFiller(fill)) {
|
|
ctx.fillStyle = fill.toLive(ctx);
|
|
this._applyPatternGradientTransform(ctx, fill);
|
|
} else {
|
|
ctx.fillStyle = fill;
|
|
}
|
|
}
|
|
}
|
|
_setClippingProperties(ctx) {
|
|
ctx.globalAlpha = 1;
|
|
ctx.strokeStyle = 'transparent';
|
|
ctx.fillStyle = '#000000';
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Sets line dash
|
|
* @param {CanvasRenderingContext2D} ctx Context to set the dash line on
|
|
* @param {Array} dashArray array representing dashes
|
|
*/
|
|
_setLineDash(ctx, dashArray) {
|
|
if (!dashArray || dashArray.length === 0) {
|
|
return;
|
|
}
|
|
// Spec requires the concatenation of two copies of the dash array when the number of elements is odd
|
|
if (1 & dashArray.length) {
|
|
dashArray.push(...dashArray);
|
|
}
|
|
ctx.setLineDash(dashArray);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_setShadow(ctx) {
|
|
if (!this.shadow) {
|
|
return;
|
|
}
|
|
const shadow = this.shadow,
|
|
canvas = this.canvas,
|
|
retinaScaling = this.getCanvasRetinaScaling(),
|
|
[sx,,, sy] = (canvas === null || canvas === void 0 ? void 0 : canvas.viewportTransform) || iMatrix,
|
|
multX = sx * retinaScaling,
|
|
multY = sy * retinaScaling,
|
|
scaling = shadow.nonScaling ? new Point(1, 1) : this.getObjectScaling();
|
|
ctx.shadowColor = shadow.color;
|
|
ctx.shadowBlur = shadow.blur * config.browserShadowBlurConstant * (multX + multY) * (scaling.x + scaling.y) / 4;
|
|
ctx.shadowOffsetX = shadow.offsetX * multX * scaling.x;
|
|
ctx.shadowOffsetY = shadow.offsetY * multY * scaling.y;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_removeShadow(ctx) {
|
|
if (!this.shadow) {
|
|
return;
|
|
}
|
|
ctx.shadowColor = '';
|
|
ctx.shadowBlur = ctx.shadowOffsetX = ctx.shadowOffsetY = 0;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {TFiller} filler {@link Pattern} or {@link Gradient}
|
|
*/
|
|
_applyPatternGradientTransform(ctx, filler) {
|
|
if (!isFiller(filler)) {
|
|
return {
|
|
offsetX: 0,
|
|
offsetY: 0
|
|
};
|
|
}
|
|
const t = filler.gradientTransform || filler.patternTransform;
|
|
const offsetX = -this.width / 2 + filler.offsetX || 0,
|
|
offsetY = -this.height / 2 + filler.offsetY || 0;
|
|
if (filler.gradientUnits === 'percentage') {
|
|
ctx.transform(this.width, 0, 0, this.height, offsetX, offsetY);
|
|
} else {
|
|
ctx.transform(1, 0, 0, 1, offsetX, offsetY);
|
|
}
|
|
if (t) {
|
|
ctx.transform(t[0], t[1], t[2], t[3], t[4], t[5]);
|
|
}
|
|
return {
|
|
offsetX: offsetX,
|
|
offsetY: offsetY
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderPaintInOrder(ctx) {
|
|
if (this.paintFirst === STROKE) {
|
|
this._renderStroke(ctx);
|
|
this._renderFill(ctx);
|
|
} else {
|
|
this._renderFill(ctx);
|
|
this._renderStroke(ctx);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* function that actually render something on the context.
|
|
* empty here to allow Obects to work on tests to benchmark fabric functionalites
|
|
* not related to rendering
|
|
* @param {CanvasRenderingContext2D} _ctx Context to render on
|
|
*/
|
|
_render(_ctx) {
|
|
// placeholder to be overridden
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderFill(ctx) {
|
|
if (!this.fill) {
|
|
return;
|
|
}
|
|
ctx.save();
|
|
this._setFillStyles(ctx, this);
|
|
if (this.fillRule === 'evenodd') {
|
|
ctx.fill('evenodd');
|
|
} else {
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderStroke(ctx) {
|
|
if (!this.stroke || this.strokeWidth === 0) {
|
|
return;
|
|
}
|
|
if (this.shadow && !this.shadow.affectStroke) {
|
|
this._removeShadow(ctx);
|
|
}
|
|
ctx.save();
|
|
if (this.strokeUniform) {
|
|
const scaling = this.getObjectScaling();
|
|
ctx.scale(1 / scaling.x, 1 / scaling.y);
|
|
}
|
|
this._setLineDash(ctx, this.strokeDashArray);
|
|
this._setStrokeStyles(ctx, this);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* This function try to patch the missing gradientTransform on canvas gradients.
|
|
* transforming a context to transform the gradient, is going to transform the stroke too.
|
|
* we want to transform the gradient but not the stroke operation, so we create
|
|
* a transformed gradient on a pattern and then we use the pattern instead of the gradient.
|
|
* this method has drawbacks: is slow, is in low resolution, needs a patch for when the size
|
|
* is limited.
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {Gradient} filler
|
|
*/
|
|
_applyPatternForTransformedGradient(ctx, filler) {
|
|
var _pCtx$createPattern;
|
|
const dims = this._limitCacheSize(this._getCacheCanvasDimensions()),
|
|
pCanvas = createCanvasElement(),
|
|
retinaScaling = this.getCanvasRetinaScaling(),
|
|
width = dims.x / this.scaleX / retinaScaling,
|
|
height = dims.y / this.scaleY / retinaScaling;
|
|
// in case width and height are less than 1px, we have to round up.
|
|
// since the pattern is no-repeat, this is fine
|
|
pCanvas.width = Math.ceil(width);
|
|
pCanvas.height = Math.ceil(height);
|
|
const pCtx = pCanvas.getContext('2d');
|
|
if (!pCtx) {
|
|
return;
|
|
}
|
|
pCtx.beginPath();
|
|
pCtx.moveTo(0, 0);
|
|
pCtx.lineTo(width, 0);
|
|
pCtx.lineTo(width, height);
|
|
pCtx.lineTo(0, height);
|
|
pCtx.closePath();
|
|
pCtx.translate(width / 2, height / 2);
|
|
pCtx.scale(dims.zoomX / this.scaleX / retinaScaling, dims.zoomY / this.scaleY / retinaScaling);
|
|
this._applyPatternGradientTransform(pCtx, filler);
|
|
pCtx.fillStyle = filler.toLive(ctx);
|
|
pCtx.fill();
|
|
ctx.translate(-this.width / 2 - this.strokeWidth / 2, -this.height / 2 - this.strokeWidth / 2);
|
|
ctx.scale(retinaScaling * this.scaleX / dims.zoomX, retinaScaling * this.scaleY / dims.zoomY);
|
|
ctx.strokeStyle = (_pCtx$createPattern = pCtx.createPattern(pCanvas, 'no-repeat')) !== null && _pCtx$createPattern !== void 0 ? _pCtx$createPattern : '';
|
|
}
|
|
|
|
/**
|
|
* This function is an helper for svg import. it returns the center of the object in the svg
|
|
* untransformed coordinates
|
|
* @private
|
|
* @return {Point} center point from element coordinates
|
|
*/
|
|
_findCenterFromElement() {
|
|
return new Point(this.left + this.width / 2, this.top + this.height / 2);
|
|
}
|
|
|
|
/**
|
|
* Clones an instance.
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @returns {Promise<FabricObject>}
|
|
*/
|
|
clone(propertiesToInclude) {
|
|
const objectForm = this.toObject(propertiesToInclude);
|
|
return this.constructor.fromObject(objectForm);
|
|
}
|
|
|
|
/**
|
|
* Creates an instance of Image out of an object
|
|
* makes use of toCanvasElement.
|
|
* Once this method was based on toDataUrl and loadImage, so it also had a quality
|
|
* and format option. toCanvasElement is faster and produce no loss of quality.
|
|
* If you need to get a real Jpeg or Png from an object, using toDataURL is the right way to do it.
|
|
* toCanvasElement and then toBlob from the obtained canvas is also a good option.
|
|
* @todo fix the export type, it could not be Image but the type that getClass return for 'image'.
|
|
* @param {ObjectToCanvasElementOptions} [options] for clone as image, passed to toDataURL
|
|
* @param {Number} [options.multiplier=1] Multiplier to scale by
|
|
* @param {Number} [options.left] Cropping left offset. Introduced in v1.2.14
|
|
* @param {Number} [options.top] Cropping top offset. Introduced in v1.2.14
|
|
* @param {Number} [options.width] Cropping width. Introduced in v1.2.14
|
|
* @param {Number} [options.height] Cropping height. Introduced in v1.2.14
|
|
* @param {Boolean} [options.enableRetinaScaling] Enable retina scaling for clone image. Introduce in 1.6.4
|
|
* @param {Boolean} [options.withoutTransform] Remove current object transform ( no scale , no angle, no flip, no skew ). Introduced in 2.3.4
|
|
* @param {Boolean} [options.withoutShadow] Remove current object shadow. Introduced in 2.4.2
|
|
* @return {FabricImage} Object cloned as image.
|
|
*/
|
|
cloneAsImage(options) {
|
|
const canvasEl = this.toCanvasElement(options);
|
|
// TODO: how to import Image w/o an import cycle?
|
|
const ImageClass = classRegistry.getClass('image');
|
|
return new ImageClass(canvasEl);
|
|
}
|
|
|
|
/**
|
|
* Converts an object into a HTMLCanvas element
|
|
* @param {ObjectToCanvasElementOptions} options Options object
|
|
* @param {Number} [options.multiplier=1] Multiplier to scale by
|
|
* @param {Number} [options.left] Cropping left offset. Introduced in v1.2.14
|
|
* @param {Number} [options.top] Cropping top offset. Introduced in v1.2.14
|
|
* @param {Number} [options.width] Cropping width. Introduced in v1.2.14
|
|
* @param {Number} [options.height] Cropping height. Introduced in v1.2.14
|
|
* @param {Boolean} [options.enableRetinaScaling] Enable retina scaling for clone image. Introduce in 1.6.4
|
|
* @param {Boolean} [options.withoutTransform] Remove current object transform ( no scale , no angle, no flip, no skew ). Introduced in 2.3.4
|
|
* @param {Boolean} [options.withoutShadow] Remove current object shadow. Introduced in 2.4.2
|
|
* @param {Boolean} [options.viewportTransform] Account for canvas viewport transform
|
|
* @param {(el?: HTMLCanvasElement) => StaticCanvas} [options.canvasProvider] Create the output canvas
|
|
* @return {HTMLCanvasElement} Returns DOM element <canvas> with the FabricObject
|
|
*/
|
|
toCanvasElement() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
const origParams = saveObjectTransform(this),
|
|
originalGroup = this.group,
|
|
originalShadow = this.shadow,
|
|
abs = Math.abs,
|
|
retinaScaling = options.enableRetinaScaling ? getDevicePixelRatio() : 1,
|
|
multiplier = (options.multiplier || 1) * retinaScaling,
|
|
canvasProvider = options.canvasProvider || (el => new StaticCanvas(el, {
|
|
enableRetinaScaling: false,
|
|
renderOnAddRemove: false,
|
|
skipOffscreen: false
|
|
}));
|
|
delete this.group;
|
|
if (options.withoutTransform) {
|
|
resetObjectTransform(this);
|
|
}
|
|
if (options.withoutShadow) {
|
|
this.shadow = null;
|
|
}
|
|
if (options.viewportTransform) {
|
|
sendObjectToPlane(this, this.getViewportTransform());
|
|
}
|
|
this.setCoords();
|
|
const el = createCanvasElement(),
|
|
boundingRect = this.getBoundingRect(),
|
|
shadow = this.shadow,
|
|
shadowOffset = new Point();
|
|
if (shadow) {
|
|
const shadowBlur = shadow.blur;
|
|
const scaling = shadow.nonScaling ? new Point(1, 1) : this.getObjectScaling();
|
|
// consider non scaling shadow.
|
|
shadowOffset.x = 2 * Math.round(abs(shadow.offsetX) + shadowBlur) * abs(scaling.x);
|
|
shadowOffset.y = 2 * Math.round(abs(shadow.offsetY) + shadowBlur) * abs(scaling.y);
|
|
}
|
|
const width = boundingRect.width + shadowOffset.x,
|
|
height = boundingRect.height + shadowOffset.y;
|
|
// if the current width/height is not an integer
|
|
// we need to make it so.
|
|
el.width = Math.ceil(width);
|
|
el.height = Math.ceil(height);
|
|
const canvas = canvasProvider(el);
|
|
if (options.format === 'jpeg') {
|
|
canvas.backgroundColor = '#fff';
|
|
}
|
|
this.setPositionByOrigin(new Point(canvas.width / 2, canvas.height / 2), CENTER, CENTER);
|
|
const originalCanvas = this.canvas;
|
|
// static canvas and canvas have both an array of InteractiveObjects
|
|
// @ts-expect-error this needs to be fixed somehow, or ignored globally
|
|
canvas._objects = [this];
|
|
this.set('canvas', canvas);
|
|
this.setCoords();
|
|
const canvasEl = canvas.toCanvasElement(multiplier || 1, options);
|
|
this.set('canvas', originalCanvas);
|
|
this.shadow = originalShadow;
|
|
if (originalGroup) {
|
|
this.group = originalGroup;
|
|
}
|
|
this.set(origParams);
|
|
this.setCoords();
|
|
// canvas.dispose will call image.dispose that will nullify the elements
|
|
// since this canvas is a simple element for the process, we remove references
|
|
// to objects in this way in order to avoid object trashing.
|
|
canvas._objects = [];
|
|
// since render has settled it is safe to destroy canvas
|
|
canvas.destroy();
|
|
return canvasEl;
|
|
}
|
|
|
|
/**
|
|
* Converts an object into a data-url-like string
|
|
* @param {Object} options Options object
|
|
* @param {String} [options.format=png] The format of the output image. Either "jpeg" or "png"
|
|
* @param {Number} [options.quality=1] Quality level (0..1). Only used for jpeg.
|
|
* @param {Number} [options.multiplier=1] Multiplier to scale by
|
|
* @param {Number} [options.left] Cropping left offset. Introduced in v1.2.14
|
|
* @param {Number} [options.top] Cropping top offset. Introduced in v1.2.14
|
|
* @param {Number} [options.width] Cropping width. Introduced in v1.2.14
|
|
* @param {Number} [options.height] Cropping height. Introduced in v1.2.14
|
|
* @param {Boolean} [options.enableRetinaScaling] Enable retina scaling for clone image. Introduce in 1.6.4
|
|
* @param {Boolean} [options.withoutTransform] Remove current object transform ( no scale , no angle, no flip, no skew ). Introduced in 2.3.4
|
|
* @param {Boolean} [options.withoutShadow] Remove current object shadow. Introduced in 2.4.2
|
|
* @return {String} Returns a data: URL containing a representation of the object in the format specified by options.format
|
|
*/
|
|
toDataURL() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
return toDataURL(this.toCanvasElement(options), options.format || 'png', options.quality || 1);
|
|
}
|
|
|
|
/**
|
|
* Returns true if any of the specified types is identical to the type of an instance
|
|
* @param {String} type Type to check against
|
|
* @return {Boolean}
|
|
*/
|
|
isType() {
|
|
for (var _len = arguments.length, types = new Array(_len), _key = 0; _key < _len; _key++) {
|
|
types[_key] = arguments[_key];
|
|
}
|
|
return types.includes(this.constructor.type) || types.includes(this.type);
|
|
}
|
|
|
|
/**
|
|
* Returns complexity of an instance
|
|
* @return {Number} complexity of this instance (is 1 unless subclassed)
|
|
*/
|
|
complexity() {
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Returns a JSON representation of an instance
|
|
* @return {Object} JSON
|
|
*/
|
|
toJSON() {
|
|
// delegate, not alias
|
|
return this.toObject();
|
|
}
|
|
|
|
/**
|
|
* Sets "angle" of an instance with centered rotation
|
|
* @param {TDegree} angle Angle value (in degrees)
|
|
*/
|
|
rotate(angle) {
|
|
const {
|
|
centeredRotation,
|
|
originX,
|
|
originY
|
|
} = this;
|
|
if (centeredRotation) {
|
|
const {
|
|
x,
|
|
y
|
|
} = this.getRelativeCenterPoint();
|
|
this.originX = CENTER;
|
|
this.originY = CENTER;
|
|
this.left = x;
|
|
this.top = y;
|
|
}
|
|
this.set('angle', angle);
|
|
if (centeredRotation) {
|
|
const {
|
|
x,
|
|
y
|
|
} = this.translateToOriginPoint(this.getRelativeCenterPoint(), originX, originY);
|
|
this.left = x;
|
|
this.top = y;
|
|
this.originX = originX;
|
|
this.originY = originY;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This callback function is called by the parent group of an object every
|
|
* time a non-delegated property changes on the group. It is passed the key
|
|
* and value as parameters. Not adding in this function's signature to avoid
|
|
* Travis build error about unused variables.
|
|
*/
|
|
setOnGroup() {
|
|
// implemented by sub-classes, as needed.
|
|
}
|
|
|
|
/**
|
|
* Sets canvas globalCompositeOperation for specific object
|
|
* custom composition operation for the particular object can be specified using globalCompositeOperation property
|
|
* @param {CanvasRenderingContext2D} ctx Rendering canvas context
|
|
*/
|
|
_setupCompositeOperation(ctx) {
|
|
if (this.globalCompositeOperation) {
|
|
ctx.globalCompositeOperation = this.globalCompositeOperation;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* cancel instance's running animations
|
|
* override if necessary to dispose artifacts such as `clipPath`
|
|
*/
|
|
dispose() {
|
|
runningAnimations.cancelByTarget(this);
|
|
this.off();
|
|
this._set('canvas', undefined);
|
|
// clear caches
|
|
this._cacheCanvas && getEnv().dispose(this._cacheCanvas);
|
|
this._cacheCanvas = undefined;
|
|
this._cacheContext = null;
|
|
}
|
|
|
|
// #region Animation methods
|
|
/**
|
|
* List of properties to consider for animating colors.
|
|
* @type String[]
|
|
*/
|
|
|
|
/**
|
|
* Animates object's properties
|
|
* @param {Record<string, number | number[] | TColorArg>} animatable map of keys and end values
|
|
* @param {Partial<AnimationOptions<T>>} options
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-2#animation}
|
|
* @return {Record<string, TAnimation<T>>} map of animation contexts
|
|
*
|
|
* As object — multiple properties
|
|
*
|
|
* object.animate({ left: ..., top: ... });
|
|
* object.animate({ left: ..., top: ... }, { duration: ... });
|
|
*/
|
|
animate(animatable, options) {
|
|
return Object.entries(animatable).reduce((acc, _ref2) => {
|
|
let [key, endValue] = _ref2;
|
|
acc[key] = this._animate(key, endValue, options);
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} key Property to animate
|
|
* @param {String} to Value to animate to
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
_animate(key, endValue) {
|
|
let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
|
|
const path = key.split('.');
|
|
const propIsColor = this.constructor.colorProperties.includes(path[path.length - 1]);
|
|
const {
|
|
abort,
|
|
startValue,
|
|
onChange,
|
|
onComplete
|
|
} = options;
|
|
const animationOptions = _objectSpread2(_objectSpread2({}, options), {}, {
|
|
target: this,
|
|
// path.reduce... is the current value in case start value isn't provided
|
|
startValue: startValue !== null && startValue !== void 0 ? startValue : path.reduce((deep, key) => deep[key], this),
|
|
endValue,
|
|
abort: abort === null || abort === void 0 ? void 0 : abort.bind(this),
|
|
onChange: (value, valueProgress, durationProgress) => {
|
|
path.reduce((deep, key, index) => {
|
|
if (index === path.length - 1) {
|
|
deep[key] = value;
|
|
}
|
|
return deep[key];
|
|
}, this);
|
|
onChange &&
|
|
// @ts-expect-error generic callback arg0 is wrong
|
|
onChange(value, valueProgress, durationProgress);
|
|
},
|
|
onComplete: (value, valueProgress, durationProgress) => {
|
|
this.setCoords();
|
|
onComplete &&
|
|
// @ts-expect-error generic callback arg0 is wrong
|
|
onComplete(value, valueProgress, durationProgress);
|
|
}
|
|
});
|
|
return propIsColor ? animateColor(animationOptions) : animate(animationOptions);
|
|
}
|
|
|
|
// #region Object stacking methods
|
|
|
|
/**
|
|
* A reference to the parent of the object
|
|
* Used to keep the original parent ref when the object has been added to an ActiveSelection, hence loosing the `group` ref
|
|
*/
|
|
|
|
/**
|
|
* Checks if object is descendant of target
|
|
* Should be used instead of {@link Group.contains} or {@link StaticCanvas.contains} for performance reasons
|
|
* @param {TAncestor} target
|
|
* @returns {boolean}
|
|
*/
|
|
isDescendantOf(target) {
|
|
const {
|
|
parent,
|
|
group
|
|
} = this;
|
|
return parent === target || group === target ||
|
|
// walk up
|
|
!!parent && parent.isDescendantOf(target) || !!group && group !== parent && group.isDescendantOf(target);
|
|
}
|
|
|
|
/**
|
|
* @returns {Ancestors} ancestors (excluding `ActiveSelection`) from bottom to top
|
|
*/
|
|
getAncestors() {
|
|
const ancestors = [];
|
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
let parent = this;
|
|
do {
|
|
parent = parent.parent;
|
|
parent && ancestors.push(parent);
|
|
} while (parent);
|
|
return ancestors;
|
|
}
|
|
|
|
/**
|
|
* Compare ancestors
|
|
*
|
|
* @param {StackedObject} other
|
|
* @returns {AncestryComparison} an object that represent the ancestry situation.
|
|
*/
|
|
findCommonAncestors(other) {
|
|
if (this === other) {
|
|
return {
|
|
fork: [],
|
|
otherFork: [],
|
|
common: [this, ...this.getAncestors()]
|
|
};
|
|
}
|
|
const ancestors = this.getAncestors();
|
|
const otherAncestors = other.getAncestors();
|
|
// if `this` has no ancestors and `this` is top ancestor of `other` we must handle the following case
|
|
if (ancestors.length === 0 && otherAncestors.length > 0 && this === otherAncestors[otherAncestors.length - 1]) {
|
|
return {
|
|
fork: [],
|
|
otherFork: [other, ...otherAncestors.slice(0, otherAncestors.length - 1)],
|
|
common: [this]
|
|
};
|
|
}
|
|
// compare ancestors
|
|
for (let i = 0, ancestor; i < ancestors.length; i++) {
|
|
ancestor = ancestors[i];
|
|
if (ancestor === other) {
|
|
return {
|
|
fork: [this, ...ancestors.slice(0, i)],
|
|
otherFork: [],
|
|
common: ancestors.slice(i)
|
|
};
|
|
}
|
|
for (let j = 0; j < otherAncestors.length; j++) {
|
|
if (this === otherAncestors[j]) {
|
|
return {
|
|
fork: [],
|
|
otherFork: [other, ...otherAncestors.slice(0, j)],
|
|
common: [this, ...ancestors]
|
|
};
|
|
}
|
|
if (ancestor === otherAncestors[j]) {
|
|
return {
|
|
fork: [this, ...ancestors.slice(0, i)],
|
|
otherFork: [other, ...otherAncestors.slice(0, j)],
|
|
common: ancestors.slice(i)
|
|
};
|
|
}
|
|
}
|
|
}
|
|
// nothing shared
|
|
return {
|
|
fork: [this, ...ancestors],
|
|
otherFork: [other, ...otherAncestors],
|
|
common: []
|
|
};
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {StackedObject} other
|
|
* @returns {boolean}
|
|
*/
|
|
hasCommonAncestors(other) {
|
|
const commonAncestors = this.findCommonAncestors(other);
|
|
return commonAncestors && !!commonAncestors.common.length;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {FabricObject} other object to compare against
|
|
* @returns {boolean | undefined} if objects do not share a common ancestor or they are strictly equal it is impossible to determine which is in front of the other; in such cases the function returns `undefined`
|
|
*/
|
|
isInFrontOf(other) {
|
|
if (this === other) {
|
|
return undefined;
|
|
}
|
|
const ancestorData = this.findCommonAncestors(other);
|
|
if (ancestorData.fork.includes(other)) {
|
|
return true;
|
|
}
|
|
if (ancestorData.otherFork.includes(this)) {
|
|
return false;
|
|
}
|
|
// if there isn't a common ancestor, we take the canvas.
|
|
// if there is no canvas, there is nothing to compare
|
|
const firstCommonAncestor = ancestorData.common[0] || this.canvas;
|
|
if (!firstCommonAncestor) {
|
|
return undefined;
|
|
}
|
|
const headOfFork = ancestorData.fork.pop(),
|
|
headOfOtherFork = ancestorData.otherFork.pop(),
|
|
thisIndex = firstCommonAncestor._objects.indexOf(headOfFork),
|
|
otherIndex = firstCommonAncestor._objects.indexOf(headOfOtherFork);
|
|
return thisIndex > -1 && thisIndex > otherIndex;
|
|
}
|
|
|
|
// #region Serialization
|
|
/**
|
|
* Define a list of custom properties that will be serialized when
|
|
* instance.toObject() gets called
|
|
*/
|
|
|
|
/**
|
|
* Returns an object representation of an instance
|
|
* @param {string[]} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} Object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
const propertiesToSerialize = propertiesToInclude.concat(FabricObject.customProperties, this.constructor.customProperties || []);
|
|
let clipPathData;
|
|
const NUM_FRACTION_DIGITS = config.NUM_FRACTION_DIGITS;
|
|
const {
|
|
clipPath,
|
|
fill,
|
|
stroke,
|
|
shadow,
|
|
strokeDashArray,
|
|
left,
|
|
top,
|
|
originX,
|
|
originY,
|
|
width,
|
|
height,
|
|
strokeWidth,
|
|
strokeLineCap,
|
|
strokeDashOffset,
|
|
strokeLineJoin,
|
|
strokeUniform,
|
|
strokeMiterLimit,
|
|
scaleX,
|
|
scaleY,
|
|
angle,
|
|
flipX,
|
|
flipY,
|
|
opacity,
|
|
visible,
|
|
backgroundColor,
|
|
fillRule,
|
|
paintFirst,
|
|
globalCompositeOperation,
|
|
skewX,
|
|
skewY
|
|
} = this;
|
|
if (clipPath && !clipPath.excludeFromExport) {
|
|
clipPathData = clipPath.toObject(propertiesToSerialize.concat('inverted', 'absolutePositioned'));
|
|
}
|
|
const toFixedBound = val => toFixed(val, NUM_FRACTION_DIGITS);
|
|
const object = _objectSpread2(_objectSpread2({}, pick(this, propertiesToSerialize)), {}, {
|
|
type: this.constructor.type,
|
|
version: VERSION,
|
|
originX,
|
|
originY,
|
|
left: toFixedBound(left),
|
|
top: toFixedBound(top),
|
|
width: toFixedBound(width),
|
|
height: toFixedBound(height),
|
|
fill: isSerializableFiller(fill) ? fill.toObject() : fill,
|
|
stroke: isSerializableFiller(stroke) ? stroke.toObject() : stroke,
|
|
strokeWidth: toFixedBound(strokeWidth),
|
|
strokeDashArray: strokeDashArray ? strokeDashArray.concat() : strokeDashArray,
|
|
strokeLineCap,
|
|
strokeDashOffset,
|
|
strokeLineJoin,
|
|
strokeUniform,
|
|
strokeMiterLimit: toFixedBound(strokeMiterLimit),
|
|
scaleX: toFixedBound(scaleX),
|
|
scaleY: toFixedBound(scaleY),
|
|
angle: toFixedBound(angle),
|
|
flipX,
|
|
flipY,
|
|
opacity: toFixedBound(opacity),
|
|
shadow: shadow ? shadow.toObject() : shadow,
|
|
visible,
|
|
backgroundColor,
|
|
fillRule,
|
|
paintFirst,
|
|
globalCompositeOperation,
|
|
skewX: toFixedBound(skewX),
|
|
skewY: toFixedBound(skewY)
|
|
}, clipPathData ? {
|
|
clipPath: clipPathData
|
|
} : null);
|
|
return !this.includeDefaultValues ? this._removeDefaultValues(object) : object;
|
|
}
|
|
|
|
/**
|
|
* Returns (dataless) object representation of an instance
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} Object representation of an instance
|
|
*/
|
|
toDatalessObject(propertiesToInclude) {
|
|
// will be overwritten by subclasses
|
|
return this.toObject(propertiesToInclude);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Object} object
|
|
*/
|
|
_removeDefaultValues(object) {
|
|
// getDefaults() ( get from static ownDefaults ) should win over prototype since anyway they get assigned to instance
|
|
// ownDefault vs prototype is swappable only if you change all the fabric objects consistently.
|
|
const defaults = this.constructor.getDefaults();
|
|
const hasStaticDefaultValues = Object.keys(defaults).length > 0;
|
|
const baseValues = hasStaticDefaultValues ? defaults : Object.getPrototypeOf(this);
|
|
return pickBy(object, (value, key) => {
|
|
if (key === LEFT || key === TOP || key === 'type') {
|
|
return true;
|
|
}
|
|
const baseValue = baseValues[key];
|
|
return value !== baseValue &&
|
|
// basically a check for [] === []
|
|
!(Array.isArray(value) && Array.isArray(baseValue) && value.length === 0 && baseValue.length === 0);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a string representation of an instance
|
|
* @return {String}
|
|
*/
|
|
toString() {
|
|
return "#<".concat(this.constructor.type, ">");
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Function} klass
|
|
* @param {object} object
|
|
* @param {object} [options]
|
|
* @param {string} [options.extraParam] property to pass as first argument to the constructor
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @returns {Promise<FabricObject>}
|
|
*/
|
|
static _fromObject(_ref3) {
|
|
let serializedObjectOptions = _objectWithoutProperties(_ref3, _excluded$e);
|
|
let _ref4 = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
|
|
{
|
|
extraParam
|
|
} = _ref4,
|
|
options = _objectWithoutProperties(_ref4, _excluded2$4);
|
|
return enlivenObjectEnlivables(serializedObjectOptions, options).then(enlivedObjectOptions => {
|
|
// from the resulting enlived options, extract options.extraParam to arg0
|
|
// to avoid accidental overrides later
|
|
if (extraParam) {
|
|
delete enlivedObjectOptions[extraParam];
|
|
return new this(serializedObjectOptions[extraParam],
|
|
// @ts-expect-error different signature
|
|
enlivedObjectOptions);
|
|
} else {
|
|
return new this(enlivedObjectOptions);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {object} object
|
|
* @param {object} [options]
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @returns {Promise<FabricObject>}
|
|
*/
|
|
static fromObject(object, options) {
|
|
return this._fromObject(object, options);
|
|
}
|
|
};
|
|
/**
|
|
* This list of properties is used to check if the state of an object is changed.
|
|
* This state change now is only used for children of groups to understand if a group
|
|
* needs its cache regenerated during a .set call
|
|
* @type Array
|
|
*/
|
|
_defineProperty(FabricObject$1, "stateProperties", stateProperties);
|
|
/**
|
|
* List of properties to consider when checking if cache needs refresh
|
|
* Those properties are checked by
|
|
* calls to Object.set(key, value). If the key is in this list, the object is marked as dirty
|
|
* and refreshed at the next render
|
|
* @type Array
|
|
*/
|
|
_defineProperty(FabricObject$1, "cacheProperties", cacheProperties);
|
|
_defineProperty(FabricObject$1, "ownDefaults", fabricObjectDefaultValues);
|
|
_defineProperty(FabricObject$1, "type", 'FabricObject');
|
|
_defineProperty(FabricObject$1, "colorProperties", [FILL, STROKE, 'backgroundColor']);
|
|
_defineProperty(FabricObject$1, "customProperties", []);
|
|
classRegistry.setClass(FabricObject$1);
|
|
classRegistry.setClass(FabricObject$1, 'object');
|
|
|
|
/**
|
|
* Wrap an action handler with firing an event if the action is performed
|
|
* @param {TModificationEvents} eventName the event we want to fire
|
|
* @param {TransformActionHandler<T>} actionHandler the function to wrap
|
|
* @param {object} extraEventInfo extra information to pas to the event handler
|
|
* @return {TransformActionHandler<T>} a function with an action handler signature
|
|
*/
|
|
const wrapWithFireEvent = (eventName, actionHandler, extraEventInfo) => {
|
|
return (eventData, transform, x, y) => {
|
|
const actionPerformed = actionHandler(eventData, transform, x, y);
|
|
if (actionPerformed) {
|
|
fireEvent(eventName, _objectSpread2(_objectSpread2({}, commonEventInfo(eventData, transform, x, y)), extraEventInfo));
|
|
}
|
|
return actionPerformed;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Wrap an action handler with saving/restoring object position on the transform.
|
|
* this is the code that permits to objects to keep their position while transforming.
|
|
* @param {Function} actionHandler the function to wrap
|
|
* @return {Function} a function with an action handler signature
|
|
*/
|
|
function wrapWithFixedAnchor(actionHandler) {
|
|
return (eventData, transform, x, y) => {
|
|
const {
|
|
target,
|
|
originX,
|
|
originY
|
|
} = transform,
|
|
centerPoint = target.getRelativeCenterPoint(),
|
|
constraint = target.translateToOriginPoint(centerPoint, originX, originY),
|
|
actionPerformed = actionHandler(eventData, transform, x, y);
|
|
// flipping requires to change the transform origin, so we read from the mutated transform
|
|
// instead of leveraging the one destructured before
|
|
target.setPositionByOrigin(constraint, transform.originX, transform.originY);
|
|
return actionPerformed;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Action handler to change object's width
|
|
* Needs to be wrapped with `wrapWithFixedAnchor` to be effective
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
*/
|
|
const changeObjectWidth = (eventData, transform, x, y) => {
|
|
const localPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
|
|
// make sure the control changes width ONLY from it's side of target
|
|
if (resolveOrigin(transform.originX) === resolveOrigin(CENTER) || resolveOrigin(transform.originX) === resolveOrigin(RIGHT) && localPoint.x < 0 || resolveOrigin(transform.originX) === resolveOrigin(LEFT) && localPoint.x > 0) {
|
|
const {
|
|
target
|
|
} = transform,
|
|
strokePadding = target.strokeWidth / (target.strokeUniform ? target.scaleX : 1),
|
|
multiplier = isTransformCentered(transform) ? 2 : 1,
|
|
oldWidth = target.width,
|
|
newWidth = Math.abs(localPoint.x * multiplier / target.scaleX) - strokePadding;
|
|
target.set('width', Math.max(newWidth, 1));
|
|
// check against actual target width in case `newWidth` was rejected
|
|
return oldWidth !== target.width;
|
|
}
|
|
return false;
|
|
};
|
|
const changeWidth = wrapWithFireEvent(RESIZING, wrapWithFixedAnchor(changeObjectWidth));
|
|
|
|
/**
|
|
* Render a round control, as per fabric features.
|
|
* This function is written to respect object properties like transparentCorners, cornerSize
|
|
* cornerColor, cornerStrokeColor
|
|
* plus the addition of offsetY and offsetX.
|
|
* @param {CanvasRenderingContext2D} ctx context to render on
|
|
* @param {Number} left x coordinate where the control center should be
|
|
* @param {Number} top y coordinate where the control center should be
|
|
* @param {Object} styleOverride override for FabricObject controls style
|
|
* @param {FabricObject} fabricObject the fabric object for which we are rendering controls
|
|
*/
|
|
function renderCircleControl(ctx, left, top, styleOverride, fabricObject) {
|
|
styleOverride = styleOverride || {};
|
|
const xSize = this.sizeX || styleOverride.cornerSize || fabricObject.cornerSize,
|
|
ySize = this.sizeY || styleOverride.cornerSize || fabricObject.cornerSize,
|
|
transparentCorners = typeof styleOverride.transparentCorners !== 'undefined' ? styleOverride.transparentCorners : fabricObject.transparentCorners,
|
|
methodName = transparentCorners ? STROKE : FILL,
|
|
stroke = !transparentCorners && (styleOverride.cornerStrokeColor || fabricObject.cornerStrokeColor);
|
|
let myLeft = left,
|
|
myTop = top,
|
|
size;
|
|
ctx.save();
|
|
ctx.fillStyle = styleOverride.cornerColor || fabricObject.cornerColor || '';
|
|
ctx.strokeStyle = styleOverride.cornerStrokeColor || fabricObject.cornerStrokeColor || '';
|
|
// TODO: use proper ellipse code.
|
|
if (xSize > ySize) {
|
|
size = xSize;
|
|
ctx.scale(1.0, ySize / xSize);
|
|
myTop = top * xSize / ySize;
|
|
} else if (ySize > xSize) {
|
|
size = ySize;
|
|
ctx.scale(xSize / ySize, 1.0);
|
|
myLeft = left * ySize / xSize;
|
|
} else {
|
|
size = xSize;
|
|
}
|
|
// this is still wrong
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(myLeft, myTop, size / 2, 0, twoMathPi, false);
|
|
ctx[methodName]();
|
|
if (stroke) {
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Render a square control, as per fabric features.
|
|
* This function is written to respect object properties like transparentCorners, cornerSize
|
|
* cornerColor, cornerStrokeColor
|
|
* plus the addition of offsetY and offsetX.
|
|
* @param {CanvasRenderingContext2D} ctx context to render on
|
|
* @param {Number} left x coordinate where the control center should be
|
|
* @param {Number} top y coordinate where the control center should be
|
|
* @param {Object} styleOverride override for FabricObject controls style
|
|
* @param {FabricObject} fabricObject the fabric object for which we are rendering controls
|
|
*/
|
|
function renderSquareControl(ctx, left, top, styleOverride, fabricObject) {
|
|
styleOverride = styleOverride || {};
|
|
const xSize = this.sizeX || styleOverride.cornerSize || fabricObject.cornerSize,
|
|
ySize = this.sizeY || styleOverride.cornerSize || fabricObject.cornerSize,
|
|
transparentCorners = typeof styleOverride.transparentCorners !== 'undefined' ? styleOverride.transparentCorners : fabricObject.transparentCorners,
|
|
methodName = transparentCorners ? STROKE : FILL,
|
|
stroke = !transparentCorners && (styleOverride.cornerStrokeColor || fabricObject.cornerStrokeColor),
|
|
xSizeBy2 = xSize / 2,
|
|
ySizeBy2 = ySize / 2;
|
|
ctx.save();
|
|
ctx.fillStyle = styleOverride.cornerColor || fabricObject.cornerColor || '';
|
|
ctx.strokeStyle = styleOverride.cornerStrokeColor || fabricObject.cornerStrokeColor || '';
|
|
// this is still wrong
|
|
ctx.lineWidth = 1;
|
|
ctx.translate(left, top);
|
|
// angle is relative to canvas plane
|
|
const angle = fabricObject.getTotalAngle();
|
|
ctx.rotate(degreesToRadians(angle));
|
|
// this does not work, and fixed with ( && ) does not make sense.
|
|
// to have real transparent corners we need the controls on upperCanvas
|
|
// transparentCorners || ctx.clearRect(-xSizeBy2, -ySizeBy2, xSize, ySize);
|
|
ctx["".concat(methodName, "Rect")](-xSizeBy2, -ySizeBy2, xSize, ySize);
|
|
if (stroke) {
|
|
ctx.strokeRect(-xSizeBy2, -ySizeBy2, xSize, ySize);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
class Control {
|
|
constructor(options) {
|
|
/**
|
|
* keep track of control visibility.
|
|
* mainly for backward compatibility.
|
|
* if you do not want to see a control, you can remove it
|
|
* from the control set.
|
|
* @type {Boolean}
|
|
* @default true
|
|
*/
|
|
_defineProperty(this, "visible", true);
|
|
/**
|
|
* Name of the action that the control will likely execute.
|
|
* This is optional. FabricJS uses to identify what the user is doing for some
|
|
* extra optimizations. If you are writing a custom control and you want to know
|
|
* somewhere else in the code what is going on, you can use this string here.
|
|
* you can also provide a custom getActionName if your control run multiple actions
|
|
* depending on some external state.
|
|
* default to scale since is the most common, used on 4 corners by default
|
|
* @type {String}
|
|
* @default 'scale'
|
|
*/
|
|
_defineProperty(this, "actionName", SCALE);
|
|
/**
|
|
* Drawing angle of the control.
|
|
* NOT used for now, but name marked as needed for internal logic
|
|
* example: to reuse the same drawing function for different rotated controls
|
|
* @type {Number}
|
|
* @default 0
|
|
*/
|
|
_defineProperty(this, "angle", 0);
|
|
/**
|
|
* Relative position of the control. X
|
|
* 0,0 is the center of the Object, while -0.5 (left) or 0.5 (right) are the extremities
|
|
* of the bounding box.
|
|
* @type {Number}
|
|
* @default 0
|
|
*/
|
|
_defineProperty(this, "x", 0);
|
|
/**
|
|
* Relative position of the control. Y
|
|
* 0,0 is the center of the Object, while -0.5 (top) or 0.5 (bottom) are the extremities
|
|
* of the bounding box.
|
|
* @type {Number}
|
|
* @default 0
|
|
*/
|
|
_defineProperty(this, "y", 0);
|
|
/**
|
|
* Horizontal offset of the control from the defined position. In pixels
|
|
* Positive offset moves the control to the right, negative to the left.
|
|
* It used when you want to have position of control that does not scale with
|
|
* the bounding box. Example: rotation control is placed at x:0, y: 0.5 on
|
|
* the boundind box, with an offset of 30 pixels vertically. Those 30 pixels will
|
|
* stay 30 pixels no matter how the object is big. Another example is having 2
|
|
* controls in the corner, that stay in the same position when the object scale.
|
|
* of the bounding box.
|
|
* @type {Number}
|
|
* @default 0
|
|
*/
|
|
_defineProperty(this, "offsetX", 0);
|
|
/**
|
|
* Vertical offset of the control from the defined position. In pixels
|
|
* Positive offset moves the control to the bottom, negative to the top.
|
|
* @type {Number}
|
|
* @default 0
|
|
*/
|
|
_defineProperty(this, "offsetY", 0);
|
|
/**
|
|
* Sets the length of the control. If null, defaults to object's cornerSize.
|
|
* Expects both sizeX and sizeY to be set when set.
|
|
* @type {?Number}
|
|
* @default null
|
|
*/
|
|
_defineProperty(this, "sizeX", 0);
|
|
/**
|
|
* Sets the height of the control. If null, defaults to object's cornerSize.
|
|
* Expects both sizeX and sizeY to be set when set.
|
|
* @type {?Number}
|
|
* @default null
|
|
*/
|
|
_defineProperty(this, "sizeY", 0);
|
|
/**
|
|
* Sets the length of the touch area of the control. If null, defaults to object's touchCornerSize.
|
|
* Expects both touchSizeX and touchSizeY to be set when set.
|
|
* @type {?Number}
|
|
* @default null
|
|
*/
|
|
_defineProperty(this, "touchSizeX", 0);
|
|
/**
|
|
* Sets the height of the touch area of the control. If null, defaults to object's touchCornerSize.
|
|
* Expects both touchSizeX and touchSizeY to be set when set.
|
|
* @type {?Number}
|
|
* @default null
|
|
*/
|
|
_defineProperty(this, "touchSizeY", 0);
|
|
/**
|
|
* Css cursor style to display when the control is hovered.
|
|
* if the method `cursorStyleHandler` is provided, this property is ignored.
|
|
* @type {String}
|
|
* @default 'crosshair'
|
|
*/
|
|
_defineProperty(this, "cursorStyle", 'crosshair');
|
|
/**
|
|
* If controls has an offsetY or offsetX, draw a line that connects
|
|
* the control to the bounding box
|
|
* @type {Boolean}
|
|
* @default false
|
|
*/
|
|
_defineProperty(this, "withConnection", false);
|
|
Object.assign(this, options);
|
|
}
|
|
|
|
/**
|
|
* The control actionHandler, provide one to handle action ( control being moved )
|
|
* @param {Event} eventData the native mouse event
|
|
* @param {Transform} transformData properties of the current transform
|
|
* @param {Number} x x position of the cursor
|
|
* @param {Number} y y position of the cursor
|
|
* @return {Boolean} true if the action/event modified the object
|
|
*/
|
|
|
|
/**
|
|
* The control handler for mouse down, provide one to handle mouse down on control
|
|
* @param {Event} eventData the native mouse event
|
|
* @param {Transform} transformData properties of the current transform
|
|
* @param {Number} x x position of the cursor
|
|
* @param {Number} y y position of the cursor
|
|
* @return {Boolean} true if the action/event modified the object
|
|
*/
|
|
|
|
/**
|
|
* The control mouseUpHandler, provide one to handle an effect on mouse up.
|
|
* @param {Event} eventData the native mouse event
|
|
* @param {Transform} transformData properties of the current transform
|
|
* @param {Number} x x position of the cursor
|
|
* @param {Number} y y position of the cursor
|
|
* @return {Boolean} true if the action/event modified the object
|
|
*/
|
|
|
|
shouldActivate(controlKey, fabricObject, pointer, _ref) {
|
|
var _fabricObject$canvas;
|
|
let {
|
|
tl,
|
|
tr,
|
|
br,
|
|
bl
|
|
} = _ref;
|
|
// TODO: locking logic can be handled here instead of in the control handler logic
|
|
return ((_fabricObject$canvas = fabricObject.canvas) === null || _fabricObject$canvas === void 0 ? void 0 : _fabricObject$canvas.getActiveObject()) === fabricObject && fabricObject.isControlVisible(controlKey) && Intersection.isPointInPolygon(pointer, [tl, tr, br, bl]);
|
|
}
|
|
|
|
/**
|
|
* Returns control actionHandler
|
|
* @param {Event} eventData the native mouse event
|
|
* @param {FabricObject} fabricObject on which the control is displayed
|
|
* @param {Control} control control for which the action handler is being asked
|
|
* @return {Function} the action handler
|
|
*/
|
|
getActionHandler(eventData, fabricObject, control) {
|
|
return this.actionHandler;
|
|
}
|
|
|
|
/**
|
|
* Returns control mouseDown handler
|
|
* @param {Event} eventData the native mouse event
|
|
* @param {FabricObject} fabricObject on which the control is displayed
|
|
* @param {Control} control control for which the action handler is being asked
|
|
* @return {Function} the action handler
|
|
*/
|
|
getMouseDownHandler(eventData, fabricObject, control) {
|
|
return this.mouseDownHandler;
|
|
}
|
|
|
|
/**
|
|
* Returns control mouseUp handler.
|
|
* During actions the fabricObject or the control can be of different obj
|
|
* @param {Event} eventData the native mouse event
|
|
* @param {FabricObject} fabricObject on which the control is displayed
|
|
* @param {Control} control control for which the action handler is being asked
|
|
* @return {Function} the action handler
|
|
*/
|
|
getMouseUpHandler(eventData, fabricObject, control) {
|
|
return this.mouseUpHandler;
|
|
}
|
|
|
|
/**
|
|
* Returns control cursorStyle for css using cursorStyle. If you need a more elaborate
|
|
* function you can pass one in the constructor
|
|
* the cursorStyle property
|
|
* @param {Event} eventData the native mouse event
|
|
* @param {Control} control the current control ( likely this)
|
|
* @param {FabricObject} object on which the control is displayed
|
|
* @return {String}
|
|
*/
|
|
cursorStyleHandler(eventData, control, fabricObject) {
|
|
return control.cursorStyle;
|
|
}
|
|
|
|
/**
|
|
* Returns the action name. The basic implementation just return the actionName property.
|
|
* @param {Event} eventData the native mouse event
|
|
* @param {Control} control the current control ( likely this)
|
|
* @param {FabricObject} object on which the control is displayed
|
|
* @return {String}
|
|
*/
|
|
getActionName(eventData, control, fabricObject) {
|
|
return control.actionName;
|
|
}
|
|
|
|
/**
|
|
* Returns controls visibility
|
|
* @param {FabricObject} object on which the control is displayed
|
|
* @param {String} controlKey key where the control is memorized on the
|
|
* @return {Boolean}
|
|
*/
|
|
getVisibility(fabricObject, controlKey) {
|
|
var _fabricObject$_contro, _fabricObject$_contro2;
|
|
return (_fabricObject$_contro = (_fabricObject$_contro2 = fabricObject._controlsVisibility) === null || _fabricObject$_contro2 === void 0 ? void 0 : _fabricObject$_contro2[controlKey]) !== null && _fabricObject$_contro !== void 0 ? _fabricObject$_contro : this.visible;
|
|
}
|
|
|
|
/**
|
|
* Sets controls visibility
|
|
* @param {Boolean} visibility for the object
|
|
* @return {Void}
|
|
*/
|
|
setVisibility(visibility, name, fabricObject) {
|
|
this.visible = visibility;
|
|
}
|
|
positionHandler(dim, finalMatrix, fabricObject, currentControl) {
|
|
return new Point(this.x * dim.x + this.offsetX, this.y * dim.y + this.offsetY).transform(finalMatrix);
|
|
}
|
|
|
|
/**
|
|
* Returns the coords for this control based on object values.
|
|
* @param {Number} objectAngle angle from the fabric object holding the control
|
|
* @param {Number} objectCornerSize cornerSize from the fabric object holding the control (or touchCornerSize if
|
|
* isTouch is true)
|
|
* @param {Number} centerX x coordinate where the control center should be
|
|
* @param {Number} centerY y coordinate where the control center should be
|
|
* @param {boolean} isTouch true if touch corner, false if normal corner
|
|
*/
|
|
calcCornerCoords(angle, objectCornerSize, centerX, centerY, isTouch, fabricObject) {
|
|
const t = multiplyTransformMatrixArray([createTranslateMatrix(centerX, centerY), createRotateMatrix({
|
|
angle
|
|
}), createScaleMatrix((isTouch ? this.touchSizeX : this.sizeX) || objectCornerSize, (isTouch ? this.touchSizeY : this.sizeY) || objectCornerSize)]);
|
|
return {
|
|
tl: new Point(-0.5, -0.5).transform(t),
|
|
tr: new Point(0.5, -0.5).transform(t),
|
|
br: new Point(0.5, 0.5).transform(t),
|
|
bl: new Point(-0.5, 0.5).transform(t)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Render function for the control.
|
|
* When this function runs the context is unscaled. unrotate. Just retina scaled.
|
|
* all the functions will have to translate to the point left,top before starting Drawing
|
|
* if they want to draw a control where the position is detected.
|
|
* left and top are the result of the positionHandler function
|
|
* @param {RenderingContext2D} ctx the context where the control will be drawn
|
|
* @param {Number} left position of the canvas where we are about to render the control.
|
|
* @param {Number} top position of the canvas where we are about to render the control.
|
|
* @param {Object} styleOverride
|
|
* @param {FabricObject} fabricObject the object where the control is about to be rendered
|
|
*/
|
|
render(ctx, left, top, styleOverride, fabricObject) {
|
|
styleOverride = styleOverride || {};
|
|
switch (styleOverride.cornerStyle || fabricObject.cornerStyle) {
|
|
case 'circle':
|
|
renderCircleControl.call(this, ctx, left, top, styleOverride, fabricObject);
|
|
break;
|
|
default:
|
|
renderSquareControl.call(this, ctx, left, top, styleOverride, fabricObject);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the correct style for the control that is used for rotation.
|
|
* this function is very simple and it just take care of not-allowed or standard cursor
|
|
* @param {Event} eventData the javascript event that is causing the scale
|
|
* @param {Control} control the control that is interested in the action
|
|
* @param {FabricObject} fabricObject the fabric object that is interested in the action
|
|
* @return {String} a valid css string for the cursor
|
|
*/
|
|
const rotationStyleHandler = (eventData, control, fabricObject) => {
|
|
if (fabricObject.lockRotation) {
|
|
return NOT_ALLOWED_CURSOR;
|
|
}
|
|
return control.cursorStyle;
|
|
};
|
|
|
|
/**
|
|
* Action handler for rotation and snapping, without anchor point.
|
|
* Needs to be wrapped with `wrapWithFixedAnchor` to be effective
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
* @private
|
|
*/
|
|
const rotateObjectWithSnapping = (eventData, _ref, x, y) => {
|
|
let {
|
|
target,
|
|
ex,
|
|
ey,
|
|
theta,
|
|
originX,
|
|
originY
|
|
} = _ref;
|
|
const pivotPoint = target.translateToOriginPoint(target.getRelativeCenterPoint(), originX, originY);
|
|
if (isLocked(target, 'lockRotation')) {
|
|
return false;
|
|
}
|
|
const lastAngle = Math.atan2(ey - pivotPoint.y, ex - pivotPoint.x),
|
|
curAngle = Math.atan2(y - pivotPoint.y, x - pivotPoint.x);
|
|
let angle = radiansToDegrees(curAngle - lastAngle + theta);
|
|
if (target.snapAngle && target.snapAngle > 0) {
|
|
const snapAngle = target.snapAngle,
|
|
snapThreshold = target.snapThreshold || snapAngle,
|
|
rightAngleLocked = Math.ceil(angle / snapAngle) * snapAngle,
|
|
leftAngleLocked = Math.floor(angle / snapAngle) * snapAngle;
|
|
if (Math.abs(angle - leftAngleLocked) < snapThreshold) {
|
|
angle = leftAngleLocked;
|
|
} else if (Math.abs(angle - rightAngleLocked) < snapThreshold) {
|
|
angle = rightAngleLocked;
|
|
}
|
|
}
|
|
|
|
// normalize angle to positive value
|
|
if (angle < 0) {
|
|
angle = 360 + angle;
|
|
}
|
|
angle %= 360;
|
|
const hasRotated = target.angle !== angle;
|
|
// TODO: why aren't we using set?
|
|
target.angle = angle;
|
|
return hasRotated;
|
|
};
|
|
const rotationWithSnapping = wrapWithFireEvent(ROTATING, wrapWithFixedAnchor(rotateObjectWithSnapping));
|
|
|
|
/**
|
|
* Inspect event and fabricObject properties to understand if the scaling action
|
|
* @param {Event} eventData from the user action
|
|
* @param {FabricObject} fabricObject the fabric object about to scale
|
|
* @return {Boolean} true if scale is proportional
|
|
*/
|
|
function scaleIsProportional(eventData, fabricObject) {
|
|
const canvas = fabricObject.canvas,
|
|
uniformIsToggled = eventData[canvas.uniScaleKey];
|
|
return canvas.uniformScaling && !uniformIsToggled || !canvas.uniformScaling && uniformIsToggled;
|
|
}
|
|
|
|
/**
|
|
* Inspect fabricObject to understand if the current scaling action is allowed
|
|
* @param {FabricObject} fabricObject the fabric object about to scale
|
|
* @param {String} by 'x' or 'y' or ''
|
|
* @param {Boolean} scaleProportionally true if we are trying to scale proportionally
|
|
* @return {Boolean} true if scaling is not allowed at current conditions
|
|
*/
|
|
function scalingIsForbidden(fabricObject, by, scaleProportionally) {
|
|
const lockX = isLocked(fabricObject, 'lockScalingX'),
|
|
lockY = isLocked(fabricObject, 'lockScalingY');
|
|
if (lockX && lockY) {
|
|
return true;
|
|
}
|
|
if (!by && (lockX || lockY) && scaleProportionally) {
|
|
return true;
|
|
}
|
|
if (lockX && by === 'x') {
|
|
return true;
|
|
}
|
|
if (lockY && by === 'y') {
|
|
return true;
|
|
}
|
|
// code crashes because of a division by 0 if a 0 sized object is scaled
|
|
// forbid to prevent scaling to happen. ISSUE-9475
|
|
const {
|
|
width,
|
|
height,
|
|
strokeWidth
|
|
} = fabricObject;
|
|
if (width === 0 && strokeWidth === 0 && by !== 'y') {
|
|
return true;
|
|
}
|
|
if (height === 0 && strokeWidth === 0 && by !== 'x') {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
const scaleMap = ['e', 'se', 's', 'sw', 'w', 'nw', 'n', 'ne', 'e'];
|
|
|
|
/**
|
|
* return the correct cursor style for the scale action
|
|
* @param {Event} eventData the javascript event that is causing the scale
|
|
* @param {Control} control the control that is interested in the action
|
|
* @param {FabricObject} fabricObject the fabric object that is interested in the action
|
|
* @return {String} a valid css string for the cursor
|
|
*/
|
|
const scaleCursorStyleHandler = (eventData, control, fabricObject) => {
|
|
const scaleProportionally = scaleIsProportional(eventData, fabricObject),
|
|
by = control.x !== 0 && control.y === 0 ? 'x' : control.x === 0 && control.y !== 0 ? 'y' : '';
|
|
if (scalingIsForbidden(fabricObject, by, scaleProportionally)) {
|
|
return NOT_ALLOWED_CURSOR;
|
|
}
|
|
const n = findCornerQuadrant(fabricObject, control);
|
|
return "".concat(scaleMap[n], "-resize");
|
|
};
|
|
|
|
/**
|
|
* Basic scaling logic, reused with different constrain for scaling X,Y, freely or equally.
|
|
* Needs to be wrapped with `wrapWithFixedAnchor` to be effective
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @param {Object} options additional information for scaling
|
|
* @param {String} options.by 'x', 'y', 'equally' or '' to indicate type of scaling
|
|
* @return {Boolean} true if some change happened
|
|
* @private
|
|
*/
|
|
function scaleObject(eventData, transform, x, y) {
|
|
let options = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : {};
|
|
const target = transform.target,
|
|
by = options.by,
|
|
scaleProportionally = scaleIsProportional(eventData, target),
|
|
forbidScaling = scalingIsForbidden(target, by, scaleProportionally);
|
|
let newPoint, scaleX, scaleY, dim, signX, signY;
|
|
if (forbidScaling) {
|
|
return false;
|
|
}
|
|
if (transform.gestureScale) {
|
|
scaleX = transform.scaleX * transform.gestureScale;
|
|
scaleY = transform.scaleY * transform.gestureScale;
|
|
} else {
|
|
newPoint = getLocalPoint(transform, transform.originX, transform.originY, x, y);
|
|
// use of sign: We use sign to detect change of direction of an action. sign usually change when
|
|
// we cross the origin point with the mouse. So a scale flip for example. There is an issue when scaling
|
|
// by center and scaling using one middle control ( default: mr, mt, ml, mb), the mouse movement can easily
|
|
// cross many time the origin point and flip the object. so we need a way to filter out the noise.
|
|
// This ternary here should be ok to filter out X scaling when we want Y only and vice versa.
|
|
signX = by !== 'y' ? Math.sign(newPoint.x || transform.signX || 1) : 1;
|
|
signY = by !== 'x' ? Math.sign(newPoint.y || transform.signY || 1) : 1;
|
|
if (!transform.signX) {
|
|
transform.signX = signX;
|
|
}
|
|
if (!transform.signY) {
|
|
transform.signY = signY;
|
|
}
|
|
if (isLocked(target, 'lockScalingFlip') && (transform.signX !== signX || transform.signY !== signY)) {
|
|
return false;
|
|
}
|
|
dim = target._getTransformedDimensions();
|
|
// missing detection of flip and logic to switch the origin
|
|
if (scaleProportionally && !by) {
|
|
// uniform scaling
|
|
const distance = Math.abs(newPoint.x) + Math.abs(newPoint.y),
|
|
{
|
|
original
|
|
} = transform,
|
|
originalDistance = Math.abs(dim.x * original.scaleX / target.scaleX) + Math.abs(dim.y * original.scaleY / target.scaleY),
|
|
scale = distance / originalDistance;
|
|
scaleX = original.scaleX * scale;
|
|
scaleY = original.scaleY * scale;
|
|
} else {
|
|
scaleX = Math.abs(newPoint.x * target.scaleX / dim.x);
|
|
scaleY = Math.abs(newPoint.y * target.scaleY / dim.y);
|
|
}
|
|
// if we are scaling by center, we need to double the scale
|
|
if (isTransformCentered(transform)) {
|
|
scaleX *= 2;
|
|
scaleY *= 2;
|
|
}
|
|
if (transform.signX !== signX && by !== 'y') {
|
|
transform.originX = invertOrigin(transform.originX);
|
|
scaleX *= -1;
|
|
transform.signX = signX;
|
|
}
|
|
if (transform.signY !== signY && by !== 'x') {
|
|
transform.originY = invertOrigin(transform.originY);
|
|
scaleY *= -1;
|
|
transform.signY = signY;
|
|
}
|
|
}
|
|
// minScale is taken care of in the setter.
|
|
const oldScaleX = target.scaleX,
|
|
oldScaleY = target.scaleY;
|
|
if (!by) {
|
|
!isLocked(target, 'lockScalingX') && target.set(SCALE_X, scaleX);
|
|
!isLocked(target, 'lockScalingY') && target.set(SCALE_Y, scaleY);
|
|
} else {
|
|
// forbidden cases already handled on top here.
|
|
by === 'x' && target.set(SCALE_X, scaleX);
|
|
by === 'y' && target.set(SCALE_Y, scaleY);
|
|
}
|
|
return oldScaleX !== target.scaleX || oldScaleY !== target.scaleY;
|
|
}
|
|
|
|
/**
|
|
* Generic scaling logic, to scale from corners either equally or freely.
|
|
* Needs to be wrapped with `wrapWithFixedAnchor` to be effective
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
*/
|
|
const scaleObjectFromCorner = (eventData, transform, x, y) => {
|
|
return scaleObject(eventData, transform, x, y);
|
|
};
|
|
|
|
/**
|
|
* Scaling logic for the X axis.
|
|
* Needs to be wrapped with `wrapWithFixedAnchor` to be effective
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
*/
|
|
const scaleObjectX = (eventData, transform, x, y) => {
|
|
return scaleObject(eventData, transform, x, y, {
|
|
by: 'x'
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Scaling logic for the Y axis.
|
|
* Needs to be wrapped with `wrapWithFixedAnchor` to be effective
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
*/
|
|
const scaleObjectY = (eventData, transform, x, y) => {
|
|
return scaleObject(eventData, transform, x, y, {
|
|
by: 'y'
|
|
});
|
|
};
|
|
const scalingEqually = wrapWithFireEvent(SCALING, wrapWithFixedAnchor(scaleObjectFromCorner));
|
|
const scalingX = wrapWithFireEvent(SCALING, wrapWithFixedAnchor(scaleObjectX));
|
|
const scalingY = wrapWithFireEvent(SCALING, wrapWithFixedAnchor(scaleObjectY));
|
|
|
|
const _excluded$d = ["target", "ex", "ey", "skewingSide"];
|
|
const AXIS_KEYS = {
|
|
x: {
|
|
counterAxis: 'y',
|
|
scale: SCALE_X,
|
|
skew: SKEW_X,
|
|
lockSkewing: 'lockSkewingX',
|
|
origin: 'originX',
|
|
flip: 'flipX'
|
|
},
|
|
y: {
|
|
counterAxis: 'x',
|
|
scale: SCALE_Y,
|
|
skew: SKEW_Y,
|
|
lockSkewing: 'lockSkewingY',
|
|
origin: 'originY',
|
|
flip: 'flipY'
|
|
}
|
|
};
|
|
const skewMap = ['ns', 'nesw', 'ew', 'nwse'];
|
|
|
|
/**
|
|
* return the correct cursor style for the skew action
|
|
* @param {Event} eventData the javascript event that is causing the scale
|
|
* @param {Control} control the control that is interested in the action
|
|
* @param {FabricObject} fabricObject the fabric object that is interested in the action
|
|
* @return {String} a valid css string for the cursor
|
|
*/
|
|
const skewCursorStyleHandler = (eventData, control, fabricObject) => {
|
|
if (control.x !== 0 && isLocked(fabricObject, 'lockSkewingY')) {
|
|
return NOT_ALLOWED_CURSOR;
|
|
}
|
|
if (control.y !== 0 && isLocked(fabricObject, 'lockSkewingX')) {
|
|
return NOT_ALLOWED_CURSOR;
|
|
}
|
|
const n = findCornerQuadrant(fabricObject, control) % 4;
|
|
return "".concat(skewMap[n], "-resize");
|
|
};
|
|
|
|
/**
|
|
* Since skewing is applied before scaling, calculations are done in a scaleless plane
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8380
|
|
*/
|
|
function skewObject(axis, _ref, pointer) {
|
|
let {
|
|
target,
|
|
ex,
|
|
ey,
|
|
skewingSide
|
|
} = _ref,
|
|
transform = _objectWithoutProperties(_ref, _excluded$d);
|
|
const {
|
|
skew: skewKey
|
|
} = AXIS_KEYS[axis],
|
|
offset = pointer.subtract(new Point(ex, ey)).divide(new Point(target.scaleX, target.scaleY))[axis],
|
|
skewingBefore = target[skewKey],
|
|
skewingStart = transform[skewKey],
|
|
shearingStart = Math.tan(degreesToRadians(skewingStart)),
|
|
// let a, b be the size of target
|
|
// let a' be the value of a after applying skewing
|
|
// then:
|
|
// a' = a + b * skewA => skewA = (a' - a) / b
|
|
// the value b is tricky since skewY is applied before skewX
|
|
b = axis === 'y' ? target._getTransformedDimensions({
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
// since skewY is applied before skewX, b (=width) is not affected by skewX
|
|
skewX: 0
|
|
}).x : target._getTransformedDimensions({
|
|
scaleX: 1,
|
|
scaleY: 1
|
|
}).y;
|
|
const shearing = 2 * offset * skewingSide /
|
|
// we max out fractions to safeguard from asymptotic behavior
|
|
Math.max(b, 1) +
|
|
// add starting state
|
|
shearingStart;
|
|
const skewing = radiansToDegrees(Math.atan(shearing));
|
|
target.set(skewKey, skewing);
|
|
const changed = skewingBefore !== target[skewKey];
|
|
if (changed && axis === 'y') {
|
|
// we don't want skewing to affect scaleX
|
|
// so we factor it by the inverse skewing diff to make it seem unchanged to the viewer
|
|
const {
|
|
skewX,
|
|
scaleX
|
|
} = target,
|
|
dimBefore = target._getTransformedDimensions({
|
|
skewY: skewingBefore
|
|
}),
|
|
dimAfter = target._getTransformedDimensions(),
|
|
compensationFactor = skewX !== 0 ? dimBefore.x / dimAfter.x : 1;
|
|
compensationFactor !== 1 && target.set(SCALE_X, compensationFactor * scaleX);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
/**
|
|
* Wrapped Action handler for skewing on a given axis, takes care of the
|
|
* skew direction and determines the correct transform origin for the anchor point
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
*/
|
|
function skewHandler(axis, eventData, transform, x, y) {
|
|
const {
|
|
target
|
|
} = transform,
|
|
{
|
|
counterAxis,
|
|
origin: originKey,
|
|
lockSkewing: lockSkewingKey,
|
|
skew: skewKey,
|
|
flip: flipKey
|
|
} = AXIS_KEYS[axis];
|
|
if (isLocked(target, lockSkewingKey)) {
|
|
return false;
|
|
}
|
|
const {
|
|
origin: counterOriginKey,
|
|
flip: counterFlipKey
|
|
} = AXIS_KEYS[counterAxis],
|
|
counterOriginFactor = resolveOrigin(transform[counterOriginKey]) * (target[counterFlipKey] ? -1 : 1),
|
|
// if the counter origin is top/left (= -0.5) then we are skewing x/y values on the bottom/right side of target respectively.
|
|
// if the counter origin is bottom/right (= 0.5) then we are skewing x/y values on the top/left side of target respectively.
|
|
// skewing direction on the top/left side of target is OPPOSITE to the direction of the movement of the pointer,
|
|
// so we factor skewing direction by this value.
|
|
skewingSide = -Math.sign(counterOriginFactor) * (target[flipKey] ? -1 : 1),
|
|
skewingDirection = (target[skewKey] === 0 &&
|
|
// in case skewing equals 0 we use the pointer offset from target center to determine the direction of skewing
|
|
getLocalPoint(transform, CENTER, CENTER, x, y)[axis] > 0 ||
|
|
// in case target has skewing we use that as the direction
|
|
target[skewKey] > 0 ? 1 : -1) * skewingSide,
|
|
// anchor to the opposite side of the skewing direction
|
|
// normalize value from [-1, 1] to origin value [0, 1]
|
|
origin = -skewingDirection * 0.5 + 0.5;
|
|
const finalHandler = wrapWithFireEvent(SKEWING, wrapWithFixedAnchor((eventData, transform, x, y) => skewObject(axis, transform, new Point(x, y))));
|
|
return finalHandler(eventData, _objectSpread2(_objectSpread2({}, transform), {}, {
|
|
[originKey]: origin,
|
|
skewingSide
|
|
}), x, y);
|
|
}
|
|
|
|
/**
|
|
* Wrapped Action handler for skewing on the X axis, takes care of the
|
|
* skew direction and determines the correct transform origin for the anchor point
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
*/
|
|
const skewHandlerX = (eventData, transform, x, y) => {
|
|
return skewHandler('x', eventData, transform, x, y);
|
|
};
|
|
|
|
/**
|
|
* Wrapped Action handler for skewing on the Y axis, takes care of the
|
|
* skew direction and determines the correct transform origin for the anchor point
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
*/
|
|
const skewHandlerY = (eventData, transform, x, y) => {
|
|
return skewHandler('y', eventData, transform, x, y);
|
|
};
|
|
|
|
function isAltAction(eventData, target) {
|
|
return eventData[target.canvas.altActionKey];
|
|
}
|
|
|
|
/**
|
|
* Inspect event, control and fabricObject to return the correct action name
|
|
* @param {Event} eventData the javascript event that is causing the scale
|
|
* @param {Control} control the control that is interested in the action
|
|
* @param {FabricObject} fabricObject the fabric object that is interested in the action
|
|
* @return {String} an action name
|
|
*/
|
|
const scaleOrSkewActionName = (eventData, control, fabricObject) => {
|
|
const isAlternative = isAltAction(eventData, fabricObject);
|
|
if (control.x === 0) {
|
|
// then is scaleY or skewX
|
|
return isAlternative ? SKEW_X : SCALE_Y;
|
|
}
|
|
if (control.y === 0) {
|
|
// then is scaleY or skewX
|
|
return isAlternative ? SKEW_Y : SCALE_X;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
/**
|
|
* Combine skew and scale style handlers to cover fabric standard use case
|
|
* @param {Event} eventData the javascript event that is causing the scale
|
|
* @param {Control} control the control that is interested in the action
|
|
* @param {FabricObject} fabricObject the fabric object that is interested in the action
|
|
* @return {String} a valid css string for the cursor
|
|
*/
|
|
const scaleSkewCursorStyleHandler = (eventData, control, fabricObject) => {
|
|
return isAltAction(eventData, fabricObject) ? skewCursorStyleHandler(eventData, control, fabricObject) : scaleCursorStyleHandler(eventData, control, fabricObject);
|
|
};
|
|
/**
|
|
* Composed action handler to either scale X or skew Y
|
|
* Needs to be wrapped with `wrapWithFixedAnchor` to be effective
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
*/
|
|
const scalingXOrSkewingY = (eventData, transform, x, y) => {
|
|
return isAltAction(eventData, transform.target) ? skewHandlerY(eventData, transform, x, y) : scalingX(eventData, transform, x, y);
|
|
};
|
|
|
|
/**
|
|
* Composed action handler to either scale Y or skew X
|
|
* Needs to be wrapped with `wrapWithFixedAnchor` to be effective
|
|
* @param {Event} eventData javascript event that is doing the transform
|
|
* @param {Object} transform javascript object containing a series of information around the current transform
|
|
* @param {number} x current mouse x position, canvas normalized
|
|
* @param {number} y current mouse y position, canvas normalized
|
|
* @return {Boolean} true if some change happened
|
|
*/
|
|
const scalingYOrSkewingX = (eventData, transform, x, y) => {
|
|
return isAltAction(eventData, transform.target) ? skewHandlerX(eventData, transform, x, y) : scalingY(eventData, transform, x, y);
|
|
};
|
|
|
|
// use this function if you want to generate new controls for every instance
|
|
const createObjectDefaultControls = () => ({
|
|
ml: new Control({
|
|
x: -0.5,
|
|
y: 0,
|
|
cursorStyleHandler: scaleSkewCursorStyleHandler,
|
|
actionHandler: scalingXOrSkewingY,
|
|
getActionName: scaleOrSkewActionName
|
|
}),
|
|
mr: new Control({
|
|
x: 0.5,
|
|
y: 0,
|
|
cursorStyleHandler: scaleSkewCursorStyleHandler,
|
|
actionHandler: scalingXOrSkewingY,
|
|
getActionName: scaleOrSkewActionName
|
|
}),
|
|
mb: new Control({
|
|
x: 0,
|
|
y: 0.5,
|
|
cursorStyleHandler: scaleSkewCursorStyleHandler,
|
|
actionHandler: scalingYOrSkewingX,
|
|
getActionName: scaleOrSkewActionName
|
|
}),
|
|
mt: new Control({
|
|
x: 0,
|
|
y: -0.5,
|
|
cursorStyleHandler: scaleSkewCursorStyleHandler,
|
|
actionHandler: scalingYOrSkewingX,
|
|
getActionName: scaleOrSkewActionName
|
|
}),
|
|
tl: new Control({
|
|
x: -0.5,
|
|
y: -0.5,
|
|
cursorStyleHandler: scaleCursorStyleHandler,
|
|
actionHandler: scalingEqually
|
|
}),
|
|
tr: new Control({
|
|
x: 0.5,
|
|
y: -0.5,
|
|
cursorStyleHandler: scaleCursorStyleHandler,
|
|
actionHandler: scalingEqually
|
|
}),
|
|
bl: new Control({
|
|
x: -0.5,
|
|
y: 0.5,
|
|
cursorStyleHandler: scaleCursorStyleHandler,
|
|
actionHandler: scalingEqually
|
|
}),
|
|
br: new Control({
|
|
x: 0.5,
|
|
y: 0.5,
|
|
cursorStyleHandler: scaleCursorStyleHandler,
|
|
actionHandler: scalingEqually
|
|
}),
|
|
mtr: new Control({
|
|
x: 0,
|
|
y: -0.5,
|
|
actionHandler: rotationWithSnapping,
|
|
cursorStyleHandler: rotationStyleHandler,
|
|
offsetY: -40,
|
|
withConnection: true,
|
|
actionName: ROTATE
|
|
})
|
|
});
|
|
const createResizeControls = () => ({
|
|
mr: new Control({
|
|
x: 0.5,
|
|
y: 0,
|
|
actionHandler: changeWidth,
|
|
cursorStyleHandler: scaleSkewCursorStyleHandler,
|
|
actionName: RESIZING
|
|
}),
|
|
ml: new Control({
|
|
x: -0.5,
|
|
y: 0,
|
|
actionHandler: changeWidth,
|
|
cursorStyleHandler: scaleSkewCursorStyleHandler,
|
|
actionName: RESIZING
|
|
})
|
|
});
|
|
const createTextboxDefaultControls = () => _objectSpread2(_objectSpread2({}, createObjectDefaultControls()), createResizeControls());
|
|
|
|
class InteractiveFabricObject extends FabricObject$1 {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), InteractiveFabricObject.ownDefaults);
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor(options) {
|
|
super();
|
|
Object.assign(this, this.constructor.createControls(), InteractiveFabricObject.ownDefaults);
|
|
this.setOptions(options);
|
|
}
|
|
|
|
/**
|
|
* Creates the default control object.
|
|
* If you prefer to have on instance of controls shared among all objects
|
|
* make this function return an empty object and add controls to the ownDefaults
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
static createControls() {
|
|
return {
|
|
controls: createObjectDefaultControls()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update width and height of the canvas for cache
|
|
* returns true or false if canvas needed resize.
|
|
* @private
|
|
* @return {Boolean} true if the canvas has been resized
|
|
*/
|
|
_updateCacheCanvas() {
|
|
const targetCanvas = this.canvas;
|
|
if (this.noScaleCache && targetCanvas && targetCanvas._currentTransform) {
|
|
const transform = targetCanvas._currentTransform,
|
|
target = transform.target,
|
|
action = transform.action;
|
|
if (this === target && action && action.startsWith(SCALE)) {
|
|
return false;
|
|
}
|
|
}
|
|
return super._updateCacheCanvas();
|
|
}
|
|
getActiveControl() {
|
|
const key = this.__corner;
|
|
return key ? {
|
|
key,
|
|
control: this.controls[key],
|
|
coord: this.oCoords[key]
|
|
} : undefined;
|
|
}
|
|
|
|
/**
|
|
* Determines which corner is under the mouse cursor, represented by `pointer`.
|
|
* This function is return a corner only if the object is the active one.
|
|
* This is done to avoid selecting corner of non active object and activating transformations
|
|
* rather than drag action. The default behavior of fabricJS is that if you want to transform
|
|
* an object, first you select it to show the control set
|
|
* @private
|
|
* @param {Object} pointer The pointer indicating the mouse position
|
|
* @param {boolean} forTouch indicates if we are looking for interaction area with a touch action
|
|
* @return {String|Boolean} corner code (tl, tr, bl, br, etc.), or 0 if nothing is found.
|
|
*/
|
|
findControl(pointer) {
|
|
let forTouch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
|
|
if (!this.hasControls || !this.canvas) {
|
|
return undefined;
|
|
}
|
|
this.__corner = undefined;
|
|
const cornerEntries = Object.entries(this.oCoords);
|
|
for (let i = cornerEntries.length - 1; i >= 0; i--) {
|
|
const [key, corner] = cornerEntries[i];
|
|
const control = this.controls[key];
|
|
if (control.shouldActivate(key, this, pointer, forTouch ? corner.touchCorner : corner.corner)) {
|
|
// this.canvas.contextTop.fillRect(pointer.x - 1, pointer.y - 1, 2, 2);
|
|
this.__corner = key;
|
|
return {
|
|
key,
|
|
control,
|
|
coord: this.oCoords[key]
|
|
};
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Calculates the coordinates of the center of each control plus the corners of the control itself
|
|
* This basically just delegates to each control positionHandler
|
|
* WARNING: changing what is passed to positionHandler is a breaking change, since position handler
|
|
* is a public api and should be done just if extremely necessary
|
|
* @return {Record<string, TOCoord>}
|
|
*/
|
|
calcOCoords() {
|
|
const vpt = this.getViewportTransform(),
|
|
center = this.getCenterPoint(),
|
|
tMatrix = createTranslateMatrix(center.x, center.y),
|
|
rMatrix = createRotateMatrix({
|
|
angle: this.getTotalAngle() - (!!this.group && this.flipX ? 180 : 0)
|
|
}),
|
|
positionMatrix = multiplyTransformMatrices(tMatrix, rMatrix),
|
|
startMatrix = multiplyTransformMatrices(vpt, positionMatrix),
|
|
finalMatrix = multiplyTransformMatrices(startMatrix, [1 / vpt[0], 0, 0, 1 / vpt[3], 0, 0]),
|
|
transformOptions = this.group ? qrDecompose(this.calcTransformMatrix()) : undefined;
|
|
// decomposing could bring negative scaling and `_calculateCurrentDimensions` can't take it
|
|
if (transformOptions) {
|
|
transformOptions.scaleX = Math.abs(transformOptions.scaleX);
|
|
transformOptions.scaleY = Math.abs(transformOptions.scaleY);
|
|
}
|
|
const dim = this._calculateCurrentDimensions(transformOptions),
|
|
coords = {};
|
|
this.forEachControl((control, key) => {
|
|
const position = control.positionHandler(dim, finalMatrix, this, control);
|
|
// coords[key] are sometimes used as points. Those are points to which we add
|
|
// the property corner and touchCorner from `_calcCornerCoords`.
|
|
// don't remove this assign for an object spread.
|
|
coords[key] = Object.assign(position, this._calcCornerCoords(control, position));
|
|
});
|
|
|
|
// debug code
|
|
/*
|
|
const canvas = this.canvas;
|
|
setTimeout(function () {
|
|
if (!canvas) return;
|
|
canvas.contextTop.clearRect(0, 0, 700, 700);
|
|
canvas.contextTop.fillStyle = 'green';
|
|
Object.keys(coords).forEach(function(key) {
|
|
const control = coords[key];
|
|
canvas.contextTop.fillRect(control.x, control.y, 3, 3);
|
|
});
|
|
} 50);
|
|
*/
|
|
return coords;
|
|
}
|
|
|
|
/**
|
|
* Sets the coordinates that determine the interaction area of each control
|
|
* note: if we would switch to ROUND corner area, all of this would disappear.
|
|
* everything would resolve to a single point and a pythagorean theorem for the distance
|
|
* @todo evaluate simplification of code switching to circle interaction area at runtime
|
|
* @private
|
|
*/
|
|
_calcCornerCoords(control, position) {
|
|
const angle = this.getTotalAngle();
|
|
const corner = control.calcCornerCoords(angle, this.cornerSize, position.x, position.y, false, this);
|
|
const touchCorner = control.calcCornerCoords(angle, this.touchCornerSize, position.x, position.y, true, this);
|
|
return {
|
|
corner,
|
|
touchCorner
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @override set controls' coordinates as well
|
|
* See {@link https://github.com/fabricjs/fabric.js/wiki/When-to-call-setCoords} and {@link http://fabricjs.com/fabric-gotchas}
|
|
* @return {void}
|
|
*/
|
|
setCoords() {
|
|
super.setCoords();
|
|
this.canvas && (this.oCoords = this.calcOCoords());
|
|
}
|
|
|
|
/**
|
|
* Calls a function for each control. The function gets called,
|
|
* with the control, the control's key and the object that is calling the iterator
|
|
* @param {Function} fn function to iterate over the controls over
|
|
*/
|
|
forEachControl(fn) {
|
|
for (const i in this.controls) {
|
|
fn(this.controls[i], i, this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draws a colored layer behind the object, inside its selection borders.
|
|
* Requires public options: padding, selectionBackgroundColor
|
|
* this function is called when the context is transformed
|
|
* has checks to be skipped when the object is on a staticCanvas
|
|
* @todo evaluate if make this disappear in favor of a pre-render hook for objects
|
|
* this was added by Andrea Bogazzi to make possible some feature for work reasons
|
|
* it seemed a good option, now is an edge case
|
|
* @param {CanvasRenderingContext2D} ctx Context to draw on
|
|
*/
|
|
drawSelectionBackground(ctx) {
|
|
if (!this.selectionBackgroundColor || this.canvas && this.canvas._activeObject !== this) {
|
|
return;
|
|
}
|
|
ctx.save();
|
|
const center = this.getRelativeCenterPoint(),
|
|
wh = this._calculateCurrentDimensions(),
|
|
vpt = this.getViewportTransform();
|
|
ctx.translate(center.x, center.y);
|
|
ctx.scale(1 / vpt[0], 1 / vpt[3]);
|
|
ctx.rotate(degreesToRadians(this.angle));
|
|
ctx.fillStyle = this.selectionBackgroundColor;
|
|
ctx.fillRect(-wh.x / 2, -wh.y / 2, wh.x, wh.y);
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* @public override this function in order to customize the drawing of the control box, e.g. rounded corners, different border style.
|
|
* @param {CanvasRenderingContext2D} ctx ctx is rotated and translated so that (0,0) is at object's center
|
|
* @param {Point} size the control box size used
|
|
*/
|
|
strokeBorders(ctx, size) {
|
|
ctx.strokeRect(-size.x / 2, -size.y / 2, size.x, size.y);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to draw on
|
|
* @param {Point} size
|
|
* @param {TStyleOverride} styleOverride object to override the object style
|
|
*/
|
|
_drawBorders(ctx, size) {
|
|
let styleOverride = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
|
|
const options = _objectSpread2({
|
|
hasControls: this.hasControls,
|
|
borderColor: this.borderColor,
|
|
borderDashArray: this.borderDashArray
|
|
}, styleOverride);
|
|
ctx.save();
|
|
ctx.strokeStyle = options.borderColor;
|
|
this._setLineDash(ctx, options.borderDashArray);
|
|
this.strokeBorders(ctx, size);
|
|
options.hasControls && this.drawControlsConnectingLines(ctx, size);
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Renders controls and borders for the object
|
|
* the context here is not transformed
|
|
* @todo move to interactivity
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {TStyleOverride} [styleOverride] properties to override the object style
|
|
*/
|
|
_renderControls(ctx) {
|
|
let styleOverride = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const {
|
|
hasBorders,
|
|
hasControls
|
|
} = this;
|
|
const styleOptions = _objectSpread2({
|
|
hasBorders,
|
|
hasControls
|
|
}, styleOverride);
|
|
const vpt = this.getViewportTransform(),
|
|
shouldDrawBorders = styleOptions.hasBorders,
|
|
shouldDrawControls = styleOptions.hasControls;
|
|
const matrix = multiplyTransformMatrices(vpt, this.calcTransformMatrix());
|
|
const options = qrDecompose(matrix);
|
|
ctx.save();
|
|
ctx.translate(options.translateX, options.translateY);
|
|
ctx.lineWidth = 1 * this.borderScaleFactor;
|
|
// since interactive groups have been introduced, an object could be inside a group and needing controls
|
|
// the following equality check `this.group === this.parent` covers:
|
|
// object without a group ( undefined === undefined )
|
|
// object inside a group
|
|
// excludes object inside a group but multi selected since group and parent will differ in value
|
|
if (this.group === this.parent) {
|
|
ctx.globalAlpha = this.isMoving ? this.borderOpacityWhenMoving : 1;
|
|
}
|
|
if (this.flipX) {
|
|
options.angle -= 180;
|
|
}
|
|
ctx.rotate(degreesToRadians(this.group ? options.angle : this.angle));
|
|
shouldDrawBorders && this.drawBorders(ctx, options, styleOverride);
|
|
shouldDrawControls && this.drawControls(ctx, styleOverride);
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draws borders of an object's bounding box.
|
|
* Requires public properties: width, height
|
|
* Requires public options: padding, borderColor
|
|
* @param {CanvasRenderingContext2D} ctx Context to draw on
|
|
* @param {object} options object representing current object parameters
|
|
* @param {TStyleOverride} [styleOverride] object to override the object style
|
|
*/
|
|
drawBorders(ctx, options, styleOverride) {
|
|
let size;
|
|
if (styleOverride && styleOverride.forActiveSelection || this.group) {
|
|
const bbox = sizeAfterTransform(this.width, this.height, calcDimensionsMatrix(options)),
|
|
stroke = !this.isStrokeAccountedForInDimensions() ? (this.strokeUniform ? new Point().scalarAdd(this.canvas ? this.canvas.getZoom() : 1) :
|
|
// this is extremely confusing. options comes from the upper function
|
|
// and is the qrDecompose of a matrix that takes in account zoom too
|
|
new Point(options.scaleX, options.scaleY)).scalarMultiply(this.strokeWidth) : ZERO;
|
|
size = bbox.add(stroke).scalarAdd(this.borderScaleFactor).scalarAdd(this.padding * 2);
|
|
} else {
|
|
size = this._calculateCurrentDimensions().scalarAdd(this.borderScaleFactor);
|
|
}
|
|
this._drawBorders(ctx, size, styleOverride);
|
|
}
|
|
|
|
/**
|
|
* Draws lines from a borders of an object's bounding box to controls that have `withConnection` property set.
|
|
* Requires public properties: width, height
|
|
* Requires public options: padding, borderColor
|
|
* @param {CanvasRenderingContext2D} ctx Context to draw on
|
|
* @param {Point} size object size x = width, y = height
|
|
*/
|
|
drawControlsConnectingLines(ctx, size) {
|
|
let shouldStroke = false;
|
|
ctx.beginPath();
|
|
this.forEachControl((control, key) => {
|
|
// in this moment, the ctx is centered on the object.
|
|
// width and height of the above function are the size of the bbox.
|
|
if (control.withConnection && control.getVisibility(this, key)) {
|
|
// reset movement for each control
|
|
shouldStroke = true;
|
|
ctx.moveTo(control.x * size.x, control.y * size.y);
|
|
ctx.lineTo(control.x * size.x + control.offsetX, control.y * size.y + control.offsetY);
|
|
}
|
|
});
|
|
shouldStroke && ctx.stroke();
|
|
}
|
|
|
|
/**
|
|
* Draws corners of an object's bounding box.
|
|
* Requires public properties: width, height
|
|
* Requires public options: cornerSize, padding
|
|
* Be aware that since fabric 6.0 this function does not call setCoords anymore.
|
|
* setCoords needs to be called manually if the object of which we are rendering controls
|
|
* is outside the standard selection and transform process.
|
|
* @param {CanvasRenderingContext2D} ctx Context to draw on
|
|
* @param {ControlRenderingStyleOverride} styleOverride object to override the object style
|
|
*/
|
|
drawControls(ctx) {
|
|
let styleOverride = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
ctx.save();
|
|
const retinaScaling = this.getCanvasRetinaScaling();
|
|
const {
|
|
cornerStrokeColor,
|
|
cornerDashArray,
|
|
cornerColor
|
|
} = this;
|
|
const options = _objectSpread2({
|
|
cornerStrokeColor,
|
|
cornerDashArray,
|
|
cornerColor
|
|
}, styleOverride);
|
|
ctx.setTransform(retinaScaling, 0, 0, retinaScaling, 0, 0);
|
|
ctx.strokeStyle = ctx.fillStyle = options.cornerColor;
|
|
if (!this.transparentCorners) {
|
|
ctx.strokeStyle = options.cornerStrokeColor;
|
|
}
|
|
this._setLineDash(ctx, options.cornerDashArray);
|
|
this.forEachControl((control, key) => {
|
|
if (control.getVisibility(this, key)) {
|
|
const p = this.oCoords[key];
|
|
control.render(ctx, p.x, p.y, options, this);
|
|
}
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Returns true if the specified control is visible, false otherwise.
|
|
* @param {string} controlKey The key of the control. Possible values are usually 'tl', 'tr', 'br', 'bl', 'ml', 'mt', 'mr', 'mb', 'mtr',
|
|
* but since the control api allow for any control name, can be any string.
|
|
* @returns {boolean} true if the specified control is visible, false otherwise
|
|
*/
|
|
isControlVisible(controlKey) {
|
|
return this.controls[controlKey] && this.controls[controlKey].getVisibility(this, controlKey);
|
|
}
|
|
|
|
/**
|
|
* Sets the visibility of the specified control.
|
|
* please do not use.
|
|
* @param {String} controlKey The key of the control. Possible values are 'tl', 'tr', 'br', 'bl', 'ml', 'mt', 'mr', 'mb', 'mtr'.
|
|
* but since the control api allow for any control name, can be any string.
|
|
* @param {Boolean} visible true to set the specified control visible, false otherwise
|
|
* @todo discuss this overlap of priority here with the team. Andrea Bogazzi for details
|
|
*/
|
|
setControlVisible(controlKey, visible) {
|
|
if (!this._controlsVisibility) {
|
|
this._controlsVisibility = {};
|
|
}
|
|
this._controlsVisibility[controlKey] = visible;
|
|
}
|
|
|
|
/**
|
|
* Sets the visibility state of object controls, this is just a bulk option for setControlVisible;
|
|
* @param {Record<string, boolean>} [options] with an optional key per control
|
|
* example: {Boolean} [options.bl] true to enable the bottom-left control, false to disable it
|
|
*/
|
|
setControlsVisibility() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
Object.entries(options).forEach(_ref => {
|
|
let [controlKey, visibility] = _ref;
|
|
return this.setControlVisible(controlKey, visibility);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clears the canvas.contextTop in a specific area that corresponds to the object's bounding box
|
|
* that is in the canvas.contextContainer.
|
|
* This function is used to clear pieces of contextTop where we render ephemeral effects on top of the object.
|
|
* Example: blinking cursor text selection, drag effects.
|
|
* @todo discuss swapping restoreManually with a renderCallback, but think of async issues
|
|
* @param {Boolean} [restoreManually] When true won't restore the context after clear, in order to draw something else.
|
|
* @return {CanvasRenderingContext2D|undefined} canvas.contextTop that is either still transformed
|
|
* with the object transformMatrix, or restored to neutral transform
|
|
*/
|
|
clearContextTop(restoreManually) {
|
|
if (!this.canvas) {
|
|
return;
|
|
}
|
|
const ctx = this.canvas.contextTop;
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
const v = this.canvas.viewportTransform;
|
|
ctx.save();
|
|
ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]);
|
|
this.transform(ctx);
|
|
// we add 4 pixel, to be sure to do not leave any pixel out
|
|
const width = this.width + 4,
|
|
height = this.height + 4;
|
|
ctx.clearRect(-width / 2, -height / 2, width, height);
|
|
restoreManually || ctx.restore();
|
|
return ctx;
|
|
}
|
|
|
|
/**
|
|
* This callback function is called every time _discardActiveObject or _setActiveObject
|
|
* try to to deselect this object. If the function returns true, the process is cancelled
|
|
* @param {Object} [_options] options sent from the upper functions
|
|
* @param {TPointerEvent} [options.e] event if the process is generated by an event
|
|
* @param {FabricObject} [options.object] next object we are setting as active, and reason why
|
|
* this is being deselected
|
|
*/
|
|
onDeselect(_options) {
|
|
// implemented by sub-classes, as needed.
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* This callback function is called every time _discardActiveObject or _setActiveObject
|
|
* try to to select this object. If the function returns true, the process is cancelled
|
|
* @param {Object} [_options] options sent from the upper functions
|
|
* @param {Event} [_options.e] event if the process is generated by an event
|
|
*/
|
|
onSelect(_options) {
|
|
// implemented by sub-classes, as needed.
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Override to customize Drag behavior
|
|
* Fired from {@link Canvas#_onMouseMove}
|
|
* @returns true in order for the window to start a drag session
|
|
*/
|
|
shouldStartDragging(_e) {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Override to customize Drag behavior\
|
|
* Fired once a drag session has started
|
|
* @returns true to handle the drag event
|
|
*/
|
|
onDragStart(_e) {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Override to customize drag and drop behavior
|
|
* @public
|
|
* @param {DragEvent} _e
|
|
* @returns {boolean} true if the object currently dragged can be dropped on the target
|
|
*/
|
|
canDrop(_e) {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Override to customize drag and drop behavior
|
|
* render a specific effect when an object is the source of a drag event
|
|
* example: render the selection status for the part of text that is being dragged from a text object
|
|
* @public
|
|
* @param {DragEvent} _e
|
|
*/
|
|
renderDragSourceEffect(_e) {
|
|
// for subclasses
|
|
}
|
|
|
|
/**
|
|
* Override to customize drag and drop behavior
|
|
* render a specific effect when an object is the target of a drag event
|
|
* used to show that the underly object can receive a drop, or to show how the
|
|
* object will change when dropping. example: show the cursor where the text is about to be dropped
|
|
* @public
|
|
* @param {DragEvent} _e
|
|
*/
|
|
renderDropTargetEffect(_e) {
|
|
// for subclasses
|
|
}
|
|
}
|
|
/**
|
|
* The object's controls' position in viewport coordinates
|
|
* Calculated by {@link Control#positionHandler} and {@link Control#calcCornerCoords}, depending on {@link padding}.
|
|
* `corner/touchCorner` describe the 4 points forming the interactive area of the corner.
|
|
* Used to draw and locate controls.
|
|
*/
|
|
/**
|
|
* keeps the value of the last hovered corner during mouse move.
|
|
* 0 is no corner, or 'mt', 'ml', 'mtr' etc..
|
|
* It should be private, but there is no harm in using it as
|
|
* a read-only property.
|
|
* this isn't cleaned automatically. Non selected objects may have wrong values
|
|
* @type [string]
|
|
*/
|
|
/**
|
|
* a map of control visibility for this object.
|
|
* this was left when controls were introduced to not break the api too much
|
|
* this takes priority over the generic control visibility
|
|
*/
|
|
/**
|
|
* holds the controls for the object.
|
|
* controls are added by default_controls.js
|
|
*/
|
|
/**
|
|
* internal boolean to signal the code that the object is
|
|
* part of the move action.
|
|
*/
|
|
/**
|
|
* A boolean used from the gesture module to keep tracking of a scaling
|
|
* action when there is no scaling transform in place.
|
|
* This is an edge case and is used twice in all codebase.
|
|
* Probably added to keep track of some performance issues
|
|
* @TODO use git blame to investigate why it was added
|
|
* DON'T USE IT. WE WILL TRY TO REMOVE IT
|
|
*/
|
|
_defineProperty(InteractiveFabricObject, "ownDefaults", interactiveObjectDefaultValues);
|
|
|
|
/***
|
|
* https://www.typescriptlang.org/docs/handbook/mixins.html#alternative-pattern
|
|
*/
|
|
function applyMixins(derivedCtor, constructors) {
|
|
constructors.forEach(baseCtor => {
|
|
Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => {
|
|
name !== 'constructor' && Object.defineProperty(derivedCtor.prototype, name, Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || Object.create(null));
|
|
});
|
|
});
|
|
return derivedCtor;
|
|
}
|
|
|
|
// TODO somehow we have to make a tree-shakeable import
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
class FabricObject extends InteractiveFabricObject {}
|
|
applyMixins(FabricObject, [FabricObjectSVGExportMixin]);
|
|
classRegistry.setClass(FabricObject);
|
|
classRegistry.setClass(FabricObject, 'object');
|
|
|
|
/**
|
|
* Returns true if context has transparent pixel
|
|
* at specified location (taking tolerance into account)
|
|
* @param {CanvasRenderingContext2D} ctx context
|
|
* @param {Number} x x coordinate in canvasElementCoordinate, not fabric space. integer
|
|
* @param {Number} y y coordinate in canvasElementCoordinate, not fabric space. integer
|
|
* @param {Number} tolerance Tolerance pixels around the point, not alpha tolerance, integer
|
|
* @return {boolean} true if transparent
|
|
*/
|
|
const isTransparent = (ctx, x, y, tolerance) => {
|
|
tolerance = Math.round(tolerance);
|
|
const size = tolerance * 2 + 1;
|
|
const {
|
|
data
|
|
} = ctx.getImageData(x - tolerance, y - tolerance, size, size);
|
|
|
|
// Split image data - for tolerance > 1, pixelDataSize = 4;
|
|
for (let i = 3; i < data.length; i += 4) {
|
|
const alphaChannel = data[i];
|
|
if (alphaChannel > 0) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Rotates `point` around `origin` with `radians`
|
|
* @deprecated use the Point.rotate
|
|
* @param {Point} origin The origin of the rotation
|
|
* @param {Point} origin The origin of the rotation
|
|
* @param {TRadian} radians The radians of the angle for the rotation
|
|
* @return {Point} The new rotated point
|
|
*/
|
|
const rotatePoint = (point, origin, radians) => point.rotate(radians, origin);
|
|
|
|
const findIndexRight = (array, predicate) => {
|
|
for (let index = array.length - 1; index >= 0; index--) {
|
|
if (predicate(array[index], index, array)) {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
/**
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8344
|
|
* @todo consider removing skewing from points before calculating stroke projection,
|
|
* see https://github.com/fabricjs/fabric.js/commit/494a10ee2f8c2278ae9a55b20bf50cf6ee25b064#commitcomment-94751537
|
|
*/
|
|
class StrokeProjectionsBase {
|
|
constructor(options) {
|
|
this.options = options;
|
|
this.strokeProjectionMagnitude = this.options.strokeWidth / 2;
|
|
this.scale = new Point(this.options.scaleX, this.options.scaleY);
|
|
this.strokeUniformScalar = this.options.strokeUniform ? new Point(1 / this.options.scaleX, 1 / this.options.scaleY) : new Point(1, 1);
|
|
}
|
|
|
|
/**
|
|
* When the stroke is uniform, scaling affects the arrangement of points. So we must take it into account.
|
|
*/
|
|
createSideVector(from, to) {
|
|
const v = createVector(from, to);
|
|
return this.options.strokeUniform ? v.multiply(this.scale) : v;
|
|
}
|
|
projectOrthogonally(from, to, magnitude) {
|
|
return this.applySkew(from.add(this.calcOrthogonalProjection(from, to, magnitude)));
|
|
}
|
|
isSkewed() {
|
|
return this.options.skewX !== 0 || this.options.skewY !== 0;
|
|
}
|
|
applySkew(point) {
|
|
const p = new Point(point);
|
|
// skewY must be applied before skewX as this distortion affects skewX calculation
|
|
p.y += p.x * Math.tan(degreesToRadians(this.options.skewY));
|
|
p.x += p.y * Math.tan(degreesToRadians(this.options.skewX));
|
|
return p;
|
|
}
|
|
scaleUnitVector(unitVector, scalar) {
|
|
return unitVector.multiply(this.strokeUniformScalar).scalarMultiply(scalar);
|
|
}
|
|
}
|
|
|
|
const zeroVector = new Point();
|
|
|
|
/**
|
|
* class in charge of finding projections for each type of line join
|
|
* @see {@link [Closed path projections at #8344](https://github.com/fabricjs/fabric.js/pull/8344#2-closed-path)}
|
|
*
|
|
* - MDN:
|
|
* - https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin
|
|
* - https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-linejoin
|
|
* - Spec: https://svgwg.org/svg2-draft/painting.html#StrokeLinejoinProperty
|
|
* - Playground to understand how the line joins works: https://hypertolosana.github.io/efficient-webgl-stroking/index.html
|
|
* - View the calculated projections for each of the control points: https://codesandbox.io/s/project-stroke-points-with-context-to-trace-b8jc4j?file=/src/index.js
|
|
*
|
|
*/
|
|
class StrokeLineJoinProjections extends StrokeProjectionsBase {
|
|
static getOrthogonalRotationFactor(vector1, vector2) {
|
|
const angle = vector2 ? calcAngleBetweenVectors(vector1, vector2) : calcVectorRotation(vector1);
|
|
return Math.abs(angle) < halfPI ? -1 : 1;
|
|
}
|
|
constructor(A, B, C, options) {
|
|
super(options);
|
|
/**
|
|
* The point being projected (the angle ∠BAC)
|
|
*/
|
|
/**
|
|
* The point before A
|
|
*/
|
|
/**
|
|
* The point after A
|
|
*/
|
|
/**
|
|
* The AB vector
|
|
*/
|
|
_defineProperty(this, "AB", void 0);
|
|
/**
|
|
* The AC vector
|
|
*/
|
|
_defineProperty(this, "AC", void 0);
|
|
/**
|
|
* The angle of A (∠BAC)
|
|
*/
|
|
_defineProperty(this, "alpha", void 0);
|
|
/**
|
|
* The bisector of A (∠BAC)
|
|
*/
|
|
_defineProperty(this, "bisector", void 0);
|
|
this.A = new Point(A);
|
|
this.B = new Point(B);
|
|
this.C = new Point(C);
|
|
this.AB = this.createSideVector(this.A, this.B);
|
|
this.AC = this.createSideVector(this.A, this.C);
|
|
this.alpha = calcAngleBetweenVectors(this.AB, this.AC);
|
|
this.bisector = getUnitVector(
|
|
// if AC is also the zero vector nothing will be projected
|
|
// in that case the next point will handle the projection
|
|
rotateVector(this.AB.eq(zeroVector) ? this.AC : this.AB, this.alpha / 2));
|
|
}
|
|
calcOrthogonalProjection(from, to) {
|
|
let magnitude = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : this.strokeProjectionMagnitude;
|
|
const vector = this.createSideVector(from, to);
|
|
const orthogonalProjection = getOrthonormalVector(vector);
|
|
const correctSide = StrokeLineJoinProjections.getOrthogonalRotationFactor(orthogonalProjection, this.bisector);
|
|
return this.scaleUnitVector(orthogonalProjection, magnitude * correctSide);
|
|
}
|
|
|
|
/**
|
|
* BEVEL
|
|
* Calculation: the projection points are formed by the vector orthogonal to the vertex.
|
|
*
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8344#2-2-bevel
|
|
*/
|
|
projectBevel() {
|
|
const projections = [];
|
|
// if `alpha` equals 0 or 2*PI, the projections are the same for `B` and `C`
|
|
(this.alpha % twoMathPi === 0 ? [this.B] : [this.B, this.C]).forEach(to => {
|
|
projections.push(this.projectOrthogonally(this.A, to));
|
|
projections.push(this.projectOrthogonally(this.A, to, -this.strokeProjectionMagnitude));
|
|
});
|
|
return projections;
|
|
}
|
|
|
|
/**
|
|
* MITER
|
|
* Calculation: the corner is formed by extending the outer edges of the stroke
|
|
* at the tangents of the path segments until they intersect.
|
|
*
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8344#2-1-miter
|
|
*/
|
|
projectMiter() {
|
|
const projections = [],
|
|
alpha = Math.abs(this.alpha),
|
|
hypotUnitScalar = 1 / Math.sin(alpha / 2),
|
|
miterVector = this.scaleUnitVector(this.bisector, -this.strokeProjectionMagnitude * hypotUnitScalar);
|
|
|
|
// When two line segments meet at a sharp angle, it is possible for the join to extend,
|
|
// far beyond the thickness of the line stroking the path. The stroke-miterlimit imposes
|
|
// a limit on the extent of the line join.
|
|
// MDN: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-miterlimit
|
|
// When the stroke is uniform, scaling changes the arrangement of points, this changes the miter-limit
|
|
const strokeMiterLimit = this.options.strokeUniform ? magnitude(this.scaleUnitVector(this.bisector, this.options.strokeMiterLimit)) : this.options.strokeMiterLimit;
|
|
if (magnitude(miterVector) / this.strokeProjectionMagnitude <= strokeMiterLimit) {
|
|
projections.push(this.applySkew(this.A.add(miterVector)));
|
|
}
|
|
/* when the miter-limit is reached, the stroke line join becomes of type bevel.
|
|
We always need two orthogonal projections which are basically bevel-type projections,
|
|
so regardless of whether the miter-limit was reached or not, we include these projections.
|
|
*/
|
|
projections.push(...this.projectBevel());
|
|
return projections;
|
|
}
|
|
|
|
/**
|
|
* ROUND (without skew)
|
|
* Calculation: the projections are the two vectors parallel to X and Y axes
|
|
*
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8344#2-3-1-round-without-skew
|
|
*/
|
|
projectRoundNoSkew(startCircle, endCircle) {
|
|
const projections = [],
|
|
// correctSide is used to only consider projecting for the outer side
|
|
correctSide = new Point(StrokeLineJoinProjections.getOrthogonalRotationFactor(this.bisector), StrokeLineJoinProjections.getOrthogonalRotationFactor(new Point(this.bisector.y, this.bisector.x))),
|
|
radiusOnAxisX = new Point(1, 0).scalarMultiply(this.strokeProjectionMagnitude).multiply(this.strokeUniformScalar).multiply(correctSide),
|
|
radiusOnAxisY = new Point(0, 1).scalarMultiply(this.strokeProjectionMagnitude).multiply(this.strokeUniformScalar).multiply(correctSide);
|
|
[radiusOnAxisX, radiusOnAxisY].forEach(vector => {
|
|
if (isBetweenVectors(vector, startCircle, endCircle)) {
|
|
projections.push(this.A.add(vector));
|
|
}
|
|
});
|
|
return projections;
|
|
}
|
|
|
|
/**
|
|
* ROUND (with skew)
|
|
* Calculation: the projections are the points furthest from the vertex in
|
|
* the direction of the X and Y axes after distortion.
|
|
*
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8344#2-3-2-round-skew
|
|
*/
|
|
projectRoundWithSkew(startCircle, endCircle) {
|
|
const projections = [];
|
|
const {
|
|
skewX,
|
|
skewY,
|
|
scaleX,
|
|
scaleY,
|
|
strokeUniform
|
|
} = this.options,
|
|
shearing = new Point(Math.tan(degreesToRadians(skewX)), Math.tan(degreesToRadians(skewY)));
|
|
// The points furthest from the vertex in the direction of the X and Y axes after distortion
|
|
const circleRadius = this.strokeProjectionMagnitude,
|
|
newY = strokeUniform ? circleRadius / scaleY / Math.sqrt(1 / scaleY ** 2 + 1 / scaleX ** 2 * shearing.y ** 2) : circleRadius / Math.sqrt(1 + shearing.y ** 2),
|
|
furthestY = new Point(
|
|
// Safe guard due to floating point precision. In some situations the square root
|
|
// was returning NaN because of a negative number close to zero.
|
|
Math.sqrt(Math.max(circleRadius ** 2 - newY ** 2, 0)), newY),
|
|
newX = strokeUniform ? circleRadius / Math.sqrt(1 + shearing.x ** 2 * (1 / scaleY) ** 2 / (1 / scaleX + 1 / scaleX * shearing.x * shearing.y) ** 2) : circleRadius / Math.sqrt(1 + shearing.x ** 2 / (1 + shearing.x * shearing.y) ** 2),
|
|
furthestX = new Point(newX, Math.sqrt(Math.max(circleRadius ** 2 - newX ** 2, 0)));
|
|
[furthestX, furthestX.scalarMultiply(-1), furthestY, furthestY.scalarMultiply(-1)]
|
|
// We need to skew the vector here as this information is used to check if
|
|
// it is between the start and end of the circle segment
|
|
.map(vector => this.applySkew(strokeUniform ? vector.multiply(this.strokeUniformScalar) : vector)).forEach(vector => {
|
|
if (isBetweenVectors(vector, startCircle, endCircle)) {
|
|
projections.push(this.applySkew(this.A).add(vector));
|
|
}
|
|
});
|
|
return projections;
|
|
}
|
|
projectRound() {
|
|
const projections = [];
|
|
/* Include the start and end points of the circle segment, so that only
|
|
the projections contained within it are included */
|
|
// add the orthogonal projections (start and end points of circle segment)
|
|
projections.push(...this.projectBevel());
|
|
// let's determines which one of the orthogonal projection is the beginning and end of the circle segment.
|
|
// when `alpha` equals 0 or 2*PI, we have a straight line, so the way to find the start/end is different.
|
|
const isStraightLine = this.alpha % twoMathPi === 0,
|
|
// change the origin of the projections to point A
|
|
// so that the cross product calculation is correct
|
|
newOrigin = this.applySkew(this.A),
|
|
proj0 = projections[isStraightLine ? 0 : 2].subtract(newOrigin),
|
|
proj1 = projections[isStraightLine ? 1 : 0].subtract(newOrigin),
|
|
// when `isStraightLine` === true, we compare with the vector opposite AB, otherwise we compare with the bisector.
|
|
comparisonVector = isStraightLine ? this.applySkew(this.AB.scalarMultiply(-1)) : this.applySkew(this.bisector.multiply(this.strokeUniformScalar).scalarMultiply(-1)),
|
|
// the beginning of the circle segment is always to the right of the comparison vector (cross product > 0)
|
|
isProj0Start = crossProduct(proj0, comparisonVector) > 0,
|
|
startCircle = isProj0Start ? proj0 : proj1,
|
|
endCircle = isProj0Start ? proj1 : proj0;
|
|
if (!this.isSkewed()) {
|
|
projections.push(...this.projectRoundNoSkew(startCircle, endCircle));
|
|
} else {
|
|
projections.push(...this.projectRoundWithSkew(startCircle, endCircle));
|
|
}
|
|
return projections;
|
|
}
|
|
|
|
/**
|
|
* Project stroke width on points returning projections for each point as follows:
|
|
* - `miter`: 1 point corresponding to the outer boundary. If the miter limit is exceeded, it will be 2 points (becomes bevel)
|
|
* - `bevel`: 2 points corresponding to the bevel possible boundaries, orthogonal to the stroke.
|
|
* - `round`: same as `bevel` when it has no skew, with skew are 4 points.
|
|
*/
|
|
projectPoints() {
|
|
switch (this.options.strokeLineJoin) {
|
|
case 'miter':
|
|
return this.projectMiter();
|
|
case 'round':
|
|
return this.projectRound();
|
|
default:
|
|
return this.projectBevel();
|
|
}
|
|
}
|
|
project() {
|
|
return this.projectPoints().map(point => ({
|
|
originPoint: this.A,
|
|
projectedPoint: point,
|
|
angle: this.alpha,
|
|
bisector: this.bisector
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* class in charge of finding projections for each type of line cap for start/end of an open path
|
|
* @see {@link [Open path projections at #8344](https://github.com/fabricjs/fabric.js/pull/8344#1-open-path)}
|
|
*
|
|
* Reference:
|
|
* - MDN:
|
|
* - https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineCap
|
|
* - https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-linecap
|
|
* - Spec: https://html.spec.whatwg.org/multipage/canvas.html#dom-context-2d-linecap-dev
|
|
* - Playground to understand how the line joins works: https://hypertolosana.github.io/efficient-webgl-stroking/index.html
|
|
* - View the calculated projections for each of the control points: https://codesandbox.io/s/project-stroke-points-with-context-to-trace-b8jc4j?file=/src/index.js
|
|
*/
|
|
class StrokeLineCapProjections extends StrokeProjectionsBase {
|
|
/**
|
|
* edge point
|
|
*/
|
|
|
|
/**
|
|
* point next to edge point
|
|
*/
|
|
|
|
constructor(A, T, options) {
|
|
super(options);
|
|
this.A = new Point(A);
|
|
this.T = new Point(T);
|
|
}
|
|
calcOrthogonalProjection(from, to) {
|
|
let magnitude = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : this.strokeProjectionMagnitude;
|
|
const vector = this.createSideVector(from, to);
|
|
return this.scaleUnitVector(getOrthonormalVector(vector), magnitude);
|
|
}
|
|
|
|
/**
|
|
* OPEN PATH START/END - Line cap: Butt
|
|
* Calculation: to find the projections, just find the points orthogonal to the stroke
|
|
*
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8344#1-1-butt
|
|
*/
|
|
projectButt() {
|
|
return [this.projectOrthogonally(this.A, this.T, this.strokeProjectionMagnitude), this.projectOrthogonally(this.A, this.T, -this.strokeProjectionMagnitude)];
|
|
}
|
|
|
|
/**
|
|
* OPEN PATH START/END - Line cap: Round
|
|
* Calculation: same as stroke line join `round`
|
|
*
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8344#1-2-round
|
|
*/
|
|
projectRound() {
|
|
const projections = [];
|
|
if (!this.isSkewed() && this.A.eq(this.T)) {
|
|
/* 1 point case without `skew`
|
|
When `strokeUniform` is true, scaling has no effect.
|
|
So we divide by scale, to remove its effect.
|
|
*/
|
|
const projection = new Point(1, 1).scalarMultiply(this.strokeProjectionMagnitude).multiply(this.strokeUniformScalar);
|
|
projections.push(this.applySkew(this.A.add(projection)), this.applySkew(this.A.subtract(projection)));
|
|
} else {
|
|
projections.push(...new StrokeLineJoinProjections(this.A, this.T, this.T, this.options).projectRound());
|
|
}
|
|
return projections;
|
|
}
|
|
|
|
/**
|
|
* OPEN PATH START/END - Line cap: Square
|
|
* Calculation: project a rectangle of points on the stroke in the opposite direction of the vector `AT`
|
|
*
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8344#1-3-square
|
|
*/
|
|
projectSquare() {
|
|
const projections = [];
|
|
if (this.A.eq(this.T)) {
|
|
/* 1 point case without `skew`
|
|
When `strokeUniform` is true, scaling has no effect.
|
|
So we divide by scale, to remove its effect.
|
|
*/
|
|
const projection = new Point(1, 1).scalarMultiply(this.strokeProjectionMagnitude).multiply(this.strokeUniformScalar);
|
|
projections.push(this.A.add(projection), this.A.subtract(projection));
|
|
} else {
|
|
const orthogonalProjection = this.calcOrthogonalProjection(this.A, this.T, this.strokeProjectionMagnitude);
|
|
const strokePointingOut = this.scaleUnitVector(getUnitVector(this.createSideVector(this.A, this.T)), -this.strokeProjectionMagnitude);
|
|
const projectedA = this.A.add(strokePointingOut);
|
|
projections.push(projectedA.add(orthogonalProjection), projectedA.subtract(orthogonalProjection));
|
|
}
|
|
return projections.map(p => this.applySkew(p));
|
|
}
|
|
projectPoints() {
|
|
switch (this.options.strokeLineCap) {
|
|
case 'round':
|
|
return this.projectRound();
|
|
case 'square':
|
|
return this.projectSquare();
|
|
default:
|
|
return this.projectButt();
|
|
}
|
|
}
|
|
project() {
|
|
return this.projectPoints().map(point => ({
|
|
originPoint: this.A,
|
|
projectedPoint: point
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Used to calculate object's bounding box
|
|
*
|
|
* @see https://github.com/fabricjs/fabric.js/pull/8344
|
|
*
|
|
*/
|
|
const projectStrokeOnPoints = function (points, options) {
|
|
let openPath = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
|
|
const projections = [];
|
|
if (points.length === 0) {
|
|
return projections;
|
|
}
|
|
|
|
// first we remove duplicate neighboring points
|
|
const reduced = points.reduce((reduced, point) => {
|
|
if (!reduced[reduced.length - 1].eq(point)) {
|
|
reduced.push(new Point(point));
|
|
}
|
|
return reduced;
|
|
}, [new Point(points[0])]);
|
|
if (reduced.length === 1) {
|
|
openPath = true;
|
|
} else if (!openPath) {
|
|
// remove points from end in case they equal the first point
|
|
// in order to correctly project the first point
|
|
const start = reduced[0];
|
|
const index = findIndexRight(reduced, point => !point.eq(start));
|
|
reduced.splice(index + 1);
|
|
}
|
|
reduced.forEach((A, index, points) => {
|
|
let B, C;
|
|
if (index === 0) {
|
|
C = points[1];
|
|
B = openPath ? A : points[points.length - 1];
|
|
} else if (index === points.length - 1) {
|
|
B = points[index - 1];
|
|
C = openPath ? A : points[0];
|
|
} else {
|
|
B = points[index - 1];
|
|
C = points[index + 1];
|
|
}
|
|
if (openPath && points.length === 1) {
|
|
projections.push(...new StrokeLineCapProjections(A, A, options).project());
|
|
} else if (openPath && (index === 0 || index === points.length - 1)) {
|
|
projections.push(...new StrokeLineCapProjections(A, index === 0 ? C : B, options).project());
|
|
} else {
|
|
projections.push(...new StrokeLineJoinProjections(A, B, C, options).project());
|
|
}
|
|
});
|
|
return projections;
|
|
};
|
|
|
|
const cloneStyles = style => {
|
|
const newObj = {};
|
|
Object.keys(style).forEach(key => {
|
|
newObj[key] = {};
|
|
Object.keys(style[key]).forEach(keyInner => {
|
|
newObj[key][keyInner] = _objectSpread2({}, style[key][keyInner]);
|
|
});
|
|
});
|
|
return newObj;
|
|
};
|
|
|
|
/**
|
|
* Capitalizes a string
|
|
* @param {String} string String to capitalize
|
|
* @param {Boolean} [firstLetterOnly] If true only first letter is capitalized
|
|
* and other letters stay untouched, if false first letter is capitalized
|
|
* and other letters are converted to lowercase.
|
|
* @return {String} Capitalized version of a string
|
|
*/
|
|
const capitalize = function (string) {
|
|
let firstLetterOnly = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
|
|
return "".concat(string.charAt(0).toUpperCase()).concat(firstLetterOnly ? string.slice(1) : string.slice(1).toLowerCase());
|
|
};
|
|
|
|
/**
|
|
* Escapes XML in a string
|
|
* @param {String} string String to escape
|
|
* @return {String} Escaped version of a string
|
|
*/
|
|
const escapeXml = string => string.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
/**
|
|
* Divide a string in the user perceived single units
|
|
* @param {String} textstring String to escape
|
|
* @return {Array} array containing the graphemes
|
|
*/
|
|
const graphemeSplit = textstring => {
|
|
const graphemes = [];
|
|
for (let i = 0, chr; i < textstring.length; i++) {
|
|
if ((chr = getWholeChar(textstring, i)) === false) {
|
|
continue;
|
|
}
|
|
graphemes.push(chr);
|
|
}
|
|
return graphemes;
|
|
};
|
|
|
|
// taken from mdn in the charAt doc page.
|
|
const getWholeChar = (str, i) => {
|
|
const code = str.charCodeAt(i);
|
|
if (isNaN(code)) {
|
|
return ''; // Position not found
|
|
}
|
|
if (code < 0xd800 || code > 0xdfff) {
|
|
return str.charAt(i);
|
|
}
|
|
|
|
// High surrogate (could change last hex to 0xDB7F to treat high private
|
|
// surrogates as single characters)
|
|
if (0xd800 <= code && code <= 0xdbff) {
|
|
if (str.length <= i + 1) {
|
|
throw 'High surrogate without following low surrogate';
|
|
}
|
|
const next = str.charCodeAt(i + 1);
|
|
if (0xdc00 > next || next > 0xdfff) {
|
|
throw 'High surrogate without following low surrogate';
|
|
}
|
|
return str.charAt(i) + str.charAt(i + 1);
|
|
}
|
|
// Low surrogate (0xDC00 <= code && code <= 0xDFFF)
|
|
if (i === 0) {
|
|
throw 'Low surrogate without preceding high surrogate';
|
|
}
|
|
const prev = str.charCodeAt(i - 1);
|
|
|
|
// (could change last hex to 0xDB7F to treat high private
|
|
// surrogates as single characters)
|
|
if (0xd800 > prev || prev > 0xdbff) {
|
|
throw 'Low surrogate without preceding high surrogate';
|
|
}
|
|
// We can pass over low surrogates now as the second component
|
|
// in a pair which we have already processed
|
|
return false;
|
|
};
|
|
|
|
var lang_string = /*#__PURE__*/Object.freeze({
|
|
__proto__: null,
|
|
capitalize: capitalize,
|
|
escapeXml: escapeXml,
|
|
graphemeSplit: graphemeSplit
|
|
});
|
|
|
|
/**
|
|
* @param {Object} prevStyle first style to compare
|
|
* @param {Object} thisStyle second style to compare
|
|
* @param {boolean} forTextSpans whether to check overline, underline, and line-through properties
|
|
* @return {boolean} true if the style changed
|
|
*/
|
|
const hasStyleChanged = function (prevStyle, thisStyle) {
|
|
let forTextSpans = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
|
|
return prevStyle.fill !== thisStyle.fill || prevStyle.stroke !== thisStyle.stroke || prevStyle.strokeWidth !== thisStyle.strokeWidth || prevStyle.fontSize !== thisStyle.fontSize || prevStyle.fontFamily !== thisStyle.fontFamily || prevStyle.fontWeight !== thisStyle.fontWeight || prevStyle.fontStyle !== thisStyle.fontStyle || prevStyle.textBackgroundColor !== thisStyle.textBackgroundColor || prevStyle.deltaY !== thisStyle.deltaY || forTextSpans && (prevStyle.overline !== thisStyle.overline || prevStyle.underline !== thisStyle.underline || prevStyle.linethrough !== thisStyle.linethrough);
|
|
};
|
|
|
|
/**
|
|
* Returns the array form of a text object's inline styles property with styles grouped in ranges
|
|
* rather than per character. This format is less verbose, and is better suited for storage
|
|
* so it is used in serialization (not during runtime).
|
|
* @param {object} styles per character styles for a text object
|
|
* @param {String} text the text string that the styles are applied to
|
|
* @return {{start: number, end: number, style: object}[]}
|
|
*/
|
|
const stylesToArray = (styles, text) => {
|
|
const textLines = text.split('\n'),
|
|
stylesArray = [];
|
|
let charIndex = -1,
|
|
prevStyle = {};
|
|
// clone style structure to prevent mutation
|
|
styles = cloneStyles(styles);
|
|
|
|
//loop through each textLine
|
|
for (let i = 0; i < textLines.length; i++) {
|
|
const chars = graphemeSplit(textLines[i]);
|
|
if (!styles[i]) {
|
|
//no styles exist for this line, so add the line's length to the charIndex total and reset prevStyle
|
|
charIndex += chars.length;
|
|
prevStyle = {};
|
|
continue;
|
|
}
|
|
//loop through each character of the current line
|
|
for (let c = 0; c < chars.length; c++) {
|
|
charIndex++;
|
|
const thisStyle = styles[i][c];
|
|
//check if style exists for this character
|
|
if (thisStyle && Object.keys(thisStyle).length > 0) {
|
|
if (hasStyleChanged(prevStyle, thisStyle, true)) {
|
|
stylesArray.push({
|
|
start: charIndex,
|
|
end: charIndex + 1,
|
|
style: thisStyle
|
|
});
|
|
} else {
|
|
//if style is the same as previous character, increase end index
|
|
stylesArray[stylesArray.length - 1].end++;
|
|
}
|
|
}
|
|
prevStyle = thisStyle || {};
|
|
}
|
|
}
|
|
return stylesArray;
|
|
};
|
|
|
|
/**
|
|
* Returns the object form of the styles property with styles that are assigned per
|
|
* character rather than grouped by range. This format is more verbose, and is
|
|
* only used during runtime (not for serialization/storage)
|
|
* @param {Array} styles the serialized form of a text object's styles
|
|
* @param {String} text the text string that the styles are applied to
|
|
* @return {Object}
|
|
*/
|
|
const stylesFromArray = (styles, text) => {
|
|
if (!Array.isArray(styles)) {
|
|
// clone to prevent mutation
|
|
return cloneStyles(styles);
|
|
}
|
|
const textLines = text.split(reNewline),
|
|
stylesObject = {};
|
|
let charIndex = -1,
|
|
styleIndex = 0;
|
|
//loop through each textLine
|
|
for (let i = 0; i < textLines.length; i++) {
|
|
const chars = graphemeSplit(textLines[i]);
|
|
|
|
//loop through each character of the current line
|
|
for (let c = 0; c < chars.length; c++) {
|
|
charIndex++;
|
|
//check if there's a style collection that includes the current character
|
|
if (styles[styleIndex] && styles[styleIndex].start <= charIndex && charIndex < styles[styleIndex].end) {
|
|
//create object for line index if it doesn't exist
|
|
stylesObject[i] = stylesObject[i] || {};
|
|
//assign a style at this character's index
|
|
stylesObject[i][c] = _objectSpread2({}, styles[styleIndex].style);
|
|
//if character is at the end of the current style collection, move to the next
|
|
if (charIndex === styles[styleIndex].end - 1) {
|
|
styleIndex++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return stylesObject;
|
|
};
|
|
|
|
/**
|
|
* Attributes parsed from all SVG elements
|
|
* @type array
|
|
*/
|
|
const SHARED_ATTRIBUTES = ['display', 'transform', FILL, 'fill-opacity', 'fill-rule', 'opacity', STROKE, 'stroke-dasharray', 'stroke-linecap', 'stroke-dashoffset', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'id', 'paint-order', 'vector-effect', 'instantiated_by_use', 'clip-path'];
|
|
|
|
function selectorMatches(element, selector) {
|
|
const nodeName = element.nodeName;
|
|
const classNames = element.getAttribute('class');
|
|
const id = element.getAttribute('id');
|
|
const azAz = '(?![a-zA-Z\\-]+)';
|
|
let matcher;
|
|
// i check if a selector matches slicing away part from it.
|
|
// if i get empty string i should match
|
|
matcher = new RegExp('^' + nodeName, 'i');
|
|
selector = selector.replace(matcher, '');
|
|
if (id && selector.length) {
|
|
matcher = new RegExp('#' + id + azAz, 'i');
|
|
selector = selector.replace(matcher, '');
|
|
}
|
|
if (classNames && selector.length) {
|
|
const splitClassNames = classNames.split(' ');
|
|
for (let i = splitClassNames.length; i--;) {
|
|
matcher = new RegExp('\\.' + splitClassNames[i] + azAz, 'i');
|
|
selector = selector.replace(matcher, '');
|
|
}
|
|
}
|
|
return selector.length === 0;
|
|
}
|
|
|
|
function doesSomeParentMatch(element, selectors) {
|
|
let selector,
|
|
parentMatching = true;
|
|
while (element.parentElement && element.parentElement.nodeType === 1 && selectors.length) {
|
|
if (parentMatching) {
|
|
selector = selectors.pop();
|
|
}
|
|
element = element.parentElement;
|
|
parentMatching = selectorMatches(element, selector);
|
|
}
|
|
return selectors.length === 0;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
|
|
function elementMatchesRule(element, selectors) {
|
|
let parentMatching = true;
|
|
// start from rightmost selector.
|
|
const firstMatching = selectorMatches(element, selectors.pop());
|
|
if (firstMatching && selectors.length) {
|
|
parentMatching = doesSomeParentMatch(element, selectors);
|
|
}
|
|
return firstMatching && parentMatching && selectors.length === 0;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
|
|
function getGlobalStylesForElement(element) {
|
|
let cssRules = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
let styles = {};
|
|
for (const rule in cssRules) {
|
|
if (elementMatchesRule(element, rule.split(' '))) {
|
|
styles = _objectSpread2(_objectSpread2({}, styles), cssRules[rule]);
|
|
}
|
|
}
|
|
return styles;
|
|
}
|
|
|
|
const normalizeAttr = attr => {
|
|
var _attributesMap;
|
|
return (_attributesMap = attributesMap[attr]) !== null && _attributesMap !== void 0 ? _attributesMap : attr;
|
|
};
|
|
|
|
const regex$1 = new RegExp("(".concat(reNum, ")"), 'gi');
|
|
const cleanupSvgAttribute = attributeValue => attributeValue.replace(regex$1, ' $1 ')
|
|
// replace annoying commas and arbitrary whitespace with single spaces
|
|
.replace(/,/gi, ' ').replace(/\s+/gi, ' ');
|
|
|
|
var _templateObject, _templateObject2, _templateObject3, _templateObject4, _templateObject5, _templateObject6, _templateObject7;
|
|
|
|
// == begin transform regexp
|
|
const p$1 = "(".concat(reNum, ")");
|
|
const skewX = String.raw(_templateObject || (_templateObject = _taggedTemplateLiteral(["(skewX)(", ")"], ["(skewX)\\(", "\\)"])), p$1);
|
|
const skewY = String.raw(_templateObject2 || (_templateObject2 = _taggedTemplateLiteral(["(skewY)(", ")"], ["(skewY)\\(", "\\)"])), p$1);
|
|
const rotate = String.raw(_templateObject3 || (_templateObject3 = _taggedTemplateLiteral(["(rotate)(", "(?: ", " ", ")?)"], ["(rotate)\\(", "(?: ", " ", ")?\\)"])), p$1, p$1, p$1);
|
|
const scale = String.raw(_templateObject4 || (_templateObject4 = _taggedTemplateLiteral(["(scale)(", "(?: ", ")?)"], ["(scale)\\(", "(?: ", ")?\\)"])), p$1, p$1);
|
|
const translate = String.raw(_templateObject5 || (_templateObject5 = _taggedTemplateLiteral(["(translate)(", "(?: ", ")?)"], ["(translate)\\(", "(?: ", ")?\\)"])), p$1, p$1);
|
|
const matrix = String.raw(_templateObject6 || (_templateObject6 = _taggedTemplateLiteral(["(matrix)(", " ", " ", " ", " ", " ", ")"], ["(matrix)\\(", " ", " ", " ", " ", " ", "\\)"])), p$1, p$1, p$1, p$1, p$1, p$1);
|
|
const transform = "(?:".concat(matrix, "|").concat(translate, "|").concat(rotate, "|").concat(scale, "|").concat(skewX, "|").concat(skewY, ")");
|
|
const transforms = "(?:".concat(transform, "*)");
|
|
const transformList = String.raw(_templateObject7 || (_templateObject7 = _taggedTemplateLiteral(["^s*(?:", "?)s*$"], ["^\\s*(?:", "?)\\s*$"])), transforms);
|
|
// http://www.w3.org/TR/SVG/coords.html#TransformAttribute
|
|
const reTransformList = new RegExp(transformList);
|
|
const reTransform = new RegExp(transform);
|
|
const reTransformAll = new RegExp(transform, 'g');
|
|
// == end transform regexp
|
|
|
|
/**
|
|
* Parses "transform" attribute, returning an array of values
|
|
* @static
|
|
* @function
|
|
* @memberOf fabric
|
|
* @param {String} attributeValue String containing attribute value
|
|
* @return {TTransformMatrix} Array of 6 elements representing transformation matrix
|
|
*/
|
|
function parseTransformAttribute(attributeValue) {
|
|
// first we clean the string
|
|
attributeValue = cleanupSvgAttribute(attributeValue)
|
|
// remove spaces around front parentheses
|
|
.replace(/\s*([()])\s*/gi, '$1');
|
|
|
|
// start with identity matrix
|
|
const matrices = [];
|
|
|
|
// return if no argument was given or
|
|
// an argument does not match transform attribute regexp
|
|
if (!attributeValue || attributeValue && !reTransformList.test(attributeValue)) {
|
|
return [...iMatrix];
|
|
}
|
|
for (const match of attributeValue.matchAll(reTransformAll)) {
|
|
const transformMatch = reTransform.exec(match[0]);
|
|
if (!transformMatch) {
|
|
continue;
|
|
}
|
|
let matrix = iMatrix;
|
|
const matchedParams = transformMatch.filter(m => !!m);
|
|
const [, operation, ...rawArgs] = matchedParams;
|
|
const [arg0, arg1, arg2, arg3, arg4, arg5] = rawArgs.map(arg => parseFloat(arg));
|
|
switch (operation) {
|
|
case 'translate':
|
|
matrix = createTranslateMatrix(arg0, arg1);
|
|
break;
|
|
case ROTATE:
|
|
matrix = createRotateMatrix({
|
|
angle: arg0
|
|
}, {
|
|
x: arg1,
|
|
y: arg2
|
|
});
|
|
break;
|
|
case SCALE:
|
|
matrix = createScaleMatrix(arg0, arg1);
|
|
break;
|
|
case SKEW_X:
|
|
matrix = createSkewXMatrix(arg0);
|
|
break;
|
|
case SKEW_Y:
|
|
matrix = createSkewYMatrix(arg0);
|
|
break;
|
|
case 'matrix':
|
|
matrix = [arg0, arg1, arg2, arg3, arg4, arg5];
|
|
break;
|
|
}
|
|
|
|
// snapshot current matrix into matrices array
|
|
matrices.push(matrix);
|
|
}
|
|
return multiplyTransformMatrixArray(matrices);
|
|
}
|
|
|
|
function normalizeValue(attr, value, parentAttributes, fontSize) {
|
|
const isArray = Array.isArray(value);
|
|
let parsed;
|
|
let ouputValue = value;
|
|
if ((attr === FILL || attr === STROKE) && value === NONE) {
|
|
ouputValue = '';
|
|
} else if (attr === 'strokeUniform') {
|
|
return value === 'non-scaling-stroke';
|
|
} else if (attr === 'strokeDashArray') {
|
|
if (value === NONE) {
|
|
ouputValue = null;
|
|
} else {
|
|
ouputValue = value.replace(/,/g, ' ').split(/\s+/).map(parseFloat);
|
|
}
|
|
} else if (attr === 'transformMatrix') {
|
|
if (parentAttributes && parentAttributes.transformMatrix) {
|
|
ouputValue = multiplyTransformMatrices(parentAttributes.transformMatrix, parseTransformAttribute(value));
|
|
} else {
|
|
ouputValue = parseTransformAttribute(value);
|
|
}
|
|
} else if (attr === 'visible') {
|
|
ouputValue = value !== NONE && value !== 'hidden';
|
|
// display=none on parent element always takes precedence over child element
|
|
if (parentAttributes && parentAttributes.visible === false) {
|
|
ouputValue = false;
|
|
}
|
|
} else if (attr === 'opacity') {
|
|
ouputValue = parseFloat(value);
|
|
if (parentAttributes && typeof parentAttributes.opacity !== 'undefined') {
|
|
ouputValue *= parentAttributes.opacity;
|
|
}
|
|
} else if (attr === 'textAnchor' /* text-anchor */) {
|
|
ouputValue = value === 'start' ? LEFT : value === 'end' ? RIGHT : CENTER;
|
|
} else if (attr === 'charSpacing') {
|
|
// parseUnit returns px and we convert it to em
|
|
parsed = parseUnit(value, fontSize) / fontSize * 1000;
|
|
} else if (attr === 'paintFirst') {
|
|
const fillIndex = value.indexOf(FILL);
|
|
const strokeIndex = value.indexOf(STROKE);
|
|
ouputValue = FILL;
|
|
if (fillIndex > -1 && strokeIndex > -1 && strokeIndex < fillIndex) {
|
|
ouputValue = STROKE;
|
|
} else if (fillIndex === -1 && strokeIndex > -1) {
|
|
ouputValue = STROKE;
|
|
}
|
|
} else if (attr === 'href' || attr === 'xlink:href' || attr === 'font' || attr === 'id') {
|
|
return value;
|
|
} else if (attr === 'imageSmoothing') {
|
|
return value === 'optimizeQuality';
|
|
} else {
|
|
parsed = isArray ? value.map(parseUnit) : parseUnit(value, fontSize);
|
|
}
|
|
return !isArray && isNaN(parsed) ? ouputValue : parsed;
|
|
}
|
|
|
|
/**
|
|
* Parses a short font declaration, building adding its properties to a style object
|
|
* @static
|
|
* @function
|
|
* @memberOf fabric
|
|
* @param {String} value font declaration
|
|
* @param {Object} oStyle definition
|
|
*/
|
|
function parseFontDeclaration(value, oStyle) {
|
|
const match = value.match(reFontDeclaration);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
const fontStyle = match[1],
|
|
// font variant is not used
|
|
// fontVariant = match[2],
|
|
fontWeight = match[3],
|
|
fontSize = match[4],
|
|
lineHeight = match[5],
|
|
fontFamily = match[6];
|
|
if (fontStyle) {
|
|
oStyle.fontStyle = fontStyle;
|
|
}
|
|
if (fontWeight) {
|
|
oStyle.fontWeight = isNaN(parseFloat(fontWeight)) ? fontWeight : parseFloat(fontWeight);
|
|
}
|
|
if (fontSize) {
|
|
oStyle.fontSize = parseUnit(fontSize);
|
|
}
|
|
if (fontFamily) {
|
|
oStyle.fontFamily = fontFamily;
|
|
}
|
|
if (lineHeight) {
|
|
oStyle.lineHeight = lineHeight === 'normal' ? 1 : lineHeight;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Takes a style object and parses it in one that has only defined values
|
|
* and lowercases properties
|
|
* @param style
|
|
* @param oStyle
|
|
*/
|
|
function parseStyleObject(style, oStyle) {
|
|
Object.entries(style).forEach(_ref => {
|
|
let [prop, value] = _ref;
|
|
if (value === undefined) {
|
|
return;
|
|
}
|
|
oStyle[prop.toLowerCase()] = value;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Takes a style string and parses it in one that has only defined values
|
|
* and lowercases properties
|
|
* @param style
|
|
* @param oStyle
|
|
*/
|
|
function parseStyleString(style, oStyle) {
|
|
style.replace(/;\s*$/, '').split(';').forEach(chunk => {
|
|
if (!chunk) return;
|
|
const [attr, value] = chunk.split(':');
|
|
oStyle[attr.trim().toLowerCase()] = value.trim();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parses "style" attribute, retuning an object with values
|
|
* @static
|
|
* @memberOf fabric
|
|
* @param {SVGElement} element Element to parse
|
|
* @return {Object} Objects with values parsed from style attribute of an element
|
|
*/
|
|
function parseStyleAttribute(element) {
|
|
const oStyle = {},
|
|
style = element.getAttribute('style');
|
|
if (!style) {
|
|
return oStyle;
|
|
}
|
|
if (typeof style === 'string') {
|
|
parseStyleString(style, oStyle);
|
|
} else {
|
|
parseStyleObject(style, oStyle);
|
|
}
|
|
return oStyle;
|
|
}
|
|
|
|
const colorAttributesMap = {
|
|
stroke: 'strokeOpacity',
|
|
fill: 'fillOpacity'
|
|
};
|
|
|
|
/**
|
|
* @private
|
|
* @param {Object} attributes Array of attributes to parse
|
|
*/
|
|
|
|
function setStrokeFillOpacity(attributes) {
|
|
const defaults = FabricObject.getDefaults();
|
|
Object.entries(colorAttributesMap).forEach(_ref => {
|
|
let [attr, colorAttr] = _ref;
|
|
if (typeof attributes[colorAttr] === 'undefined' || attributes[attr] === '') {
|
|
return;
|
|
}
|
|
if (typeof attributes[attr] === 'undefined') {
|
|
if (!defaults[attr]) {
|
|
return;
|
|
}
|
|
attributes[attr] = defaults[attr];
|
|
}
|
|
if (attributes[attr].indexOf('url(') === 0) {
|
|
return;
|
|
}
|
|
const color = new Color(attributes[attr]);
|
|
attributes[attr] = color.setAlpha(toFixed(color.getAlpha() * attributes[colorAttr], 2)).toRgba();
|
|
});
|
|
return attributes;
|
|
}
|
|
|
|
/**
|
|
* Returns an object of attributes' name/value, given element and an array of attribute names;
|
|
* Parses parent "g" nodes recursively upwards.
|
|
* @param {SVGElement | HTMLElement} element Element to parse
|
|
* @param {Array} attributes Array of attributes to parse
|
|
* @return {Object} object containing parsed attributes' names/values
|
|
*/
|
|
function parseAttributes(element, attributes, cssRules) {
|
|
if (!element) {
|
|
return {};
|
|
}
|
|
let parentAttributes = {},
|
|
fontSize,
|
|
parentFontSize = DEFAULT_SVG_FONT_SIZE;
|
|
|
|
// if there's a parent container (`g` or `a` or `symbol` node), parse its attributes recursively upwards
|
|
if (element.parentNode && svgValidParentsRegEx.test(element.parentNode.nodeName)) {
|
|
parentAttributes = parseAttributes(element.parentElement, attributes, cssRules);
|
|
if (parentAttributes.fontSize) {
|
|
fontSize = parentFontSize = parseUnit(parentAttributes.fontSize);
|
|
}
|
|
}
|
|
const ownAttributes = _objectSpread2(_objectSpread2(_objectSpread2({}, attributes.reduce((memo, attr) => {
|
|
const value = element.getAttribute(attr);
|
|
if (value) {
|
|
memo[attr] = value;
|
|
}
|
|
return memo;
|
|
}, {})), getGlobalStylesForElement(element, cssRules)), parseStyleAttribute(element));
|
|
if (ownAttributes[cPath]) {
|
|
element.setAttribute(cPath, ownAttributes[cPath]);
|
|
}
|
|
if (ownAttributes[fSize]) {
|
|
// looks like the minimum should be 9px when dealing with ems. this is what looks like in browsers.
|
|
fontSize = parseUnit(ownAttributes[fSize], parentFontSize);
|
|
ownAttributes[fSize] = "".concat(fontSize);
|
|
}
|
|
|
|
// this should have its own complex type
|
|
const normalizedStyle = {};
|
|
for (const attr in ownAttributes) {
|
|
const normalizedAttr = normalizeAttr(attr);
|
|
const normalizedValue = normalizeValue(normalizedAttr, ownAttributes[attr], parentAttributes, fontSize);
|
|
normalizedStyle[normalizedAttr] = normalizedValue;
|
|
}
|
|
if (normalizedStyle && normalizedStyle.font) {
|
|
parseFontDeclaration(normalizedStyle.font, normalizedStyle);
|
|
}
|
|
const mergedAttrs = _objectSpread2(_objectSpread2({}, parentAttributes), normalizedStyle);
|
|
return svgValidParentsRegEx.test(element.nodeName) ? mergedAttrs : setStrokeFillOpacity(mergedAttrs);
|
|
}
|
|
|
|
const _excluded$c = ["left", "top", "width", "height", "visible"];
|
|
const rectDefaultValues = {
|
|
rx: 0,
|
|
ry: 0
|
|
};
|
|
const RECT_PROPS = ['rx', 'ry'];
|
|
class Rect extends FabricObject {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), Rect.ownDefaults);
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor(options) {
|
|
super();
|
|
Object.assign(this, Rect.ownDefaults);
|
|
this.setOptions(options);
|
|
this._initRxRy();
|
|
}
|
|
/**
|
|
* Initializes rx/ry attributes
|
|
* @private
|
|
*/
|
|
_initRxRy() {
|
|
const {
|
|
rx,
|
|
ry
|
|
} = this;
|
|
if (rx && !ry) {
|
|
this.ry = rx;
|
|
} else if (ry && !rx) {
|
|
this.rx = ry;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_render(ctx) {
|
|
const {
|
|
width: w,
|
|
height: h
|
|
} = this;
|
|
const x = -w / 2;
|
|
const y = -h / 2;
|
|
const rx = this.rx ? Math.min(this.rx, w / 2) : 0;
|
|
const ry = this.ry ? Math.min(this.ry, h / 2) : 0;
|
|
const isRounded = rx !== 0 || ry !== 0;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + rx, y);
|
|
ctx.lineTo(x + w - rx, y);
|
|
isRounded && ctx.bezierCurveTo(x + w - kRect * rx, y, x + w, y + kRect * ry, x + w, y + ry);
|
|
ctx.lineTo(x + w, y + h - ry);
|
|
isRounded && ctx.bezierCurveTo(x + w, y + h - kRect * ry, x + w - kRect * rx, y + h, x + w - rx, y + h);
|
|
ctx.lineTo(x + rx, y + h);
|
|
isRounded && ctx.bezierCurveTo(x + kRect * rx, y + h, x, y + h - kRect * ry, x, y + h - ry);
|
|
ctx.lineTo(x, y + ry);
|
|
isRounded && ctx.bezierCurveTo(x, y + kRect * ry, x + kRect * rx, y, x + rx, y);
|
|
ctx.closePath();
|
|
this._renderPaintInOrder(ctx);
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
return super.toObject([...RECT_PROPS, ...propertiesToInclude]);
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @return {Array} an array of strings with the specific svg representation
|
|
* of the instance
|
|
*/
|
|
_toSVG() {
|
|
const {
|
|
width,
|
|
height,
|
|
rx,
|
|
ry
|
|
} = this;
|
|
return ['<rect ', 'COMMON_PARTS', "x=\"".concat(-width / 2, "\" y=\"").concat(-height / 2, "\" rx=\"").concat(rx, "\" ry=\"").concat(ry, "\" width=\"").concat(width, "\" height=\"").concat(height, "\" />\n")];
|
|
}
|
|
|
|
/**
|
|
* List of attribute names to account for when parsing SVG element (used by `Rect.fromElement`)
|
|
* @static
|
|
* @memberOf Rect
|
|
* @see: http://www.w3.org/TR/SVG/shapes.html#RectElement
|
|
*/
|
|
|
|
/* _FROM_SVG_START_ */
|
|
|
|
/**
|
|
* Returns {@link Rect} instance from an SVG element
|
|
* @static
|
|
* @memberOf Rect
|
|
* @param {HTMLElement} element Element to parse
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
static async fromElement(element, options, cssRules) {
|
|
const _parseAttributes = parseAttributes(element, this.ATTRIBUTE_NAMES, cssRules),
|
|
{
|
|
left = 0,
|
|
top = 0,
|
|
width = 0,
|
|
height = 0,
|
|
visible = true
|
|
} = _parseAttributes,
|
|
restOfparsedAttributes = _objectWithoutProperties(_parseAttributes, _excluded$c);
|
|
return new this(_objectSpread2(_objectSpread2(_objectSpread2({}, options), restOfparsedAttributes), {}, {
|
|
left,
|
|
top,
|
|
width,
|
|
height,
|
|
visible: Boolean(visible && width && height)
|
|
}));
|
|
}
|
|
|
|
/* _FROM_SVG_END_ */
|
|
}
|
|
/**
|
|
* Horizontal border radius
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Vertical border radius
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(Rect, "type", 'Rect');
|
|
_defineProperty(Rect, "cacheProperties", [...cacheProperties, ...RECT_PROPS]);
|
|
_defineProperty(Rect, "ownDefaults", rectDefaultValues);
|
|
_defineProperty(Rect, "ATTRIBUTE_NAMES", [...SHARED_ATTRIBUTES, 'x', 'y', 'rx', 'ry', 'width', 'height']);
|
|
classRegistry.setClass(Rect);
|
|
classRegistry.setSVGClass(Rect);
|
|
|
|
const LAYOUT_TYPE_INITIALIZATION = 'initialization';
|
|
const LAYOUT_TYPE_ADDED = 'added';
|
|
const LAYOUT_TYPE_REMOVED = 'removed';
|
|
const LAYOUT_TYPE_IMPERATIVE = 'imperative';
|
|
const LAYOUT_TYPE_OBJECT_MODIFIED = 'object_modified';
|
|
const LAYOUT_TYPE_OBJECT_MODIFYING = 'object_modifying';
|
|
|
|
/**
|
|
* @returns 2 points, the tl and br corners of the non rotated bounding box of an object
|
|
* in the {@link group} plane, taking into account objects that {@link group} is their parent
|
|
* but also belong to the active selection.
|
|
*/
|
|
const getObjectBounds = (destinationGroup, object) => {
|
|
const {
|
|
strokeUniform,
|
|
strokeWidth,
|
|
width,
|
|
height,
|
|
group: currentGroup
|
|
} = object;
|
|
const t = currentGroup && currentGroup !== destinationGroup ? calcPlaneChangeMatrix(currentGroup.calcTransformMatrix(), destinationGroup.calcTransformMatrix()) : null;
|
|
const objectCenter = t ? object.getRelativeCenterPoint().transform(t) : object.getRelativeCenterPoint();
|
|
const accountForStroke = !object['isStrokeAccountedForInDimensions']();
|
|
const strokeUniformVector = strokeUniform && accountForStroke ? sendVectorToPlane(new Point(strokeWidth, strokeWidth), undefined, destinationGroup.calcTransformMatrix()) : ZERO;
|
|
const scalingStrokeWidth = !strokeUniform && accountForStroke ? strokeWidth : 0;
|
|
const sizeVector = sizeAfterTransform(width + scalingStrokeWidth, height + scalingStrokeWidth, multiplyTransformMatrixArray([t, object.calcOwnMatrix()], true)).add(strokeUniformVector).scalarDivide(2);
|
|
return [objectCenter.subtract(sizeVector), objectCenter.add(sizeVector)];
|
|
};
|
|
|
|
/**
|
|
* Exposes a main public method {@link calcLayoutResult} that is used by the `LayoutManager` to perform layout.
|
|
* Returning `undefined` signals the `LayoutManager` to skip the layout.
|
|
*
|
|
* In charge of calculating the bounding box of the passed objects.
|
|
*/
|
|
class LayoutStrategy {
|
|
/**
|
|
* Used by the `LayoutManager` to perform layout
|
|
* @TODO/fix: if this method is calcResult, should calc unconditionally.
|
|
* the condition to not calc should be evaluated by the layoutManager.
|
|
* @returns layout result **OR** `undefined` to skip layout
|
|
*/
|
|
calcLayoutResult(context, objects) {
|
|
if (this.shouldPerformLayout(context)) {
|
|
return this.calcBoundingBox(objects, context);
|
|
}
|
|
}
|
|
shouldPerformLayout(_ref) {
|
|
let {
|
|
type,
|
|
prevStrategy,
|
|
strategy
|
|
} = _ref;
|
|
return type === LAYOUT_TYPE_INITIALIZATION || type === LAYOUT_TYPE_IMPERATIVE || !!prevStrategy && strategy !== prevStrategy;
|
|
}
|
|
shouldLayoutClipPath(_ref2) {
|
|
let {
|
|
type,
|
|
target: {
|
|
clipPath
|
|
}
|
|
} = _ref2;
|
|
return type !== LAYOUT_TYPE_INITIALIZATION && clipPath && !clipPath.absolutePositioned;
|
|
}
|
|
getInitialSize(context, result) {
|
|
return result.size;
|
|
}
|
|
|
|
/**
|
|
* Override this method to customize layout.
|
|
*/
|
|
calcBoundingBox(objects, context) {
|
|
const {
|
|
type,
|
|
target
|
|
} = context;
|
|
if (type === LAYOUT_TYPE_IMPERATIVE && context.overrides) {
|
|
return context.overrides;
|
|
}
|
|
if (objects.length === 0) {
|
|
return;
|
|
}
|
|
const {
|
|
left,
|
|
top,
|
|
width,
|
|
height
|
|
} = makeBoundingBoxFromPoints(objects.map(object => getObjectBounds(target, object)).reduce((coords, curr) => coords.concat(curr), []));
|
|
const bboxSize = new Point(width, height);
|
|
const bboxLeftTop = new Point(left, top);
|
|
const bboxCenter = bboxLeftTop.add(bboxSize.scalarDivide(2));
|
|
if (type === LAYOUT_TYPE_INITIALIZATION) {
|
|
const actualSize = this.getInitialSize(context, {
|
|
size: bboxSize,
|
|
center: bboxCenter
|
|
});
|
|
return {
|
|
// in `initialization` we do not account for target's transformation matrix
|
|
center: bboxCenter,
|
|
// TODO: investigate if this is still necessary
|
|
relativeCorrection: new Point(0, 0),
|
|
size: actualSize
|
|
};
|
|
} else {
|
|
// we send `relativeCenter` up to group's containing plane
|
|
const center = bboxCenter.transform(target.calcOwnMatrix());
|
|
return {
|
|
center,
|
|
size: bboxSize
|
|
};
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* override by subclass for persistence (TS does not support `static abstract`)
|
|
*/
|
|
_defineProperty(LayoutStrategy, "type", 'strategy');
|
|
|
|
/**
|
|
* Layout will adjust the bounding box to fit target's objects.
|
|
*/
|
|
class FitContentLayout extends LayoutStrategy {
|
|
/**
|
|
* @override layout on all triggers
|
|
* Override at will
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
shouldPerformLayout(context) {
|
|
return true;
|
|
}
|
|
}
|
|
_defineProperty(FitContentLayout, "type", 'fit-content');
|
|
classRegistry.setClass(FitContentLayout);
|
|
|
|
const _excluded$b = ["strategy"],
|
|
_excluded2$3 = ["target", "strategy", "bubbles", "prevStrategy"];
|
|
const LAYOUT_MANAGER = 'layoutManager';
|
|
class LayoutManager {
|
|
constructor() {
|
|
let strategy = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : new FitContentLayout();
|
|
_defineProperty(this, "strategy", void 0);
|
|
this.strategy = strategy;
|
|
this._subscriptions = new Map();
|
|
}
|
|
performLayout(context) {
|
|
const strictContext = _objectSpread2(_objectSpread2({
|
|
bubbles: true,
|
|
strategy: this.strategy
|
|
}, context), {}, {
|
|
prevStrategy: this._prevLayoutStrategy,
|
|
stopPropagation() {
|
|
this.bubbles = false;
|
|
}
|
|
});
|
|
this.onBeforeLayout(strictContext);
|
|
const layoutResult = this.getLayoutResult(strictContext);
|
|
if (layoutResult) {
|
|
this.commitLayout(strictContext, layoutResult);
|
|
}
|
|
this.onAfterLayout(strictContext, layoutResult);
|
|
this._prevLayoutStrategy = strictContext.strategy;
|
|
}
|
|
|
|
/**
|
|
* Attach handlers for events that we know will invalidate the layout when
|
|
* performed on child objects ( general transforms ).
|
|
* Returns the disposers for later unsubscribing and cleanup
|
|
* @param {FabricObject} object
|
|
* @param {RegistrationContext & Partial<StrictLayoutContext>} context
|
|
* @returns {VoidFunction[]} disposers remove the handlers
|
|
*/
|
|
attachHandlers(object, context) {
|
|
const {
|
|
target
|
|
} = context;
|
|
return [MODIFIED, MOVING, RESIZING, ROTATING, SCALING, SKEWING, CHANGED, MODIFY_POLY, MODIFY_PATH].map(key => object.on(key, e => this.performLayout(key === MODIFIED ? {
|
|
type: LAYOUT_TYPE_OBJECT_MODIFIED,
|
|
trigger: key,
|
|
e,
|
|
target
|
|
} : {
|
|
type: LAYOUT_TYPE_OBJECT_MODIFYING,
|
|
trigger: key,
|
|
e,
|
|
target
|
|
})));
|
|
}
|
|
|
|
/**
|
|
* Subscribe an object to transform events that will trigger a layout change on the parent
|
|
* This is important only for interactive groups.
|
|
* @param object
|
|
* @param context
|
|
*/
|
|
subscribe(object, context) {
|
|
this.unsubscribe(object, context);
|
|
const disposers = this.attachHandlers(object, context);
|
|
this._subscriptions.set(object, disposers);
|
|
}
|
|
|
|
/**
|
|
* unsubscribe object layout triggers
|
|
*/
|
|
unsubscribe(object, _context) {
|
|
(this._subscriptions.get(object) || []).forEach(d => d());
|
|
this._subscriptions.delete(object);
|
|
}
|
|
unsubscribeTargets(context) {
|
|
context.targets.forEach(object => this.unsubscribe(object, context));
|
|
}
|
|
subscribeTargets(context) {
|
|
context.targets.forEach(object => this.subscribe(object, context));
|
|
}
|
|
onBeforeLayout(context) {
|
|
const {
|
|
target,
|
|
type
|
|
} = context;
|
|
const {
|
|
canvas
|
|
} = target;
|
|
// handle layout triggers subscription
|
|
// @TODO: gate the registration when the group is interactive
|
|
if (type === LAYOUT_TYPE_INITIALIZATION || type === LAYOUT_TYPE_ADDED) {
|
|
this.subscribeTargets(context);
|
|
} else if (type === LAYOUT_TYPE_REMOVED) {
|
|
this.unsubscribeTargets(context);
|
|
}
|
|
// fire layout event (event will fire only for layouts after initialization layout)
|
|
target.fire('layout:before', {
|
|
context
|
|
});
|
|
canvas && canvas.fire('object:layout:before', {
|
|
target,
|
|
context
|
|
});
|
|
if (type === LAYOUT_TYPE_IMPERATIVE && context.deep) {
|
|
const tricklingContext = _objectWithoutProperties(context, _excluded$b);
|
|
// traverse the tree
|
|
target.forEachObject(object => object.layoutManager && object.layoutManager.performLayout(_objectSpread2(_objectSpread2({}, tricklingContext), {}, {
|
|
bubbles: false,
|
|
target: object
|
|
})));
|
|
}
|
|
}
|
|
getLayoutResult(context) {
|
|
const {
|
|
target,
|
|
strategy,
|
|
type
|
|
} = context;
|
|
const result = strategy.calcLayoutResult(context, target.getObjects());
|
|
if (!result) {
|
|
return;
|
|
}
|
|
const prevCenter = type === LAYOUT_TYPE_INITIALIZATION ? new Point() : target.getRelativeCenterPoint();
|
|
const {
|
|
center: nextCenter,
|
|
correction = new Point(),
|
|
relativeCorrection = new Point()
|
|
} = result;
|
|
const offset = prevCenter.subtract(nextCenter).add(correction).transform(
|
|
// in `initialization` we do not account for target's transformation matrix
|
|
type === LAYOUT_TYPE_INITIALIZATION ? iMatrix : invertTransform(target.calcOwnMatrix()), true).add(relativeCorrection);
|
|
return {
|
|
result,
|
|
prevCenter,
|
|
nextCenter,
|
|
offset
|
|
};
|
|
}
|
|
commitLayout(context, layoutResult) {
|
|
const {
|
|
target
|
|
} = context;
|
|
const {
|
|
result: {
|
|
size
|
|
},
|
|
nextCenter
|
|
} = layoutResult;
|
|
// set dimensions
|
|
target.set({
|
|
width: size.x,
|
|
height: size.y
|
|
});
|
|
// layout descendants
|
|
this.layoutObjects(context, layoutResult);
|
|
// set position
|
|
// in `initialization` we do not account for target's transformation matrix
|
|
if (context.type === LAYOUT_TYPE_INITIALIZATION) {
|
|
var _context$x, _context$y;
|
|
// TODO: what about strokeWidth?
|
|
target.set({
|
|
left: (_context$x = context.x) !== null && _context$x !== void 0 ? _context$x : nextCenter.x + size.x * resolveOrigin(target.originX),
|
|
top: (_context$y = context.y) !== null && _context$y !== void 0 ? _context$y : nextCenter.y + size.y * resolveOrigin(target.originY)
|
|
});
|
|
} else {
|
|
target.setPositionByOrigin(nextCenter, CENTER, CENTER);
|
|
// invalidate
|
|
target.setCoords();
|
|
target.set('dirty', true);
|
|
}
|
|
}
|
|
layoutObjects(context, layoutResult) {
|
|
const {
|
|
target
|
|
} = context;
|
|
// adjust objects to account for new center
|
|
target.forEachObject(object => {
|
|
object.group === target && this.layoutObject(context, layoutResult, object);
|
|
});
|
|
// adjust clip path to account for new center
|
|
context.strategy.shouldLayoutClipPath(context) && this.layoutObject(context, layoutResult, target.clipPath);
|
|
}
|
|
|
|
/**
|
|
* @param {FabricObject} object
|
|
* @param {Point} offset
|
|
*/
|
|
layoutObject(context, _ref, object) {
|
|
let {
|
|
offset
|
|
} = _ref;
|
|
// TODO: this is here for cache invalidation.
|
|
// verify if this is necessary since we have explicit
|
|
// cache invalidation at the end of commitLayout
|
|
object.set({
|
|
left: object.left + offset.x,
|
|
top: object.top + offset.y
|
|
});
|
|
}
|
|
onAfterLayout(context, layoutResult) {
|
|
const {
|
|
target,
|
|
strategy,
|
|
bubbles,
|
|
prevStrategy: _
|
|
} = context,
|
|
bubblingContext = _objectWithoutProperties(context, _excluded2$3);
|
|
const {
|
|
canvas
|
|
} = target;
|
|
|
|
// fire layout event (event will fire only for layouts after initialization layout)
|
|
target.fire('layout:after', {
|
|
context,
|
|
result: layoutResult
|
|
});
|
|
canvas && canvas.fire('object:layout:after', {
|
|
context,
|
|
result: layoutResult,
|
|
target
|
|
});
|
|
|
|
// bubble
|
|
const parent = target.parent;
|
|
if (bubbles && parent !== null && parent !== void 0 && parent.layoutManager) {
|
|
// add target to context#path
|
|
(bubblingContext.path || (bubblingContext.path = [])).push(target);
|
|
// all parents should invalidate their layout
|
|
parent.layoutManager.performLayout(_objectSpread2(_objectSpread2({}, bubblingContext), {}, {
|
|
target: parent
|
|
}));
|
|
}
|
|
target.set('dirty', true);
|
|
}
|
|
dispose() {
|
|
const {
|
|
_subscriptions
|
|
} = this;
|
|
_subscriptions.forEach(disposers => disposers.forEach(d => d()));
|
|
_subscriptions.clear();
|
|
}
|
|
toObject() {
|
|
return {
|
|
type: LAYOUT_MANAGER,
|
|
strategy: this.strategy.constructor.type
|
|
};
|
|
}
|
|
toJSON() {
|
|
return this.toObject();
|
|
}
|
|
}
|
|
classRegistry.setClass(LayoutManager, LAYOUT_MANAGER);
|
|
|
|
const _excluded$a = ["type", "objects", "layoutManager"];
|
|
/**
|
|
* This class handles the specific case of creating a group using {@link Group#fromObject} and is not meant to be used in any other case.
|
|
* We could have used a boolean in the constructor, as we did previously, but we think the boolean
|
|
* would stay in the group's constructor interface and create confusion, therefore it was removed.
|
|
* This layout manager doesn't do anything and therefore keeps the exact layout the group had when {@link Group#toObject} was called.
|
|
*/
|
|
class NoopLayoutManager extends LayoutManager {
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
performLayout() {}
|
|
}
|
|
const groupDefaultValues = {
|
|
strokeWidth: 0,
|
|
subTargetCheck: false,
|
|
interactive: false
|
|
};
|
|
|
|
/**
|
|
* @fires object:added
|
|
* @fires object:removed
|
|
* @fires layout:before
|
|
* @fires layout:after
|
|
*/
|
|
class Group extends createCollectionMixin(FabricObject) {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), Group.ownDefaults);
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param {FabricObject[]} [objects] instance objects
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor() {
|
|
let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
super();
|
|
/**
|
|
* Used to optimize performance
|
|
* set to `false` if you don't need contained objects to be targets of events
|
|
* @default
|
|
* @type boolean
|
|
*/
|
|
/**
|
|
* Used to allow targeting of object inside groups.
|
|
* set to true if you want to select an object inside a group.\
|
|
* **REQUIRES** `subTargetCheck` set to true
|
|
* This will be not removed but slowly replaced with a method setInteractive
|
|
* that will take care of enabling subTargetCheck and necessary object events.
|
|
* There is too much attached to group interactivity to just be evaluated by a
|
|
* boolean in the code
|
|
* @default
|
|
* @deprecated
|
|
* @type boolean
|
|
*/
|
|
/**
|
|
* Used internally to optimize performance
|
|
* Once an object is selected, instance is rendered without the selected object.
|
|
* This way instance is cached only once for the entire interaction with the selected object.
|
|
* @private
|
|
*/
|
|
_defineProperty(this, "_activeObjects", []);
|
|
_defineProperty(this, "__objectSelectionTracker", void 0);
|
|
_defineProperty(this, "__objectSelectionDisposer", void 0);
|
|
Object.assign(this, Group.ownDefaults);
|
|
this.setOptions(options);
|
|
this.groupInit(objects, options);
|
|
}
|
|
|
|
/**
|
|
* Shared code between group and active selection
|
|
* Meant to be used by the constructor.
|
|
*/
|
|
groupInit(objects, options) {
|
|
var _options$layoutManage;
|
|
this._objects = [...objects]; // Avoid unwanted mutations of Collection to affect the caller
|
|
|
|
this.__objectSelectionTracker = this.__objectSelectionMonitor.bind(this, true);
|
|
this.__objectSelectionDisposer = this.__objectSelectionMonitor.bind(this, false);
|
|
this.forEachObject(object => {
|
|
this.enterGroup(object, false);
|
|
});
|
|
|
|
// perform initial layout
|
|
this.layoutManager = (_options$layoutManage = options.layoutManager) !== null && _options$layoutManage !== void 0 ? _options$layoutManage : new LayoutManager();
|
|
this.layoutManager.performLayout({
|
|
type: LAYOUT_TYPE_INITIALIZATION,
|
|
target: this,
|
|
targets: [...objects],
|
|
// @TODO remove this concept from the layout manager.
|
|
// Layout manager will calculate the correct position,
|
|
// group options can override it later.
|
|
x: options.left,
|
|
y: options.top
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Checks if object can enter group and logs relevant warnings
|
|
* @private
|
|
* @param {FabricObject} object
|
|
* @returns
|
|
*/
|
|
canEnterGroup(object) {
|
|
if (object === this || this.isDescendantOf(object)) {
|
|
// prevent circular object tree
|
|
log('error', 'Group: circular object trees are not supported, this call has no effect');
|
|
return false;
|
|
} else if (this._objects.indexOf(object) !== -1) {
|
|
// is already in the objects array
|
|
log('error', 'Group: duplicate objects are not supported inside group, this call has no effect');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Override this method to enhance performance (for groups with a lot of objects).
|
|
* If Overriding, be sure not pass illegal objects to group - it will break your app.
|
|
* @private
|
|
*/
|
|
_filterObjectsBeforeEnteringGroup(objects) {
|
|
return objects.filter((object, index, array) => {
|
|
// can enter AND is the first occurrence of the object in the passed args (to prevent adding duplicates)
|
|
return this.canEnterGroup(object) && array.indexOf(object) === index;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add objects
|
|
* @param {...FabricObject[]} objects
|
|
*/
|
|
add() {
|
|
for (var _len = arguments.length, objects = new Array(_len), _key = 0; _key < _len; _key++) {
|
|
objects[_key] = arguments[_key];
|
|
}
|
|
const allowedObjects = this._filterObjectsBeforeEnteringGroup(objects);
|
|
const size = super.add(...allowedObjects);
|
|
this._onAfterObjectsChange(LAYOUT_TYPE_ADDED, allowedObjects);
|
|
return size;
|
|
}
|
|
|
|
/**
|
|
* Inserts an object into collection at specified index
|
|
* @param {FabricObject[]} objects Object to insert
|
|
* @param {Number} index Index to insert object at
|
|
*/
|
|
insertAt(index) {
|
|
for (var _len2 = arguments.length, objects = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {
|
|
objects[_key2 - 1] = arguments[_key2];
|
|
}
|
|
const allowedObjects = this._filterObjectsBeforeEnteringGroup(objects);
|
|
const size = super.insertAt(index, ...allowedObjects);
|
|
this._onAfterObjectsChange(LAYOUT_TYPE_ADDED, allowedObjects);
|
|
return size;
|
|
}
|
|
|
|
/**
|
|
* Remove objects
|
|
* @param {...FabricObject[]} objects
|
|
* @returns {FabricObject[]} removed objects
|
|
*/
|
|
remove() {
|
|
const removed = super.remove(...arguments);
|
|
this._onAfterObjectsChange(LAYOUT_TYPE_REMOVED, removed);
|
|
return removed;
|
|
}
|
|
_onObjectAdded(object) {
|
|
this.enterGroup(object, true);
|
|
this.fire('object:added', {
|
|
target: object
|
|
});
|
|
object.fire('added', {
|
|
target: this
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {FabricObject} object
|
|
* @param {boolean} [removeParentTransform] true if object should exit group without applying group's transform to it
|
|
*/
|
|
_onObjectRemoved(object, removeParentTransform) {
|
|
this.exitGroup(object, removeParentTransform);
|
|
this.fire('object:removed', {
|
|
target: object
|
|
});
|
|
object.fire('removed', {
|
|
target: this
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {'added'|'removed'} type
|
|
* @param {FabricObject[]} targets
|
|
*/
|
|
_onAfterObjectsChange(type, targets) {
|
|
this.layoutManager.performLayout({
|
|
type,
|
|
targets,
|
|
target: this
|
|
});
|
|
}
|
|
_onStackOrderChanged() {
|
|
this._set('dirty', true);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {string} key
|
|
* @param {*} value
|
|
*/
|
|
_set(key, value) {
|
|
const prev = this[key];
|
|
super._set(key, value);
|
|
if (key === 'canvas' && prev !== value) {
|
|
(this._objects || []).forEach(object => {
|
|
object._set(key, value);
|
|
});
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_shouldSetNestedCoords() {
|
|
return this.subTargetCheck;
|
|
}
|
|
|
|
/**
|
|
* Remove all objects
|
|
* @returns {FabricObject[]} removed objects
|
|
*/
|
|
removeAll() {
|
|
this._activeObjects = [];
|
|
return this.remove(...this._objects);
|
|
}
|
|
|
|
/**
|
|
* keeps track of the selected objects
|
|
* @private
|
|
*/
|
|
__objectSelectionMonitor(selected, _ref) {
|
|
let {
|
|
target: object
|
|
} = _ref;
|
|
const activeObjects = this._activeObjects;
|
|
if (selected) {
|
|
activeObjects.push(object);
|
|
this._set('dirty', true);
|
|
} else if (activeObjects.length > 0) {
|
|
const index = activeObjects.indexOf(object);
|
|
if (index > -1) {
|
|
activeObjects.splice(index, 1);
|
|
this._set('dirty', true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {boolean} watch
|
|
* @param {FabricObject} object
|
|
*/
|
|
_watchObject(watch, object) {
|
|
// make sure we listen only once
|
|
watch && this._watchObject(false, object);
|
|
if (watch) {
|
|
object.on('selected', this.__objectSelectionTracker);
|
|
object.on('deselected', this.__objectSelectionDisposer);
|
|
} else {
|
|
object.off('selected', this.__objectSelectionTracker);
|
|
object.off('deselected', this.__objectSelectionDisposer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {FabricObject} object
|
|
* @param {boolean} [removeParentTransform] true if object is in canvas coordinate plane
|
|
*/
|
|
enterGroup(object, removeParentTransform) {
|
|
object.group && object.group.remove(object);
|
|
object._set('parent', this);
|
|
this._enterGroup(object, removeParentTransform);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {FabricObject} object
|
|
* @param {boolean} [removeParentTransform] true if object is in canvas coordinate plane
|
|
*/
|
|
_enterGroup(object, removeParentTransform) {
|
|
if (removeParentTransform) {
|
|
// can this be converted to utils (sendObjectToPlane)?
|
|
applyTransformToObject(object, multiplyTransformMatrices(invertTransform(this.calcTransformMatrix()), object.calcTransformMatrix()));
|
|
}
|
|
this._shouldSetNestedCoords() && object.setCoords();
|
|
object._set('group', this);
|
|
object._set('canvas', this.canvas);
|
|
this._watchObject(true, object);
|
|
const activeObject = this.canvas && this.canvas.getActiveObject && this.canvas.getActiveObject();
|
|
// if we are adding the activeObject in a group
|
|
if (activeObject && (activeObject === object || object.isDescendantOf(activeObject))) {
|
|
this._activeObjects.push(object);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {FabricObject} object
|
|
* @param {boolean} [removeParentTransform] true if object should exit group without applying group's transform to it
|
|
*/
|
|
exitGroup(object, removeParentTransform) {
|
|
this._exitGroup(object, removeParentTransform);
|
|
object._set('parent', undefined);
|
|
object._set('canvas', undefined);
|
|
}
|
|
|
|
/**
|
|
* Executes the inner fabric logic of exiting a group.
|
|
* - Stop watching the object
|
|
* - Remove the object from the optimization map this._activeObjects
|
|
* - unset the group property of the object
|
|
* @protected
|
|
* @param {FabricObject} object
|
|
* @param {boolean} [removeParentTransform] true if object should exit group without applying group's transform to it
|
|
*/
|
|
_exitGroup(object, removeParentTransform) {
|
|
object._set('group', undefined);
|
|
if (!removeParentTransform) {
|
|
applyTransformToObject(object, multiplyTransformMatrices(this.calcTransformMatrix(), object.calcTransformMatrix()));
|
|
object.setCoords();
|
|
}
|
|
this._watchObject(false, object);
|
|
const index = this._activeObjects.length > 0 ? this._activeObjects.indexOf(object) : -1;
|
|
if (index > -1) {
|
|
this._activeObjects.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decide if the object should cache or not. Create its own cache level
|
|
* needsItsOwnCache should be used when the object drawing method requires
|
|
* a cache step. None of the fabric classes requires it.
|
|
* Generally you do not cache objects in groups because the group is already cached.
|
|
* @return {Boolean}
|
|
*/
|
|
shouldCache() {
|
|
const ownCache = FabricObject.prototype.shouldCache.call(this);
|
|
if (ownCache) {
|
|
for (let i = 0; i < this._objects.length; i++) {
|
|
if (this._objects[i].willDrawShadow()) {
|
|
this.ownCaching = false;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return ownCache;
|
|
}
|
|
|
|
/**
|
|
* Check if this object or a child object will cast a shadow
|
|
* @return {Boolean}
|
|
*/
|
|
willDrawShadow() {
|
|
if (super.willDrawShadow()) {
|
|
return true;
|
|
}
|
|
for (let i = 0; i < this._objects.length; i++) {
|
|
if (this._objects[i].willDrawShadow()) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if instance or its group are caching, recursively up
|
|
* @return {Boolean}
|
|
*/
|
|
isOnACache() {
|
|
return this.ownCaching || !!this.parent && this.parent.isOnACache();
|
|
}
|
|
|
|
/**
|
|
* Execute the drawing operation for an object on a specified context
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
drawObject(ctx) {
|
|
this._renderBackground(ctx);
|
|
for (let i = 0; i < this._objects.length; i++) {
|
|
var _this$canvas;
|
|
// TODO: handle rendering edge case somehow
|
|
if ((_this$canvas = this.canvas) !== null && _this$canvas !== void 0 && _this$canvas.preserveObjectStacking && this._objects[i].group !== this) {
|
|
ctx.save();
|
|
ctx.transform(...invertTransform(this.calcTransformMatrix()));
|
|
this._objects[i].render(ctx);
|
|
ctx.restore();
|
|
} else if (this._objects[i].group === this) {
|
|
this._objects[i].render(ctx);
|
|
}
|
|
}
|
|
this._drawClipPath(ctx, this.clipPath);
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @return {Boolean}
|
|
*/
|
|
setCoords() {
|
|
super.setCoords();
|
|
this._shouldSetNestedCoords() && this.forEachObject(object => object.setCoords());
|
|
}
|
|
triggerLayout() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
this.layoutManager.performLayout(_objectSpread2({
|
|
target: this,
|
|
type: LAYOUT_TYPE_IMPERATIVE
|
|
}, options));
|
|
}
|
|
|
|
/**
|
|
* Renders instance on a given context
|
|
* @param {CanvasRenderingContext2D} ctx context to render instance on
|
|
*/
|
|
render(ctx) {
|
|
this._transformDone = true;
|
|
super.render(ctx);
|
|
this._transformDone = false;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {'toObject'|'toDatalessObject'} [method]
|
|
* @param {string[]} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @returns {FabricObject[]} serialized objects
|
|
*/
|
|
__serializeObjects(method, propertiesToInclude) {
|
|
const _includeDefaultValues = this.includeDefaultValues;
|
|
return this._objects.filter(function (obj) {
|
|
return !obj.excludeFromExport;
|
|
}).map(function (obj) {
|
|
const originalDefaults = obj.includeDefaultValues;
|
|
obj.includeDefaultValues = _includeDefaultValues;
|
|
const data = obj[method || 'toObject'](propertiesToInclude);
|
|
obj.includeDefaultValues = originalDefaults;
|
|
// delete data.version;
|
|
return data;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @param {string[]} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
const layoutManager = this.layoutManager.toObject();
|
|
return _objectSpread2(_objectSpread2(_objectSpread2({}, super.toObject(['subTargetCheck', 'interactive', ...propertiesToInclude])), layoutManager.strategy !== 'fit-content' || this.includeDefaultValues ? {
|
|
layoutManager
|
|
} : {}), {}, {
|
|
objects: this.__serializeObjects('toObject', propertiesToInclude)
|
|
});
|
|
}
|
|
toString() {
|
|
return "#<Group: (".concat(this.complexity(), ")>");
|
|
}
|
|
dispose() {
|
|
this.layoutManager.unsubscribeTargets({
|
|
targets: this.getObjects(),
|
|
target: this
|
|
});
|
|
this._activeObjects = [];
|
|
this.forEachObject(object => {
|
|
this._watchObject(false, object);
|
|
object.dispose();
|
|
});
|
|
super.dispose();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_createSVGBgRect(reviver) {
|
|
if (!this.backgroundColor) {
|
|
return '';
|
|
}
|
|
const fillStroke = Rect.prototype._toSVG.call(this);
|
|
const commons = fillStroke.indexOf('COMMON_PARTS');
|
|
fillStroke[commons] = 'for="group" ';
|
|
const markup = fillStroke.join('');
|
|
return reviver ? reviver(markup) : markup;
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @param {TSVGReviver} [reviver] Method for further parsing of svg representation.
|
|
* @return {String} svg representation of an instance
|
|
*/
|
|
_toSVG(reviver) {
|
|
const svgString = ['<g ', 'COMMON_PARTS', ' >\n'];
|
|
const bg = this._createSVGBgRect(reviver);
|
|
bg && svgString.push('\t\t', bg);
|
|
for (let i = 0; i < this._objects.length; i++) {
|
|
svgString.push('\t\t', this._objects[i].toSVG(reviver));
|
|
}
|
|
svgString.push('</g>\n');
|
|
return svgString;
|
|
}
|
|
|
|
/**
|
|
* Returns styles-string for svg-export, specific version for group
|
|
* @return {String}
|
|
*/
|
|
getSvgStyles() {
|
|
const opacity = typeof this.opacity !== 'undefined' && this.opacity !== 1 ? "opacity: ".concat(this.opacity, ";") : '',
|
|
visibility = this.visible ? '' : ' visibility: hidden;';
|
|
return [opacity, this.getSvgFilter(), visibility].join('');
|
|
}
|
|
|
|
/**
|
|
* Returns svg clipPath representation of an instance
|
|
* @param {Function} [reviver] Method for further parsing of svg representation.
|
|
* @return {String} svg representation of an instance
|
|
*/
|
|
toClipPathSVG(reviver) {
|
|
const svgString = [];
|
|
const bg = this._createSVGBgRect(reviver);
|
|
bg && svgString.push('\t', bg);
|
|
for (let i = 0; i < this._objects.length; i++) {
|
|
svgString.push('\t', this._objects[i].toClipPathSVG(reviver));
|
|
}
|
|
return this._createBaseClipPathSVGMarkup(svgString, {
|
|
reviver
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @todo support loading from svg
|
|
* @private
|
|
* @static
|
|
* @memberOf Group
|
|
* @param {Object} object Object to create a group from
|
|
* @returns {Promise<Group>}
|
|
*/
|
|
static fromObject(_ref2, abortable) {
|
|
let {
|
|
type,
|
|
objects = [],
|
|
layoutManager
|
|
} = _ref2,
|
|
options = _objectWithoutProperties(_ref2, _excluded$a);
|
|
return Promise.all([enlivenObjects(objects, abortable), enlivenObjectEnlivables(options, abortable)]).then(_ref3 => {
|
|
let [objects, hydratedOptions] = _ref3;
|
|
const group = new this(objects, _objectSpread2(_objectSpread2(_objectSpread2({}, options), hydratedOptions), {}, {
|
|
layoutManager: new NoopLayoutManager()
|
|
}));
|
|
if (layoutManager) {
|
|
const layoutClass = classRegistry.getClass(layoutManager.type);
|
|
const strategyClass = classRegistry.getClass(layoutManager.strategy);
|
|
group.layoutManager = new layoutClass(new strategyClass());
|
|
} else {
|
|
group.layoutManager = new LayoutManager();
|
|
}
|
|
group.layoutManager.subscribeTargets({
|
|
type: LAYOUT_TYPE_INITIALIZATION,
|
|
target: group,
|
|
targets: group.getObjects()
|
|
});
|
|
group.setCoords();
|
|
return group;
|
|
});
|
|
}
|
|
}
|
|
_defineProperty(Group, "type", 'Group');
|
|
_defineProperty(Group, "ownDefaults", groupDefaultValues);
|
|
classRegistry.setClass(Group);
|
|
|
|
/**
|
|
* TODO experiment with different layout manager and svg results ( fixed fit content )
|
|
* Groups SVG elements (usually those retrieved from SVG document)
|
|
* @static
|
|
* @param {FabricObject[]} elements FabricObject(s) parsed from svg, to group
|
|
* @return {FabricObject | Group}
|
|
*/
|
|
const groupSVGElements = (elements, options) => {
|
|
if (elements && elements.length === 1) {
|
|
return elements[0];
|
|
}
|
|
return new Group(elements, options);
|
|
};
|
|
|
|
/**
|
|
* Finds the scale for the object source to fit inside the object destination,
|
|
* keeping aspect ratio intact.
|
|
* respect the total allowed area for the cache.
|
|
* @param {TSize} source natural unscaled size of the object
|
|
* @param {TSize} destination natural unscaled size of the object
|
|
* @return {Number} scale factor to apply to source to fit into destination
|
|
*/
|
|
const findScaleToFit = (source, destination) => Math.min(destination.width / source.width, destination.height / source.height);
|
|
|
|
/**
|
|
* Finds the scale for the object source to cover entirely the object destination,
|
|
* keeping aspect ratio intact.
|
|
* respect the total allowed area for the cache.
|
|
* @param {TSize} source natural unscaled size of the object
|
|
* @param {TSize} destination natural unscaled size of the object
|
|
* @return {Number} scale factor to apply to source to cover destination
|
|
*/
|
|
const findScaleToCover = (source, destination) => Math.max(destination.width / source.width, destination.height / source.height);
|
|
|
|
const commaWsp = "\\s*,?\\s*";
|
|
|
|
/**
|
|
* p for param
|
|
* using "bad naming" here because it makes the regex much easier to read
|
|
* p is a number that is preceded by an arbitary number of spaces, maybe 0,
|
|
* a comma or not, and then possibly more spaces or not.
|
|
*/
|
|
const p = "".concat(commaWsp, "(").concat(reNum, ")");
|
|
|
|
// const reMoveToCommand = `(M) ?(?:${p}${p} ?)+`;
|
|
|
|
// const reLineCommand = `(L) ?(?:${p}${p} ?)+`;
|
|
|
|
// const reHorizontalLineCommand = `(H) ?(?:${p} ?)+`;
|
|
|
|
// const reVerticalLineCommand = `(V) ?(?:${p} ?)+`;
|
|
|
|
// const reClosePathCommand = String.raw`(Z)\s*`;
|
|
|
|
// const reCubicCurveCommand = `(C) ?(?:${p}${p}${p}${p}${p}${p} ?)+`;
|
|
|
|
// const reCubicCurveShortcutCommand = `(S) ?(?:${p}${p}${p}${p} ?)+`;
|
|
|
|
// const reQuadraticCurveCommand = `(Q) ?(?:${p}${p}${p}${p} ?)+`;
|
|
|
|
// const reQuadraticCurveShortcutCommand = `(T) ?(?:${p}${p} ?)+`;
|
|
|
|
const reArcCommandPoints = "".concat(p).concat(p).concat(p).concat(commaWsp, "([01])").concat(commaWsp, "([01])").concat(p).concat(p);
|
|
// const reArcCommand = `(A) ?(?:${reArcCommandPoints} ?)+`;
|
|
|
|
// export const rePathCommandGroups =
|
|
// `(?:(?:${reMoveToCommand})` +
|
|
// `|(?:${reLineCommand})` +
|
|
// `|(?:${reHorizontalLineCommand})` +
|
|
// `|(?:${reVerticalLineCommand})` +
|
|
// `|(?:${reClosePathCommand})` +
|
|
// `|(?:${reCubicCurveCommand})` +
|
|
// `|(?:${reCubicCurveShortcutCommand})` +
|
|
// `|(?:${reQuadraticCurveCommand})` +
|
|
// `|(?:${reQuadraticCurveShortcutCommand})` +
|
|
// `|(?:${reArcCommand}))`;
|
|
|
|
const rePathCommand = '[mzlhvcsqta][^mzlhvcsqta]*';
|
|
|
|
/**
|
|
* Commands that may be repeated
|
|
*/
|
|
const repeatedCommands = {
|
|
m: 'l',
|
|
M: 'L'
|
|
};
|
|
|
|
/**
|
|
* Convert an arc of a rotated ellipse to a Bezier Curve
|
|
* @param {TRadian} theta1 start of the arc
|
|
* @param {TRadian} theta2 end of the arc
|
|
* @param cosTh cosine of the angle of rotation
|
|
* @param sinTh sine of the angle of rotation
|
|
* @param rx x-axis radius (before rotation)
|
|
* @param ry y-axis radius (before rotation)
|
|
* @param cx1 center x of the ellipse
|
|
* @param cy1 center y of the ellipse
|
|
* @param mT
|
|
* @param fromX starting point of arc x
|
|
* @param fromY starting point of arc y
|
|
*/
|
|
const segmentToBezier = (theta1, theta2, cosTh, sinTh, rx, ry, cx1, cy1, mT, fromX, fromY) => {
|
|
const costh1 = cos(theta1),
|
|
sinth1 = sin(theta1),
|
|
costh2 = cos(theta2),
|
|
sinth2 = sin(theta2),
|
|
toX = cosTh * rx * costh2 - sinTh * ry * sinth2 + cx1,
|
|
toY = sinTh * rx * costh2 + cosTh * ry * sinth2 + cy1,
|
|
cp1X = fromX + mT * (-cosTh * rx * sinth1 - sinTh * ry * costh1),
|
|
cp1Y = fromY + mT * (-sinTh * rx * sinth1 + cosTh * ry * costh1),
|
|
cp2X = toX + mT * (cosTh * rx * sinth2 + sinTh * ry * costh2),
|
|
cp2Y = toY + mT * (sinTh * rx * sinth2 - cosTh * ry * costh2);
|
|
return ['C', cp1X, cp1Y, cp2X, cp2Y, toX, toY];
|
|
};
|
|
|
|
/**
|
|
* Adapted from {@link http://dxr.mozilla.org/mozilla-central/source/dom/svg/SVGPathDataParser.cpp}
|
|
* by Andrea Bogazzi code is under MPL. if you don't have a copy of the license you can take it here
|
|
* http://mozilla.org/MPL/2.0/
|
|
* @param toX
|
|
* @param toY
|
|
* @param rx
|
|
* @param ry
|
|
* @param {number} large 0 or 1 flag
|
|
* @param {number} sweep 0 or 1 flag
|
|
* @param rotateX
|
|
*/
|
|
const arcToSegments = (toX, toY, rx, ry, large, sweep, rotateX) => {
|
|
if (rx === 0 || ry === 0) {
|
|
return [];
|
|
}
|
|
let fromX = 0,
|
|
fromY = 0,
|
|
root = 0;
|
|
const PI = Math.PI,
|
|
theta = rotateX * PiBy180,
|
|
sinTheta = sin(theta),
|
|
cosTh = cos(theta),
|
|
px = 0.5 * (-cosTh * toX - sinTheta * toY),
|
|
py = 0.5 * (-cosTh * toY + sinTheta * toX),
|
|
rx2 = rx ** 2,
|
|
ry2 = ry ** 2,
|
|
py2 = py ** 2,
|
|
px2 = px ** 2,
|
|
pl = rx2 * ry2 - rx2 * py2 - ry2 * px2;
|
|
let _rx = Math.abs(rx);
|
|
let _ry = Math.abs(ry);
|
|
if (pl < 0) {
|
|
const s = Math.sqrt(1 - pl / (rx2 * ry2));
|
|
_rx *= s;
|
|
_ry *= s;
|
|
} else {
|
|
root = (large === sweep ? -1.0 : 1.0) * Math.sqrt(pl / (rx2 * py2 + ry2 * px2));
|
|
}
|
|
const cx = root * _rx * py / _ry,
|
|
cy = -root * _ry * px / _rx,
|
|
cx1 = cosTh * cx - sinTheta * cy + toX * 0.5,
|
|
cy1 = sinTheta * cx + cosTh * cy + toY * 0.5;
|
|
let mTheta = calcVectorAngle(1, 0, (px - cx) / _rx, (py - cy) / _ry);
|
|
let dtheta = calcVectorAngle((px - cx) / _rx, (py - cy) / _ry, (-px - cx) / _rx, (-py - cy) / _ry);
|
|
if (sweep === 0 && dtheta > 0) {
|
|
dtheta -= 2 * PI;
|
|
} else if (sweep === 1 && dtheta < 0) {
|
|
dtheta += 2 * PI;
|
|
}
|
|
|
|
// Convert into cubic bezier segments <= 90deg
|
|
const segments = Math.ceil(Math.abs(dtheta / PI * 2)),
|
|
result = [],
|
|
mDelta = dtheta / segments,
|
|
mT = 8 / 3 * Math.sin(mDelta / 4) * Math.sin(mDelta / 4) / Math.sin(mDelta / 2);
|
|
let th3 = mTheta + mDelta;
|
|
for (let i = 0; i < segments; i++) {
|
|
result[i] = segmentToBezier(mTheta, th3, cosTh, sinTheta, _rx, _ry, cx1, cy1, mT, fromX, fromY);
|
|
fromX = result[i][5];
|
|
fromY = result[i][6];
|
|
mTheta = th3;
|
|
th3 += mDelta;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* @private
|
|
* Calculate the angle between two vectors
|
|
* @param ux u endpoint x
|
|
* @param uy u endpoint y
|
|
* @param vx v endpoint x
|
|
* @param vy v endpoint y
|
|
*/
|
|
const calcVectorAngle = (ux, uy, vx, vy) => {
|
|
const ta = Math.atan2(uy, ux),
|
|
tb = Math.atan2(vy, vx);
|
|
if (tb >= ta) {
|
|
return tb - ta;
|
|
} else {
|
|
return 2 * Math.PI - (ta - tb);
|
|
}
|
|
};
|
|
|
|
// functions for the Cubic beizer
|
|
// taken from: https://github.com/konvajs/konva/blob/7.0.5/src/shapes/Path.ts#L350
|
|
const CB1 = t => t ** 3;
|
|
const CB2 = t => 3 * t ** 2 * (1 - t);
|
|
const CB3 = t => 3 * t * (1 - t) ** 2;
|
|
const CB4 = t => (1 - t) ** 3;
|
|
|
|
/**
|
|
* Calculate bounding box of a cubic Bezier curve
|
|
* Taken from http://jsbin.com/ivomiq/56/edit (no credits available)
|
|
* TODO: can we normalize this with the starting points set at 0 and then translated the bbox?
|
|
* @param {number} begx starting point
|
|
* @param {number} begy
|
|
* @param {number} cp1x first control point
|
|
* @param {number} cp1y
|
|
* @param {number} cp2x second control point
|
|
* @param {number} cp2y
|
|
* @param {number} endx end of bezier
|
|
* @param {number} endy
|
|
* @return {TRectBounds} the rectangular bounds
|
|
*/
|
|
function getBoundsOfCurve(begx, begy, cp1x, cp1y, cp2x, cp2y, endx, endy) {
|
|
let argsString;
|
|
if (config.cachesBoundsOfCurve) {
|
|
// eslint-disable-next-line
|
|
argsString = [...arguments].join();
|
|
if (cache.boundsOfCurveCache[argsString]) {
|
|
return cache.boundsOfCurveCache[argsString];
|
|
}
|
|
}
|
|
const sqrt = Math.sqrt,
|
|
abs = Math.abs,
|
|
tvalues = [],
|
|
bounds = [[0, 0], [0, 0]];
|
|
let b = 6 * begx - 12 * cp1x + 6 * cp2x;
|
|
let a = -3 * begx + 9 * cp1x - 9 * cp2x + 3 * endx;
|
|
let c = 3 * cp1x - 3 * begx;
|
|
for (let i = 0; i < 2; ++i) {
|
|
if (i > 0) {
|
|
b = 6 * begy - 12 * cp1y + 6 * cp2y;
|
|
a = -3 * begy + 9 * cp1y - 9 * cp2y + 3 * endy;
|
|
c = 3 * cp1y - 3 * begy;
|
|
}
|
|
if (abs(a) < 1e-12) {
|
|
if (abs(b) < 1e-12) {
|
|
continue;
|
|
}
|
|
const t = -c / b;
|
|
if (0 < t && t < 1) {
|
|
tvalues.push(t);
|
|
}
|
|
continue;
|
|
}
|
|
const b2ac = b * b - 4 * c * a;
|
|
if (b2ac < 0) {
|
|
continue;
|
|
}
|
|
const sqrtb2ac = sqrt(b2ac);
|
|
const t1 = (-b + sqrtb2ac) / (2 * a);
|
|
if (0 < t1 && t1 < 1) {
|
|
tvalues.push(t1);
|
|
}
|
|
const t2 = (-b - sqrtb2ac) / (2 * a);
|
|
if (0 < t2 && t2 < 1) {
|
|
tvalues.push(t2);
|
|
}
|
|
}
|
|
let j = tvalues.length;
|
|
const jlen = j;
|
|
const iterator = getPointOnCubicBezierIterator(begx, begy, cp1x, cp1y, cp2x, cp2y, endx, endy);
|
|
while (j--) {
|
|
const {
|
|
x,
|
|
y
|
|
} = iterator(tvalues[j]);
|
|
bounds[0][j] = x;
|
|
bounds[1][j] = y;
|
|
}
|
|
bounds[0][jlen] = begx;
|
|
bounds[1][jlen] = begy;
|
|
bounds[0][jlen + 1] = endx;
|
|
bounds[1][jlen + 1] = endy;
|
|
const result = [new Point(Math.min(...bounds[0]), Math.min(...bounds[1])), new Point(Math.max(...bounds[0]), Math.max(...bounds[1]))];
|
|
if (config.cachesBoundsOfCurve) {
|
|
cache.boundsOfCurveCache[argsString] = result;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Converts arc to a bunch of cubic Bezier curves
|
|
* @param {number} fx starting point x
|
|
* @param {number} fy starting point y
|
|
* @param {TParsedArcCommand} coords Arc command
|
|
*/
|
|
const fromArcToBeziers = (fx, fy, _ref) => {
|
|
let [_, rx, ry, rot, large, sweep, tx, ty] = _ref;
|
|
const segsNorm = arcToSegments(tx - fx, ty - fy, rx, ry, large, sweep, rot);
|
|
for (let i = 0, len = segsNorm.length; i < len; i++) {
|
|
segsNorm[i][1] += fx;
|
|
segsNorm[i][2] += fy;
|
|
segsNorm[i][3] += fx;
|
|
segsNorm[i][4] += fy;
|
|
segsNorm[i][5] += fx;
|
|
segsNorm[i][6] += fy;
|
|
}
|
|
return segsNorm;
|
|
};
|
|
|
|
/**
|
|
* This function takes a parsed SVG path and makes it simpler for fabricJS logic.
|
|
* Simplification consist of:
|
|
* - All commands converted to absolute (lowercase to uppercase)
|
|
* - S converted to C
|
|
* - T converted to Q
|
|
* - A converted to C
|
|
* @param {TComplexPathData} path the array of commands of a parsed SVG path for `Path`
|
|
* @return {TSimplePathData} the simplified array of commands of a parsed SVG path for `Path`
|
|
* TODO: figure out how to remove the type assertions in a nice way
|
|
*/
|
|
const makePathSimpler = path => {
|
|
// x and y represent the last point of the path, AKA the previous command point.
|
|
// we add them to each relative command to make it an absolute comment.
|
|
// we also swap the v V h H with L, because are easier to transform.
|
|
let x = 0,
|
|
y = 0;
|
|
// x1 and y1 represent the last point of the subpath. the subpath is started with
|
|
// m or M command. When a z or Z command is drawn, x and y need to be resetted to
|
|
// the last x1 and y1.
|
|
let x1 = 0,
|
|
y1 = 0;
|
|
// previous will host the letter of the previous command, to handle S and T.
|
|
// controlX and controlY will host the previous reflected control point
|
|
const destinationPath = [];
|
|
let previous,
|
|
// placeholders
|
|
controlX = 0,
|
|
controlY = 0;
|
|
for (const parsedCommand of path) {
|
|
const current = [...parsedCommand];
|
|
let converted;
|
|
switch (current[0] // first letter
|
|
) {
|
|
case 'l':
|
|
// lineto, relative
|
|
current[1] += x;
|
|
current[2] += y;
|
|
// falls through
|
|
case 'L':
|
|
x = current[1];
|
|
y = current[2];
|
|
converted = ['L', x, y];
|
|
break;
|
|
case 'h':
|
|
// horizontal lineto, relative
|
|
current[1] += x;
|
|
// falls through
|
|
case 'H':
|
|
x = current[1];
|
|
converted = ['L', x, y];
|
|
break;
|
|
case 'v':
|
|
// vertical lineto, relative
|
|
current[1] += y;
|
|
// falls through
|
|
case 'V':
|
|
y = current[1];
|
|
converted = ['L', x, y];
|
|
break;
|
|
case 'm':
|
|
// moveTo, relative
|
|
current[1] += x;
|
|
current[2] += y;
|
|
// falls through
|
|
case 'M':
|
|
x = current[1];
|
|
y = current[2];
|
|
x1 = current[1];
|
|
y1 = current[2];
|
|
converted = ['M', x, y];
|
|
break;
|
|
case 'c':
|
|
// bezierCurveTo, relative
|
|
current[1] += x;
|
|
current[2] += y;
|
|
current[3] += x;
|
|
current[4] += y;
|
|
current[5] += x;
|
|
current[6] += y;
|
|
// falls through
|
|
case 'C':
|
|
controlX = current[3];
|
|
controlY = current[4];
|
|
x = current[5];
|
|
y = current[6];
|
|
converted = ['C', current[1], current[2], controlX, controlY, x, y];
|
|
break;
|
|
case 's':
|
|
// shorthand cubic bezierCurveTo, relative
|
|
current[1] += x;
|
|
current[2] += y;
|
|
current[3] += x;
|
|
current[4] += y;
|
|
// falls through
|
|
case 'S':
|
|
// would be sScC but since we are swapping sSc for C, we check just that.
|
|
if (previous === 'C') {
|
|
// calculate reflection of previous control points
|
|
controlX = 2 * x - controlX;
|
|
controlY = 2 * y - controlY;
|
|
} else {
|
|
// If there is no previous command or if the previous command was not a C, c, S, or s,
|
|
// the control point is coincident with the current point
|
|
controlX = x;
|
|
controlY = y;
|
|
}
|
|
x = current[3];
|
|
y = current[4];
|
|
converted = ['C', controlX, controlY, current[1], current[2], x, y];
|
|
// converted[3] and converted[4] are NOW the second control point.
|
|
// we keep it for the next reflection.
|
|
controlX = converted[3];
|
|
controlY = converted[4];
|
|
break;
|
|
case 'q':
|
|
// quadraticCurveTo, relative
|
|
current[1] += x;
|
|
current[2] += y;
|
|
current[3] += x;
|
|
current[4] += y;
|
|
// falls through
|
|
case 'Q':
|
|
controlX = current[1];
|
|
controlY = current[2];
|
|
x = current[3];
|
|
y = current[4];
|
|
converted = ['Q', controlX, controlY, x, y];
|
|
break;
|
|
case 't':
|
|
// shorthand quadraticCurveTo, relative
|
|
current[1] += x;
|
|
current[2] += y;
|
|
// falls through
|
|
case 'T':
|
|
if (previous === 'Q') {
|
|
// calculate reflection of previous control point
|
|
controlX = 2 * x - controlX;
|
|
controlY = 2 * y - controlY;
|
|
} else {
|
|
// If there is no previous command or if the previous command was not a Q, q, T or t,
|
|
// assume the control point is coincident with the current point
|
|
controlX = x;
|
|
controlY = y;
|
|
}
|
|
x = current[1];
|
|
y = current[2];
|
|
converted = ['Q', controlX, controlY, x, y];
|
|
break;
|
|
case 'a':
|
|
current[6] += x;
|
|
current[7] += y;
|
|
// falls through
|
|
case 'A':
|
|
fromArcToBeziers(x, y, current).forEach(b => destinationPath.push(b));
|
|
x = current[6];
|
|
y = current[7];
|
|
break;
|
|
case 'z':
|
|
case 'Z':
|
|
x = x1;
|
|
y = y1;
|
|
converted = ['Z'];
|
|
break;
|
|
}
|
|
if (converted) {
|
|
destinationPath.push(converted);
|
|
previous = converted[0];
|
|
} else {
|
|
previous = '';
|
|
}
|
|
}
|
|
return destinationPath;
|
|
};
|
|
|
|
// todo verify if we can just use the point class here
|
|
/**
|
|
* Calc length from point x1,y1 to x2,y2
|
|
* @param {number} x1 starting point x
|
|
* @param {number} y1 starting point y
|
|
* @param {number} x2 starting point x
|
|
* @param {number} y2 starting point y
|
|
* @return {number} length of segment
|
|
*/
|
|
const calcLineLength = (x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
|
|
|
/**
|
|
* Get an iterator that takes a percentage and returns a point
|
|
* @param {number} begx
|
|
* @param {number} begy
|
|
* @param {number} cp1x
|
|
* @param {number} cp1y
|
|
* @param {number} cp2x
|
|
* @param {number} cp2y
|
|
* @param {number} endx
|
|
* @param {number} endy
|
|
*/
|
|
const getPointOnCubicBezierIterator = (begx, begy, cp1x, cp1y, cp2x, cp2y, endx, endy) => pct => {
|
|
const c1 = CB1(pct),
|
|
c2 = CB2(pct),
|
|
c3 = CB3(pct),
|
|
c4 = CB4(pct);
|
|
return new Point(endx * c1 + cp2x * c2 + cp1x * c3 + begx * c4, endy * c1 + cp2y * c2 + cp1y * c3 + begy * c4);
|
|
};
|
|
const QB1 = t => t ** 2;
|
|
const QB2 = t => 2 * t * (1 - t);
|
|
const QB3 = t => (1 - t) ** 2;
|
|
const getTangentCubicIterator = (p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y) => pct => {
|
|
const qb1 = QB1(pct),
|
|
qb2 = QB2(pct),
|
|
qb3 = QB3(pct),
|
|
tangentX = 3 * (qb3 * (p2x - p1x) + qb2 * (p3x - p2x) + qb1 * (p4x - p3x)),
|
|
tangentY = 3 * (qb3 * (p2y - p1y) + qb2 * (p3y - p2y) + qb1 * (p4y - p3y));
|
|
return Math.atan2(tangentY, tangentX);
|
|
};
|
|
const getPointOnQuadraticBezierIterator = (p1x, p1y, p2x, p2y, p3x, p3y) => pct => {
|
|
const c1 = QB1(pct),
|
|
c2 = QB2(pct),
|
|
c3 = QB3(pct);
|
|
return new Point(p3x * c1 + p2x * c2 + p1x * c3, p3y * c1 + p2y * c2 + p1y * c3);
|
|
};
|
|
const getTangentQuadraticIterator = (p1x, p1y, p2x, p2y, p3x, p3y) => pct => {
|
|
const invT = 1 - pct,
|
|
tangentX = 2 * (invT * (p2x - p1x) + pct * (p3x - p2x)),
|
|
tangentY = 2 * (invT * (p2y - p1y) + pct * (p3y - p2y));
|
|
return Math.atan2(tangentY, tangentX);
|
|
};
|
|
|
|
// this will run over a path segment (a cubic or quadratic segment) and approximate it
|
|
// with 100 segments. This will good enough to calculate the length of the curve
|
|
const pathIterator = (iterator, x1, y1) => {
|
|
let tempP = new Point(x1, y1),
|
|
tmpLen = 0;
|
|
for (let perc = 1; perc <= 100; perc += 1) {
|
|
const p = iterator(perc / 100);
|
|
tmpLen += calcLineLength(tempP.x, tempP.y, p.x, p.y);
|
|
tempP = p;
|
|
}
|
|
return tmpLen;
|
|
};
|
|
|
|
/**
|
|
* Given a pathInfo, and a distance in pixels, find the percentage from 0 to 1
|
|
* that correspond to that pixels run over the path.
|
|
* The percentage will be then used to find the correct point on the canvas for the path.
|
|
* @param {Array} segInfo fabricJS collection of information on a parsed path
|
|
* @param {number} distance from starting point, in pixels.
|
|
* @return {TPointAngle} info object with x and y ( the point on canvas ) and angle, the tangent on that point;
|
|
*/
|
|
const findPercentageForDistance = (segInfo, distance) => {
|
|
let perc = 0,
|
|
tmpLen = 0,
|
|
tempP = {
|
|
x: segInfo.x,
|
|
y: segInfo.y
|
|
},
|
|
p = _objectSpread2({}, tempP),
|
|
nextLen,
|
|
nextStep = 0.01,
|
|
lastPerc = 0;
|
|
// nextStep > 0.0001 covers 0.00015625 that 1/64th of 1/100
|
|
// the path
|
|
const iterator = segInfo.iterator,
|
|
angleFinder = segInfo.angleFinder;
|
|
while (tmpLen < distance && nextStep > 0.0001) {
|
|
p = iterator(perc);
|
|
lastPerc = perc;
|
|
nextLen = calcLineLength(tempP.x, tempP.y, p.x, p.y);
|
|
// compare tmpLen each cycle with distance, decide next perc to test.
|
|
if (nextLen + tmpLen > distance) {
|
|
// we discard this step and we make smaller steps.
|
|
perc -= nextStep;
|
|
nextStep /= 2;
|
|
} else {
|
|
tempP = p;
|
|
perc += nextStep;
|
|
tmpLen += nextLen;
|
|
}
|
|
}
|
|
return _objectSpread2(_objectSpread2({}, p), {}, {
|
|
angle: angleFinder(lastPerc)
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Run over a parsed and simplified path and extract some information (length of each command and starting point)
|
|
* @param {TSimplePathData} path parsed path commands
|
|
* @return {TPathSegmentInfo[]} path commands information
|
|
*/
|
|
const getPathSegmentsInfo = path => {
|
|
let totalLength = 0,
|
|
//x2 and y2 are the coords of segment start
|
|
//x1 and y1 are the coords of the current point
|
|
x1 = 0,
|
|
y1 = 0,
|
|
x2 = 0,
|
|
y2 = 0,
|
|
iterator,
|
|
tempInfo;
|
|
const info = [];
|
|
for (const current of path) {
|
|
const basicInfo = {
|
|
x: x1,
|
|
y: y1,
|
|
command: current[0],
|
|
length: 0
|
|
};
|
|
switch (current[0] //first letter
|
|
) {
|
|
case 'M':
|
|
tempInfo = basicInfo;
|
|
tempInfo.x = x2 = x1 = current[1];
|
|
tempInfo.y = y2 = y1 = current[2];
|
|
break;
|
|
case 'L':
|
|
tempInfo = basicInfo;
|
|
tempInfo.length = calcLineLength(x1, y1, current[1], current[2]);
|
|
x1 = current[1];
|
|
y1 = current[2];
|
|
break;
|
|
case 'C':
|
|
iterator = getPointOnCubicBezierIterator(x1, y1, current[1], current[2], current[3], current[4], current[5], current[6]);
|
|
tempInfo = basicInfo;
|
|
tempInfo.iterator = iterator;
|
|
tempInfo.angleFinder = getTangentCubicIterator(x1, y1, current[1], current[2], current[3], current[4], current[5], current[6]);
|
|
tempInfo.length = pathIterator(iterator, x1, y1);
|
|
x1 = current[5];
|
|
y1 = current[6];
|
|
break;
|
|
case 'Q':
|
|
iterator = getPointOnQuadraticBezierIterator(x1, y1, current[1], current[2], current[3], current[4]);
|
|
tempInfo = basicInfo;
|
|
tempInfo.iterator = iterator;
|
|
tempInfo.angleFinder = getTangentQuadraticIterator(x1, y1, current[1], current[2], current[3], current[4]);
|
|
tempInfo.length = pathIterator(iterator, x1, y1);
|
|
x1 = current[3];
|
|
y1 = current[4];
|
|
break;
|
|
case 'Z':
|
|
// we add those in order to ease calculations later
|
|
tempInfo = basicInfo;
|
|
tempInfo.destX = x2;
|
|
tempInfo.destY = y2;
|
|
tempInfo.length = calcLineLength(x1, y1, x2, y2);
|
|
x1 = x2;
|
|
y1 = y2;
|
|
break;
|
|
}
|
|
totalLength += tempInfo.length;
|
|
info.push(tempInfo);
|
|
}
|
|
info.push({
|
|
length: totalLength,
|
|
x: x1,
|
|
y: y1
|
|
});
|
|
return info;
|
|
};
|
|
|
|
/**
|
|
* Get the point on the path that is distance along the path
|
|
* @param path
|
|
* @param distance
|
|
* @param infos
|
|
*/
|
|
const getPointOnPath = function (path, distance) {
|
|
let infos = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : getPathSegmentsInfo(path);
|
|
let i = 0;
|
|
while (distance - infos[i].length > 0 && i < infos.length - 2) {
|
|
distance -= infos[i].length;
|
|
i++;
|
|
}
|
|
const segInfo = infos[i],
|
|
segPercent = distance / segInfo.length,
|
|
segment = path[i];
|
|
switch (segInfo.command) {
|
|
case 'M':
|
|
return {
|
|
x: segInfo.x,
|
|
y: segInfo.y,
|
|
angle: 0
|
|
};
|
|
case 'Z':
|
|
return _objectSpread2(_objectSpread2({}, new Point(segInfo.x, segInfo.y).lerp(new Point(segInfo.destX, segInfo.destY), segPercent)), {}, {
|
|
angle: Math.atan2(segInfo.destY - segInfo.y, segInfo.destX - segInfo.x)
|
|
});
|
|
case 'L':
|
|
return _objectSpread2(_objectSpread2({}, new Point(segInfo.x, segInfo.y).lerp(new Point(segment[1], segment[2]), segPercent)), {}, {
|
|
angle: Math.atan2(segment[2] - segInfo.y, segment[1] - segInfo.x)
|
|
});
|
|
case 'C':
|
|
return findPercentageForDistance(segInfo, distance);
|
|
case 'Q':
|
|
return findPercentageForDistance(segInfo, distance);
|
|
// throw Error('Invalid command');
|
|
}
|
|
};
|
|
const rePathCmdAll = new RegExp(rePathCommand, 'gi');
|
|
const regExpArcCommandPoints = new RegExp(reArcCommandPoints, 'g');
|
|
const reMyNum = new RegExp(reNum, 'gi');
|
|
const commandLengths = {
|
|
m: 2,
|
|
l: 2,
|
|
h: 1,
|
|
v: 1,
|
|
c: 6,
|
|
s: 4,
|
|
q: 4,
|
|
t: 2,
|
|
a: 7
|
|
};
|
|
/**
|
|
*
|
|
* @param {string} pathString
|
|
* @return {TComplexPathData} An array of SVG path commands
|
|
* @example <caption>Usage</caption>
|
|
* parsePath('M 3 4 Q 3 5 2 1 4 0 Q 9 12 2 1 4 0') === [
|
|
* ['M', 3, 4],
|
|
* ['Q', 3, 5, 2, 1, 4, 0],
|
|
* ['Q', 9, 12, 2, 1, 4, 0],
|
|
* ];
|
|
*/
|
|
const parsePath = pathString => {
|
|
var _pathString$match;
|
|
const chain = [];
|
|
const all = (_pathString$match = pathString.match(rePathCmdAll)) !== null && _pathString$match !== void 0 ? _pathString$match : [];
|
|
for (const matchStr of all) {
|
|
// take match string and save the first letter as the command
|
|
const commandLetter = matchStr[0];
|
|
// in case of Z we have very little to do
|
|
if (commandLetter === 'z' || commandLetter === 'Z') {
|
|
chain.push([commandLetter]);
|
|
continue;
|
|
}
|
|
const commandLength = commandLengths[commandLetter.toLowerCase()];
|
|
let paramArr = [];
|
|
if (commandLetter === 'a' || commandLetter === 'A') {
|
|
// the arc command ha some peculariaties that requires a special regex other than numbers
|
|
// it is possible to avoid using a space between the sweep and large arc flags, making them either
|
|
// 00, 01, 10 or 11, making them identical to a plain number for the regex reMyNum
|
|
// reset the regexp
|
|
regExpArcCommandPoints.lastIndex = 0;
|
|
for (let out = null; out = regExpArcCommandPoints.exec(matchStr);) {
|
|
paramArr.push(...out.slice(1));
|
|
}
|
|
} else {
|
|
paramArr = matchStr.match(reMyNum) || [];
|
|
}
|
|
|
|
// inspect the length of paramArr, if is longer than commandLength
|
|
// we are dealing with repeated commands
|
|
for (let i = 0; i < paramArr.length; i += commandLength) {
|
|
const newCommand = new Array(commandLength);
|
|
const transformedCommand = repeatedCommands[commandLetter];
|
|
newCommand[0] = i > 0 && transformedCommand ? transformedCommand : commandLetter;
|
|
for (let j = 0; j < commandLength; j++) {
|
|
newCommand[j + 1] = parseFloat(paramArr[i + j]);
|
|
}
|
|
chain.push(newCommand);
|
|
}
|
|
}
|
|
return chain;
|
|
};
|
|
|
|
/**
|
|
*
|
|
* Converts points to a smooth SVG path
|
|
* @param {XY[]} points Array of points
|
|
* @param {number} [correction] Apply a correction to the path (usually we use `width / 1000`). If value is undefined 0 is used as the correction value.
|
|
* @return {(string|number)[][]} An array of SVG path commands
|
|
*/
|
|
const getSmoothPathFromPoints = function (points) {
|
|
let correction = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
|
|
let p1 = new Point(points[0]),
|
|
p2 = new Point(points[1]),
|
|
multSignX = 1,
|
|
multSignY = 0;
|
|
const path = [],
|
|
len = points.length,
|
|
manyPoints = len > 2;
|
|
if (manyPoints) {
|
|
multSignX = points[2].x < p2.x ? -1 : points[2].x === p2.x ? 0 : 1;
|
|
multSignY = points[2].y < p2.y ? -1 : points[2].y === p2.y ? 0 : 1;
|
|
}
|
|
path.push(['M', p1.x - multSignX * correction, p1.y - multSignY * correction]);
|
|
let i;
|
|
for (i = 1; i < len; i++) {
|
|
if (!p1.eq(p2)) {
|
|
const midPoint = p1.midPointFrom(p2);
|
|
// p1 is our bezier control point
|
|
// midpoint is our endpoint
|
|
// start point is p(i-1) value.
|
|
path.push(['Q', p1.x, p1.y, midPoint.x, midPoint.y]);
|
|
}
|
|
p1 = points[i];
|
|
if (i + 1 < points.length) {
|
|
p2 = points[i + 1];
|
|
}
|
|
}
|
|
if (manyPoints) {
|
|
multSignX = p1.x > points[i - 2].x ? 1 : p1.x === points[i - 2].x ? 0 : -1;
|
|
multSignY = p1.y > points[i - 2].y ? 1 : p1.y === points[i - 2].y ? 0 : -1;
|
|
}
|
|
path.push(['L', p1.x + multSignX * correction, p1.y + multSignY * correction]);
|
|
return path;
|
|
};
|
|
|
|
/**
|
|
* Transform a path by transforming each segment.
|
|
* it has to be a simplified path or it won't work.
|
|
* WARNING: this depends from pathOffset for correct operation
|
|
* @param {TSimplePathData} path fabricJS parsed and simplified path commands
|
|
* @param {TMat2D} transform matrix that represent the transformation
|
|
* @param {Point} [pathOffset] `Path.pathOffset`
|
|
* @returns {TSimplePathData} the transformed path
|
|
*/
|
|
const transformPath = (path, transform, pathOffset) => {
|
|
if (pathOffset) {
|
|
transform = multiplyTransformMatrices(transform, [1, 0, 0, 1, -pathOffset.x, -pathOffset.y]);
|
|
}
|
|
return path.map(pathSegment => {
|
|
const newSegment = [...pathSegment];
|
|
for (let i = 1; i < pathSegment.length - 1; i += 2) {
|
|
// TODO: is there a way to get around casting to any?
|
|
const {
|
|
x,
|
|
y
|
|
} = transformPoint({
|
|
x: pathSegment[i],
|
|
y: pathSegment[i + 1]
|
|
}, transform);
|
|
newSegment[i] = x;
|
|
newSegment[i + 1] = y;
|
|
}
|
|
return newSegment;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Returns an array of path commands to create a regular polygon
|
|
* @param {number} numVertexes
|
|
* @param {number} radius
|
|
* @returns {TSimplePathData} An array of SVG path commands
|
|
*/
|
|
const getRegularPolygonPath = (numVertexes, radius) => {
|
|
const interiorAngle = Math.PI * 2 / numVertexes;
|
|
// rotationAdjustment rotates the path by 1/2 the interior angle so that the polygon always has a flat side on the bottom
|
|
// This isn't strictly necessary, but it's how we tend to think of and expect polygons to be drawn
|
|
let rotationAdjustment = -halfPI;
|
|
if (numVertexes % 2 === 0) {
|
|
rotationAdjustment += interiorAngle / 2;
|
|
}
|
|
const d = new Array(numVertexes + 1);
|
|
for (let i = 0; i < numVertexes; i++) {
|
|
const rad = i * interiorAngle + rotationAdjustment;
|
|
const {
|
|
x,
|
|
y
|
|
} = new Point(cos(rad), sin(rad)).scalarMultiply(radius);
|
|
d[i] = [i === 0 ? 'M' : 'L', x, y];
|
|
}
|
|
d[numVertexes] = ['Z'];
|
|
return d;
|
|
};
|
|
|
|
/**
|
|
* Join path commands to go back to svg format
|
|
* @param {TSimplePathData} pathData fabricJS parsed path commands
|
|
* @param {number} fractionDigits number of fraction digits to "leave"
|
|
* @return {String} joined path 'M 0 0 L 20 30'
|
|
*/
|
|
const joinPath = (pathData, fractionDigits) => pathData.map(segment => {
|
|
return segment.map((arg, i) => {
|
|
if (i === 0) return arg;
|
|
return fractionDigits === undefined ? arg : toFixed(arg, fractionDigits);
|
|
}).join(' ');
|
|
}).join(' ');
|
|
|
|
// TODO this file needs to go away, cross browser style support is not fabricjs domain.
|
|
|
|
/**
|
|
* wrapper for setting element's style
|
|
* @param {HTMLElement} element
|
|
* @param {Object | string} styles
|
|
*/
|
|
function setStyle(element, styles) {
|
|
const elementStyle = element.style;
|
|
if (!elementStyle || !styles) {
|
|
return;
|
|
} else if (typeof styles === 'string') {
|
|
elementStyle.cssText += ';' + styles;
|
|
} else {
|
|
Object.entries(styles).forEach(_ref => {
|
|
let [property, value] = _ref;
|
|
return elementStyle.setProperty(property, value);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merges 2 clip paths into one visually equal clip path
|
|
*
|
|
* **IMPORTANT**:\
|
|
* Does **NOT** clone the arguments, clone them proir if necessary.
|
|
*
|
|
* Creates a wrapper (group) that contains one clip path and is clipped by the other so content is kept where both overlap.
|
|
* Use this method if both the clip paths may have nested clip paths of their own, so assigning one to the other's clip path property is not possible.
|
|
*
|
|
* In order to handle the `inverted` property we follow logic described in the following cases:\
|
|
* **(1)** both clip paths are inverted - the clip paths pass the inverted prop to the wrapper and loose it themselves.\
|
|
* **(2)** one is inverted and the other isn't - the wrapper shouldn't become inverted and the inverted clip path must clip the non inverted one to produce an identical visual effect.\
|
|
* **(3)** both clip paths are not inverted - wrapper and clip paths remain unchanged.
|
|
*
|
|
* @memberOf fabric.util
|
|
* @param {fabric.Object} c1
|
|
* @param {fabric.Object} c2
|
|
* @returns {fabric.Object} merged clip path
|
|
*/
|
|
const mergeClipPaths = (c1, c2) => {
|
|
var _b$group;
|
|
let a = c1,
|
|
b = c2;
|
|
if (a.inverted && !b.inverted) {
|
|
// case (2)
|
|
a = c2;
|
|
b = c1;
|
|
}
|
|
// `b` becomes `a`'s clip path so we transform `b` to `a` coordinate plane
|
|
sendObjectToPlane(b, (_b$group = b.group) === null || _b$group === void 0 ? void 0 : _b$group.calcTransformMatrix(), a.calcTransformMatrix());
|
|
// assign the `inverted` prop to the wrapping group
|
|
const inverted = a.inverted && b.inverted;
|
|
if (inverted) {
|
|
// case (1)
|
|
a.inverted = b.inverted = false;
|
|
}
|
|
return new Group([a], {
|
|
clipPath: b,
|
|
inverted
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Returns random number between 2 specified ones.
|
|
* @param {Number} min lower limit
|
|
* @param {Number} max upper limit
|
|
* @return {Number} random value (between min and max)
|
|
*/
|
|
const getRandomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
|
|
|
/**
|
|
* Cross-browser abstraction for sending XMLHttpRequest
|
|
* @deprecated this has to go away, we can use a modern browser method to do the same.
|
|
* @param {String} url URL to send XMLHttpRequest to
|
|
* @param {Object} [options] Options object
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @param {Function} options.onComplete Callback to invoke when request is completed
|
|
* @return {XMLHttpRequest} request
|
|
*/
|
|
|
|
function request(url) {
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const onComplete = options.onComplete || noop,
|
|
xhr = new (getFabricWindow().XMLHttpRequest)(),
|
|
signal = options.signal,
|
|
abort = function () {
|
|
xhr.abort();
|
|
},
|
|
removeListener = function () {
|
|
signal && signal.removeEventListener('abort', abort);
|
|
xhr.onerror = xhr.ontimeout = noop;
|
|
};
|
|
if (signal && signal.aborted) {
|
|
throw new SignalAbortedError('request');
|
|
} else if (signal) {
|
|
signal.addEventListener('abort', abort, {
|
|
once: true
|
|
});
|
|
}
|
|
|
|
/** @ignore */
|
|
xhr.onreadystatechange = function () {
|
|
if (xhr.readyState === 4) {
|
|
removeListener();
|
|
onComplete(xhr);
|
|
xhr.onreadystatechange = noop;
|
|
}
|
|
};
|
|
xhr.onerror = xhr.ontimeout = removeListener;
|
|
xhr.open('get', url, true);
|
|
xhr.send();
|
|
return xhr;
|
|
}
|
|
|
|
/**
|
|
* This function is an helper for svg import. it decompose the transformMatrix
|
|
* and assign properties to object.
|
|
* untransformed coordinates
|
|
* @private
|
|
*/
|
|
const _assignTransformMatrixProps = object => {
|
|
if (object.transformMatrix) {
|
|
const {
|
|
scaleX,
|
|
scaleY,
|
|
angle,
|
|
skewX
|
|
} = qrDecompose(object.transformMatrix);
|
|
object.flipX = false;
|
|
object.flipY = false;
|
|
object.set(SCALE_X, scaleX);
|
|
object.set(SCALE_Y, scaleY);
|
|
object.angle = angle;
|
|
object.skewX = skewX;
|
|
object.skewY = 0;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* This function is an helper for svg import. it removes the transform matrix
|
|
* and set to object properties that fabricjs can handle
|
|
* @private
|
|
* @param {Object} preserveAspectRatioOptions
|
|
*/
|
|
const removeTransformMatrixForSvgParsing = (object, preserveAspectRatioOptions) => {
|
|
let center = object._findCenterFromElement();
|
|
if (object.transformMatrix) {
|
|
_assignTransformMatrixProps(object);
|
|
center = center.transform(object.transformMatrix);
|
|
}
|
|
delete object.transformMatrix;
|
|
if (preserveAspectRatioOptions) {
|
|
object.scaleX *= preserveAspectRatioOptions.scaleX;
|
|
object.scaleY *= preserveAspectRatioOptions.scaleY;
|
|
object.cropX = preserveAspectRatioOptions.cropX;
|
|
object.cropY = preserveAspectRatioOptions.cropY;
|
|
center.x += preserveAspectRatioOptions.offsetLeft;
|
|
center.y += preserveAspectRatioOptions.offsetTop;
|
|
object.width = preserveAspectRatioOptions.width;
|
|
object.height = preserveAspectRatioOptions.height;
|
|
}
|
|
object.setPositionByOrigin(center, CENTER, CENTER);
|
|
};
|
|
|
|
var index$1 = /*#__PURE__*/Object.freeze({
|
|
__proto__: null,
|
|
addTransformToObject: addTransformToObject,
|
|
animate: animate,
|
|
animateColor: animateColor,
|
|
applyTransformToObject: applyTransformToObject,
|
|
calcAngleBetweenVectors: calcAngleBetweenVectors,
|
|
calcDimensionsMatrix: calcDimensionsMatrix,
|
|
calcPlaneChangeMatrix: calcPlaneChangeMatrix,
|
|
calcVectorRotation: calcVectorRotation,
|
|
cancelAnimFrame: cancelAnimFrame,
|
|
capValue: capValue,
|
|
composeMatrix: composeMatrix,
|
|
copyCanvasElement: copyCanvasElement,
|
|
cos: cos,
|
|
createCanvasElement: createCanvasElement,
|
|
createImage: createImage,
|
|
createRotateMatrix: createRotateMatrix,
|
|
createScaleMatrix: createScaleMatrix,
|
|
createSkewXMatrix: createSkewXMatrix,
|
|
createSkewYMatrix: createSkewYMatrix,
|
|
createTranslateMatrix: createTranslateMatrix,
|
|
createVector: createVector,
|
|
crossProduct: crossProduct,
|
|
degreesToRadians: degreesToRadians,
|
|
dotProduct: dotProduct,
|
|
ease: easing,
|
|
enlivenObjectEnlivables: enlivenObjectEnlivables,
|
|
enlivenObjects: enlivenObjects,
|
|
findScaleToCover: findScaleToCover,
|
|
findScaleToFit: findScaleToFit,
|
|
getBoundsOfCurve: getBoundsOfCurve,
|
|
getOrthonormalVector: getOrthonormalVector,
|
|
getPathSegmentsInfo: getPathSegmentsInfo,
|
|
getPointOnPath: getPointOnPath,
|
|
getPointer: getPointer,
|
|
getRandomInt: getRandomInt,
|
|
getRegularPolygonPath: getRegularPolygonPath,
|
|
getSmoothPathFromPoints: getSmoothPathFromPoints,
|
|
getSvgAttributes: getSvgAttributes,
|
|
getUnitVector: getUnitVector,
|
|
groupSVGElements: groupSVGElements,
|
|
hasStyleChanged: hasStyleChanged,
|
|
invertTransform: invertTransform,
|
|
isBetweenVectors: isBetweenVectors,
|
|
isIdentityMatrix: isIdentityMatrix,
|
|
isTouchEvent: isTouchEvent,
|
|
isTransparent: isTransparent,
|
|
joinPath: joinPath,
|
|
loadImage: loadImage,
|
|
magnitude: magnitude,
|
|
makeBoundingBoxFromPoints: makeBoundingBoxFromPoints,
|
|
makePathSimpler: makePathSimpler,
|
|
matrixToSVG: matrixToSVG,
|
|
mergeClipPaths: mergeClipPaths,
|
|
multiplyTransformMatrices: multiplyTransformMatrices,
|
|
multiplyTransformMatrixArray: multiplyTransformMatrixArray,
|
|
parsePath: parsePath,
|
|
parsePreserveAspectRatioAttribute: parsePreserveAspectRatioAttribute,
|
|
parseUnit: parseUnit,
|
|
pick: pick,
|
|
projectStrokeOnPoints: projectStrokeOnPoints,
|
|
qrDecompose: qrDecompose,
|
|
radiansToDegrees: radiansToDegrees,
|
|
removeFromArray: removeFromArray,
|
|
removeTransformFromObject: removeTransformFromObject,
|
|
removeTransformMatrixForSvgParsing: removeTransformMatrixForSvgParsing,
|
|
request: request,
|
|
requestAnimFrame: requestAnimFrame,
|
|
resetObjectTransform: resetObjectTransform,
|
|
rotatePoint: rotatePoint,
|
|
rotateVector: rotateVector,
|
|
saveObjectTransform: saveObjectTransform,
|
|
sendObjectToPlane: sendObjectToPlane,
|
|
sendPointToPlane: sendPointToPlane,
|
|
sendVectorToPlane: sendVectorToPlane,
|
|
setStyle: setStyle,
|
|
sin: sin,
|
|
sizeAfterTransform: sizeAfterTransform,
|
|
string: lang_string,
|
|
stylesFromArray: stylesFromArray,
|
|
stylesToArray: stylesToArray,
|
|
toDataURL: toDataURL,
|
|
toFixed: toFixed,
|
|
transformPath: transformPath,
|
|
transformPoint: transformPoint
|
|
});
|
|
|
|
class CanvasDOMManager extends StaticCanvasDOMManager {
|
|
constructor(arg0) {
|
|
let {
|
|
allowTouchScrolling = false,
|
|
containerClass = ''
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
super(arg0);
|
|
_defineProperty(this, "upper", void 0);
|
|
_defineProperty(this, "container", void 0);
|
|
const {
|
|
el: lowerCanvasEl
|
|
} = this.lower;
|
|
const upperCanvasEl = this.createUpperCanvas();
|
|
this.upper = {
|
|
el: upperCanvasEl,
|
|
ctx: upperCanvasEl.getContext('2d')
|
|
};
|
|
this.applyCanvasStyle(lowerCanvasEl, {
|
|
allowTouchScrolling
|
|
});
|
|
this.applyCanvasStyle(upperCanvasEl, {
|
|
allowTouchScrolling,
|
|
styles: {
|
|
position: 'absolute',
|
|
left: '0',
|
|
top: '0'
|
|
}
|
|
});
|
|
const container = this.createContainerElement();
|
|
container.classList.add(containerClass);
|
|
if (lowerCanvasEl.parentNode) {
|
|
lowerCanvasEl.parentNode.replaceChild(container, lowerCanvasEl);
|
|
}
|
|
container.append(lowerCanvasEl, upperCanvasEl);
|
|
this.container = container;
|
|
}
|
|
createUpperCanvas() {
|
|
const {
|
|
el: lowerCanvasEl
|
|
} = this.lower;
|
|
const el = createCanvasElement();
|
|
// we assign the same classname of the lowerCanvas
|
|
el.className = lowerCanvasEl.className;
|
|
// but then we remove the lower-canvas specific className
|
|
el.classList.remove('lower-canvas');
|
|
// we add the specific upper-canvas class
|
|
el.classList.add('upper-canvas');
|
|
el.setAttribute('data-fabric', 'top');
|
|
el.style.cssText = lowerCanvasEl.style.cssText;
|
|
el.setAttribute('draggable', 'true');
|
|
return el;
|
|
}
|
|
createContainerElement() {
|
|
const container = getFabricDocument().createElement('div');
|
|
container.setAttribute('data-fabric', 'wrapper');
|
|
setStyle(container, {
|
|
position: 'relative'
|
|
});
|
|
makeElementUnselectable(container);
|
|
return container;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {HTMLCanvasElement} element canvas element to apply styles on
|
|
*/
|
|
applyCanvasStyle(element, options) {
|
|
const {
|
|
styles,
|
|
allowTouchScrolling
|
|
} = options;
|
|
setStyle(element, _objectSpread2(_objectSpread2({}, styles), {}, {
|
|
'touch-action': allowTouchScrolling ? 'manipulation' : NONE
|
|
}));
|
|
makeElementUnselectable(element);
|
|
}
|
|
setDimensions(size, retinaScaling) {
|
|
super.setDimensions(size, retinaScaling);
|
|
const {
|
|
el,
|
|
ctx
|
|
} = this.upper;
|
|
setCanvasDimensions(el, ctx, size, retinaScaling);
|
|
}
|
|
setCSSDimensions(size) {
|
|
super.setCSSDimensions(size);
|
|
setCSSDimensions(this.upper.el, size);
|
|
setCSSDimensions(this.container, size);
|
|
}
|
|
cleanupDOM(size) {
|
|
const container = this.container,
|
|
{
|
|
el: lowerCanvasEl
|
|
} = this.lower,
|
|
{
|
|
el: upperCanvasEl
|
|
} = this.upper;
|
|
super.cleanupDOM(size);
|
|
container.removeChild(upperCanvasEl);
|
|
container.removeChild(lowerCanvasEl);
|
|
if (container.parentNode) {
|
|
container.parentNode.replaceChild(lowerCanvasEl, container);
|
|
}
|
|
}
|
|
dispose() {
|
|
super.dispose();
|
|
getEnv().dispose(this.upper.el);
|
|
// @ts-expect-error disposing
|
|
delete this.upper;
|
|
// @ts-expect-error disposing
|
|
delete this.container;
|
|
}
|
|
}
|
|
|
|
const canvasDefaults = {
|
|
uniformScaling: true,
|
|
uniScaleKey: 'shiftKey',
|
|
centeredScaling: false,
|
|
centeredRotation: false,
|
|
centeredKey: 'altKey',
|
|
altActionKey: 'shiftKey',
|
|
selection: true,
|
|
selectionKey: 'shiftKey',
|
|
selectionColor: 'rgba(100, 100, 255, 0.3)',
|
|
selectionDashArray: [],
|
|
selectionBorderColor: 'rgba(255, 255, 255, 0.3)',
|
|
selectionLineWidth: 1,
|
|
selectionFullyContained: false,
|
|
hoverCursor: 'move',
|
|
moveCursor: 'move',
|
|
defaultCursor: 'default',
|
|
freeDrawingCursor: 'crosshair',
|
|
notAllowedCursor: 'not-allowed',
|
|
perPixelTargetFind: false,
|
|
targetFindTolerance: 0,
|
|
skipTargetFind: false,
|
|
stopContextMenu: false,
|
|
fireRightClick: false,
|
|
fireMiddleClick: false,
|
|
enablePointerEvents: false,
|
|
containerClass: 'canvas-container',
|
|
preserveObjectStacking: false
|
|
};
|
|
|
|
/**
|
|
* Canvas class
|
|
* @class Canvas
|
|
* @extends StaticCanvas
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-1#canvas}
|
|
*
|
|
* @fires object:modified at the end of a transform
|
|
* @fires object:rotating while an object is being rotated from the control
|
|
* @fires object:scaling while an object is being scaled by controls
|
|
* @fires object:moving while an object is being dragged
|
|
* @fires object:skewing while an object is being skewed from the controls
|
|
*
|
|
* @fires before:transform before a transform is is started
|
|
* @fires before:selection:cleared
|
|
* @fires selection:cleared
|
|
* @fires selection:updated
|
|
* @fires selection:created
|
|
*
|
|
* @fires path:created after a drawing operation ends and the path is added
|
|
* @fires mouse:down
|
|
* @fires mouse:move
|
|
* @fires mouse:up
|
|
* @fires mouse:down:before on mouse down, before the inner fabric logic runs
|
|
* @fires mouse:move:before on mouse move, before the inner fabric logic runs
|
|
* @fires mouse:up:before on mouse up, before the inner fabric logic runs
|
|
* @fires mouse:over
|
|
* @fires mouse:out
|
|
* @fires mouse:dblclick whenever a native dbl click event fires on the canvas.
|
|
*
|
|
* @fires dragover
|
|
* @fires dragenter
|
|
* @fires dragleave
|
|
* @fires drag:enter object drag enter
|
|
* @fires drag:leave object drag leave
|
|
* @fires drop:before before drop event. Prepare for the drop event (same native event).
|
|
* @fires drop
|
|
* @fires drop:after after drop event. Run logic on canvas after event has been accepted/declined (same native event).
|
|
* @example
|
|
* let a: fabric.Object, b: fabric.Object;
|
|
* let flag = false;
|
|
* canvas.add(a, b);
|
|
* a.on('drop:before', opt => {
|
|
* // we want a to accept the drop even though it's below b in the stack
|
|
* flag = this.canDrop(opt.e);
|
|
* });
|
|
* b.canDrop = function(e) {
|
|
* !flag && this.draggableTextDelegate.canDrop(e);
|
|
* }
|
|
* b.on('dragover', opt => b.set('fill', opt.dropTarget === b ? 'pink' : 'black'));
|
|
* a.on('drop', opt => {
|
|
* opt.e.defaultPrevented // drop occurred
|
|
* opt.didDrop // drop occurred on canvas
|
|
* opt.target // drop target
|
|
* opt.target !== a && a.set('text', 'I lost');
|
|
* });
|
|
* canvas.on('drop:after', opt => {
|
|
* // inform user who won
|
|
* if(!opt.e.defaultPrevented) {
|
|
* // no winners
|
|
* }
|
|
* else if(!opt.didDrop) {
|
|
* // my objects didn't win, some other lucky object
|
|
* }
|
|
* else {
|
|
* // we have a winner it's opt.target!!
|
|
* }
|
|
* })
|
|
*
|
|
* @fires after:render at the end of the render process, receives the context in the callback
|
|
* @fires before:render at start the render process, receives the context in the callback
|
|
*
|
|
* @fires contextmenu:before
|
|
* @fires contextmenu
|
|
* @example
|
|
* let handler;
|
|
* targets.forEach(target => {
|
|
* target.on('contextmenu:before', opt => {
|
|
* // decide which target should handle the event before canvas hijacks it
|
|
* if (someCaseHappens && opt.targets.includes(target)) {
|
|
* handler = target;
|
|
* }
|
|
* });
|
|
* target.on('contextmenu', opt => {
|
|
* // do something fantastic
|
|
* });
|
|
* });
|
|
* canvas.on('contextmenu', opt => {
|
|
* if (!handler) {
|
|
* // no one takes responsibility, it's always left to me
|
|
* // let's show them how it's done!
|
|
* }
|
|
* });
|
|
*
|
|
*/
|
|
class SelectableCanvas extends StaticCanvas {
|
|
constructor() {
|
|
super(...arguments);
|
|
// transform config
|
|
// selection config
|
|
// cursors
|
|
// target find config
|
|
/**
|
|
* When true, mouse events on canvas (mousedown/mousemove/mouseup) result in free drawing.
|
|
* After mousedown, mousemove creates a shape,
|
|
* and then mouseup finalizes it and adds an instance of `fabric.Path` onto canvas.
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-4#free_drawing}
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
// event config
|
|
/**
|
|
* Keep track of the subTargets for Mouse Events, ordered bottom up from innermost nested subTarget
|
|
* @type FabricObject[]
|
|
*/
|
|
_defineProperty(this, "targets", []);
|
|
/**
|
|
* hold the list of nested targets hovered
|
|
* @type FabricObject[]
|
|
* @private
|
|
*/
|
|
_defineProperty(this, "_hoveredTargets", []);
|
|
/**
|
|
* hold the list of objects to render
|
|
* @type FabricObject[]
|
|
* @private
|
|
*/
|
|
_defineProperty(this, "_objectsToRender", void 0);
|
|
/**
|
|
* hold a reference to a data structure that contains information
|
|
* on the current on going transform
|
|
* @type
|
|
* @private
|
|
*/
|
|
_defineProperty(this, "_currentTransform", null);
|
|
/**
|
|
* hold a reference to a data structure used to track the selection
|
|
* box on canvas drag
|
|
* on the current on going transform
|
|
* x, y, deltaX and deltaY are in scene plane
|
|
* @type
|
|
* @private
|
|
*/
|
|
_defineProperty(this, "_groupSelector", null);
|
|
/**
|
|
* internal flag used to understand if the context top requires a cleanup
|
|
* in case this is true, the contextTop will be cleared at the next render
|
|
* @type boolean
|
|
* @private
|
|
*/
|
|
_defineProperty(this, "contextTopDirty", false);
|
|
}
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), SelectableCanvas.ownDefaults);
|
|
}
|
|
get upperCanvasEl() {
|
|
var _this$elements$upper;
|
|
return (_this$elements$upper = this.elements.upper) === null || _this$elements$upper === void 0 ? void 0 : _this$elements$upper.el;
|
|
}
|
|
get contextTop() {
|
|
var _this$elements$upper2;
|
|
return (_this$elements$upper2 = this.elements.upper) === null || _this$elements$upper2 === void 0 ? void 0 : _this$elements$upper2.ctx;
|
|
}
|
|
get wrapperEl() {
|
|
return this.elements.container;
|
|
}
|
|
initElements(el) {
|
|
this.elements = new CanvasDOMManager(el, {
|
|
allowTouchScrolling: this.allowTouchScrolling,
|
|
containerClass: this.containerClass
|
|
});
|
|
this._createCacheCanvas();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {FabricObject} obj Object that was added
|
|
*/
|
|
_onObjectAdded(obj) {
|
|
this._objectsToRender = undefined;
|
|
super._onObjectAdded(obj);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {FabricObject} obj Object that was removed
|
|
*/
|
|
_onObjectRemoved(obj) {
|
|
this._objectsToRender = undefined;
|
|
// removing active object should fire "selection:cleared" events
|
|
if (obj === this._activeObject) {
|
|
this.fire('before:selection:cleared', {
|
|
deselected: [obj]
|
|
});
|
|
this._discardActiveObject();
|
|
this.fire('selection:cleared', {
|
|
deselected: [obj]
|
|
});
|
|
obj.fire('deselected', {
|
|
target: obj
|
|
});
|
|
}
|
|
if (obj === this._hoveredTarget) {
|
|
this._hoveredTarget = undefined;
|
|
this._hoveredTargets = [];
|
|
}
|
|
super._onObjectRemoved(obj);
|
|
}
|
|
_onStackOrderChanged() {
|
|
this._objectsToRender = undefined;
|
|
super._onStackOrderChanged();
|
|
}
|
|
|
|
/**
|
|
* Divides objects in two groups, one to render immediately
|
|
* and one to render as activeGroup.
|
|
* @return {Array} objects to render immediately and pushes the other in the activeGroup.
|
|
*/
|
|
_chooseObjectsToRender() {
|
|
const activeObject = this._activeObject;
|
|
return !this.preserveObjectStacking && activeObject ? this._objects.filter(object => !object.group && object !== activeObject).concat(activeObject) : this._objects;
|
|
}
|
|
|
|
/**
|
|
* Renders both the top canvas and the secondary container canvas.
|
|
*/
|
|
renderAll() {
|
|
this.cancelRequestedRender();
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
if (this.contextTopDirty && !this._groupSelector && !this.isDrawingMode) {
|
|
this.clearContext(this.contextTop);
|
|
this.contextTopDirty = false;
|
|
}
|
|
if (this.hasLostContext) {
|
|
this.renderTopLayer(this.contextTop);
|
|
this.hasLostContext = false;
|
|
}
|
|
!this._objectsToRender && (this._objectsToRender = this._chooseObjectsToRender());
|
|
this.renderCanvas(this.getContext(), this._objectsToRender);
|
|
}
|
|
|
|
/**
|
|
* text selection is rendered by the active text instance during the rendering cycle
|
|
*/
|
|
renderTopLayer(ctx) {
|
|
ctx.save();
|
|
if (this.isDrawingMode && this._isCurrentlyDrawing) {
|
|
this.freeDrawingBrush && this.freeDrawingBrush._render();
|
|
this.contextTopDirty = true;
|
|
}
|
|
// we render the top context - last object
|
|
if (this.selection && this._groupSelector) {
|
|
this._drawSelection(ctx);
|
|
this.contextTopDirty = true;
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Method to render only the top canvas.
|
|
* Also used to render the group selection box.
|
|
* Does not render text selection.
|
|
*/
|
|
renderTop() {
|
|
const ctx = this.contextTop;
|
|
this.clearContext(ctx);
|
|
this.renderTopLayer(ctx);
|
|
// todo: how do i know if the after:render is for the top or normal contex?
|
|
this.fire('after:render', {
|
|
ctx
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set the canvas tolerance value for pixel taret find.
|
|
* Use only integer numbers.
|
|
* @private
|
|
*/
|
|
setTargetFindTolerance(value) {
|
|
value = Math.round(value);
|
|
this.targetFindTolerance = value;
|
|
const retina = this.getRetinaScaling();
|
|
const size = Math.ceil((value * 2 + 1) * retina);
|
|
this.pixelFindCanvasEl.width = this.pixelFindCanvasEl.height = size;
|
|
this.pixelFindContext.scale(retina, retina);
|
|
}
|
|
|
|
/**
|
|
* Returns true if object is transparent at a certain location
|
|
* Clarification: this is `is target transparent at location X or are controls there`
|
|
* @TODO this seems dumb that we treat controls with transparency. we can find controls
|
|
* programmatically without painting them, the cache canvas optimization is always valid
|
|
* @param {FabricObject} target Object to check
|
|
* @param {Number} x Left coordinate in viewport space
|
|
* @param {Number} y Top coordinate in viewport space
|
|
* @return {Boolean}
|
|
*/
|
|
isTargetTransparent(target, x, y) {
|
|
const tolerance = this.targetFindTolerance;
|
|
const ctx = this.pixelFindContext;
|
|
this.clearContext(ctx);
|
|
ctx.save();
|
|
ctx.translate(-x + tolerance, -y + tolerance);
|
|
ctx.transform(...this.viewportTransform);
|
|
const selectionBgc = target.selectionBackgroundColor;
|
|
target.selectionBackgroundColor = '';
|
|
target.render(ctx);
|
|
target.selectionBackgroundColor = selectionBgc;
|
|
ctx.restore();
|
|
// our canvas is square, and made around tolerance.
|
|
// so tolerance in this case also represent the center of the canvas.
|
|
const enhancedTolerance = Math.round(tolerance * this.getRetinaScaling());
|
|
return isTransparent(ctx, enhancedTolerance, enhancedTolerance, enhancedTolerance);
|
|
}
|
|
|
|
/**
|
|
* takes an event and determines if selection key has been pressed
|
|
* @private
|
|
* @param {TPointerEvent} e Event object
|
|
*/
|
|
_isSelectionKeyPressed(e) {
|
|
const sKey = this.selectionKey;
|
|
if (!sKey) {
|
|
return false;
|
|
}
|
|
if (Array.isArray(sKey)) {
|
|
return !!sKey.find(key => !!key && e[key] === true);
|
|
} else {
|
|
return e[sKey];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {TPointerEvent} e Event object
|
|
* @param {FabricObject} target
|
|
*/
|
|
_shouldClearSelection(e, target) {
|
|
const activeObjects = this.getActiveObjects(),
|
|
activeObject = this._activeObject;
|
|
return !!(!target || target && activeObject && activeObjects.length > 1 && activeObjects.indexOf(target) === -1 && activeObject !== target && !this._isSelectionKeyPressed(e) || target && !target.evented || target && !target.selectable && activeObject && activeObject !== target);
|
|
}
|
|
|
|
/**
|
|
* This method will take in consideration a modifier key pressed and the control we are
|
|
* about to drag, and try to guess the anchor point ( origin ) of the transormation.
|
|
* This should be really in the realm of controls, and we should remove specific code for legacy
|
|
* embedded actions.
|
|
* @TODO this probably deserve discussion/rediscovery and change/refactor
|
|
* @private
|
|
* @deprecated
|
|
* @param {FabricObject} target
|
|
* @param {string} action
|
|
* @param {boolean} altKey
|
|
* @returns {boolean} true if the transformation should be centered
|
|
*/
|
|
_shouldCenterTransform(target, action, modifierKeyPressed) {
|
|
if (!target) {
|
|
return;
|
|
}
|
|
let centerTransform;
|
|
if (action === SCALE || action === SCALE_X || action === SCALE_Y || action === RESIZING) {
|
|
centerTransform = this.centeredScaling || target.centeredScaling;
|
|
} else if (action === ROTATE) {
|
|
centerTransform = this.centeredRotation || target.centeredRotation;
|
|
}
|
|
return centerTransform ? !modifierKeyPressed : modifierKeyPressed;
|
|
}
|
|
|
|
/**
|
|
* Given the control clicked, determine the origin of the transform.
|
|
* This is bad because controls can totally have custom names
|
|
* should disappear before release 4.0
|
|
* @private
|
|
* @deprecated
|
|
*/
|
|
_getOriginFromCorner(target, controlName) {
|
|
const origin = {
|
|
x: target.originX,
|
|
y: target.originY
|
|
};
|
|
if (!controlName) {
|
|
return origin;
|
|
}
|
|
|
|
// is a left control ?
|
|
if (['ml', 'tl', 'bl'].includes(controlName)) {
|
|
origin.x = RIGHT;
|
|
// is a right control ?
|
|
} else if (['mr', 'tr', 'br'].includes(controlName)) {
|
|
origin.x = LEFT;
|
|
}
|
|
// is a top control ?
|
|
if (['tl', 'mt', 'tr'].includes(controlName)) {
|
|
origin.y = BOTTOM;
|
|
// is a bottom control ?
|
|
} else if (['bl', 'mb', 'br'].includes(controlName)) {
|
|
origin.y = TOP;
|
|
}
|
|
return origin;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object
|
|
* @param {FabricObject} target
|
|
* @param {boolean} [alreadySelected] pass true to setup the active control
|
|
*/
|
|
_setupCurrentTransform(e, target, alreadySelected) {
|
|
var _control$getActionHan;
|
|
const pointer = target.group ?
|
|
// transform pointer to target's containing coordinate plane
|
|
sendPointToPlane(this.getScenePoint(e), undefined, target.group.calcTransformMatrix()) : this.getScenePoint(e);
|
|
const {
|
|
key: corner = '',
|
|
control
|
|
} = target.getActiveControl() || {},
|
|
actionHandler = alreadySelected && control ? (_control$getActionHan = control.getActionHandler(e, target, control)) === null || _control$getActionHan === void 0 ? void 0 : _control$getActionHan.bind(control) : dragHandler,
|
|
action = getActionFromCorner(alreadySelected, corner, e, target),
|
|
altKey = e[this.centeredKey],
|
|
origin = this._shouldCenterTransform(target, action, altKey) ? {
|
|
x: CENTER,
|
|
y: CENTER
|
|
} : this._getOriginFromCorner(target, corner),
|
|
/**
|
|
* relative to target's containing coordinate plane
|
|
* both agree on every point
|
|
**/
|
|
transform = {
|
|
target: target,
|
|
action,
|
|
actionHandler,
|
|
actionPerformed: false,
|
|
corner,
|
|
scaleX: target.scaleX,
|
|
scaleY: target.scaleY,
|
|
skewX: target.skewX,
|
|
skewY: target.skewY,
|
|
offsetX: pointer.x - target.left,
|
|
offsetY: pointer.y - target.top,
|
|
originX: origin.x,
|
|
originY: origin.y,
|
|
ex: pointer.x,
|
|
ey: pointer.y,
|
|
lastX: pointer.x,
|
|
lastY: pointer.y,
|
|
theta: degreesToRadians(target.angle),
|
|
width: target.width,
|
|
height: target.height,
|
|
shiftKey: e.shiftKey,
|
|
altKey,
|
|
original: _objectSpread2(_objectSpread2({}, saveObjectTransform(target)), {}, {
|
|
originX: origin.x,
|
|
originY: origin.y
|
|
})
|
|
};
|
|
this._currentTransform = transform;
|
|
this.fire('before:transform', {
|
|
e,
|
|
transform
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set the cursor type of the canvas element
|
|
* @param {String} value Cursor type of the canvas element.
|
|
* @see http://www.w3.org/TR/css3-ui/#cursor
|
|
*/
|
|
setCursor(value) {
|
|
this.upperCanvasEl.style.cursor = value;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx to draw the selection on
|
|
*/
|
|
_drawSelection(ctx) {
|
|
const {
|
|
x,
|
|
y,
|
|
deltaX,
|
|
deltaY
|
|
} = this._groupSelector,
|
|
start = new Point(x, y).transform(this.viewportTransform),
|
|
extent = new Point(x + deltaX, y + deltaY).transform(this.viewportTransform),
|
|
strokeOffset = this.selectionLineWidth / 2;
|
|
let minX = Math.min(start.x, extent.x),
|
|
minY = Math.min(start.y, extent.y),
|
|
maxX = Math.max(start.x, extent.x),
|
|
maxY = Math.max(start.y, extent.y);
|
|
if (this.selectionColor) {
|
|
ctx.fillStyle = this.selectionColor;
|
|
ctx.fillRect(minX, minY, maxX - minX, maxY - minY);
|
|
}
|
|
if (!this.selectionLineWidth || !this.selectionBorderColor) {
|
|
return;
|
|
}
|
|
ctx.lineWidth = this.selectionLineWidth;
|
|
ctx.strokeStyle = this.selectionBorderColor;
|
|
minX += strokeOffset;
|
|
minY += strokeOffset;
|
|
maxX -= strokeOffset;
|
|
maxY -= strokeOffset;
|
|
// selection border
|
|
// @TODO: is _setLineDash still necessary on modern canvas?
|
|
FabricObject.prototype._setLineDash.call(this, ctx, this.selectionDashArray);
|
|
ctx.strokeRect(minX, minY, maxX - minX, maxY - minY);
|
|
}
|
|
|
|
/**
|
|
* Method that determines what object we are clicking on
|
|
* 11/09/2018 TODO: would be cool if findTarget could discern between being a full target
|
|
* or the outside part of the corner.
|
|
* @param {Event} e mouse event
|
|
* @return {FabricObject | null} the target found
|
|
*/
|
|
findTarget(e) {
|
|
if (this.skipTargetFind) {
|
|
return undefined;
|
|
}
|
|
const pointer = this.getViewportPoint(e),
|
|
activeObject = this._activeObject,
|
|
aObjects = this.getActiveObjects();
|
|
this.targets = [];
|
|
if (activeObject && aObjects.length >= 1) {
|
|
if (activeObject.findControl(pointer, isTouchEvent(e))) {
|
|
// if we hit the corner of the active object, let's return that.
|
|
return activeObject;
|
|
} else if (aObjects.length > 1 &&
|
|
// check pointer is over active selection and possibly perform `subTargetCheck`
|
|
this.searchPossibleTargets([activeObject], pointer)) {
|
|
// active selection does not select sub targets like normal groups
|
|
return activeObject;
|
|
} else if (activeObject === this.searchPossibleTargets([activeObject], pointer)) {
|
|
// active object is not an active selection
|
|
if (!this.preserveObjectStacking) {
|
|
return activeObject;
|
|
} else {
|
|
const subTargets = this.targets;
|
|
this.targets = [];
|
|
const target = this.searchPossibleTargets(this._objects, pointer);
|
|
if (e[this.altSelectionKey] && target && target !== activeObject) {
|
|
// alt selection: select active object even though it is not the top most target
|
|
// restore targets
|
|
this.targets = subTargets;
|
|
return activeObject;
|
|
}
|
|
return target;
|
|
}
|
|
}
|
|
}
|
|
return this.searchPossibleTargets(this._objects, pointer);
|
|
}
|
|
|
|
/**
|
|
* Checks if the point is inside the object selection area including padding
|
|
* @param {FabricObject} obj Object to test against
|
|
* @param {Object} [pointer] point in scene coordinates
|
|
* @return {Boolean} true if point is contained within an area of given object
|
|
* @private
|
|
*/
|
|
_pointIsInObjectSelectionArea(obj, point) {
|
|
// getCoords will already take care of group de-nesting
|
|
let coords = obj.getCoords();
|
|
const viewportZoom = this.getZoom();
|
|
const padding = obj.padding / viewportZoom;
|
|
if (padding) {
|
|
const [tl, tr, br, bl] = coords;
|
|
// what is the angle of the object?
|
|
// we could use getTotalAngle, but is way easier to look at it
|
|
// from how coords are oriented, since if something went wrong
|
|
// at least we are consistent.
|
|
const angleRadians = Math.atan2(tr.y - tl.y, tr.x - tl.x),
|
|
cosP = cos(angleRadians) * padding,
|
|
sinP = sin(angleRadians) * padding,
|
|
cosPSinP = cosP + sinP,
|
|
cosPMinusSinP = cosP - sinP;
|
|
coords = [new Point(tl.x - cosPMinusSinP, tl.y - cosPSinP), new Point(tr.x + cosPSinP, tr.y - cosPMinusSinP), new Point(br.x + cosPMinusSinP, br.y + cosPSinP), new Point(bl.x - cosPSinP, bl.y + cosPMinusSinP)];
|
|
// in case of padding we calculate the new coords on the fly.
|
|
// otherwise we have to maintain 2 sets of coordinates for everything.
|
|
// we can reiterate on storing them.
|
|
// if this is slow, for now the semplification is large and doesn't impact
|
|
// rendering.
|
|
// the idea behind this is that outside target check we don't need ot know
|
|
// where those coords are
|
|
}
|
|
return Intersection.isPointInPolygon(point, coords);
|
|
}
|
|
|
|
/**
|
|
* Checks point is inside the object selection condition. Either area with padding
|
|
* or over pixels if perPixelTargetFind is enabled
|
|
* @param {FabricObject} obj Object to test against
|
|
* @param {Object} [pointer] point from viewport.
|
|
* @return {Boolean} true if point is contained within an area of given object
|
|
* @private
|
|
*/
|
|
_checkTarget(obj, pointer) {
|
|
if (obj && obj.visible && obj.evented && this._pointIsInObjectSelectionArea(obj, sendPointToPlane(pointer, undefined, this.viewportTransform))) {
|
|
if ((this.perPixelTargetFind || obj.perPixelTargetFind) && !obj.isEditing) {
|
|
if (!this.isTargetTransparent(obj, pointer.x, pointer.y)) {
|
|
return true;
|
|
}
|
|
} else {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Internal Function used to search inside objects an object that contains pointer in bounding box or that contains pointerOnCanvas when painted
|
|
* @param {Array} [objects] objects array to look into
|
|
* @param {Object} [pointer] x,y object of point coordinates we want to check.
|
|
* @return {FabricObject} **top most object from given `objects`** that contains pointer
|
|
* @private
|
|
*/
|
|
_searchPossibleTargets(objects, pointer) {
|
|
// Cache all targets where their bounding box contains point.
|
|
let i = objects.length;
|
|
// Do not check for currently grouped objects, since we check the parent group itself.
|
|
// until we call this function specifically to search inside the activeGroup
|
|
while (i--) {
|
|
const target = objects[i];
|
|
if (this._checkTarget(target, pointer)) {
|
|
if (isCollection(target) && target.subTargetCheck) {
|
|
const subTarget = this._searchPossibleTargets(target._objects, pointer);
|
|
subTarget && this.targets.push(subTarget);
|
|
}
|
|
return target;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Function used to search inside objects an object that contains pointer in bounding box or that contains pointerOnCanvas when painted
|
|
* @see {@link _searchPossibleTargets}
|
|
* @param {FabricObject[]} [objects] objects array to look into
|
|
* @param {Point} [pointer] coordinates from viewport to check.
|
|
* @return {FabricObject} **top most object on screen** that contains pointer
|
|
*/
|
|
searchPossibleTargets(objects, pointer) {
|
|
const target = this._searchPossibleTargets(objects, pointer);
|
|
|
|
// if we found something in this.targets, and the group is interactive, return the innermost subTarget
|
|
// that is still interactive
|
|
// TODO: reverify why interactive. the target should be returned always, but selected only
|
|
// if interactive.
|
|
if (target && isCollection(target) && target.interactive && this.targets[0]) {
|
|
/** targets[0] is the innermost nested target, but it could be inside non interactive groups and so not a selection target */
|
|
const targets = this.targets;
|
|
for (let i = targets.length - 1; i > 0; i--) {
|
|
const t = targets[i];
|
|
if (!(isCollection(t) && t.interactive)) {
|
|
// one of the subtargets was not interactive. that is the last subtarget we can return.
|
|
// we can't dig more deep;
|
|
return t;
|
|
}
|
|
}
|
|
return targets[0];
|
|
}
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* @returns point existing in the same plane as the {@link HTMLCanvasElement},
|
|
* `(0, 0)` being the top left corner of the {@link HTMLCanvasElement}.
|
|
* This means that changes to the {@link viewportTransform} do not change the values of the point
|
|
* and it remains unchanged from the viewer's perspective.
|
|
*
|
|
* @example
|
|
* const scenePoint = sendPointToPlane(
|
|
* this.getViewportPoint(e),
|
|
* undefined,
|
|
* canvas.viewportTransform
|
|
* );
|
|
*
|
|
*/
|
|
getViewportPoint(e) {
|
|
if (this._pointer) {
|
|
return this._pointer;
|
|
}
|
|
return this.getPointer(e, true);
|
|
}
|
|
|
|
/**
|
|
* @returns point existing in the scene (the same plane as the plane {@link FabricObject#getCenterPoint} exists in).
|
|
* This means that changes to the {@link viewportTransform} do not change the values of the point,
|
|
* however, from the viewer's perspective, the point is changed.
|
|
*
|
|
* @example
|
|
* const viewportPoint = sendPointToPlane(
|
|
* this.getScenePoint(e),
|
|
* canvas.viewportTransform
|
|
* );
|
|
*
|
|
*/
|
|
getScenePoint(e) {
|
|
if (this._absolutePointer) {
|
|
return this._absolutePointer;
|
|
}
|
|
return this.getPointer(e);
|
|
}
|
|
|
|
/**
|
|
* Returns pointer relative to canvas.
|
|
*
|
|
* @deprecated This method is deprecated since v6 to protect you from misuse.
|
|
* Use {@link getViewportPoint} or {@link getScenePoint} instead.
|
|
*
|
|
* @param {Event} e
|
|
* @param {Boolean} [fromViewport] whether to return the point from the viewport or in the scene
|
|
* @return {Point}
|
|
*/
|
|
getPointer(e) {
|
|
let fromViewport = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
|
|
const upperCanvasEl = this.upperCanvasEl,
|
|
bounds = upperCanvasEl.getBoundingClientRect();
|
|
let pointer = getPointer(e),
|
|
boundsWidth = bounds.width || 0,
|
|
boundsHeight = bounds.height || 0;
|
|
if (!boundsWidth || !boundsHeight) {
|
|
if (TOP in bounds && BOTTOM in bounds) {
|
|
boundsHeight = Math.abs(bounds.top - bounds.bottom);
|
|
}
|
|
if (RIGHT in bounds && LEFT in bounds) {
|
|
boundsWidth = Math.abs(bounds.right - bounds.left);
|
|
}
|
|
}
|
|
this.calcOffset();
|
|
pointer.x = pointer.x - this._offset.left;
|
|
pointer.y = pointer.y - this._offset.top;
|
|
if (!fromViewport) {
|
|
pointer = sendPointToPlane(pointer, undefined, this.viewportTransform);
|
|
}
|
|
const retinaScaling = this.getRetinaScaling();
|
|
if (retinaScaling !== 1) {
|
|
pointer.x /= retinaScaling;
|
|
pointer.y /= retinaScaling;
|
|
}
|
|
|
|
// If bounds are not available (i.e. not visible), do not apply scale.
|
|
const cssScale = boundsWidth === 0 || boundsHeight === 0 ? new Point(1, 1) : new Point(upperCanvasEl.width / boundsWidth, upperCanvasEl.height / boundsHeight);
|
|
return pointer.multiply(cssScale);
|
|
}
|
|
|
|
/**
|
|
* Internal use only
|
|
* @protected
|
|
*/
|
|
_setDimensionsImpl(dimensions, options) {
|
|
// @ts-expect-error this method exists in the subclass - should be moved or declared as abstract
|
|
this._resetTransformEventData();
|
|
super._setDimensionsImpl(dimensions, options);
|
|
if (this._isCurrentlyDrawing) {
|
|
this.freeDrawingBrush && this.freeDrawingBrush._setBrushStyles(this.contextTop);
|
|
}
|
|
}
|
|
_createCacheCanvas() {
|
|
this.pixelFindCanvasEl = createCanvasElement();
|
|
this.pixelFindContext = this.pixelFindCanvasEl.getContext('2d', {
|
|
willReadFrequently: true
|
|
});
|
|
this.setTargetFindTolerance(this.targetFindTolerance);
|
|
}
|
|
|
|
/**
|
|
* Returns context of top canvas where interactions are drawn
|
|
* @returns {CanvasRenderingContext2D}
|
|
*/
|
|
getTopContext() {
|
|
return this.elements.upper.ctx;
|
|
}
|
|
|
|
/**
|
|
* Returns context of canvas where object selection is drawn
|
|
* @alias
|
|
* @return {CanvasRenderingContext2D}
|
|
*/
|
|
getSelectionContext() {
|
|
return this.elements.upper.ctx;
|
|
}
|
|
|
|
/**
|
|
* Returns <canvas> element on which object selection is drawn
|
|
* @return {HTMLCanvasElement}
|
|
*/
|
|
getSelectionElement() {
|
|
return this.elements.upper.el;
|
|
}
|
|
|
|
/**
|
|
* Returns currently active object
|
|
* @return {FabricObject | null} active object
|
|
*/
|
|
getActiveObject() {
|
|
return this._activeObject;
|
|
}
|
|
|
|
/**
|
|
* Returns an array with the current selected objects
|
|
* @return {FabricObject[]} active objects array
|
|
*/
|
|
getActiveObjects() {
|
|
const active = this._activeObject;
|
|
return isActiveSelection(active) ? active.getObjects() : active ? [active] : [];
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Compares the old activeObject with the current one and fires correct events
|
|
* @param {FabricObject[]} oldObjects old activeObject
|
|
* @param {TPointerEvent} e mouse event triggering the selection events
|
|
*/
|
|
_fireSelectionEvents(oldObjects, e) {
|
|
let somethingChanged = false,
|
|
invalidate = false;
|
|
const objects = this.getActiveObjects(),
|
|
added = [],
|
|
removed = [];
|
|
oldObjects.forEach(target => {
|
|
if (!objects.includes(target)) {
|
|
somethingChanged = true;
|
|
target.fire('deselected', {
|
|
e,
|
|
target
|
|
});
|
|
removed.push(target);
|
|
}
|
|
});
|
|
objects.forEach(target => {
|
|
if (!oldObjects.includes(target)) {
|
|
somethingChanged = true;
|
|
target.fire('selected', {
|
|
e,
|
|
target
|
|
});
|
|
added.push(target);
|
|
}
|
|
});
|
|
if (oldObjects.length > 0 && objects.length > 0) {
|
|
invalidate = true;
|
|
somethingChanged && this.fire('selection:updated', {
|
|
e,
|
|
selected: added,
|
|
deselected: removed
|
|
});
|
|
} else if (objects.length > 0) {
|
|
invalidate = true;
|
|
this.fire('selection:created', {
|
|
e,
|
|
selected: added
|
|
});
|
|
} else if (oldObjects.length > 0) {
|
|
invalidate = true;
|
|
this.fire('selection:cleared', {
|
|
e,
|
|
deselected: removed
|
|
});
|
|
}
|
|
invalidate && (this._objectsToRender = undefined);
|
|
}
|
|
|
|
/**
|
|
* Sets given object as the only active object on canvas
|
|
* @param {FabricObject} object Object to set as an active one
|
|
* @param {TPointerEvent} [e] Event (passed along when firing "object:selected")
|
|
* @return {Boolean} true if the object has been selected
|
|
*/
|
|
setActiveObject(object, e) {
|
|
// we can't inline this, since _setActiveObject will change what getActiveObjects returns
|
|
const currentActives = this.getActiveObjects();
|
|
const selected = this._setActiveObject(object, e);
|
|
this._fireSelectionEvents(currentActives, e);
|
|
return selected;
|
|
}
|
|
|
|
/**
|
|
* This is supposed to be equivalent to setActiveObject but without firing
|
|
* any event. There is commitment to have this stay this way.
|
|
* This is the functional part of setActiveObject.
|
|
* @param {Object} object to set as active
|
|
* @param {Event} [e] Event (passed along when firing "object:selected")
|
|
* @return {Boolean} true if the object has been selected
|
|
*/
|
|
_setActiveObject(object, e) {
|
|
const prevActiveObject = this._activeObject;
|
|
if (prevActiveObject === object) {
|
|
return false;
|
|
}
|
|
// after calling this._discardActiveObject, this,_activeObject could be undefined
|
|
if (!this._discardActiveObject(e, object) && this._activeObject) {
|
|
// refused to deselect
|
|
return false;
|
|
}
|
|
if (object.onSelect({
|
|
e
|
|
})) {
|
|
return false;
|
|
}
|
|
this._activeObject = object;
|
|
if (isActiveSelection(object) && prevActiveObject !== object) {
|
|
object.set('canvas', this);
|
|
}
|
|
object.setCoords();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* This is supposed to be equivalent to discardActiveObject but without firing
|
|
* any selection events ( can still fire object transformation events ). There is commitment to have this stay this way.
|
|
* This is the functional part of discardActiveObject.
|
|
* @param {Event} [e] Event (passed along when firing "object:deselected")
|
|
* @param {Object} object the next object to set as active, reason why we are discarding this
|
|
* @return {Boolean} true if the active object has been discarded
|
|
*/
|
|
_discardActiveObject(e, object) {
|
|
const obj = this._activeObject;
|
|
if (obj) {
|
|
// onDeselect return TRUE to cancel selection;
|
|
if (obj.onDeselect({
|
|
e,
|
|
object
|
|
})) {
|
|
return false;
|
|
}
|
|
if (this._currentTransform && this._currentTransform.target === obj) {
|
|
this.endCurrentTransform(e);
|
|
}
|
|
if (isActiveSelection(obj) && obj === this._hoveredTarget) {
|
|
this._hoveredTarget = undefined;
|
|
}
|
|
this._activeObject = undefined;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Discards currently active object and fire events. If the function is called by fabric
|
|
* as a consequence of a mouse event, the event is passed as a parameter and
|
|
* sent to the fire function for the custom events. When used as a method the
|
|
* e param does not have any application.
|
|
* @param {event} e
|
|
* @return {Boolean} true if the active object has been discarded
|
|
*/
|
|
discardActiveObject(e) {
|
|
const currentActives = this.getActiveObjects(),
|
|
activeObject = this.getActiveObject();
|
|
if (currentActives.length) {
|
|
this.fire('before:selection:cleared', {
|
|
e,
|
|
deselected: [activeObject]
|
|
});
|
|
}
|
|
const discarded = this._discardActiveObject(e);
|
|
this._fireSelectionEvents(currentActives, e);
|
|
return discarded;
|
|
}
|
|
|
|
/**
|
|
* End the current transform.
|
|
* You don't usually need to call this method unless you are interrupting a user initiated transform
|
|
* because of some other event ( a press of key combination, or something that block the user UX )
|
|
* @param {Event} [e] send the mouse event that generate the finalize down, so it can be used in the event
|
|
*/
|
|
endCurrentTransform(e) {
|
|
const transform = this._currentTransform;
|
|
this._finalizeCurrentTransform(e);
|
|
if (transform && transform.target) {
|
|
// this could probably go inside _finalizeCurrentTransform
|
|
transform.target.isMoving = false;
|
|
}
|
|
this._currentTransform = null;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e send the mouse event that generate the finalize down, so it can be used in the event
|
|
*/
|
|
_finalizeCurrentTransform(e) {
|
|
const transform = this._currentTransform,
|
|
target = transform.target,
|
|
options = {
|
|
e,
|
|
target,
|
|
transform,
|
|
action: transform.action
|
|
};
|
|
if (target._scaling) {
|
|
target._scaling = false;
|
|
}
|
|
target.setCoords();
|
|
if (transform.actionPerformed) {
|
|
this.fire('object:modified', options);
|
|
target.fire(MODIFIED, options);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets viewport transformation of this canvas instance
|
|
* @param {Array} vpt a Canvas 2D API transform matrix
|
|
*/
|
|
setViewportTransform(vpt) {
|
|
super.setViewportTransform(vpt);
|
|
const activeObject = this._activeObject;
|
|
if (activeObject) {
|
|
activeObject.setCoords();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @override clears active selection ref and interactive canvas elements and contexts
|
|
*/
|
|
destroy() {
|
|
// dispose of active selection
|
|
const activeObject = this._activeObject;
|
|
if (isActiveSelection(activeObject)) {
|
|
activeObject.removeAll();
|
|
activeObject.dispose();
|
|
}
|
|
delete this._activeObject;
|
|
super.destroy();
|
|
|
|
// free resources
|
|
|
|
// pixel find canvas
|
|
// @ts-expect-error disposing
|
|
this.pixelFindContext = null;
|
|
// @ts-expect-error disposing
|
|
this.pixelFindCanvasEl = undefined;
|
|
}
|
|
|
|
/**
|
|
* Clears all contexts (background, main, top) of an instance
|
|
*/
|
|
clear() {
|
|
// discard active object and fire events
|
|
this.discardActiveObject();
|
|
// make sure we clear the active object in case it refused to be discarded
|
|
this._activeObject = undefined;
|
|
this.clearContext(this.contextTop);
|
|
super.clear();
|
|
}
|
|
|
|
/**
|
|
* Draws objects' controls (borders/controls)
|
|
* @param {CanvasRenderingContext2D} ctx Context to render controls on
|
|
*/
|
|
drawControls(ctx) {
|
|
const activeObject = this._activeObject;
|
|
if (activeObject) {
|
|
activeObject._renderControls(ctx);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_toObject(instance, methodName, propertiesToInclude) {
|
|
// If the object is part of the current selection group, it should
|
|
// be transformed appropriately
|
|
// i.e. it should be serialised as it would appear if the selection group
|
|
// were to be destroyed.
|
|
const originalProperties = this._realizeGroupTransformOnObject(instance),
|
|
object = super._toObject(instance, methodName, propertiesToInclude);
|
|
//Undo the damage we did by changing all of its properties
|
|
instance.set(originalProperties);
|
|
return object;
|
|
}
|
|
|
|
/**
|
|
* Realizes an object's group transformation on it
|
|
* @private
|
|
* @param {FabricObject} [instance] the object to transform (gets mutated)
|
|
* @returns the original values of instance which were changed
|
|
*/
|
|
_realizeGroupTransformOnObject(instance) {
|
|
const {
|
|
group
|
|
} = instance;
|
|
if (group && isActiveSelection(group) && this._activeObject === group) {
|
|
const layoutProps = ['angle', 'flipX', 'flipY', LEFT, SCALE_X, SCALE_Y, SKEW_X, SKEW_Y, TOP];
|
|
const originalValues = pick(instance, layoutProps);
|
|
addTransformToObject(instance, group.calcOwnMatrix());
|
|
return originalValues;
|
|
} else {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_setSVGObject(markup, instance, reviver) {
|
|
// If the object is in a selection group, simulate what would happen to that
|
|
// object when the group is deselected
|
|
const originalProperties = this._realizeGroupTransformOnObject(instance);
|
|
super._setSVGObject(markup, instance, reviver);
|
|
instance.set(originalProperties);
|
|
}
|
|
}
|
|
_defineProperty(SelectableCanvas, "ownDefaults", canvasDefaults);
|
|
|
|
/**
|
|
* In charge of synchronizing all interactive text instances of a canvas
|
|
*/
|
|
class TextEditingManager {
|
|
constructor(canvas) {
|
|
_defineProperty(this, "targets", []);
|
|
_defineProperty(this, "__disposer", void 0);
|
|
const cb = () => {
|
|
const {
|
|
hiddenTextarea
|
|
} = canvas.getActiveObject() || {};
|
|
hiddenTextarea && hiddenTextarea.focus();
|
|
};
|
|
const el = canvas.upperCanvasEl;
|
|
el.addEventListener('click', cb);
|
|
this.__disposer = () => el.removeEventListener('click', cb);
|
|
}
|
|
exitTextEditing() {
|
|
this.target = undefined;
|
|
this.targets.forEach(target => {
|
|
if (target.isEditing) {
|
|
target.exitEditing();
|
|
}
|
|
});
|
|
}
|
|
add(target) {
|
|
this.targets.push(target);
|
|
}
|
|
remove(target) {
|
|
this.unregister(target);
|
|
removeFromArray(this.targets, target);
|
|
}
|
|
register(target) {
|
|
this.target = target;
|
|
}
|
|
unregister(target) {
|
|
if (target === this.target) {
|
|
this.target = undefined;
|
|
}
|
|
}
|
|
onMouseMove(e) {
|
|
var _this$target;
|
|
((_this$target = this.target) === null || _this$target === void 0 ? void 0 : _this$target.isEditing) && this.target.updateSelectionOnMouseMove(e);
|
|
}
|
|
clear() {
|
|
this.targets = [];
|
|
this.target = undefined;
|
|
}
|
|
dispose() {
|
|
this.clear();
|
|
this.__disposer();
|
|
// @ts-expect-error disposing
|
|
delete this.__disposer;
|
|
}
|
|
}
|
|
|
|
const _excluded$9 = ["target", "oldTarget", "fireCanvas", "e"];
|
|
const addEventOptions = {
|
|
passive: false
|
|
};
|
|
const getEventPoints = (canvas, e) => {
|
|
const viewportPoint = canvas.getViewportPoint(e);
|
|
const scenePoint = canvas.getScenePoint(e);
|
|
return {
|
|
viewportPoint,
|
|
scenePoint,
|
|
pointer: viewportPoint,
|
|
absolutePointer: scenePoint
|
|
};
|
|
};
|
|
|
|
// just to be clear, the utils are now deprecated and those are here exactly as minifier helpers
|
|
// because el.addEventListener can't me be minified while a const yes and we use it 47 times in this file.
|
|
// few bytes but why give it away.
|
|
const addListener = function (el) {
|
|
for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
|
|
args[_key - 1] = arguments[_key];
|
|
}
|
|
return el.addEventListener(...args);
|
|
};
|
|
const removeListener = function (el) {
|
|
for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {
|
|
args[_key2 - 1] = arguments[_key2];
|
|
}
|
|
return el.removeEventListener(...args);
|
|
};
|
|
const syntheticEventConfig = {
|
|
mouse: {
|
|
in: 'over',
|
|
out: 'out',
|
|
targetIn: 'mouseover',
|
|
targetOut: 'mouseout',
|
|
canvasIn: 'mouse:over',
|
|
canvasOut: 'mouse:out'
|
|
},
|
|
drag: {
|
|
in: 'enter',
|
|
out: 'leave',
|
|
targetIn: 'dragenter',
|
|
targetOut: 'dragleave',
|
|
canvasIn: 'drag:enter',
|
|
canvasOut: 'drag:leave'
|
|
}
|
|
};
|
|
class Canvas extends SelectableCanvas {
|
|
constructor(el) {
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
super(el, options);
|
|
// bind event handlers
|
|
/**
|
|
* Contains the id of the touch event that owns the fabric transform
|
|
* @type Number
|
|
* @private
|
|
*/
|
|
/**
|
|
* Holds a reference to a setTimeout timer for event synchronization
|
|
* @type number
|
|
* @private
|
|
*/
|
|
/**
|
|
* Holds a reference to an object on the canvas that is receiving the drag over event.
|
|
* @type FabricObject
|
|
* @private
|
|
*/
|
|
/**
|
|
* Holds a reference to an object on the canvas from where the drag operation started
|
|
* @type FabricObject
|
|
* @private
|
|
*/
|
|
/**
|
|
* Holds a reference to an object on the canvas that is the current drop target
|
|
* May differ from {@link _draggedoverTarget}
|
|
* @todo inspect whether {@link _draggedoverTarget} and {@link _dropTarget} should be merged somehow
|
|
* @type FabricObject
|
|
* @private
|
|
*/
|
|
_defineProperty(this, "_isClick", void 0);
|
|
_defineProperty(this, "textEditingManager", new TextEditingManager(this));
|
|
['_onMouseDown', '_onTouchStart', '_onMouseMove', '_onMouseUp', '_onTouchEnd', '_onResize',
|
|
// '_onGesture',
|
|
// '_onDrag',
|
|
// '_onShake',
|
|
// '_onLongPress',
|
|
// '_onOrientationChange',
|
|
'_onMouseWheel', '_onMouseOut', '_onMouseEnter', '_onContextMenu', '_onDoubleClick', '_onDragStart', '_onDragEnd', '_onDragProgress', '_onDragOver', '_onDragEnter', '_onDragLeave', '_onDrop'].forEach(eventHandler => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
this[eventHandler] = this[eventHandler].bind(this);
|
|
});
|
|
// register event handlers
|
|
this.addOrRemove(addListener, 'add');
|
|
}
|
|
|
|
/**
|
|
* return an event prefix pointer or mouse.
|
|
* @private
|
|
*/
|
|
_getEventPrefix() {
|
|
return this.enablePointerEvents ? 'pointer' : 'mouse';
|
|
}
|
|
addOrRemove(functor, _eventjsFunctor) {
|
|
const canvasElement = this.upperCanvasEl,
|
|
eventTypePrefix = this._getEventPrefix();
|
|
functor(getWindowFromElement(canvasElement), 'resize', this._onResize);
|
|
functor(canvasElement, eventTypePrefix + 'down', this._onMouseDown);
|
|
functor(canvasElement, "".concat(eventTypePrefix, "move"), this._onMouseMove, addEventOptions);
|
|
functor(canvasElement, "".concat(eventTypePrefix, "out"), this._onMouseOut);
|
|
functor(canvasElement, "".concat(eventTypePrefix, "enter"), this._onMouseEnter);
|
|
functor(canvasElement, 'wheel', this._onMouseWheel);
|
|
functor(canvasElement, 'contextmenu', this._onContextMenu);
|
|
functor(canvasElement, 'dblclick', this._onDoubleClick);
|
|
functor(canvasElement, 'dragstart', this._onDragStart);
|
|
functor(canvasElement, 'dragend', this._onDragEnd);
|
|
functor(canvasElement, 'dragover', this._onDragOver);
|
|
functor(canvasElement, 'dragenter', this._onDragEnter);
|
|
functor(canvasElement, 'dragleave', this._onDragLeave);
|
|
functor(canvasElement, 'drop', this._onDrop);
|
|
if (!this.enablePointerEvents) {
|
|
functor(canvasElement, 'touchstart', this._onTouchStart, addEventOptions);
|
|
}
|
|
// if (typeof eventjs !== 'undefined' && eventjsFunctor in eventjs) {
|
|
// eventjs[eventjsFunctor](canvasElement, 'gesture', this._onGesture);
|
|
// eventjs[eventjsFunctor](canvasElement, 'drag', this._onDrag);
|
|
// eventjs[eventjsFunctor](
|
|
// canvasElement,
|
|
// 'orientation',
|
|
// this._onOrientationChange
|
|
// );
|
|
// eventjs[eventjsFunctor](canvasElement, 'shake', this._onShake);
|
|
// eventjs[eventjsFunctor](canvasElement, 'longpress', this._onLongPress);
|
|
// }
|
|
}
|
|
|
|
/**
|
|
* Removes all event listeners
|
|
*/
|
|
removeListeners() {
|
|
this.addOrRemove(removeListener, 'remove');
|
|
// if you dispose on a mouseDown, before mouse up, you need to clean document to...
|
|
const eventTypePrefix = this._getEventPrefix();
|
|
const doc = getDocumentFromElement(this.upperCanvasEl);
|
|
removeListener(doc, "".concat(eventTypePrefix, "up"), this._onMouseUp);
|
|
removeListener(doc, 'touchend', this._onTouchEnd, addEventOptions);
|
|
removeListener(doc, "".concat(eventTypePrefix, "move"), this._onMouseMove, addEventOptions);
|
|
removeListener(doc, 'touchmove', this._onMouseMove, addEventOptions);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} [e] Event object fired on wheel event
|
|
*/
|
|
_onMouseWheel(e) {
|
|
this.__onMouseWheel(e);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mousedown
|
|
*/
|
|
_onMouseOut(e) {
|
|
const target = this._hoveredTarget;
|
|
const shared = _objectSpread2({
|
|
e
|
|
}, getEventPoints(this, e));
|
|
this.fire('mouse:out', _objectSpread2(_objectSpread2({}, shared), {}, {
|
|
target
|
|
}));
|
|
this._hoveredTarget = undefined;
|
|
target && target.fire('mouseout', _objectSpread2({}, shared));
|
|
this._hoveredTargets.forEach(nestedTarget => {
|
|
this.fire('mouse:out', _objectSpread2(_objectSpread2({}, shared), {}, {
|
|
target: nestedTarget
|
|
}));
|
|
nestedTarget && nestedTarget.fire('mouseout', _objectSpread2({}, shared));
|
|
});
|
|
this._hoveredTargets = [];
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mouseenter
|
|
*/
|
|
_onMouseEnter(e) {
|
|
// This find target and consequent 'mouse:over' is used to
|
|
// clear old instances on hovered target.
|
|
// calling findTarget has the side effect of killing target.__corner.
|
|
// as a short term fix we are not firing this if we are currently transforming.
|
|
// as a long term fix we need to separate the action of finding a target with the
|
|
// side effects we added to it.
|
|
if (!this._currentTransform && !this.findTarget(e)) {
|
|
this.fire('mouse:over', _objectSpread2({
|
|
e
|
|
}, getEventPoints(this, e)));
|
|
this._hoveredTarget = undefined;
|
|
this._hoveredTargets = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* supports native like text dragging
|
|
* @private
|
|
* @param {DragEvent} e
|
|
*/
|
|
_onDragStart(e) {
|
|
this._isClick = false;
|
|
const activeObject = this.getActiveObject();
|
|
if (activeObject && activeObject.onDragStart(e)) {
|
|
this._dragSource = activeObject;
|
|
const options = {
|
|
e,
|
|
target: activeObject
|
|
};
|
|
this.fire('dragstart', options);
|
|
activeObject.fire('dragstart', options);
|
|
addListener(this.upperCanvasEl, 'drag', this._onDragProgress);
|
|
return;
|
|
}
|
|
stopEvent(e);
|
|
}
|
|
|
|
/**
|
|
* First we clear top context where the effects are being rendered.
|
|
* Then we render the effects.
|
|
* Doing so will render the correct effect for all cases including an overlap between `source` and `target`.
|
|
* @private
|
|
*/
|
|
_renderDragEffects(e, source, target) {
|
|
let dirty = false;
|
|
// clear top context
|
|
const dropTarget = this._dropTarget;
|
|
if (dropTarget && dropTarget !== source && dropTarget !== target) {
|
|
dropTarget.clearContextTop();
|
|
dirty = true;
|
|
}
|
|
source === null || source === void 0 || source.clearContextTop();
|
|
target !== source && (target === null || target === void 0 ? void 0 : target.clearContextTop());
|
|
// render effects
|
|
const ctx = this.contextTop;
|
|
ctx.save();
|
|
ctx.transform(...this.viewportTransform);
|
|
if (source) {
|
|
ctx.save();
|
|
source.transform(ctx);
|
|
source.renderDragSourceEffect(e);
|
|
ctx.restore();
|
|
dirty = true;
|
|
}
|
|
if (target) {
|
|
ctx.save();
|
|
target.transform(ctx);
|
|
target.renderDropTargetEffect(e);
|
|
ctx.restore();
|
|
dirty = true;
|
|
}
|
|
ctx.restore();
|
|
dirty && (this.contextTopDirty = true);
|
|
}
|
|
|
|
/**
|
|
* supports native like text dragging
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#finishing_a_drag
|
|
* @private
|
|
* @param {DragEvent} e
|
|
*/
|
|
_onDragEnd(e) {
|
|
const didDrop = !!e.dataTransfer && e.dataTransfer.dropEffect !== NONE,
|
|
dropTarget = didDrop ? this._activeObject : undefined,
|
|
options = {
|
|
e,
|
|
target: this._dragSource,
|
|
subTargets: this.targets,
|
|
dragSource: this._dragSource,
|
|
didDrop,
|
|
dropTarget: dropTarget
|
|
};
|
|
removeListener(this.upperCanvasEl, 'drag', this._onDragProgress);
|
|
this.fire('dragend', options);
|
|
this._dragSource && this._dragSource.fire('dragend', options);
|
|
delete this._dragSource;
|
|
// we need to call mouse up synthetically because the browser won't
|
|
this._onMouseUp(e);
|
|
}
|
|
|
|
/**
|
|
* fire `drag` event on canvas and drag source
|
|
* @private
|
|
* @param {DragEvent} e
|
|
*/
|
|
_onDragProgress(e) {
|
|
const options = {
|
|
e,
|
|
target: this._dragSource,
|
|
dragSource: this._dragSource,
|
|
dropTarget: this._draggedoverTarget
|
|
};
|
|
this.fire('drag', options);
|
|
this._dragSource && this._dragSource.fire('drag', options);
|
|
}
|
|
|
|
/**
|
|
* As opposed to {@link findTarget} we want the top most object to be returned w/o the active object cutting in line.
|
|
* Override at will
|
|
*/
|
|
findDragTargets(e) {
|
|
this.targets = [];
|
|
const target = this._searchPossibleTargets(this._objects, this.getViewportPoint(e));
|
|
return {
|
|
target,
|
|
targets: [...this.targets]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* prevent default to allow drop event to be fired
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#specifying_drop_targets
|
|
* @private
|
|
* @param {DragEvent} [e] Event object fired on Event.js shake
|
|
*/
|
|
_onDragOver(e) {
|
|
const eventType = 'dragover';
|
|
const {
|
|
target,
|
|
targets
|
|
} = this.findDragTargets(e);
|
|
const dragSource = this._dragSource;
|
|
const options = {
|
|
e,
|
|
target,
|
|
subTargets: targets,
|
|
dragSource,
|
|
canDrop: false,
|
|
dropTarget: undefined
|
|
};
|
|
let dropTarget;
|
|
// fire on canvas
|
|
this.fire(eventType, options);
|
|
// make sure we fire dragenter events before dragover
|
|
// if dragleave is needed, object will not fire dragover so we don't need to trouble ourselves with it
|
|
this._fireEnterLeaveEvents(target, options);
|
|
if (target) {
|
|
if (target.canDrop(e)) {
|
|
dropTarget = target;
|
|
}
|
|
target.fire(eventType, options);
|
|
}
|
|
// propagate the event to subtargets
|
|
for (let i = 0; i < targets.length; i++) {
|
|
const subTarget = targets[i];
|
|
// accept event only if previous targets didn't (the accepting target calls `preventDefault` to inform that the event is taken)
|
|
// TODO: verify if those should loop in inverse order then?
|
|
// what is the order of subtargets?
|
|
if (subTarget.canDrop(e)) {
|
|
dropTarget = subTarget;
|
|
}
|
|
subTarget.fire(eventType, options);
|
|
}
|
|
// render drag effects now that relations between source and target is clear
|
|
this._renderDragEffects(e, dragSource, dropTarget);
|
|
this._dropTarget = dropTarget;
|
|
}
|
|
|
|
/**
|
|
* fire `dragleave` on `dragover` targets
|
|
* @private
|
|
* @param {Event} [e] Event object fired on Event.js shake
|
|
*/
|
|
_onDragEnter(e) {
|
|
const {
|
|
target,
|
|
targets
|
|
} = this.findDragTargets(e);
|
|
const options = {
|
|
e,
|
|
target,
|
|
subTargets: targets,
|
|
dragSource: this._dragSource
|
|
};
|
|
this.fire('dragenter', options);
|
|
// fire dragenter on targets
|
|
this._fireEnterLeaveEvents(target, options);
|
|
}
|
|
|
|
/**
|
|
* fire `dragleave` on `dragover` targets
|
|
* @private
|
|
* @param {Event} [e] Event object fired on Event.js shake
|
|
*/
|
|
_onDragLeave(e) {
|
|
const options = {
|
|
e,
|
|
target: this._draggedoverTarget,
|
|
subTargets: this.targets,
|
|
dragSource: this._dragSource
|
|
};
|
|
this.fire('dragleave', options);
|
|
|
|
// fire dragleave on targets
|
|
this._fireEnterLeaveEvents(undefined, options);
|
|
this._renderDragEffects(e, this._dragSource);
|
|
this._dropTarget = undefined;
|
|
// clear targets
|
|
this.targets = [];
|
|
this._hoveredTargets = [];
|
|
}
|
|
|
|
/**
|
|
* `drop:before` is a an event that allows you to schedule logic
|
|
* before the `drop` event. Prefer `drop` event always, but if you need
|
|
* to run some drop-disabling logic on an event, since there is no way
|
|
* to handle event handlers ordering, use `drop:before`
|
|
* @private
|
|
* @param {Event} e
|
|
*/
|
|
_onDrop(e) {
|
|
const {
|
|
target,
|
|
targets
|
|
} = this.findDragTargets(e);
|
|
const options = this._basicEventHandler('drop:before', _objectSpread2({
|
|
e,
|
|
target,
|
|
subTargets: targets,
|
|
dragSource: this._dragSource
|
|
}, getEventPoints(this, e)));
|
|
// will be set by the drop target
|
|
options.didDrop = false;
|
|
// will be set by the drop target, used in case options.target refuses the drop
|
|
options.dropTarget = undefined;
|
|
// fire `drop`
|
|
this._basicEventHandler('drop', options);
|
|
// inform canvas of the drop
|
|
// we do this because canvas was unaware of what happened at the time the `drop` event was fired on it
|
|
// use for side effects
|
|
this.fire('drop:after', options);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mousedown
|
|
*/
|
|
_onContextMenu(e) {
|
|
const target = this.findTarget(e),
|
|
subTargets = this.targets || [];
|
|
const options = this._basicEventHandler('contextmenu:before', {
|
|
e,
|
|
target,
|
|
subTargets
|
|
});
|
|
// TODO: this line is silly because the dev can subscribe to the event and prevent it themselves
|
|
this.stopContextMenu && stopEvent(e);
|
|
this._basicEventHandler('contextmenu', options);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mousedown
|
|
*/
|
|
_onDoubleClick(e) {
|
|
this._cacheTransformEventData(e);
|
|
this._handleEvent(e, 'dblclick');
|
|
this._resetTransformEventData();
|
|
}
|
|
|
|
/**
|
|
* Return a the id of an event.
|
|
* returns either the pointerId or the identifier or 0 for the mouse event
|
|
* @private
|
|
* @param {Event} evt Event object
|
|
*/
|
|
getPointerId(evt) {
|
|
const changedTouches = evt.changedTouches;
|
|
if (changedTouches) {
|
|
return changedTouches[0] && changedTouches[0].identifier;
|
|
}
|
|
if (this.enablePointerEvents) {
|
|
return evt.pointerId;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Determines if an event has the id of the event that is considered main
|
|
* @private
|
|
* @param {evt} event Event object
|
|
*/
|
|
_isMainEvent(evt) {
|
|
if (evt.isPrimary === true) {
|
|
return true;
|
|
}
|
|
if (evt.isPrimary === false) {
|
|
return false;
|
|
}
|
|
if (evt.type === 'touchend' && evt.touches.length === 0) {
|
|
return true;
|
|
}
|
|
if (evt.changedTouches) {
|
|
return evt.changedTouches[0].identifier === this.mainTouchId;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mousedown
|
|
*/
|
|
_onTouchStart(e) {
|
|
e.preventDefault();
|
|
if (this.mainTouchId === undefined) {
|
|
this.mainTouchId = this.getPointerId(e);
|
|
}
|
|
this.__onMouseDown(e);
|
|
this._resetTransformEventData();
|
|
const canvasElement = this.upperCanvasEl,
|
|
eventTypePrefix = this._getEventPrefix();
|
|
const doc = getDocumentFromElement(canvasElement);
|
|
addListener(doc, 'touchend', this._onTouchEnd, addEventOptions);
|
|
addListener(doc, 'touchmove', this._onMouseMove, addEventOptions);
|
|
// Unbind mousedown to prevent double triggers from touch devices
|
|
removeListener(canvasElement, "".concat(eventTypePrefix, "down"), this._onMouseDown);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mousedown
|
|
*/
|
|
_onMouseDown(e) {
|
|
this.__onMouseDown(e);
|
|
this._resetTransformEventData();
|
|
const canvasElement = this.upperCanvasEl,
|
|
eventTypePrefix = this._getEventPrefix();
|
|
removeListener(canvasElement, "".concat(eventTypePrefix, "move"), this._onMouseMove, addEventOptions);
|
|
const doc = getDocumentFromElement(canvasElement);
|
|
addListener(doc, "".concat(eventTypePrefix, "up"), this._onMouseUp);
|
|
addListener(doc, "".concat(eventTypePrefix, "move"), this._onMouseMove, addEventOptions);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mousedown
|
|
*/
|
|
_onTouchEnd(e) {
|
|
if (e.touches.length > 0) {
|
|
// if there are still touches stop here
|
|
return;
|
|
}
|
|
this.__onMouseUp(e);
|
|
this._resetTransformEventData();
|
|
delete this.mainTouchId;
|
|
const eventTypePrefix = this._getEventPrefix();
|
|
const doc = getDocumentFromElement(this.upperCanvasEl);
|
|
removeListener(doc, 'touchend', this._onTouchEnd, addEventOptions);
|
|
removeListener(doc, 'touchmove', this._onMouseMove, addEventOptions);
|
|
if (this._willAddMouseDown) {
|
|
clearTimeout(this._willAddMouseDown);
|
|
}
|
|
this._willAddMouseDown = setTimeout(() => {
|
|
// Wait 400ms before rebinding mousedown to prevent double triggers
|
|
// from touch devices
|
|
addListener(this.upperCanvasEl, "".concat(eventTypePrefix, "down"), this._onMouseDown);
|
|
this._willAddMouseDown = 0;
|
|
}, 400);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mouseup
|
|
*/
|
|
_onMouseUp(e) {
|
|
this.__onMouseUp(e);
|
|
this._resetTransformEventData();
|
|
const canvasElement = this.upperCanvasEl,
|
|
eventTypePrefix = this._getEventPrefix();
|
|
if (this._isMainEvent(e)) {
|
|
const doc = getDocumentFromElement(this.upperCanvasEl);
|
|
removeListener(doc, "".concat(eventTypePrefix, "up"), this._onMouseUp);
|
|
removeListener(doc, "".concat(eventTypePrefix, "move"), this._onMouseMove, addEventOptions);
|
|
addListener(canvasElement, "".concat(eventTypePrefix, "move"), this._onMouseMove, addEventOptions);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mousemove
|
|
*/
|
|
_onMouseMove(e) {
|
|
const activeObject = this.getActiveObject();
|
|
!this.allowTouchScrolling && (!activeObject ||
|
|
// a drag event sequence is started by the active object flagging itself on mousedown / mousedown:before
|
|
// we must not prevent the event's default behavior in order for the window to start dragging
|
|
!activeObject.shouldStartDragging(e)) && e.preventDefault && e.preventDefault();
|
|
this.__onMouseMove(e);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_onResize() {
|
|
this.calcOffset();
|
|
this._resetTransformEventData();
|
|
}
|
|
|
|
/**
|
|
* Decides whether the canvas should be redrawn in mouseup and mousedown events.
|
|
* @private
|
|
* @param {Object} target
|
|
*/
|
|
_shouldRender(target) {
|
|
const activeObject = this.getActiveObject();
|
|
// if just one of them is available or if they are both but are different objects
|
|
// this covers: switch of target, from target to no target, selection of target
|
|
// multiSelection with key and mouse
|
|
return !!activeObject !== !!target || activeObject && target && activeObject !== target;
|
|
}
|
|
|
|
/**
|
|
* Method that defines the actions when mouse is released on canvas.
|
|
* The method resets the currentTransform parameters, store the image corner
|
|
* position in the image object and render the canvas on top.
|
|
* @private
|
|
* @param {Event} e Event object fired on mouseup
|
|
*/
|
|
__onMouseUp(e) {
|
|
var _this$_activeObject;
|
|
this._cacheTransformEventData(e);
|
|
this._handleEvent(e, 'up:before');
|
|
const transform = this._currentTransform;
|
|
const isClick = this._isClick;
|
|
const target = this._target;
|
|
|
|
// if right/middle click just fire events and return
|
|
// target undefined will make the _handleEvent search the target
|
|
const {
|
|
button
|
|
} = e;
|
|
if (button) {
|
|
(this.fireMiddleClick && button === 1 || this.fireRightClick && button === 2) && this._handleEvent(e, 'up');
|
|
this._resetTransformEventData();
|
|
return;
|
|
}
|
|
if (this.isDrawingMode && this._isCurrentlyDrawing) {
|
|
this._onMouseUpInDrawingMode(e);
|
|
return;
|
|
}
|
|
if (!this._isMainEvent(e)) {
|
|
return;
|
|
}
|
|
let shouldRender = false;
|
|
if (transform) {
|
|
this._finalizeCurrentTransform(e);
|
|
shouldRender = transform.actionPerformed;
|
|
}
|
|
if (!isClick) {
|
|
const targetWasActive = target === this._activeObject;
|
|
this.handleSelection(e);
|
|
if (!shouldRender) {
|
|
shouldRender = this._shouldRender(target) || !targetWasActive && target === this._activeObject;
|
|
}
|
|
}
|
|
let pointer, corner;
|
|
if (target) {
|
|
const found = target.findControl(this.getViewportPoint(e), isTouchEvent(e));
|
|
const {
|
|
key,
|
|
control
|
|
} = found || {};
|
|
corner = key;
|
|
if (target.selectable && target !== this._activeObject && target.activeOn === 'up') {
|
|
this.setActiveObject(target, e);
|
|
shouldRender = true;
|
|
} else if (control) {
|
|
const mouseUpHandler = control.getMouseUpHandler(e, target, control);
|
|
if (mouseUpHandler) {
|
|
pointer = this.getScenePoint(e);
|
|
mouseUpHandler.call(control, e, transform, pointer.x, pointer.y);
|
|
}
|
|
}
|
|
target.isMoving = false;
|
|
}
|
|
// if we are ending up a transform on a different control or a new object
|
|
// fire the original mouse up from the corner that started the transform
|
|
if (transform && (transform.target !== target || transform.corner !== corner)) {
|
|
const originalControl = transform.target && transform.target.controls[transform.corner],
|
|
originalMouseUpHandler = originalControl && originalControl.getMouseUpHandler(e, transform.target, originalControl);
|
|
pointer = pointer || this.getScenePoint(e);
|
|
originalMouseUpHandler && originalMouseUpHandler.call(originalControl, e, transform, pointer.x, pointer.y);
|
|
}
|
|
this._setCursorFromEvent(e, target);
|
|
this._handleEvent(e, 'up');
|
|
this._groupSelector = null;
|
|
this._currentTransform = null;
|
|
// reset the target information about which corner is selected
|
|
target && (target.__corner = undefined);
|
|
if (shouldRender) {
|
|
this.requestRenderAll();
|
|
} else if (!isClick && !((_this$_activeObject = this._activeObject) !== null && _this$_activeObject !== void 0 && _this$_activeObject.isEditing)) {
|
|
this.renderTop();
|
|
}
|
|
}
|
|
_basicEventHandler(eventType, options) {
|
|
const {
|
|
target,
|
|
subTargets = []
|
|
} = options;
|
|
this.fire(eventType, options);
|
|
target && target.fire(eventType, options);
|
|
for (let i = 0; i < subTargets.length; i++) {
|
|
subTargets[i] !== target && subTargets[i].fire(eventType, options);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Handle event firing for target and subtargets
|
|
* @param {TPointerEvent} e event from mouse
|
|
* @param {TPointerEventNames} eventType
|
|
*/
|
|
_handleEvent(e, eventType) {
|
|
const target = this._target,
|
|
targets = this.targets || [],
|
|
options = _objectSpread2(_objectSpread2({
|
|
e,
|
|
target,
|
|
subTargets: targets
|
|
}, getEventPoints(this, e)), {}, {
|
|
transform: this._currentTransform
|
|
}, eventType === 'up:before' || eventType === 'up' ? {
|
|
isClick: this._isClick,
|
|
currentTarget: this.findTarget(e),
|
|
// set by the preceding `findTarget` call
|
|
currentSubTargets: this.targets
|
|
} : {});
|
|
this.fire("mouse:".concat(eventType), options);
|
|
// this may be a little be more complicated of what we want to handle
|
|
target && target.fire("mouse".concat(eventType), options);
|
|
for (let i = 0; i < targets.length; i++) {
|
|
targets[i] !== target && targets[i].fire("mouse".concat(eventType), options);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mousedown
|
|
*/
|
|
_onMouseDownInDrawingMode(e) {
|
|
this._isCurrentlyDrawing = true;
|
|
if (this.getActiveObject()) {
|
|
this.discardActiveObject(e);
|
|
this.requestRenderAll();
|
|
}
|
|
// TODO: this is a scene point so it should be renamed
|
|
const pointer = this.getScenePoint(e);
|
|
this.freeDrawingBrush && this.freeDrawingBrush.onMouseDown(pointer, {
|
|
e,
|
|
pointer
|
|
});
|
|
this._handleEvent(e, 'down');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mousemove
|
|
*/
|
|
_onMouseMoveInDrawingMode(e) {
|
|
if (this._isCurrentlyDrawing) {
|
|
const pointer = this.getScenePoint(e);
|
|
this.freeDrawingBrush && this.freeDrawingBrush.onMouseMove(pointer, {
|
|
e,
|
|
// this is an absolute pointer, the naming is wrong
|
|
pointer
|
|
});
|
|
}
|
|
this.setCursor(this.freeDrawingCursor);
|
|
this._handleEvent(e, 'move');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event object fired on mouseup
|
|
*/
|
|
_onMouseUpInDrawingMode(e) {
|
|
const pointer = this.getScenePoint(e);
|
|
if (this.freeDrawingBrush) {
|
|
this._isCurrentlyDrawing = !!this.freeDrawingBrush.onMouseUp({
|
|
e: e,
|
|
// this is an absolute pointer, the naming is wrong
|
|
pointer
|
|
});
|
|
} else {
|
|
this._isCurrentlyDrawing = false;
|
|
}
|
|
this._handleEvent(e, 'up');
|
|
}
|
|
|
|
/**
|
|
* Method that defines the actions when mouse is clicked on canvas.
|
|
* The method inits the currentTransform parameters and renders all the
|
|
* canvas so the current image can be placed on the top canvas and the rest
|
|
* in on the container one.
|
|
* @private
|
|
* @param {Event} e Event object fired on mousedown
|
|
*/
|
|
__onMouseDown(e) {
|
|
this._isClick = true;
|
|
this._cacheTransformEventData(e);
|
|
this._handleEvent(e, 'down:before');
|
|
let target = this._target;
|
|
|
|
// if right/middle click just fire events
|
|
const {
|
|
button
|
|
} = e;
|
|
if (button) {
|
|
(this.fireMiddleClick && button === 1 || this.fireRightClick && button === 2) && this._handleEvent(e, 'down');
|
|
this._resetTransformEventData();
|
|
return;
|
|
}
|
|
if (this.isDrawingMode) {
|
|
this._onMouseDownInDrawingMode(e);
|
|
return;
|
|
}
|
|
if (!this._isMainEvent(e)) {
|
|
return;
|
|
}
|
|
|
|
// ignore if some object is being transformed at this moment
|
|
if (this._currentTransform) {
|
|
return;
|
|
}
|
|
let shouldRender = this._shouldRender(target);
|
|
let grouped = false;
|
|
if (this.handleMultiSelection(e, target)) {
|
|
// active object might have changed while grouping
|
|
target = this._activeObject;
|
|
grouped = true;
|
|
shouldRender = true;
|
|
} else if (this._shouldClearSelection(e, target)) {
|
|
this.discardActiveObject(e);
|
|
}
|
|
// we start a group selector rectangle if
|
|
// selection is enabled
|
|
// and there is no target, or the following 3 conditions are satisfied:
|
|
// target is not selectable ( otherwise we selected it )
|
|
// target is not editing
|
|
// target is not already selected ( otherwise we drag )
|
|
if (this.selection && (!target || !target.selectable && !target.isEditing && target !== this._activeObject)) {
|
|
const p = this.getScenePoint(e);
|
|
this._groupSelector = {
|
|
x: p.x,
|
|
y: p.y,
|
|
deltaY: 0,
|
|
deltaX: 0
|
|
};
|
|
}
|
|
if (target) {
|
|
const alreadySelected = target === this._activeObject;
|
|
if (target.selectable && target.activeOn === 'down') {
|
|
this.setActiveObject(target, e);
|
|
}
|
|
const handle = target.findControl(this.getViewportPoint(e), isTouchEvent(e));
|
|
if (target === this._activeObject && (handle || !grouped)) {
|
|
this._setupCurrentTransform(e, target, alreadySelected);
|
|
const control = handle ? handle.control : undefined,
|
|
pointer = this.getScenePoint(e),
|
|
mouseDownHandler = control && control.getMouseDownHandler(e, target, control);
|
|
mouseDownHandler && mouseDownHandler.call(control, e, this._currentTransform, pointer.x, pointer.y);
|
|
}
|
|
}
|
|
// we clear `_objectsToRender` in case of a change in order to repopulate it at rendering
|
|
// run before firing the `down` event to give the dev a chance to populate it themselves
|
|
shouldRender && (this._objectsToRender = undefined);
|
|
this._handleEvent(e, 'down');
|
|
// we must renderAll so that we update the visuals
|
|
shouldRender && this.requestRenderAll();
|
|
}
|
|
|
|
/**
|
|
* reset cache form common information needed during event processing
|
|
* @private
|
|
*/
|
|
_resetTransformEventData() {
|
|
this._target = undefined;
|
|
this._pointer = undefined;
|
|
this._absolutePointer = undefined;
|
|
}
|
|
|
|
/**
|
|
* Cache common information needed during event processing
|
|
* @private
|
|
* @param {Event} e Event object fired on event
|
|
*/
|
|
_cacheTransformEventData(e) {
|
|
// reset in order to avoid stale caching
|
|
this._resetTransformEventData();
|
|
this._pointer = this.getViewportPoint(e);
|
|
this._absolutePointer = sendPointToPlane(this._pointer, undefined, this.viewportTransform);
|
|
this._target = this._currentTransform ? this._currentTransform.target : this.findTarget(e);
|
|
}
|
|
|
|
/**
|
|
* Method that defines the actions when mouse is hovering the canvas.
|
|
* The currentTransform parameter will define whether the user is rotating/scaling/translating
|
|
* an image or neither of them (only hovering). A group selection is also possible and would cancel
|
|
* all any other type of action.
|
|
* In case of an image transformation only the top canvas will be rendered.
|
|
* @private
|
|
* @param {Event} e Event object fired on mousemove
|
|
*/
|
|
__onMouseMove(e) {
|
|
this._isClick = false;
|
|
this._cacheTransformEventData(e);
|
|
this._handleEvent(e, 'move:before');
|
|
if (this.isDrawingMode) {
|
|
this._onMouseMoveInDrawingMode(e);
|
|
return;
|
|
}
|
|
if (!this._isMainEvent(e)) {
|
|
return;
|
|
}
|
|
const groupSelector = this._groupSelector;
|
|
|
|
// We initially clicked in an empty area, so we draw a box for multiple selection
|
|
if (groupSelector) {
|
|
const pointer = this.getScenePoint(e);
|
|
groupSelector.deltaX = pointer.x - groupSelector.x;
|
|
groupSelector.deltaY = pointer.y - groupSelector.y;
|
|
this.renderTop();
|
|
} else if (!this._currentTransform) {
|
|
const target = this.findTarget(e);
|
|
this._setCursorFromEvent(e, target);
|
|
this._fireOverOutEvents(e, target);
|
|
} else {
|
|
this._transformObject(e);
|
|
}
|
|
this.textEditingManager.onMouseMove(e);
|
|
this._handleEvent(e, 'move');
|
|
this._resetTransformEventData();
|
|
}
|
|
|
|
/**
|
|
* Manage the mouseout, mouseover events for the fabric object on the canvas
|
|
* @param {Fabric.Object} target the target where the target from the mousemove event
|
|
* @param {Event} e Event object fired on mousemove
|
|
* @private
|
|
*/
|
|
_fireOverOutEvents(e, target) {
|
|
const _hoveredTarget = this._hoveredTarget,
|
|
_hoveredTargets = this._hoveredTargets,
|
|
targets = this.targets,
|
|
length = Math.max(_hoveredTargets.length, targets.length);
|
|
this.fireSyntheticInOutEvents('mouse', {
|
|
e,
|
|
target,
|
|
oldTarget: _hoveredTarget,
|
|
fireCanvas: true
|
|
});
|
|
for (let i = 0; i < length; i++) {
|
|
this.fireSyntheticInOutEvents('mouse', {
|
|
e,
|
|
target: targets[i],
|
|
oldTarget: _hoveredTargets[i]
|
|
});
|
|
}
|
|
this._hoveredTarget = target;
|
|
this._hoveredTargets = this.targets.concat();
|
|
}
|
|
|
|
/**
|
|
* Manage the dragEnter, dragLeave events for the fabric objects on the canvas
|
|
* @param {Fabric.Object} target the target where the target from the onDrag event
|
|
* @param {Object} data Event object fired on dragover
|
|
* @private
|
|
*/
|
|
_fireEnterLeaveEvents(target, data) {
|
|
const draggedoverTarget = this._draggedoverTarget,
|
|
_hoveredTargets = this._hoveredTargets,
|
|
targets = this.targets,
|
|
length = Math.max(_hoveredTargets.length, targets.length);
|
|
this.fireSyntheticInOutEvents('drag', _objectSpread2(_objectSpread2({}, data), {}, {
|
|
target,
|
|
oldTarget: draggedoverTarget,
|
|
fireCanvas: true
|
|
}));
|
|
for (let i = 0; i < length; i++) {
|
|
this.fireSyntheticInOutEvents('drag', _objectSpread2(_objectSpread2({}, data), {}, {
|
|
target: targets[i],
|
|
oldTarget: _hoveredTargets[i]
|
|
}));
|
|
}
|
|
this._draggedoverTarget = target;
|
|
}
|
|
|
|
/**
|
|
* Manage the synthetic in/out events for the fabric objects on the canvas
|
|
* @param {Fabric.Object} target the target where the target from the supported events
|
|
* @param {Object} data Event object fired
|
|
* @param {Object} config configuration for the function to work
|
|
* @param {String} config.targetName property on the canvas where the old target is stored
|
|
* @param {String} [config.canvasEvtOut] name of the event to fire at canvas level for out
|
|
* @param {String} config.evtOut name of the event to fire for out
|
|
* @param {String} [config.canvasEvtIn] name of the event to fire at canvas level for in
|
|
* @param {String} config.evtIn name of the event to fire for in
|
|
* @private
|
|
*/
|
|
fireSyntheticInOutEvents(type, _ref) {
|
|
let {
|
|
target,
|
|
oldTarget,
|
|
fireCanvas,
|
|
e
|
|
} = _ref,
|
|
data = _objectWithoutProperties(_ref, _excluded$9);
|
|
const {
|
|
targetIn,
|
|
targetOut,
|
|
canvasIn,
|
|
canvasOut
|
|
} = syntheticEventConfig[type];
|
|
const targetChanged = oldTarget !== target;
|
|
if (oldTarget && targetChanged) {
|
|
const outOpt = _objectSpread2(_objectSpread2({}, data), {}, {
|
|
e,
|
|
target: oldTarget,
|
|
nextTarget: target
|
|
}, getEventPoints(this, e));
|
|
fireCanvas && this.fire(canvasOut, outOpt);
|
|
oldTarget.fire(targetOut, outOpt);
|
|
}
|
|
if (target && targetChanged) {
|
|
const inOpt = _objectSpread2(_objectSpread2({}, data), {}, {
|
|
e,
|
|
target,
|
|
previousTarget: oldTarget
|
|
}, getEventPoints(this, e));
|
|
fireCanvas && this.fire(canvasIn, inOpt);
|
|
target.fire(targetIn, inOpt);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Method that defines actions when an Event Mouse Wheel
|
|
* @param {Event} e Event object fired on mouseup
|
|
*/
|
|
__onMouseWheel(e) {
|
|
this._cacheTransformEventData(e);
|
|
this._handleEvent(e, 'wheel');
|
|
this._resetTransformEventData();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Event} e Event fired on mousemove
|
|
*/
|
|
_transformObject(e) {
|
|
const scenePoint = this.getScenePoint(e),
|
|
transform = this._currentTransform,
|
|
target = transform.target,
|
|
// transform pointer to target's containing coordinate plane
|
|
// both pointer and object should agree on every point
|
|
localPointer = target.group ? sendPointToPlane(scenePoint, undefined, target.group.calcTransformMatrix()) : scenePoint;
|
|
transform.shiftKey = e.shiftKey;
|
|
transform.altKey = !!this.centeredKey && e[this.centeredKey];
|
|
this._performTransformAction(e, transform, localPointer);
|
|
transform.actionPerformed && this.requestRenderAll();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_performTransformAction(e, transform, pointer) {
|
|
const {
|
|
action,
|
|
actionHandler,
|
|
target
|
|
} = transform;
|
|
const actionPerformed = !!actionHandler && actionHandler(e, transform, pointer.x, pointer.y);
|
|
actionPerformed && target.setCoords();
|
|
|
|
// this object could be created from the function in the control handlers
|
|
if (action === 'drag' && actionPerformed) {
|
|
transform.target.isMoving = true;
|
|
this.setCursor(transform.target.moveCursor || this.moveCursor);
|
|
}
|
|
transform.actionPerformed = transform.actionPerformed || actionPerformed;
|
|
}
|
|
|
|
/**
|
|
* Sets the cursor depending on where the canvas is being hovered.
|
|
* Note: very buggy in Opera
|
|
* @param {Event} e Event object
|
|
* @param {Object} target Object that the mouse is hovering, if so.
|
|
*/
|
|
_setCursorFromEvent(e, target) {
|
|
if (!target) {
|
|
this.setCursor(this.defaultCursor);
|
|
return;
|
|
}
|
|
let hoverCursor = target.hoverCursor || this.hoverCursor;
|
|
const activeSelection = isActiveSelection(this._activeObject) ? this._activeObject : null,
|
|
// only show proper corner when group selection is not active
|
|
corner = (!activeSelection || target.group !== activeSelection) &&
|
|
// here we call findTargetCorner always with undefined for the touch parameter.
|
|
// we assume that if you are using a cursor you do not need to interact with
|
|
// the bigger touch area.
|
|
target.findControl(this.getViewportPoint(e));
|
|
if (!corner) {
|
|
if (target.subTargetCheck) {
|
|
// hoverCursor should come from top-most subTarget,
|
|
// so we walk the array backwards
|
|
this.targets.concat().reverse().map(_target => {
|
|
hoverCursor = _target.hoverCursor || hoverCursor;
|
|
});
|
|
}
|
|
this.setCursor(hoverCursor);
|
|
} else {
|
|
const control = corner.control;
|
|
this.setCursor(control.cursorStyleHandler(e, control, target));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ## Handles multiple selection
|
|
* - toggles `target` selection (selects/deselects `target` if it isn't/is selected respectively)
|
|
* - sets the active object in case it is not set or in case there is a single active object left under active selection.
|
|
* ---
|
|
* - If the active object is the active selection we add/remove `target` from it
|
|
* - If not, add the active object and `target` to the active selection and make it the active object.
|
|
* @private
|
|
* @param {TPointerEvent} e Event object
|
|
* @param {FabricObject} target target of event to select/deselect
|
|
* @returns true if grouping occurred
|
|
*/
|
|
handleMultiSelection(e, target) {
|
|
const activeObject = this._activeObject;
|
|
const isAS = isActiveSelection(activeObject);
|
|
if (
|
|
// check if an active object exists on canvas and if the user is pressing the `selectionKey` while canvas supports multi selection.
|
|
!!activeObject && this._isSelectionKeyPressed(e) && this.selection &&
|
|
// on top of that the user also has to hit a target that is selectable.
|
|
!!target && target.selectable && (
|
|
// group target and active object only if they are different objects
|
|
// else we try to find a subtarget of `ActiveSelection`
|
|
activeObject !== target || isAS) && (
|
|
// make sure `activeObject` and `target` aren't ancestors of each other in case `activeObject` is not `ActiveSelection`
|
|
// if it is then we want to remove `target` from it
|
|
isAS || !target.isDescendantOf(activeObject) && !activeObject.isDescendantOf(target)) &&
|
|
// target accepts selection
|
|
!target.onSelect({
|
|
e
|
|
}) &&
|
|
// make sure we are not on top of a control
|
|
!activeObject.getActiveControl()) {
|
|
if (isAS) {
|
|
const prevActiveObjects = activeObject.getObjects();
|
|
if (target === activeObject) {
|
|
const pointer = this.getViewportPoint(e);
|
|
target =
|
|
// first search active objects for a target to remove
|
|
this.searchPossibleTargets(prevActiveObjects, pointer) ||
|
|
// if not found, search under active selection for a target to add
|
|
// `prevActiveObjects` will be searched but we already know they will not be found
|
|
this.searchPossibleTargets(this._objects, pointer);
|
|
// if nothing is found bail out
|
|
if (!target || !target.selectable) {
|
|
return false;
|
|
}
|
|
}
|
|
if (target.group === activeObject) {
|
|
// `target` is part of active selection => remove it
|
|
activeObject.remove(target);
|
|
this._hoveredTarget = target;
|
|
this._hoveredTargets = [...this.targets];
|
|
// if after removing an object we are left with one only...
|
|
if (activeObject.size() === 1) {
|
|
// activate last remaining object
|
|
// deselecting the active selection will remove the remaining object from it
|
|
this._setActiveObject(activeObject.item(0), e);
|
|
}
|
|
} else {
|
|
// `target` isn't part of active selection => add it
|
|
activeObject.multiSelectAdd(target);
|
|
this._hoveredTarget = activeObject;
|
|
this._hoveredTargets = [...this.targets];
|
|
}
|
|
this._fireSelectionEvents(prevActiveObjects, e);
|
|
} else {
|
|
activeObject.exitEditing && activeObject.exitEditing();
|
|
// add the active object and the target to the active selection and set it as the active object
|
|
const klass = classRegistry.getClass('ActiveSelection');
|
|
const newActiveSelection = new klass([], {
|
|
/**
|
|
* it is crucial to pass the canvas ref before calling {@link ActiveSelection#multiSelectAdd}
|
|
* since it uses {@link FabricObject#isInFrontOf} which relies on the canvas ref
|
|
*/
|
|
canvas: this
|
|
});
|
|
newActiveSelection.multiSelectAdd(activeObject, target);
|
|
this._hoveredTarget = newActiveSelection;
|
|
// ISSUE 4115: should we consider subTargets here?
|
|
// this._hoveredTargets = [];
|
|
// this._hoveredTargets = this.targets.concat();
|
|
this._setActiveObject(newActiveSelection, e);
|
|
this._fireSelectionEvents([activeObject], e);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* ## Handles selection
|
|
* - selects objects that are contained in (and possibly intersecting) the selection bounding box
|
|
* - sets the active object
|
|
* ---
|
|
* runs on mouse up after a mouse move
|
|
*/
|
|
handleSelection(e) {
|
|
if (!this.selection || !this._groupSelector) {
|
|
return false;
|
|
}
|
|
const {
|
|
x,
|
|
y,
|
|
deltaX,
|
|
deltaY
|
|
} = this._groupSelector,
|
|
point1 = new Point(x, y),
|
|
point2 = point1.add(new Point(deltaX, deltaY)),
|
|
tl = point1.min(point2),
|
|
br = point1.max(point2),
|
|
size = br.subtract(tl);
|
|
const collectedObjects = this.collectObjects({
|
|
left: tl.x,
|
|
top: tl.y,
|
|
width: size.x,
|
|
height: size.y
|
|
}, {
|
|
includeIntersecting: !this.selectionFullyContained
|
|
});
|
|
const objects =
|
|
// though this method runs only after mouse move the pointer could do a mouse up on the same position as mouse down
|
|
// should it be handled as is?
|
|
point1.eq(point2) ? collectedObjects[0] ? [collectedObjects[0]] : [] : collectedObjects.length > 1 ? collectedObjects.filter(object => !object.onSelect({
|
|
e
|
|
})).reverse() :
|
|
// `setActiveObject` will call `onSelect(collectedObjects[0])` in this case
|
|
collectedObjects;
|
|
|
|
// set active object
|
|
if (objects.length === 1) {
|
|
// set as active object
|
|
this.setActiveObject(objects[0], e);
|
|
} else if (objects.length > 1) {
|
|
// add to active selection and make it the active object
|
|
const klass = classRegistry.getClass('ActiveSelection');
|
|
this.setActiveObject(new klass(objects, {
|
|
canvas: this
|
|
}), e);
|
|
}
|
|
|
|
// cleanup
|
|
this._groupSelector = null;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @override clear {@link textEditingManager}
|
|
*/
|
|
clear() {
|
|
this.textEditingManager.clear();
|
|
super.clear();
|
|
}
|
|
|
|
/**
|
|
* @override clear {@link textEditingManager}
|
|
*/
|
|
destroy() {
|
|
this.removeListeners();
|
|
this.textEditingManager.dispose();
|
|
super.destroy();
|
|
}
|
|
}
|
|
|
|
const linearDefaultCoords = {
|
|
x1: 0,
|
|
y1: 0,
|
|
x2: 0,
|
|
y2: 0
|
|
};
|
|
const radialDefaultCoords = _objectSpread2(_objectSpread2({}, linearDefaultCoords), {}, {
|
|
r1: 0,
|
|
r2: 0
|
|
});
|
|
|
|
/**
|
|
*
|
|
* @param value value to check if NaN
|
|
* @param [valueIfNaN]
|
|
* @returns `fallback` is `value is NaN
|
|
*/
|
|
const ifNaN = (value, valueIfNaN) => {
|
|
return isNaN(value) && typeof valueIfNaN === 'number' ? valueIfNaN : value;
|
|
};
|
|
|
|
const RE_PERCENT = /^(\d+\.\d+)%|(\d+)%$/;
|
|
function isPercent(value) {
|
|
return value && RE_PERCENT.test(value);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param value
|
|
* @param valueIfNaN
|
|
* @returns ∈ [0, 1]
|
|
*/
|
|
function parsePercent(value, valueIfNaN) {
|
|
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? parseFloat(value) / (isPercent(value) ? 100 : 1) : NaN;
|
|
return capValue(0, ifNaN(parsed, valueIfNaN), 1);
|
|
}
|
|
|
|
const RE_KEY_VALUE_PAIRS = /\s*;\s*/;
|
|
const RE_KEY_VALUE = /\s*:\s*/;
|
|
function parseColorStop(el, multiplier) {
|
|
let colorValue, opacity;
|
|
const style = el.getAttribute('style');
|
|
if (style) {
|
|
const keyValuePairs = style.split(RE_KEY_VALUE_PAIRS);
|
|
if (keyValuePairs[keyValuePairs.length - 1] === '') {
|
|
keyValuePairs.pop();
|
|
}
|
|
for (let i = keyValuePairs.length; i--;) {
|
|
const [key, value] = keyValuePairs[i].split(RE_KEY_VALUE).map(s => s.trim());
|
|
if (key === 'stop-color') {
|
|
colorValue = value;
|
|
} else if (key === 'stop-opacity') {
|
|
opacity = value;
|
|
}
|
|
}
|
|
}
|
|
const color = new Color(colorValue || el.getAttribute('stop-color') || 'rgb(0,0,0)');
|
|
return {
|
|
offset: parsePercent(el.getAttribute('offset'), 0),
|
|
color: color.toRgb(),
|
|
opacity: ifNaN(parseFloat(opacity || el.getAttribute('stop-opacity') || ''), 1) * color.getAlpha() * multiplier
|
|
};
|
|
}
|
|
function parseColorStops(el, opacityAttr) {
|
|
const colorStops = [],
|
|
colorStopEls = el.getElementsByTagName('stop'),
|
|
multiplier = parsePercent(opacityAttr, 1);
|
|
for (let i = colorStopEls.length; i--;) {
|
|
colorStops.push(parseColorStop(colorStopEls[i], multiplier));
|
|
}
|
|
return colorStops;
|
|
}
|
|
|
|
function parseType(el) {
|
|
return el.nodeName === 'linearGradient' || el.nodeName === 'LINEARGRADIENT' ? 'linear' : 'radial';
|
|
}
|
|
function parseGradientUnits(el) {
|
|
return el.getAttribute('gradientUnits') === 'userSpaceOnUse' ? 'pixels' : 'percentage';
|
|
}
|
|
|
|
function convertPercentUnitsToValues(valuesToConvert, _ref) {
|
|
let {
|
|
width,
|
|
height,
|
|
gradientUnits
|
|
} = _ref;
|
|
let finalValue;
|
|
return Object.keys(valuesToConvert).reduce((acc, prop) => {
|
|
const propValue = valuesToConvert[prop];
|
|
if (propValue === 'Infinity') {
|
|
finalValue = 1;
|
|
} else if (propValue === '-Infinity') {
|
|
finalValue = 0;
|
|
} else {
|
|
finalValue = typeof propValue === 'string' ? parseFloat(propValue) : propValue;
|
|
if (typeof propValue === 'string' && isPercent(propValue)) {
|
|
finalValue *= 0.01;
|
|
if (gradientUnits === 'pixels') {
|
|
// then we need to fix those percentages here in svg parsing
|
|
if (prop === 'x1' || prop === 'x2' || prop === 'r2') {
|
|
finalValue *= width;
|
|
}
|
|
if (prop === 'y1' || prop === 'y2') {
|
|
finalValue *= height;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
acc[prop] = finalValue;
|
|
return acc;
|
|
}, {});
|
|
}
|
|
function getValue(el, key) {
|
|
return el.getAttribute(key);
|
|
}
|
|
function parseLinearCoords(el) {
|
|
return {
|
|
x1: getValue(el, 'x1') || 0,
|
|
y1: getValue(el, 'y1') || 0,
|
|
x2: getValue(el, 'x2') || '100%',
|
|
y2: getValue(el, 'y2') || 0
|
|
};
|
|
}
|
|
function parseRadialCoords(el) {
|
|
return {
|
|
x1: getValue(el, 'fx') || getValue(el, 'cx') || '50%',
|
|
y1: getValue(el, 'fy') || getValue(el, 'cy') || '50%',
|
|
r1: 0,
|
|
x2: getValue(el, 'cx') || '50%',
|
|
y2: getValue(el, 'cy') || '50%',
|
|
r2: getValue(el, 'r') || '50%'
|
|
};
|
|
}
|
|
function parseCoords(el, size) {
|
|
return convertPercentUnitsToValues(parseType(el) === 'linear' ? parseLinearCoords(el) : parseRadialCoords(el), _objectSpread2(_objectSpread2({}, size), {}, {
|
|
gradientUnits: parseGradientUnits(el)
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Gradient class
|
|
* @class Gradient
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-2#gradients}
|
|
*/
|
|
class Gradient {
|
|
constructor(options) {
|
|
const {
|
|
type = 'linear',
|
|
gradientUnits = 'pixels',
|
|
coords = {},
|
|
colorStops = [],
|
|
offsetX = 0,
|
|
offsetY = 0,
|
|
gradientTransform,
|
|
id
|
|
} = options || {};
|
|
Object.assign(this, {
|
|
type,
|
|
gradientUnits,
|
|
coords: _objectSpread2(_objectSpread2({}, type === 'radial' ? radialDefaultCoords : linearDefaultCoords), coords),
|
|
colorStops,
|
|
offsetX,
|
|
offsetY,
|
|
gradientTransform,
|
|
id: id ? "".concat(id, "_").concat(uid()) : uid()
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adds another colorStop
|
|
* @param {Record<string, string>} colorStop Object with offset and color
|
|
* @return {Gradient} thisArg
|
|
*/
|
|
addColorStop(colorStops) {
|
|
for (const position in colorStops) {
|
|
const color = new Color(colorStops[position]);
|
|
this.colorStops.push({
|
|
offset: parseFloat(position),
|
|
color: color.toRgb(),
|
|
opacity: color.getAlpha()
|
|
});
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of a gradient
|
|
* @param {string[]} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {object}
|
|
*/
|
|
toObject(propertiesToInclude) {
|
|
return _objectSpread2(_objectSpread2({}, pick(this, propertiesToInclude)), {}, {
|
|
type: this.type,
|
|
coords: _objectSpread2({}, this.coords),
|
|
colorStops: this.colorStops.map(colorStop => _objectSpread2({}, colorStop)),
|
|
offsetX: this.offsetX,
|
|
offsetY: this.offsetY,
|
|
gradientUnits: this.gradientUnits,
|
|
gradientTransform: this.gradientTransform ? [...this.gradientTransform] : undefined
|
|
});
|
|
}
|
|
|
|
/* _TO_SVG_START_ */
|
|
/**
|
|
* Returns SVG representation of an gradient
|
|
* @param {FabricObject} object Object to create a gradient for
|
|
* @return {String} SVG representation of an gradient (linear/radial)
|
|
*/
|
|
toSVG(object) {
|
|
let {
|
|
additionalTransform: preTransform
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const markup = [],
|
|
transform = this.gradientTransform ? this.gradientTransform.concat() : iMatrix.concat(),
|
|
gradientUnits = this.gradientUnits === 'pixels' ? 'userSpaceOnUse' : 'objectBoundingBox';
|
|
// colorStops must be sorted ascending, and guarded against deep mutations
|
|
const colorStops = this.colorStops.map(colorStop => _objectSpread2({}, colorStop)).sort((a, b) => {
|
|
return a.offset - b.offset;
|
|
});
|
|
let offsetX = -this.offsetX,
|
|
offsetY = -this.offsetY;
|
|
if (gradientUnits === 'objectBoundingBox') {
|
|
offsetX /= object.width;
|
|
offsetY /= object.height;
|
|
} else {
|
|
offsetX += object.width / 2;
|
|
offsetY += object.height / 2;
|
|
}
|
|
// todo what about polygon/polyline?
|
|
if (isPath(object) && this.gradientUnits !== 'percentage') {
|
|
offsetX -= object.pathOffset.x;
|
|
offsetY -= object.pathOffset.y;
|
|
}
|
|
transform[4] -= offsetX;
|
|
transform[5] -= offsetY;
|
|
const commonAttributes = ["id=\"SVGID_".concat(this.id, "\""), "gradientUnits=\"".concat(gradientUnits, "\""), "gradientTransform=\"".concat(preTransform ? preTransform + ' ' : '').concat(matrixToSVG(transform), "\""), ''].join(' ');
|
|
if (this.type === 'linear') {
|
|
const {
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2
|
|
} = this.coords;
|
|
markup.push('<linearGradient ', commonAttributes, ' x1="', x1, '" y1="', y1, '" x2="', x2, '" y2="', y2, '">\n');
|
|
} else if (this.type === 'radial') {
|
|
const {
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
r1,
|
|
r2
|
|
} = this.coords;
|
|
const needsSwap = r1 > r2;
|
|
// svg radial gradient has just 1 radius. the biggest.
|
|
markup.push('<radialGradient ', commonAttributes, ' cx="', needsSwap ? x1 : x2, '" cy="', needsSwap ? y1 : y2, '" r="', needsSwap ? r1 : r2, '" fx="', needsSwap ? x2 : x1, '" fy="', needsSwap ? y2 : y1, '">\n');
|
|
if (needsSwap) {
|
|
// svg goes from internal to external radius. if radius are inverted, swap color stops.
|
|
colorStops.reverse(); // mutates array
|
|
colorStops.forEach(colorStop => {
|
|
colorStop.offset = 1 - colorStop.offset;
|
|
});
|
|
}
|
|
const minRadius = Math.min(r1, r2);
|
|
if (minRadius > 0) {
|
|
// i have to shift all colorStops and add new one in 0.
|
|
const maxRadius = Math.max(r1, r2),
|
|
percentageShift = minRadius / maxRadius;
|
|
colorStops.forEach(colorStop => {
|
|
colorStop.offset += percentageShift * (1 - colorStop.offset);
|
|
});
|
|
}
|
|
}
|
|
colorStops.forEach(_ref => {
|
|
let {
|
|
color,
|
|
offset,
|
|
opacity
|
|
} = _ref;
|
|
markup.push('<stop ', 'offset="', offset * 100 + '%', '" style="stop-color:', color, typeof opacity !== 'undefined' ? ';stop-opacity: ' + opacity : ';', '"/>\n');
|
|
});
|
|
markup.push(this.type === 'linear' ? '</linearGradient>' : '</radialGradient>', '\n');
|
|
return markup.join('');
|
|
}
|
|
/* _TO_SVG_END_ */
|
|
|
|
/**
|
|
* Returns an instance of CanvasGradient
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @return {CanvasGradient}
|
|
*/
|
|
toLive(ctx) {
|
|
const {
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
r1,
|
|
r2
|
|
} = this.coords;
|
|
const gradient = this.type === 'linear' ? ctx.createLinearGradient(x1, y1, x2, y2) : ctx.createRadialGradient(x1, y1, r1, x2, y2, r2);
|
|
this.colorStops.forEach(_ref2 => {
|
|
let {
|
|
color,
|
|
opacity,
|
|
offset
|
|
} = _ref2;
|
|
gradient.addColorStop(offset, typeof opacity !== 'undefined' ? new Color(color).setAlpha(opacity).toRgba() : color);
|
|
});
|
|
return gradient;
|
|
}
|
|
static async fromObject(options) {
|
|
const {
|
|
colorStops,
|
|
gradientTransform
|
|
} = options;
|
|
return new this(_objectSpread2(_objectSpread2({}, options), {}, {
|
|
colorStops: colorStops ? colorStops.map(colorStop => _objectSpread2({}, colorStop)) : undefined,
|
|
gradientTransform: gradientTransform ? [...gradientTransform] : undefined
|
|
}));
|
|
}
|
|
|
|
/* _FROM_SVG_START_ */
|
|
/**
|
|
* Returns {@link Gradient} instance from an SVG element
|
|
* @static
|
|
* @memberOf Gradient
|
|
* @param {SVGGradientElement} el SVG gradient element
|
|
* @param {FabricObject} instance
|
|
* @param {String} opacity A fill-opacity or stroke-opacity attribute to multiply to each stop's opacity.
|
|
* @param {SVGOptions} svgOptions an object containing the size of the SVG in order to parse correctly gradients
|
|
* that uses gradientUnits as 'userSpaceOnUse' and percentages.
|
|
* @return {Gradient} Gradient instance
|
|
* @see http://www.w3.org/TR/SVG/pservers.html#LinearGradientElement
|
|
* @see http://www.w3.org/TR/SVG/pservers.html#RadialGradientElement
|
|
*
|
|
* @example
|
|
*
|
|
* <linearGradient id="linearGrad1">
|
|
* <stop offset="0%" stop-color="white"/>
|
|
* <stop offset="100%" stop-color="black"/>
|
|
* </linearGradient>
|
|
*
|
|
* OR
|
|
*
|
|
* <linearGradient id="linearGrad2">
|
|
* <stop offset="0" style="stop-color:rgb(255,255,255)"/>
|
|
* <stop offset="1" style="stop-color:rgb(0,0,0)"/>
|
|
* </linearGradient>
|
|
*
|
|
* OR
|
|
*
|
|
* <radialGradient id="radialGrad1">
|
|
* <stop offset="0%" stop-color="white" stop-opacity="1" />
|
|
* <stop offset="50%" stop-color="black" stop-opacity="0.5" />
|
|
* <stop offset="100%" stop-color="white" stop-opacity="1" />
|
|
* </radialGradient>
|
|
*
|
|
* OR
|
|
*
|
|
* <radialGradient id="radialGrad2">
|
|
* <stop offset="0" stop-color="rgb(255,255,255)" />
|
|
* <stop offset="0.5" stop-color="rgb(0,0,0)" />
|
|
* <stop offset="1" stop-color="rgb(255,255,255)" />
|
|
* </radialGradient>
|
|
*
|
|
*/
|
|
static fromElement(el, instance, svgOptions) {
|
|
const gradientUnits = parseGradientUnits(el);
|
|
const center = instance._findCenterFromElement();
|
|
return new this(_objectSpread2({
|
|
id: el.getAttribute('id') || undefined,
|
|
type: parseType(el),
|
|
coords: parseCoords(el, {
|
|
width: svgOptions.viewBoxWidth || svgOptions.width,
|
|
height: svgOptions.viewBoxHeight || svgOptions.height
|
|
}),
|
|
colorStops: parseColorStops(el, svgOptions.opacity),
|
|
gradientUnits,
|
|
gradientTransform: parseTransformAttribute(el.getAttribute('gradientTransform') || '')
|
|
}, gradientUnits === 'pixels' ? {
|
|
offsetX: instance.width / 2 - center.x,
|
|
offsetY: instance.height / 2 - center.y
|
|
} : {
|
|
offsetX: 0,
|
|
offsetY: 0
|
|
}));
|
|
}
|
|
/* _FROM_SVG_END_ */
|
|
}
|
|
/**
|
|
* Horizontal offset for aligning gradients coming from SVG when outside pathgroups
|
|
* @type Number
|
|
* @default 0
|
|
*/
|
|
/**
|
|
* Vertical offset for aligning gradients coming from SVG when outside pathgroups
|
|
* @type Number
|
|
* @default 0
|
|
*/
|
|
/**
|
|
* A transform matrix to apply to the gradient before painting.
|
|
* Imported from svg gradients, is not applied with the current transform in the center.
|
|
* Before this transform is applied, the origin point is at the top left corner of the object
|
|
* plus the addition of offsetY and offsetX.
|
|
* @type Number[]
|
|
* @default null
|
|
*/
|
|
/**
|
|
* coordinates units for coords.
|
|
* If `pixels`, the number of coords are in the same unit of width / height.
|
|
* If set as `percentage` the coords are still a number, but 1 means 100% of width
|
|
* for the X and 100% of the height for the y. It can be bigger than 1 and negative.
|
|
* allowed values pixels or percentage.
|
|
* @type GradientUnits
|
|
* @default 'pixels'
|
|
*/
|
|
/**
|
|
* Gradient type linear or radial
|
|
* @type GradientType
|
|
* @default 'linear'
|
|
*/
|
|
/**
|
|
* Defines how the gradient is located in space and spread
|
|
* @type GradientCoords
|
|
*/
|
|
/**
|
|
* Defines how many colors a gradient has and how they are located on the axis
|
|
* defined by coords
|
|
* @type GradientCoords
|
|
*/
|
|
/**
|
|
* If true, this object will not be exported during the serialization of a canvas
|
|
* @type boolean
|
|
*/
|
|
/**
|
|
* ID used for SVG export functionalities
|
|
* @type number | string
|
|
*/
|
|
_defineProperty(Gradient, "type", 'Gradient');
|
|
classRegistry.setClass(Gradient, 'gradient');
|
|
classRegistry.setClass(Gradient, 'linear');
|
|
classRegistry.setClass(Gradient, 'radial');
|
|
|
|
const _excluded$8 = ["type", "source", "patternTransform"];
|
|
|
|
/**
|
|
* @see {@link http://fabricjs.com/patterns demo}
|
|
* @see {@link http://fabricjs.com/dynamic-patterns demo}
|
|
*/
|
|
class Pattern {
|
|
/**
|
|
* Legacy identifier of the class. Prefer using this.constructor.type 'Pattern'
|
|
* or utils like isPattern, or instance of to indentify a pattern in your code.
|
|
* Will be removed in future versiones
|
|
* @TODO add sustainable warning message
|
|
* @type string
|
|
* @deprecated
|
|
*/
|
|
get type() {
|
|
return 'pattern';
|
|
}
|
|
set type(value) {
|
|
log('warn', 'Setting type has no effect', value);
|
|
}
|
|
|
|
/**
|
|
* @type PatternRepeat
|
|
* @defaults
|
|
*/
|
|
|
|
/**
|
|
* transform matrix to change the pattern, imported from svgs.
|
|
* @todo verify if using the identity matrix as default makes the rest of the code more easy
|
|
* @type Array
|
|
* @default
|
|
*/
|
|
|
|
/**
|
|
* The actual pixel source of the pattern
|
|
*/
|
|
|
|
/**
|
|
* If true, this object will not be exported during the serialization of a canvas
|
|
* @type boolean
|
|
*/
|
|
|
|
/**
|
|
* ID used for SVG export functionalities
|
|
* @type number
|
|
*/
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {Object} [options] Options object
|
|
* @param {option.source} [source] the pattern source, eventually empty or a drawable
|
|
*/
|
|
constructor(options) {
|
|
_defineProperty(this, "repeat", 'repeat');
|
|
/**
|
|
* Pattern horizontal offset from object's left/top corner
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "offsetX", 0);
|
|
/**
|
|
* Pattern vertical offset from object's left/top corner
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "offsetY", 0);
|
|
/**
|
|
* @type TCrossOrigin
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "crossOrigin", '');
|
|
this.id = uid();
|
|
Object.assign(this, options);
|
|
}
|
|
|
|
/**
|
|
* @returns true if {@link source} is an <img> element
|
|
*/
|
|
isImageSource() {
|
|
return !!this.source && typeof this.source.src === 'string';
|
|
}
|
|
|
|
/**
|
|
* @returns true if {@link source} is a <canvas> element
|
|
*/
|
|
isCanvasSource() {
|
|
return !!this.source && !!this.source.toDataURL;
|
|
}
|
|
sourceToString() {
|
|
return this.isImageSource() ? this.source.src : this.isCanvasSource() ? this.source.toDataURL() : '';
|
|
}
|
|
|
|
/**
|
|
* Returns an instance of CanvasPattern
|
|
* @param {CanvasRenderingContext2D} ctx Context to create pattern
|
|
* @return {CanvasPattern}
|
|
*/
|
|
toLive(ctx) {
|
|
if (
|
|
// if the image failed to load, return, and allow rest to continue loading
|
|
!this.source ||
|
|
// if an image
|
|
this.isImageSource() && (!this.source.complete || this.source.naturalWidth === 0 || this.source.naturalHeight === 0)) {
|
|
return null;
|
|
}
|
|
return ctx.createPattern(this.source, this.repeat);
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of a pattern
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {object} Object representation of a pattern instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
const {
|
|
repeat,
|
|
crossOrigin
|
|
} = this;
|
|
return _objectSpread2(_objectSpread2({}, pick(this, propertiesToInclude)), {}, {
|
|
type: 'pattern',
|
|
source: this.sourceToString(),
|
|
repeat,
|
|
crossOrigin,
|
|
offsetX: toFixed(this.offsetX, config.NUM_FRACTION_DIGITS),
|
|
offsetY: toFixed(this.offsetY, config.NUM_FRACTION_DIGITS),
|
|
patternTransform: this.patternTransform ? [...this.patternTransform] : null
|
|
});
|
|
}
|
|
|
|
/* _TO_SVG_START_ */
|
|
/**
|
|
* Returns SVG representation of a pattern
|
|
*/
|
|
toSVG(_ref) {
|
|
let {
|
|
width,
|
|
height
|
|
} = _ref;
|
|
const {
|
|
source: patternSource,
|
|
repeat,
|
|
id
|
|
} = this,
|
|
patternOffsetX = ifNaN(this.offsetX / width, 0),
|
|
patternOffsetY = ifNaN(this.offsetY / height, 0),
|
|
patternWidth = repeat === 'repeat-y' || repeat === 'no-repeat' ? 1 + Math.abs(patternOffsetX || 0) : ifNaN(patternSource.width / width, 0),
|
|
patternHeight = repeat === 'repeat-x' || repeat === 'no-repeat' ? 1 + Math.abs(patternOffsetY || 0) : ifNaN(patternSource.height / height, 0);
|
|
return ["<pattern id=\"SVGID_".concat(id, "\" x=\"").concat(patternOffsetX, "\" y=\"").concat(patternOffsetY, "\" width=\"").concat(patternWidth, "\" height=\"").concat(patternHeight, "\">"), "<image x=\"0\" y=\"0\" width=\"".concat(patternSource.width, "\" height=\"").concat(patternSource.height, "\" xlink:href=\"").concat(this.sourceToString(), "\"></image>"), "</pattern>", ''].join('\n');
|
|
}
|
|
/* _TO_SVG_END_ */
|
|
|
|
static async fromObject(_ref2, options) {
|
|
let {
|
|
type,
|
|
source,
|
|
patternTransform
|
|
} = _ref2,
|
|
otherOptions = _objectWithoutProperties(_ref2, _excluded$8);
|
|
const img = await loadImage(source, _objectSpread2(_objectSpread2({}, options), {}, {
|
|
crossOrigin: otherOptions.crossOrigin
|
|
}));
|
|
return new this(_objectSpread2(_objectSpread2({}, otherOptions), {}, {
|
|
patternTransform: patternTransform && patternTransform.slice(0),
|
|
source: img
|
|
}));
|
|
}
|
|
}
|
|
_defineProperty(Pattern, "type", 'Pattern');
|
|
classRegistry.setClass(Pattern);
|
|
// kept for compatibility reason
|
|
classRegistry.setClass(Pattern, 'pattern');
|
|
|
|
/**
|
|
* @see {@link http://fabricjs.com/freedrawing|Freedrawing demo}
|
|
*/
|
|
class BaseBrush {
|
|
/**
|
|
* @todo add type
|
|
*/
|
|
|
|
constructor(canvas) {
|
|
/**
|
|
* Color of a brush
|
|
* @type String
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "color", 'rgb(0, 0, 0)');
|
|
/**
|
|
* Width of a brush, has to be a Number, no string literals
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "width", 1);
|
|
/**
|
|
* Shadow object representing shadow of this shape.
|
|
* <b>Backwards incompatibility note:</b> This property replaces "shadowColor" (String), "shadowOffsetX" (Number),
|
|
* "shadowOffsetY" (Number) and "shadowBlur" (Number) since v1.2.12
|
|
* @type Shadow
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "shadow", null);
|
|
/**
|
|
* Line endings style of a brush (one of "butt", "round", "square")
|
|
* @type String
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "strokeLineCap", 'round');
|
|
/**
|
|
* Corner style of a brush (one of "bevel", "round", "miter")
|
|
* @type String
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "strokeLineJoin", 'round');
|
|
/**
|
|
* Maximum miter length (used for strokeLineJoin = "miter") of a brush's
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "strokeMiterLimit", 10);
|
|
/**
|
|
* Stroke Dash Array.
|
|
* @type Array
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "strokeDashArray", null);
|
|
/**
|
|
* When `true`, the free drawing is limited to the whiteboard size. Default to false.
|
|
* @type Boolean
|
|
* @default false
|
|
*/
|
|
_defineProperty(this, "limitedToCanvasSize", false);
|
|
this.canvas = canvas;
|
|
}
|
|
|
|
/**
|
|
* @returns true if brush should continue blocking interaction
|
|
*/
|
|
|
|
/**
|
|
* Sets brush styles
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
*/
|
|
_setBrushStyles(ctx) {
|
|
ctx.strokeStyle = this.color;
|
|
ctx.lineWidth = this.width;
|
|
ctx.lineCap = this.strokeLineCap;
|
|
ctx.miterLimit = this.strokeMiterLimit;
|
|
ctx.lineJoin = this.strokeLineJoin;
|
|
ctx.setLineDash(this.strokeDashArray || []);
|
|
}
|
|
|
|
/**
|
|
* Sets the transformation on given context
|
|
* @param {CanvasRenderingContext2D} ctx context to render on
|
|
* @private
|
|
*/
|
|
_saveAndTransform(ctx) {
|
|
const v = this.canvas.viewportTransform;
|
|
ctx.save();
|
|
ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]);
|
|
}
|
|
needsFullRender() {
|
|
const color = new Color(this.color);
|
|
return color.getAlpha() < 1 || !!this.shadow;
|
|
}
|
|
|
|
/**
|
|
* Sets brush shadow styles
|
|
* @private
|
|
*/
|
|
_setShadow() {
|
|
if (!this.shadow || !this.canvas) {
|
|
return;
|
|
}
|
|
const canvas = this.canvas,
|
|
shadow = this.shadow,
|
|
ctx = canvas.contextTop,
|
|
zoom = canvas.getZoom() * canvas.getRetinaScaling();
|
|
ctx.shadowColor = shadow.color;
|
|
ctx.shadowBlur = shadow.blur * zoom;
|
|
ctx.shadowOffsetX = shadow.offsetX * zoom;
|
|
ctx.shadowOffsetY = shadow.offsetY * zoom;
|
|
}
|
|
|
|
/**
|
|
* Removes brush shadow styles
|
|
* @private
|
|
*/
|
|
_resetShadow() {
|
|
const ctx = this.canvas.contextTop;
|
|
ctx.shadowColor = '';
|
|
ctx.shadowBlur = ctx.shadowOffsetX = ctx.shadowOffsetY = 0;
|
|
}
|
|
|
|
/**
|
|
* Check is pointer is outside canvas boundaries
|
|
* @param {Object} pointer
|
|
* @private
|
|
*/
|
|
_isOutSideCanvas(pointer) {
|
|
return pointer.x < 0 || pointer.x > this.canvas.getWidth() || pointer.y < 0 || pointer.y > this.canvas.getHeight();
|
|
}
|
|
}
|
|
|
|
const _excluded$7 = ["path", "left", "top"],
|
|
_excluded2$2 = ["d"];
|
|
class Path extends FabricObject {
|
|
/**
|
|
* Constructor
|
|
* @param {TComplexPathData} path Path data (sequence of coordinates and corresponding "command" tokens)
|
|
* @param {Partial<PathProps>} [options] Options object
|
|
* @return {Path} thisArg
|
|
*/
|
|
constructor(path) {
|
|
let _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {},
|
|
{
|
|
path: _,
|
|
left,
|
|
top
|
|
} = _ref,
|
|
options = _objectWithoutProperties(_ref, _excluded$7);
|
|
super();
|
|
Object.assign(this, Path.ownDefaults);
|
|
this.setOptions(options);
|
|
this._setPath(path || [], true);
|
|
typeof left === 'number' && this.set(LEFT, left);
|
|
typeof top === 'number' && this.set(TOP, top);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {TComplexPathData | string} path Path data (sequence of coordinates and corresponding "command" tokens)
|
|
* @param {boolean} [adjustPosition] pass true to reposition the object according to the bounding box
|
|
* @returns {Point} top left position of the bounding box, useful for complementary positioning
|
|
*/
|
|
_setPath(path, adjustPosition) {
|
|
this.path = makePathSimpler(Array.isArray(path) ? path : parsePath(path));
|
|
this.setBoundingBox(adjustPosition);
|
|
}
|
|
|
|
/**
|
|
* This function is an helper for svg import. it returns the center of the object in the svg
|
|
* untransformed coordinates, by look at the polyline/polygon points.
|
|
* @private
|
|
* @return {Point} center point from element coordinates
|
|
*/
|
|
_findCenterFromElement() {
|
|
const bbox = this._calcBoundsFromPath();
|
|
return new Point(bbox.left + bbox.width / 2, bbox.top + bbox.height / 2);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx context to render path on
|
|
*/
|
|
_renderPathCommands(ctx) {
|
|
const l = -this.pathOffset.x,
|
|
t = -this.pathOffset.y;
|
|
ctx.beginPath();
|
|
for (const command of this.path) {
|
|
switch (command[0] // first letter
|
|
) {
|
|
case 'L':
|
|
// lineto, absolute
|
|
ctx.lineTo(command[1] + l, command[2] + t);
|
|
break;
|
|
case 'M':
|
|
// moveTo, absolute
|
|
ctx.moveTo(command[1] + l, command[2] + t);
|
|
break;
|
|
case 'C':
|
|
// bezierCurveTo, absolute
|
|
ctx.bezierCurveTo(command[1] + l, command[2] + t, command[3] + l, command[4] + t, command[5] + l, command[6] + t);
|
|
break;
|
|
case 'Q':
|
|
// quadraticCurveTo, absolute
|
|
ctx.quadraticCurveTo(command[1] + l, command[2] + t, command[3] + l, command[4] + t);
|
|
break;
|
|
case 'Z':
|
|
ctx.closePath();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx context to render path on
|
|
*/
|
|
_render(ctx) {
|
|
this._renderPathCommands(ctx);
|
|
this._renderPaintInOrder(ctx);
|
|
}
|
|
|
|
/**
|
|
* Returns string representation of an instance
|
|
* @return {string} string representation of an instance
|
|
*/
|
|
toString() {
|
|
return "#<Path (".concat(this.complexity(), "): { \"top\": ").concat(this.top, ", \"left\": ").concat(this.left, " }>");
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
return _objectSpread2(_objectSpread2({}, super.toObject(propertiesToInclude)), {}, {
|
|
path: this.path.map(pathCmd => pathCmd.slice())
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns dataless object representation of an instance
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toDatalessObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
const o = this.toObject(propertiesToInclude);
|
|
if (this.sourcePath) {
|
|
delete o.path;
|
|
o.sourcePath = this.sourcePath;
|
|
}
|
|
return o;
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @return {Array} an array of strings with the specific svg representation
|
|
* of the instance
|
|
*/
|
|
_toSVG() {
|
|
const path = joinPath(this.path, config.NUM_FRACTION_DIGITS);
|
|
return ['<path ', 'COMMON_PARTS', "d=\"".concat(path, "\" stroke-linecap=\"round\" />\n")];
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @return the path command's translate transform attribute
|
|
*/
|
|
_getOffsetTransform() {
|
|
const digits = config.NUM_FRACTION_DIGITS;
|
|
return " translate(".concat(toFixed(-this.pathOffset.x, digits), ", ").concat(toFixed(-this.pathOffset.y, digits), ")");
|
|
}
|
|
|
|
/**
|
|
* Returns svg clipPath representation of an instance
|
|
* @param {Function} [reviver] Method for further parsing of svg representation.
|
|
* @return {string} svg representation of an instance
|
|
*/
|
|
toClipPathSVG(reviver) {
|
|
const additionalTransform = this._getOffsetTransform();
|
|
return '\t' + this._createBaseClipPathSVGMarkup(this._toSVG(), {
|
|
reviver,
|
|
additionalTransform: additionalTransform
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @param {Function} [reviver] Method for further parsing of svg representation.
|
|
* @return {string} svg representation of an instance
|
|
*/
|
|
toSVG(reviver) {
|
|
const additionalTransform = this._getOffsetTransform();
|
|
return this._createBaseSVGMarkup(this._toSVG(), {
|
|
reviver,
|
|
additionalTransform: additionalTransform
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns number representation of an instance complexity
|
|
* @return {number} complexity of this instance
|
|
*/
|
|
complexity() {
|
|
return this.path.length;
|
|
}
|
|
setDimensions() {
|
|
this.setBoundingBox();
|
|
}
|
|
setBoundingBox(adjustPosition) {
|
|
const {
|
|
width,
|
|
height,
|
|
pathOffset
|
|
} = this._calcDimensions();
|
|
this.set({
|
|
width,
|
|
height,
|
|
pathOffset
|
|
});
|
|
// using pathOffset because it match the use case.
|
|
// if pathOffset change here we need to use left + width/2 , top + height/2
|
|
adjustPosition && this.setPositionByOrigin(pathOffset, CENTER, CENTER);
|
|
}
|
|
_calcBoundsFromPath() {
|
|
const bounds = [];
|
|
let subpathStartX = 0,
|
|
subpathStartY = 0,
|
|
x = 0,
|
|
// current x
|
|
y = 0; // current y
|
|
|
|
for (const command of this.path) {
|
|
// current instruction
|
|
switch (command[0] // first letter
|
|
) {
|
|
case 'L':
|
|
// lineto, absolute
|
|
x = command[1];
|
|
y = command[2];
|
|
bounds.push({
|
|
x: subpathStartX,
|
|
y: subpathStartY
|
|
}, {
|
|
x,
|
|
y
|
|
});
|
|
break;
|
|
case 'M':
|
|
// moveTo, absolute
|
|
x = command[1];
|
|
y = command[2];
|
|
subpathStartX = x;
|
|
subpathStartY = y;
|
|
break;
|
|
case 'C':
|
|
// bezierCurveTo, absolute
|
|
bounds.push(...getBoundsOfCurve(x, y, command[1], command[2], command[3], command[4], command[5], command[6]));
|
|
x = command[5];
|
|
y = command[6];
|
|
break;
|
|
case 'Q':
|
|
// quadraticCurveTo, absolute
|
|
bounds.push(...getBoundsOfCurve(x, y, command[1], command[2], command[1], command[2], command[3], command[4]));
|
|
x = command[3];
|
|
y = command[4];
|
|
break;
|
|
case 'Z':
|
|
x = subpathStartX;
|
|
y = subpathStartY;
|
|
break;
|
|
}
|
|
}
|
|
return makeBoundingBoxFromPoints(bounds);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_calcDimensions() {
|
|
const bbox = this._calcBoundsFromPath();
|
|
return _objectSpread2(_objectSpread2({}, bbox), {}, {
|
|
pathOffset: new Point(bbox.left + bbox.width / 2, bbox.top + bbox.height / 2)
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List of attribute names to account for when parsing SVG element (used by `Path.fromElement`)
|
|
* @static
|
|
* @memberOf Path
|
|
* @see http://www.w3.org/TR/SVG/paths.html#PathElement
|
|
*/
|
|
|
|
/**
|
|
* Creates an instance of Path from an object
|
|
* @static
|
|
* @memberOf Path
|
|
* @param {Object} object
|
|
* @returns {Promise<Path>}
|
|
*/
|
|
static fromObject(object) {
|
|
return this._fromObject(object, {
|
|
extraParam: 'path'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates an instance of Path from an SVG <path> element
|
|
* @static
|
|
* @memberOf Path
|
|
* @param {HTMLElement} element to parse
|
|
* @param {Partial<PathProps>} [options] Options object
|
|
*/
|
|
static async fromElement(element, options, cssRules) {
|
|
const _parseAttributes = parseAttributes(element, this.ATTRIBUTE_NAMES, cssRules),
|
|
{
|
|
d
|
|
} = _parseAttributes,
|
|
parsedAttributes = _objectWithoutProperties(_parseAttributes, _excluded2$2);
|
|
return new this(d, _objectSpread2(_objectSpread2(_objectSpread2({}, parsedAttributes), options), {}, {
|
|
// we pass undefined to instruct the constructor to position the object using the bbox
|
|
left: undefined,
|
|
top: undefined
|
|
}));
|
|
}
|
|
}
|
|
/**
|
|
* Array of path points
|
|
* @type Array
|
|
* @default
|
|
*/
|
|
_defineProperty(Path, "type", 'Path');
|
|
_defineProperty(Path, "cacheProperties", [...cacheProperties, 'path', 'fillRule']);
|
|
_defineProperty(Path, "ATTRIBUTE_NAMES", [...SHARED_ATTRIBUTES, 'd']);
|
|
classRegistry.setClass(Path);
|
|
classRegistry.setSVGClass(Path);
|
|
|
|
/* _FROM_SVG_START_ */
|
|
|
|
/**
|
|
* @private
|
|
* @param {TSimplePathData} pathData SVG path commands
|
|
* @returns {boolean}
|
|
*/
|
|
function isEmptySVGPath(pathData) {
|
|
return joinPath(pathData) === 'M 0 0 Q 0 0 0 0 L 0 0';
|
|
}
|
|
class PencilBrush extends BaseBrush {
|
|
constructor(canvas) {
|
|
super(canvas);
|
|
/**
|
|
* Discard points that are less than `decimate` pixel distant from each other
|
|
* @type Number
|
|
* @default 0.4
|
|
*/
|
|
_defineProperty(this, "decimate", 0.4);
|
|
/**
|
|
* Draws a straight line between last recorded point to current pointer
|
|
* Used for `shift` functionality
|
|
*
|
|
* @type boolean
|
|
* @default false
|
|
*/
|
|
_defineProperty(this, "drawStraightLine", false);
|
|
/**
|
|
* The event modifier key that makes the brush draw a straight line.
|
|
* If `null` or 'none' or any other string that is not a modifier key the feature is disabled.
|
|
* @type {ModifierKey | undefined | null}
|
|
*/
|
|
_defineProperty(this, "straightLineKey", 'shiftKey');
|
|
this._points = [];
|
|
this._hasStraightLine = false;
|
|
}
|
|
needsFullRender() {
|
|
return super.needsFullRender() || this._hasStraightLine;
|
|
}
|
|
static drawSegment(ctx, p1, p2) {
|
|
const midPoint = p1.midPointFrom(p2);
|
|
ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
|
|
return midPoint;
|
|
}
|
|
|
|
/**
|
|
* Invoked on mouse down
|
|
* @param {Point} pointer
|
|
*/
|
|
onMouseDown(pointer, _ref) {
|
|
let {
|
|
e
|
|
} = _ref;
|
|
if (!this.canvas._isMainEvent(e)) {
|
|
return;
|
|
}
|
|
this.drawStraightLine = !!this.straightLineKey && e[this.straightLineKey];
|
|
this._prepareForDrawing(pointer);
|
|
// capture coordinates immediately
|
|
// this allows to draw dots (when movement never occurs)
|
|
this._addPoint(pointer);
|
|
this._render();
|
|
}
|
|
|
|
/**
|
|
* Invoked on mouse move
|
|
* @param {Point} pointer
|
|
*/
|
|
onMouseMove(pointer, _ref2) {
|
|
let {
|
|
e
|
|
} = _ref2;
|
|
if (!this.canvas._isMainEvent(e)) {
|
|
return;
|
|
}
|
|
this.drawStraightLine = !!this.straightLineKey && e[this.straightLineKey];
|
|
if (this.limitedToCanvasSize === true && this._isOutSideCanvas(pointer)) {
|
|
return;
|
|
}
|
|
if (this._addPoint(pointer) && this._points.length > 1) {
|
|
if (this.needsFullRender()) {
|
|
// redraw curve
|
|
// clear top canvas
|
|
this.canvas.clearContext(this.canvas.contextTop);
|
|
this._render();
|
|
} else {
|
|
const points = this._points,
|
|
length = points.length,
|
|
ctx = this.canvas.contextTop;
|
|
// draw the curve update
|
|
this._saveAndTransform(ctx);
|
|
if (this.oldEnd) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(this.oldEnd.x, this.oldEnd.y);
|
|
}
|
|
this.oldEnd = PencilBrush.drawSegment(ctx, points[length - 2], points[length - 1]);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invoked on mouse up
|
|
*/
|
|
onMouseUp(_ref3) {
|
|
let {
|
|
e
|
|
} = _ref3;
|
|
if (!this.canvas._isMainEvent(e)) {
|
|
return true;
|
|
}
|
|
this.drawStraightLine = false;
|
|
this.oldEnd = undefined;
|
|
this._finalizeAndAddPath();
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Point} pointer Actual mouse position related to the canvas.
|
|
*/
|
|
_prepareForDrawing(pointer) {
|
|
this._reset();
|
|
this._addPoint(pointer);
|
|
this.canvas.contextTop.moveTo(pointer.x, pointer.y);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Point} point Point to be added to points array
|
|
*/
|
|
_addPoint(point) {
|
|
if (this._points.length > 1 && point.eq(this._points[this._points.length - 1])) {
|
|
return false;
|
|
}
|
|
if (this.drawStraightLine && this._points.length > 1) {
|
|
this._hasStraightLine = true;
|
|
this._points.pop();
|
|
}
|
|
this._points.push(point);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Clear points array and set contextTop canvas style.
|
|
* @private
|
|
*/
|
|
_reset() {
|
|
this._points = [];
|
|
this._setBrushStyles(this.canvas.contextTop);
|
|
this._setShadow();
|
|
this._hasStraightLine = false;
|
|
}
|
|
|
|
/**
|
|
* Draw a smooth path on the topCanvas using quadraticCurveTo
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} [ctx]
|
|
*/
|
|
_render() {
|
|
let ctx = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.canvas.contextTop;
|
|
let p1 = this._points[0],
|
|
p2 = this._points[1];
|
|
this._saveAndTransform(ctx);
|
|
ctx.beginPath();
|
|
//if we only have 2 points in the path and they are the same
|
|
//it means that the user only clicked the canvas without moving the mouse
|
|
//then we should be drawing a dot. A path isn't drawn between two identical dots
|
|
//that's why we set them apart a bit
|
|
if (this._points.length === 2 && p1.x === p2.x && p1.y === p2.y) {
|
|
const width = this.width / 1000;
|
|
p1.x -= width;
|
|
p2.x += width;
|
|
}
|
|
ctx.moveTo(p1.x, p1.y);
|
|
for (let i = 1; i < this._points.length; i++) {
|
|
// we pick the point between pi + 1 & pi + 2 as the
|
|
// end point and p1 as our control point.
|
|
PencilBrush.drawSegment(ctx, p1, p2);
|
|
p1 = this._points[i];
|
|
p2 = this._points[i + 1];
|
|
}
|
|
// Draw last line as a straight line while
|
|
// we wait for the next point to be able to calculate
|
|
// the bezier control point
|
|
ctx.lineTo(p1.x, p1.y);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Converts points to SVG path
|
|
* @param {Point[]} points Array of points
|
|
* @return {TSimplePathData} SVG path commands
|
|
*/
|
|
convertPointsToSVGPath(points) {
|
|
const correction = this.width / 1000;
|
|
return getSmoothPathFromPoints(points, correction);
|
|
}
|
|
|
|
/**
|
|
* Creates a Path object to add on canvas
|
|
* @param {TSimplePathData} pathData Path data
|
|
* @return {Path} Path to add on canvas
|
|
*/
|
|
createPath(pathData) {
|
|
const path = new Path(pathData, {
|
|
fill: null,
|
|
stroke: this.color,
|
|
strokeWidth: this.width,
|
|
strokeLineCap: this.strokeLineCap,
|
|
strokeMiterLimit: this.strokeMiterLimit,
|
|
strokeLineJoin: this.strokeLineJoin,
|
|
strokeDashArray: this.strokeDashArray
|
|
});
|
|
if (this.shadow) {
|
|
this.shadow.affectStroke = true;
|
|
path.shadow = new Shadow(this.shadow);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Decimate points array with the decimate value
|
|
*/
|
|
decimatePoints(points, distance) {
|
|
if (points.length <= 2) {
|
|
return points;
|
|
}
|
|
let lastPoint = points[0],
|
|
cDistance;
|
|
const zoom = this.canvas.getZoom(),
|
|
adjustedDistance = Math.pow(distance / zoom, 2),
|
|
l = points.length - 1,
|
|
newPoints = [lastPoint];
|
|
for (let i = 1; i < l - 1; i++) {
|
|
cDistance = Math.pow(lastPoint.x - points[i].x, 2) + Math.pow(lastPoint.y - points[i].y, 2);
|
|
if (cDistance >= adjustedDistance) {
|
|
lastPoint = points[i];
|
|
newPoints.push(lastPoint);
|
|
}
|
|
}
|
|
// Add the last point from the original line to the end of the array.
|
|
// This ensures decimate doesn't delete the last point on the line, and ensures the line is > 1 point.
|
|
newPoints.push(points[l]);
|
|
return newPoints;
|
|
}
|
|
|
|
/**
|
|
* On mouseup after drawing the path on contextTop canvas
|
|
* we use the points captured to create an new Path object
|
|
* and add it to the canvas.
|
|
*/
|
|
_finalizeAndAddPath() {
|
|
const ctx = this.canvas.contextTop;
|
|
ctx.closePath();
|
|
if (this.decimate) {
|
|
this._points = this.decimatePoints(this._points, this.decimate);
|
|
}
|
|
const pathData = this.convertPointsToSVGPath(this._points);
|
|
if (isEmptySVGPath(pathData)) {
|
|
// do not create 0 width/height paths, as they are
|
|
// rendered inconsistently across browsers
|
|
// Firefox 4, for example, renders a dot,
|
|
// whereas Chrome 10 renders nothing
|
|
this.canvas.requestRenderAll();
|
|
return;
|
|
}
|
|
const path = this.createPath(pathData);
|
|
this.canvas.clearContext(this.canvas.contextTop);
|
|
this.canvas.fire('before:path:created', {
|
|
path: path
|
|
});
|
|
this.canvas.add(path);
|
|
this.canvas.requestRenderAll();
|
|
path.setCoords();
|
|
this._resetShadow();
|
|
|
|
// fire event 'path' created
|
|
this.canvas.fire('path:created', {
|
|
path: path
|
|
});
|
|
}
|
|
}
|
|
|
|
const _excluded$6 = ["left", "top", "radius"];
|
|
const CIRCLE_PROPS = ['radius', 'startAngle', 'endAngle', 'counterClockwise'];
|
|
const circleDefaultValues = {
|
|
radius: 0,
|
|
startAngle: 0,
|
|
endAngle: 360,
|
|
counterClockwise: false
|
|
};
|
|
class Circle extends FabricObject {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), Circle.ownDefaults);
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor(options) {
|
|
super();
|
|
Object.assign(this, Circle.ownDefaults);
|
|
this.setOptions(options);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} key
|
|
* @param {*} value
|
|
*/
|
|
_set(key, value) {
|
|
super._set(key, value);
|
|
if (key === 'radius') {
|
|
this.setRadius(value);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx context to render on
|
|
*/
|
|
_render(ctx) {
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, this.radius, degreesToRadians(this.startAngle), degreesToRadians(this.endAngle), this.counterClockwise);
|
|
this._renderPaintInOrder(ctx);
|
|
}
|
|
|
|
/**
|
|
* Returns horizontal radius of an object (according to how an object is scaled)
|
|
* @return {Number}
|
|
*/
|
|
getRadiusX() {
|
|
return this.get('radius') * this.get(SCALE_X);
|
|
}
|
|
|
|
/**
|
|
* Returns vertical radius of an object (according to how an object is scaled)
|
|
* @return {Number}
|
|
*/
|
|
getRadiusY() {
|
|
return this.get('radius') * this.get(SCALE_Y);
|
|
}
|
|
|
|
/**
|
|
* Sets radius of an object (and updates width accordingly)
|
|
*/
|
|
setRadius(value) {
|
|
this.radius = value;
|
|
this.set({
|
|
width: value * 2,
|
|
height: value * 2
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
return super.toObject([...CIRCLE_PROPS, ...propertiesToInclude]);
|
|
}
|
|
|
|
/* _TO_SVG_START_ */
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @return {Array} an array of strings with the specific svg representation
|
|
* of the instance
|
|
*/
|
|
_toSVG() {
|
|
const angle = (this.endAngle - this.startAngle) % 360;
|
|
if (angle === 0) {
|
|
return ['<circle ', 'COMMON_PARTS', 'cx="0" cy="0" ', 'r="', "".concat(this.radius), '" />\n'];
|
|
} else {
|
|
const {
|
|
radius
|
|
} = this;
|
|
const start = degreesToRadians(this.startAngle),
|
|
end = degreesToRadians(this.endAngle),
|
|
startX = cos(start) * radius,
|
|
startY = sin(start) * radius,
|
|
endX = cos(end) * radius,
|
|
endY = sin(end) * radius,
|
|
largeFlag = angle > 180 ? 1 : 0,
|
|
sweepFlag = this.counterClockwise ? 0 : 1;
|
|
return ["<path d=\"M ".concat(startX, " ").concat(startY, " A ").concat(radius, " ").concat(radius, " 0 ").concat(largeFlag, " ").concat(sweepFlag, " ").concat(endX, " ").concat(endY, "\" "), 'COMMON_PARTS', ' />\n'];
|
|
}
|
|
}
|
|
/* _TO_SVG_END_ */
|
|
|
|
/* _FROM_SVG_START_ */
|
|
/**
|
|
* List of attribute names to account for when parsing SVG element (used by {@link Circle.fromElement})
|
|
* @static
|
|
* @memberOf Circle
|
|
* @see: http://www.w3.org/TR/SVG/shapes.html#CircleElement
|
|
*/
|
|
|
|
/**
|
|
* Returns {@link Circle} instance from an SVG element
|
|
* @static
|
|
* @memberOf Circle
|
|
* @param {HTMLElement} element Element to parse
|
|
* @param {Object} [options] Partial Circle object to default missing properties on the element.
|
|
* @throws {Error} If value of `r` attribute is missing or invalid
|
|
*/
|
|
static async fromElement(element, options, cssRules) {
|
|
const _ref = parseAttributes(element, this.ATTRIBUTE_NAMES, cssRules),
|
|
{
|
|
left = 0,
|
|
top = 0,
|
|
radius = 0
|
|
} = _ref,
|
|
otherParsedAttributes = _objectWithoutProperties(_ref, _excluded$6);
|
|
|
|
// this probably requires to be fixed for default origins not being top/left.
|
|
|
|
return new this(_objectSpread2(_objectSpread2({}, otherParsedAttributes), {}, {
|
|
radius,
|
|
left: left - radius,
|
|
top: top - radius
|
|
}));
|
|
}
|
|
|
|
/* _FROM_SVG_END_ */
|
|
|
|
/**
|
|
* @todo how do we declare this??
|
|
*/
|
|
static fromObject(object) {
|
|
return super._fromObject(object);
|
|
}
|
|
}
|
|
_defineProperty(Circle, "type", 'Circle');
|
|
_defineProperty(Circle, "cacheProperties", [...cacheProperties, ...CIRCLE_PROPS]);
|
|
_defineProperty(Circle, "ownDefaults", circleDefaultValues);
|
|
_defineProperty(Circle, "ATTRIBUTE_NAMES", ['cx', 'cy', 'r', ...SHARED_ATTRIBUTES]);
|
|
classRegistry.setClass(Circle);
|
|
classRegistry.setSVGClass(Circle);
|
|
|
|
class CircleBrush extends BaseBrush {
|
|
constructor(canvas) {
|
|
super(canvas);
|
|
/**
|
|
* Width of a brush
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "width", 10);
|
|
this.points = [];
|
|
}
|
|
|
|
/**
|
|
* Invoked inside on mouse down and mouse move
|
|
* @param {Point} pointer
|
|
*/
|
|
drawDot(pointer) {
|
|
const point = this.addPoint(pointer),
|
|
ctx = this.canvas.contextTop;
|
|
this._saveAndTransform(ctx);
|
|
this.dot(ctx, point);
|
|
ctx.restore();
|
|
}
|
|
dot(ctx, point) {
|
|
ctx.fillStyle = point.fill;
|
|
ctx.beginPath();
|
|
ctx.arc(point.x, point.y, point.radius, 0, Math.PI * 2, false);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
|
|
/**
|
|
* Invoked on mouse down
|
|
*/
|
|
onMouseDown(pointer) {
|
|
this.points = [];
|
|
this.canvas.clearContext(this.canvas.contextTop);
|
|
this._setShadow();
|
|
this.drawDot(pointer);
|
|
}
|
|
|
|
/**
|
|
* Render the full state of the brush
|
|
* @private
|
|
*/
|
|
_render() {
|
|
const ctx = this.canvas.contextTop,
|
|
points = this.points;
|
|
this._saveAndTransform(ctx);
|
|
for (let i = 0; i < points.length; i++) {
|
|
this.dot(ctx, points[i]);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Invoked on mouse move
|
|
* @param {Point} pointer
|
|
*/
|
|
onMouseMove(pointer) {
|
|
if (this.limitedToCanvasSize === true && this._isOutSideCanvas(pointer)) {
|
|
return;
|
|
}
|
|
if (this.needsFullRender()) {
|
|
this.canvas.clearContext(this.canvas.contextTop);
|
|
this.addPoint(pointer);
|
|
this._render();
|
|
} else {
|
|
this.drawDot(pointer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invoked on mouse up
|
|
*/
|
|
onMouseUp() {
|
|
const originalRenderOnAddRemove = this.canvas.renderOnAddRemove;
|
|
this.canvas.renderOnAddRemove = false;
|
|
const circles = [];
|
|
for (let i = 0; i < this.points.length; i++) {
|
|
const point = this.points[i],
|
|
circle = new Circle({
|
|
radius: point.radius,
|
|
left: point.x,
|
|
top: point.y,
|
|
originX: CENTER,
|
|
originY: CENTER,
|
|
fill: point.fill
|
|
});
|
|
this.shadow && (circle.shadow = new Shadow(this.shadow));
|
|
circles.push(circle);
|
|
}
|
|
const group = new Group(circles, {
|
|
canvas: this.canvas
|
|
});
|
|
this.canvas.fire('before:path:created', {
|
|
path: group
|
|
});
|
|
this.canvas.add(group);
|
|
this.canvas.fire('path:created', {
|
|
path: group
|
|
});
|
|
this.canvas.clearContext(this.canvas.contextTop);
|
|
this._resetShadow();
|
|
this.canvas.renderOnAddRemove = originalRenderOnAddRemove;
|
|
this.canvas.requestRenderAll();
|
|
}
|
|
|
|
/**
|
|
* @param {Object} pointer
|
|
* @return {Point} Just added pointer point
|
|
*/
|
|
addPoint(_ref) {
|
|
let {
|
|
x,
|
|
y
|
|
} = _ref;
|
|
const pointerPoint = {
|
|
x,
|
|
y,
|
|
radius: getRandomInt(Math.max(0, this.width - 20), this.width + 20) / 2,
|
|
fill: new Color(this.color).setAlpha(getRandomInt(0, 100) / 100).toRgba()
|
|
};
|
|
this.points.push(pointerPoint);
|
|
return pointerPoint;
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param rects
|
|
* @returns
|
|
*/
|
|
function getUniqueRects(rects) {
|
|
const uniqueRects = {};
|
|
const uniqueRectsArray = [];
|
|
for (let i = 0, key; i < rects.length; i++) {
|
|
key = "".concat(rects[i].left).concat(rects[i].top);
|
|
if (!uniqueRects[key]) {
|
|
uniqueRects[key] = true;
|
|
uniqueRectsArray.push(rects[i]);
|
|
}
|
|
}
|
|
return uniqueRectsArray;
|
|
}
|
|
class SprayBrush extends BaseBrush {
|
|
/**
|
|
* Constructor
|
|
* @param {Canvas} canvas
|
|
* @return {SprayBrush} Instance of a spray brush
|
|
*/
|
|
constructor(canvas) {
|
|
super(canvas);
|
|
/**
|
|
* Width of a spray
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "width", 10);
|
|
/**
|
|
* Density of a spray (number of dots per chunk)
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "density", 20);
|
|
/**
|
|
* Width of spray dots
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "dotWidth", 1);
|
|
/**
|
|
* Width variance of spray dots
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "dotWidthVariance", 1);
|
|
/**
|
|
* Whether opacity of a dot should be random
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "randomOpacity", false);
|
|
/**
|
|
* Whether overlapping dots (rectangles) should be removed (for performance reasons)
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
_defineProperty(this, "optimizeOverlapping", true);
|
|
this.sprayChunks = [];
|
|
this.sprayChunk = [];
|
|
}
|
|
|
|
/**
|
|
* Invoked on mouse down
|
|
* @param {Point} pointer
|
|
*/
|
|
onMouseDown(pointer) {
|
|
this.sprayChunks = [];
|
|
this.canvas.clearContext(this.canvas.contextTop);
|
|
this._setShadow();
|
|
this.addSprayChunk(pointer);
|
|
this.renderChunck(this.sprayChunk);
|
|
}
|
|
|
|
/**
|
|
* Invoked on mouse move
|
|
* @param {Point} pointer
|
|
*/
|
|
onMouseMove(pointer) {
|
|
if (this.limitedToCanvasSize === true && this._isOutSideCanvas(pointer)) {
|
|
return;
|
|
}
|
|
this.addSprayChunk(pointer);
|
|
this.renderChunck(this.sprayChunk);
|
|
}
|
|
|
|
/**
|
|
* Invoked on mouse up
|
|
*/
|
|
onMouseUp() {
|
|
const originalRenderOnAddRemove = this.canvas.renderOnAddRemove;
|
|
this.canvas.renderOnAddRemove = false;
|
|
const rects = [];
|
|
for (let i = 0; i < this.sprayChunks.length; i++) {
|
|
const sprayChunk = this.sprayChunks[i];
|
|
for (let j = 0; j < sprayChunk.length; j++) {
|
|
const chunck = sprayChunk[j];
|
|
const rect = new Rect({
|
|
width: chunck.width,
|
|
height: chunck.width,
|
|
left: chunck.x + 1,
|
|
top: chunck.y + 1,
|
|
originX: CENTER,
|
|
originY: CENTER,
|
|
fill: this.color
|
|
});
|
|
rects.push(rect);
|
|
}
|
|
}
|
|
const group = new Group(this.optimizeOverlapping ? getUniqueRects(rects) : rects, {
|
|
objectCaching: true,
|
|
subTargetCheck: false,
|
|
interactive: false
|
|
});
|
|
this.shadow && group.set('shadow', new Shadow(this.shadow));
|
|
this.canvas.fire('before:path:created', {
|
|
path: group
|
|
});
|
|
this.canvas.add(group);
|
|
this.canvas.fire('path:created', {
|
|
path: group
|
|
});
|
|
this.canvas.clearContext(this.canvas.contextTop);
|
|
this._resetShadow();
|
|
this.canvas.renderOnAddRemove = originalRenderOnAddRemove;
|
|
this.canvas.requestRenderAll();
|
|
}
|
|
renderChunck(sprayChunck) {
|
|
const ctx = this.canvas.contextTop;
|
|
ctx.fillStyle = this.color;
|
|
this._saveAndTransform(ctx);
|
|
for (let i = 0; i < sprayChunck.length; i++) {
|
|
const point = sprayChunck[i];
|
|
ctx.globalAlpha = point.opacity;
|
|
ctx.fillRect(point.x, point.y, point.width, point.width);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Render all spray chunks
|
|
*/
|
|
_render() {
|
|
const ctx = this.canvas.contextTop;
|
|
ctx.fillStyle = this.color;
|
|
this._saveAndTransform(ctx);
|
|
for (let i = 0; i < this.sprayChunks.length; i++) {
|
|
this.renderChunck(this.sprayChunks[i]);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* @param {Point} pointer
|
|
*/
|
|
addSprayChunk(pointer) {
|
|
this.sprayChunk = [];
|
|
const radius = this.width / 2;
|
|
for (let i = 0; i < this.density; i++) {
|
|
this.sprayChunk.push({
|
|
x: getRandomInt(pointer.x - radius, pointer.x + radius),
|
|
y: getRandomInt(pointer.y - radius, pointer.y + radius),
|
|
width: this.dotWidthVariance ? getRandomInt(
|
|
// bottom clamp width to 1
|
|
Math.max(1, this.dotWidth - this.dotWidthVariance), this.dotWidth + this.dotWidthVariance) : this.dotWidth,
|
|
opacity: this.randomOpacity ? getRandomInt(0, 100) / 100 : 1
|
|
});
|
|
}
|
|
this.sprayChunks.push(this.sprayChunk);
|
|
}
|
|
}
|
|
|
|
class PatternBrush extends PencilBrush {
|
|
constructor(canvas) {
|
|
super(canvas);
|
|
}
|
|
getPatternSrc() {
|
|
const dotWidth = 20,
|
|
dotDistance = 5,
|
|
patternCanvas = createCanvasElement(),
|
|
patternCtx = patternCanvas.getContext('2d');
|
|
patternCanvas.width = patternCanvas.height = dotWidth + dotDistance;
|
|
if (patternCtx) {
|
|
patternCtx.fillStyle = this.color;
|
|
patternCtx.beginPath();
|
|
patternCtx.arc(dotWidth / 2, dotWidth / 2, dotWidth / 2, 0, Math.PI * 2, false);
|
|
patternCtx.closePath();
|
|
patternCtx.fill();
|
|
}
|
|
return patternCanvas;
|
|
}
|
|
|
|
/**
|
|
* Creates "pattern" instance property
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
*/
|
|
getPattern(ctx) {
|
|
return ctx.createPattern(this.source || this.getPatternSrc(), 'repeat');
|
|
}
|
|
|
|
/**
|
|
* Sets brush styles
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
*/
|
|
_setBrushStyles(ctx) {
|
|
super._setBrushStyles(ctx);
|
|
const pattern = this.getPattern(ctx);
|
|
pattern && (ctx.strokeStyle = pattern);
|
|
}
|
|
|
|
/**
|
|
* Creates path
|
|
*/
|
|
createPath(pathData) {
|
|
const path = super.createPath(pathData),
|
|
topLeft = path._getLeftTopCoords().scalarAdd(path.strokeWidth / 2);
|
|
path.stroke = new Pattern({
|
|
source: this.source || this.getPatternSrc(),
|
|
offsetX: -topLeft.x,
|
|
offsetY: -topLeft.y
|
|
});
|
|
return path;
|
|
}
|
|
}
|
|
|
|
const _excluded$5 = ["x1", "y1", "x2", "y2"],
|
|
_excluded2$1 = ["x1", "y1", "x2", "y2"];
|
|
// @TODO this code is terrible and Line should be a special case of polyline.
|
|
|
|
const coordProps = ['x1', 'x2', 'y1', 'y2'];
|
|
class Line extends FabricObject {
|
|
/**
|
|
* Constructor
|
|
* @param {Array} [points] Array of points
|
|
* @param {Object} [options] Options object
|
|
* @return {Line} thisArg
|
|
*/
|
|
constructor() {
|
|
let [x1, y1, x2, y2] = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [0, 0, 0, 0];
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
super();
|
|
Object.assign(this, Line.ownDefaults);
|
|
this.setOptions(options);
|
|
this.x1 = x1;
|
|
this.x2 = x2;
|
|
this.y1 = y1;
|
|
this.y2 = y2;
|
|
this._setWidthHeight();
|
|
const {
|
|
left,
|
|
top
|
|
} = options;
|
|
typeof left === 'number' && this.set(LEFT, left);
|
|
typeof top === 'number' && this.set(TOP, top);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Object} [options] Options
|
|
*/
|
|
_setWidthHeight() {
|
|
const {
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2
|
|
} = this;
|
|
this.width = Math.abs(x2 - x1);
|
|
this.height = Math.abs(y2 - y1);
|
|
const {
|
|
left,
|
|
top,
|
|
width,
|
|
height
|
|
} = makeBoundingBoxFromPoints([{
|
|
x: x1,
|
|
y: y1
|
|
}, {
|
|
x: x2,
|
|
y: y2
|
|
}]);
|
|
const position = new Point(left + width / 2, top + height / 2);
|
|
this.setPositionByOrigin(position, CENTER, CENTER);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} key
|
|
* @param {*} value
|
|
*/
|
|
_set(key, value) {
|
|
super._set(key, value);
|
|
if (coordProps.includes(key)) {
|
|
// this doesn't make sense very much, since setting x1 when top or left
|
|
// are already set, is just going to show a strange result since the
|
|
// line will move way more than the developer expect.
|
|
// in fabric5 it worked only when the line didn't have extra transformations,
|
|
// in fabric6 too. With extra transform they behave bad in different ways.
|
|
// This needs probably a good rework or a tutorial if you have to create a dynamic line
|
|
this._setWidthHeight();
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_render(ctx) {
|
|
ctx.beginPath();
|
|
const p = this.calcLinePoints();
|
|
ctx.moveTo(p.x1, p.y1);
|
|
ctx.lineTo(p.x2, p.y2);
|
|
ctx.lineWidth = this.strokeWidth;
|
|
|
|
// TODO: test this
|
|
// make sure setting "fill" changes color of a line
|
|
// (by copying fillStyle to strokeStyle, since line is stroked, not filled)
|
|
const origStrokeStyle = ctx.strokeStyle;
|
|
if (isFiller(this.stroke)) {
|
|
ctx.strokeStyle = this.stroke.toLive(ctx);
|
|
} else {
|
|
var _this$stroke;
|
|
ctx.strokeStyle = (_this$stroke = this.stroke) !== null && _this$stroke !== void 0 ? _this$stroke : ctx.fillStyle;
|
|
}
|
|
this.stroke && this._renderStroke(ctx);
|
|
ctx.strokeStyle = origStrokeStyle;
|
|
}
|
|
|
|
/**
|
|
* This function is an helper for svg import. it returns the center of the object in the svg
|
|
* untransformed coordinates
|
|
* @private
|
|
* @return {Point} center point from element coordinates
|
|
*/
|
|
_findCenterFromElement() {
|
|
return new Point((this.x1 + this.x2) / 2, (this.y1 + this.y2) / 2);
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @method toObject
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
return _objectSpread2(_objectSpread2({}, super.toObject(propertiesToInclude)), this.calcLinePoints());
|
|
}
|
|
|
|
/*
|
|
* Calculate object dimensions from its properties
|
|
* @private
|
|
*/
|
|
_getNonTransformedDimensions() {
|
|
const dim = super._getNonTransformedDimensions();
|
|
if (this.strokeLineCap === 'butt') {
|
|
if (this.width === 0) {
|
|
dim.y -= this.strokeWidth;
|
|
}
|
|
if (this.height === 0) {
|
|
dim.x -= this.strokeWidth;
|
|
}
|
|
}
|
|
return dim;
|
|
}
|
|
|
|
/**
|
|
* Recalculates line points given width and height
|
|
* Those points are simply placed around the center,
|
|
* This is not useful outside internal render functions and svg output
|
|
* Is not meant to be for the developer.
|
|
* @private
|
|
*/
|
|
calcLinePoints() {
|
|
const {
|
|
x1: _x1,
|
|
x2: _x2,
|
|
y1: _y1,
|
|
y2: _y2,
|
|
width,
|
|
height
|
|
} = this;
|
|
const xMult = _x1 <= _x2 ? -1 : 1,
|
|
yMult = _y1 <= _y2 ? -1 : 1,
|
|
x1 = xMult * width / 2,
|
|
y1 = yMult * height / 2,
|
|
x2 = xMult * -width / 2,
|
|
y2 = yMult * -height / 2;
|
|
return {
|
|
x1,
|
|
x2,
|
|
y1,
|
|
y2
|
|
};
|
|
}
|
|
|
|
/* _FROM_SVG_START_ */
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @return {Array} an array of strings with the specific svg representation
|
|
* of the instance
|
|
*/
|
|
_toSVG() {
|
|
const {
|
|
x1,
|
|
x2,
|
|
y1,
|
|
y2
|
|
} = this.calcLinePoints();
|
|
return ['<line ', 'COMMON_PARTS', "x1=\"".concat(x1, "\" y1=\"").concat(y1, "\" x2=\"").concat(x2, "\" y2=\"").concat(y2, "\" />\n")];
|
|
}
|
|
|
|
/**
|
|
* List of attribute names to account for when parsing SVG element (used by {@link Line.fromElement})
|
|
* @static
|
|
* @memberOf Line
|
|
* @see http://www.w3.org/TR/SVG/shapes.html#LineElement
|
|
*/
|
|
|
|
/**
|
|
* Returns Line instance from an SVG element
|
|
* @static
|
|
* @memberOf Line
|
|
* @param {HTMLElement} element Element to parse
|
|
* @param {Object} [options] Options object
|
|
* @param {Function} [callback] callback function invoked after parsing
|
|
*/
|
|
static async fromElement(element, options, cssRules) {
|
|
const _parseAttributes = parseAttributes(element, this.ATTRIBUTE_NAMES, cssRules),
|
|
{
|
|
x1 = 0,
|
|
y1 = 0,
|
|
x2 = 0,
|
|
y2 = 0
|
|
} = _parseAttributes,
|
|
parsedAttributes = _objectWithoutProperties(_parseAttributes, _excluded$5);
|
|
return new this([x1, y1, x2, y2], parsedAttributes);
|
|
}
|
|
|
|
/* _FROM_SVG_END_ */
|
|
|
|
/**
|
|
* Returns Line instance from an object representation
|
|
* @static
|
|
* @memberOf Line
|
|
* @param {Object} object Object to create an instance from
|
|
* @returns {Promise<Line>}
|
|
*/
|
|
static fromObject(_ref) {
|
|
let {
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2
|
|
} = _ref,
|
|
object = _objectWithoutProperties(_ref, _excluded2$1);
|
|
return this._fromObject(_objectSpread2(_objectSpread2({}, object), {}, {
|
|
points: [x1, y1, x2, y2]
|
|
}), {
|
|
extraParam: 'points'
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* x value or first line edge
|
|
* @type number
|
|
* @default
|
|
*/
|
|
/**
|
|
* y value or first line edge
|
|
* @type number
|
|
* @default
|
|
*/
|
|
/**
|
|
* x value or second line edge
|
|
* @type number
|
|
* @default
|
|
*/
|
|
/**
|
|
* y value or second line edge
|
|
* @type number
|
|
* @default
|
|
*/
|
|
_defineProperty(Line, "type", 'Line');
|
|
_defineProperty(Line, "cacheProperties", [...cacheProperties, ...coordProps]);
|
|
_defineProperty(Line, "ATTRIBUTE_NAMES", SHARED_ATTRIBUTES.concat(coordProps));
|
|
classRegistry.setClass(Line);
|
|
classRegistry.setSVGClass(Line);
|
|
|
|
const triangleDefaultValues = {
|
|
width: 100,
|
|
height: 100
|
|
};
|
|
class Triangle extends FabricObject {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), Triangle.ownDefaults);
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor(options) {
|
|
super();
|
|
Object.assign(this, Triangle.ownDefaults);
|
|
this.setOptions(options);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_render(ctx) {
|
|
const widthBy2 = this.width / 2,
|
|
heightBy2 = this.height / 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-widthBy2, heightBy2);
|
|
ctx.lineTo(0, -heightBy2);
|
|
ctx.lineTo(widthBy2, heightBy2);
|
|
ctx.closePath();
|
|
this._renderPaintInOrder(ctx);
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @return {Array} an array of strings with the specific svg representation
|
|
* of the instance
|
|
*/
|
|
_toSVG() {
|
|
const widthBy2 = this.width / 2,
|
|
heightBy2 = this.height / 2,
|
|
points = "".concat(-widthBy2, " ").concat(heightBy2, ",0 ").concat(-heightBy2, ",").concat(widthBy2, " ").concat(heightBy2);
|
|
return ['<polygon ', 'COMMON_PARTS', 'points="', points, '" />'];
|
|
}
|
|
}
|
|
_defineProperty(Triangle, "type", 'Triangle');
|
|
_defineProperty(Triangle, "ownDefaults", triangleDefaultValues);
|
|
classRegistry.setClass(Triangle);
|
|
classRegistry.setSVGClass(Triangle);
|
|
|
|
const ellipseDefaultValues = {
|
|
rx: 0,
|
|
ry: 0
|
|
};
|
|
const ELLIPSE_PROPS = ['rx', 'ry'];
|
|
class Ellipse extends FabricObject {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), Ellipse.ownDefaults);
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor(options) {
|
|
super();
|
|
Object.assign(this, Ellipse.ownDefaults);
|
|
this.setOptions(options);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} key
|
|
* @param {*} value
|
|
* @return {Ellipse} thisArg
|
|
*/
|
|
_set(key, value) {
|
|
super._set(key, value);
|
|
switch (key) {
|
|
case 'rx':
|
|
this.rx = value;
|
|
this.set('width', value * 2);
|
|
break;
|
|
case 'ry':
|
|
this.ry = value;
|
|
this.set('height', value * 2);
|
|
break;
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Returns horizontal radius of an object (according to how an object is scaled)
|
|
* @return {Number}
|
|
*/
|
|
getRx() {
|
|
return this.get('rx') * this.get(SCALE_X);
|
|
}
|
|
|
|
/**
|
|
* Returns Vertical radius of an object (according to how an object is scaled)
|
|
* @return {Number}
|
|
*/
|
|
getRy() {
|
|
return this.get('ry') * this.get(SCALE_Y);
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
return super.toObject([...ELLIPSE_PROPS, ...propertiesToInclude]);
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @return {Array} an array of strings with the specific svg representation
|
|
* of the instance
|
|
*/
|
|
_toSVG() {
|
|
return ['<ellipse ', 'COMMON_PARTS', "cx=\"0\" cy=\"0\" rx=\"".concat(this.rx, "\" ry=\"").concat(this.ry, "\" />\n")];
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx context to render on
|
|
*/
|
|
_render(ctx) {
|
|
ctx.beginPath();
|
|
ctx.save();
|
|
ctx.transform(1, 0, 0, this.ry / this.rx, 0, 0);
|
|
ctx.arc(0, 0, this.rx, 0, twoMathPi, false);
|
|
ctx.restore();
|
|
this._renderPaintInOrder(ctx);
|
|
}
|
|
|
|
/* _FROM_SVG_START_ */
|
|
|
|
/**
|
|
* List of attribute names to account for when parsing SVG element (used by {@link Ellipse.fromElement})
|
|
* @static
|
|
* @memberOf Ellipse
|
|
* @see http://www.w3.org/TR/SVG/shapes.html#EllipseElement
|
|
*/
|
|
|
|
/**
|
|
* Returns {@link Ellipse} instance from an SVG element
|
|
* @static
|
|
* @memberOf Ellipse
|
|
* @param {HTMLElement} element Element to parse
|
|
* @return {Ellipse}
|
|
*/
|
|
static async fromElement(element, options, cssRules) {
|
|
const parsedAttributes = parseAttributes(element, this.ATTRIBUTE_NAMES, cssRules);
|
|
parsedAttributes.left = (parsedAttributes.left || 0) - parsedAttributes.rx;
|
|
parsedAttributes.top = (parsedAttributes.top || 0) - parsedAttributes.ry;
|
|
return new this(parsedAttributes);
|
|
}
|
|
|
|
/* _FROM_SVG_END_ */
|
|
}
|
|
/**
|
|
* Horizontal radius
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Vertical radius
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(Ellipse, "type", 'Ellipse');
|
|
_defineProperty(Ellipse, "cacheProperties", [...cacheProperties, ...ELLIPSE_PROPS]);
|
|
_defineProperty(Ellipse, "ownDefaults", ellipseDefaultValues);
|
|
_defineProperty(Ellipse, "ATTRIBUTE_NAMES", [...SHARED_ATTRIBUTES, 'cx', 'cy', 'rx', 'ry']);
|
|
classRegistry.setClass(Ellipse);
|
|
classRegistry.setSVGClass(Ellipse);
|
|
|
|
/**
|
|
* Parses "points" attribute, returning an array of values
|
|
* @static
|
|
* @memberOf fabric
|
|
* @param {String} points points attribute string
|
|
* @return {Array} array of points
|
|
*/
|
|
function parsePointsAttribute(points) {
|
|
// points attribute is required and must not be empty
|
|
if (!points) {
|
|
return [];
|
|
}
|
|
|
|
// replace commas with whitespace and remove bookending whitespace
|
|
const pointsSplit = points.replace(/,/g, ' ').trim().split(/\s+/);
|
|
const parsedPoints = [];
|
|
for (let i = 0; i < pointsSplit.length; i += 2) {
|
|
parsedPoints.push({
|
|
x: parseFloat(pointsSplit[i]),
|
|
y: parseFloat(pointsSplit[i + 1])
|
|
});
|
|
}
|
|
|
|
// odd number of points is an error
|
|
// if (parsedPoints.length % 2 !== 0) {
|
|
// return null;
|
|
// }
|
|
return parsedPoints;
|
|
}
|
|
|
|
const _excluded$4 = ["left", "top"];
|
|
const polylineDefaultValues = {
|
|
/**
|
|
* @deprecated transient option soon to be removed in favor of a different design
|
|
*/
|
|
exactBoundingBox: false
|
|
};
|
|
class Polyline extends FabricObject {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), Polyline.ownDefaults);
|
|
}
|
|
|
|
/**
|
|
* A list of properties that if changed trigger a recalculation of dimensions
|
|
* @todo check if you really need to recalculate for all cases
|
|
*/
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {Array} points Array of points (where each point is an object with x and y)
|
|
* @param {Object} [options] Options object
|
|
* @return {Polyline} thisArg
|
|
* @example
|
|
* var poly = new Polyline([
|
|
* { x: 10, y: 10 },
|
|
* { x: 50, y: 30 },
|
|
* { x: 40, y: 70 },
|
|
* { x: 60, y: 50 },
|
|
* { x: 100, y: 150 },
|
|
* { x: 40, y: 100 }
|
|
* ], {
|
|
* stroke: 'red',
|
|
* left: 100,
|
|
* top: 100
|
|
* });
|
|
*/
|
|
constructor() {
|
|
let points = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
super();
|
|
_defineProperty(this, "strokeDiff", void 0);
|
|
Object.assign(this, Polyline.ownDefaults);
|
|
this.setOptions(options);
|
|
this.points = points;
|
|
const {
|
|
left,
|
|
top
|
|
} = options;
|
|
this.initialized = true;
|
|
this.setBoundingBox(true);
|
|
typeof left === 'number' && this.set(LEFT, left);
|
|
typeof top === 'number' && this.set(TOP, top);
|
|
}
|
|
isOpen() {
|
|
return true;
|
|
}
|
|
_projectStrokeOnPoints(options) {
|
|
return projectStrokeOnPoints(this.points, options, this.isOpen());
|
|
}
|
|
|
|
/**
|
|
* Calculate the polygon bounding box
|
|
* @private
|
|
*/
|
|
_calcDimensions(options) {
|
|
options = _objectSpread2({
|
|
scaleX: this.scaleX,
|
|
scaleY: this.scaleY,
|
|
skewX: this.skewX,
|
|
skewY: this.skewY,
|
|
strokeLineCap: this.strokeLineCap,
|
|
strokeLineJoin: this.strokeLineJoin,
|
|
strokeMiterLimit: this.strokeMiterLimit,
|
|
strokeUniform: this.strokeUniform,
|
|
strokeWidth: this.strokeWidth
|
|
}, options || {});
|
|
const points = this.exactBoundingBox ? this._projectStrokeOnPoints(options).map(projection => projection.projectedPoint) : this.points;
|
|
if (points.length === 0) {
|
|
return {
|
|
left: 0,
|
|
top: 0,
|
|
width: 0,
|
|
height: 0,
|
|
pathOffset: new Point(),
|
|
strokeOffset: new Point(),
|
|
strokeDiff: new Point()
|
|
};
|
|
}
|
|
const bbox = makeBoundingBoxFromPoints(points),
|
|
// Remove scale effect, since it's applied after
|
|
matrix = calcDimensionsMatrix(_objectSpread2(_objectSpread2({}, options), {}, {
|
|
scaleX: 1,
|
|
scaleY: 1
|
|
})),
|
|
bboxNoStroke = makeBoundingBoxFromPoints(this.points.map(p => transformPoint(p, matrix, true))),
|
|
scale = new Point(this.scaleX, this.scaleY);
|
|
let offsetX = bbox.left + bbox.width / 2,
|
|
offsetY = bbox.top + bbox.height / 2;
|
|
if (this.exactBoundingBox) {
|
|
offsetX = offsetX - offsetY * Math.tan(degreesToRadians(this.skewX));
|
|
// Order of those assignments is important.
|
|
// offsetY relies on offsetX being already changed by the line above
|
|
offsetY = offsetY - offsetX * Math.tan(degreesToRadians(this.skewY));
|
|
}
|
|
return _objectSpread2(_objectSpread2({}, bbox), {}, {
|
|
pathOffset: new Point(offsetX, offsetY),
|
|
strokeOffset: new Point(bboxNoStroke.left, bboxNoStroke.top).subtract(new Point(bbox.left, bbox.top)).multiply(scale),
|
|
strokeDiff: new Point(bbox.width, bbox.height).subtract(new Point(bboxNoStroke.width, bboxNoStroke.height)).multiply(scale)
|
|
});
|
|
}
|
|
|
|
/**
|
|
* This function is an helper for svg import. it returns the center of the object in the svg
|
|
* untransformed coordinates, by look at the polyline/polygon points.
|
|
* @private
|
|
* @return {Point} center point from element coordinates
|
|
*/
|
|
_findCenterFromElement() {
|
|
const bbox = makeBoundingBoxFromPoints(this.points);
|
|
return new Point(bbox.left + bbox.width / 2, bbox.top + bbox.height / 2);
|
|
}
|
|
setDimensions() {
|
|
this.setBoundingBox();
|
|
}
|
|
setBoundingBox(adjustPosition) {
|
|
const {
|
|
left,
|
|
top,
|
|
width,
|
|
height,
|
|
pathOffset,
|
|
strokeOffset,
|
|
strokeDiff
|
|
} = this._calcDimensions();
|
|
this.set({
|
|
width,
|
|
height,
|
|
pathOffset,
|
|
strokeOffset,
|
|
strokeDiff
|
|
});
|
|
adjustPosition && this.setPositionByOrigin(new Point(left + width / 2, top + height / 2), CENTER, CENTER);
|
|
}
|
|
|
|
/**
|
|
* @deprecated intermidiate method to be removed, do not use
|
|
*/
|
|
isStrokeAccountedForInDimensions() {
|
|
return this.exactBoundingBox;
|
|
}
|
|
|
|
/**
|
|
* @override stroke is taken in account in size
|
|
*/
|
|
_getNonTransformedDimensions() {
|
|
return this.exactBoundingBox ?
|
|
// TODO: fix this
|
|
new Point(this.width, this.height) : super._getNonTransformedDimensions();
|
|
}
|
|
|
|
/**
|
|
* @override stroke and skewing are taken into account when projecting stroke on points,
|
|
* therefore we don't want the default calculation to account for skewing as well.
|
|
* Though it is possible to pass `width` and `height` in `options`, doing so is very strange, use with discretion.
|
|
*
|
|
* @private
|
|
*/
|
|
_getTransformedDimensions() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
if (this.exactBoundingBox) {
|
|
let size;
|
|
/* When `strokeUniform = true`, any changes to the properties require recalculating the `width` and `height` because
|
|
the stroke projections are affected.
|
|
When `strokeUniform = false`, we don't need to recalculate for scale transformations, as the effect of scale on
|
|
projections follows a linear function (e.g. scaleX of 2 just multiply width by 2)*/
|
|
if (Object.keys(options).some(key => this.strokeUniform || this.constructor.layoutProperties.includes(key))) {
|
|
var _options$width, _options$height;
|
|
const {
|
|
width,
|
|
height
|
|
} = this._calcDimensions(options);
|
|
size = new Point((_options$width = options.width) !== null && _options$width !== void 0 ? _options$width : width, (_options$height = options.height) !== null && _options$height !== void 0 ? _options$height : height);
|
|
} else {
|
|
var _options$width2, _options$height2;
|
|
size = new Point((_options$width2 = options.width) !== null && _options$width2 !== void 0 ? _options$width2 : this.width, (_options$height2 = options.height) !== null && _options$height2 !== void 0 ? _options$height2 : this.height);
|
|
}
|
|
return size.multiply(new Point(options.scaleX || this.scaleX, options.scaleY || this.scaleY));
|
|
} else {
|
|
return super._getTransformedDimensions(options);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recalculates dimensions when changing skew and scale
|
|
* @private
|
|
*/
|
|
_set(key, value) {
|
|
const changed = this.initialized && this[key] !== value;
|
|
const output = super._set(key, value);
|
|
if (this.exactBoundingBox && changed && ((key === SCALE_X || key === SCALE_Y) && this.strokeUniform && this.constructor.layoutProperties.includes('strokeUniform') || this.constructor.layoutProperties.includes(key))) {
|
|
this.setDimensions();
|
|
}
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} Object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
return _objectSpread2(_objectSpread2({}, super.toObject(propertiesToInclude)), {}, {
|
|
points: this.points.map(_ref => {
|
|
let {
|
|
x,
|
|
y
|
|
} = _ref;
|
|
return {
|
|
x,
|
|
y
|
|
};
|
|
})
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @return {Array} an array of strings with the specific svg representation
|
|
* of the instance
|
|
*/
|
|
_toSVG() {
|
|
const points = [],
|
|
diffX = this.pathOffset.x,
|
|
diffY = this.pathOffset.y,
|
|
NUM_FRACTION_DIGITS = config.NUM_FRACTION_DIGITS;
|
|
for (let i = 0, len = this.points.length; i < len; i++) {
|
|
points.push(toFixed(this.points[i].x - diffX, NUM_FRACTION_DIGITS), ',', toFixed(this.points[i].y - diffY, NUM_FRACTION_DIGITS), ' ');
|
|
}
|
|
return ["<".concat(this.constructor.type.toLowerCase(), " "), 'COMMON_PARTS', "points=\"".concat(points.join(''), "\" />\n")];
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_render(ctx) {
|
|
const len = this.points.length,
|
|
x = this.pathOffset.x,
|
|
y = this.pathOffset.y;
|
|
if (!len || isNaN(this.points[len - 1].y)) {
|
|
// do not draw if no points or odd points
|
|
// NaN comes from parseFloat of a empty string in parser
|
|
return;
|
|
}
|
|
ctx.beginPath();
|
|
ctx.moveTo(this.points[0].x - x, this.points[0].y - y);
|
|
for (let i = 0; i < len; i++) {
|
|
const point = this.points[i];
|
|
ctx.lineTo(point.x - x, point.y - y);
|
|
}
|
|
!this.isOpen() && ctx.closePath();
|
|
this._renderPaintInOrder(ctx);
|
|
}
|
|
|
|
/**
|
|
* Returns complexity of an instance
|
|
* @return {Number} complexity of this instance
|
|
*/
|
|
complexity() {
|
|
return this.points.length;
|
|
}
|
|
|
|
/* _FROM_SVG_START_ */
|
|
|
|
/**
|
|
* List of attribute names to account for when parsing SVG element (used by {@link Polyline.fromElement})
|
|
* @static
|
|
* @memberOf Polyline
|
|
* @see: http://www.w3.org/TR/SVG/shapes.html#PolylineElement
|
|
*/
|
|
|
|
/**
|
|
* Returns Polyline instance from an SVG element
|
|
* @static
|
|
* @memberOf Polyline
|
|
* @param {HTMLElement} element Element to parser
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
static async fromElement(element, options, cssRules) {
|
|
const points = parsePointsAttribute(element.getAttribute('points')),
|
|
_parseAttributes = parseAttributes(element, this.ATTRIBUTE_NAMES, cssRules),
|
|
parsedAttributes = _objectWithoutProperties(_parseAttributes, _excluded$4);
|
|
return new this(points, _objectSpread2(_objectSpread2({}, parsedAttributes), options));
|
|
}
|
|
|
|
/* _FROM_SVG_END_ */
|
|
|
|
/**
|
|
* Returns Polyline instance from an object representation
|
|
* @static
|
|
* @memberOf Polyline
|
|
* @param {Object} object Object to create an instance from
|
|
* @returns {Promise<Polyline>}
|
|
*/
|
|
static fromObject(object) {
|
|
return this._fromObject(object, {
|
|
extraParam: 'points'
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Points array
|
|
* @type Array
|
|
* @default
|
|
*/
|
|
/**
|
|
* WARNING: Feature in progress
|
|
* Calculate the exact bounding box taking in account strokeWidth on acute angles
|
|
* this will be turned to true by default on fabric 6.0
|
|
* maybe will be left in as an optimization since calculations may be slow
|
|
* @deprecated transient option soon to be removed in favor of a different design
|
|
* @type Boolean
|
|
* @default false
|
|
*/
|
|
_defineProperty(Polyline, "ownDefaults", polylineDefaultValues);
|
|
_defineProperty(Polyline, "type", 'Polyline');
|
|
_defineProperty(Polyline, "layoutProperties", [SKEW_X, SKEW_Y, 'strokeLineCap', 'strokeLineJoin', 'strokeMiterLimit', 'strokeWidth', 'strokeUniform', 'points']);
|
|
_defineProperty(Polyline, "cacheProperties", [...cacheProperties, 'points']);
|
|
_defineProperty(Polyline, "ATTRIBUTE_NAMES", [...SHARED_ATTRIBUTES]);
|
|
classRegistry.setClass(Polyline);
|
|
classRegistry.setSVGClass(Polyline);
|
|
|
|
class Polygon extends Polyline {
|
|
isOpen() {
|
|
return false;
|
|
}
|
|
}
|
|
_defineProperty(Polygon, "ownDefaults", polylineDefaultValues);
|
|
_defineProperty(Polygon, "type", 'Polygon');
|
|
classRegistry.setClass(Polygon);
|
|
classRegistry.setSVGClass(Polygon);
|
|
|
|
const fontProperties = ['fontSize', 'fontWeight', 'fontFamily', 'fontStyle'];
|
|
const textDecorationProperties = ['underline', 'overline', 'linethrough'];
|
|
const textLayoutProperties = [...fontProperties, 'lineHeight', 'text', 'charSpacing', 'textAlign', 'styles', 'path', 'pathStartOffset', 'pathSide', 'pathAlign'];
|
|
const additionalProps = [...textLayoutProperties, ...textDecorationProperties, 'textBackgroundColor', 'direction'];
|
|
const styleProperties = [...fontProperties, ...textDecorationProperties, STROKE, 'strokeWidth', FILL, 'deltaY', 'textBackgroundColor'];
|
|
|
|
// @TODO: Many things here are configuration related and shouldn't be on the class nor prototype
|
|
// regexes, list of properties that are not suppose to change by instances, magic consts.
|
|
// this will be a separated effort
|
|
const textDefaultValues = {
|
|
_reNewline: reNewline,
|
|
_reSpacesAndTabs: /[ \t\r]/g,
|
|
_reSpaceAndTab: /[ \t\r]/,
|
|
_reWords: /\S+/g,
|
|
fontSize: 40,
|
|
fontWeight: 'normal',
|
|
fontFamily: 'Times New Roman',
|
|
underline: false,
|
|
overline: false,
|
|
linethrough: false,
|
|
textAlign: LEFT,
|
|
fontStyle: 'normal',
|
|
lineHeight: 1.16,
|
|
superscript: {
|
|
size: 0.6,
|
|
// fontSize factor
|
|
baseline: -0.35 // baseline-shift factor (upwards)
|
|
},
|
|
subscript: {
|
|
size: 0.6,
|
|
// fontSize factor
|
|
baseline: 0.11 // baseline-shift factor (downwards)
|
|
},
|
|
textBackgroundColor: '',
|
|
stroke: null,
|
|
shadow: null,
|
|
path: undefined,
|
|
pathStartOffset: 0,
|
|
pathSide: LEFT,
|
|
pathAlign: 'baseline',
|
|
_fontSizeFraction: 0.222,
|
|
offsets: {
|
|
underline: 0.1,
|
|
linethrough: -0.315,
|
|
overline: -0.88
|
|
},
|
|
_fontSizeMult: 1.13,
|
|
charSpacing: 0,
|
|
deltaY: 0,
|
|
direction: 'ltr',
|
|
CACHE_FONT_SIZE: 400,
|
|
MIN_TEXT_WIDTH: 2
|
|
};
|
|
const JUSTIFY = 'justify';
|
|
const JUSTIFY_LEFT = 'justify-left';
|
|
const JUSTIFY_RIGHT = 'justify-right';
|
|
const JUSTIFY_CENTER = 'justify-center';
|
|
|
|
class StyledText extends FabricObject {
|
|
/**
|
|
* Returns true if object has no styling or no styling in a line
|
|
* @param {Number} lineIndex , lineIndex is on wrapped lines.
|
|
* @return {Boolean}
|
|
*/
|
|
isEmptyStyles(lineIndex) {
|
|
if (!this.styles) {
|
|
return true;
|
|
}
|
|
if (typeof lineIndex !== 'undefined' && !this.styles[lineIndex]) {
|
|
return true;
|
|
}
|
|
const obj = typeof lineIndex === 'undefined' ? this.styles : {
|
|
line: this.styles[lineIndex]
|
|
};
|
|
for (const p1 in obj) {
|
|
for (const p2 in obj[p1]) {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for (const p3 in obj[p1][p2]) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Returns true if object has a style property or has it ina specified line
|
|
* This function is used to detect if a text will use a particular property or not.
|
|
* @param {String} property to check for
|
|
* @param {Number} lineIndex to check the style on
|
|
* @return {Boolean}
|
|
*/
|
|
styleHas(property, lineIndex) {
|
|
if (!this.styles) {
|
|
return false;
|
|
}
|
|
if (typeof lineIndex !== 'undefined' && !this.styles[lineIndex]) {
|
|
return false;
|
|
}
|
|
const obj = typeof lineIndex === 'undefined' ? this.styles : {
|
|
0: this.styles[lineIndex]
|
|
};
|
|
// eslint-disable-next-line
|
|
for (const p1 in obj) {
|
|
// eslint-disable-next-line
|
|
for (const p2 in obj[p1]) {
|
|
if (typeof obj[p1][p2][property] !== 'undefined') {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if characters in a text have a value for a property
|
|
* whose value matches the textbox's value for that property. If so,
|
|
* the character-level property is deleted. If the character
|
|
* has no other properties, then it is also deleted. Finally,
|
|
* if the line containing that character has no other characters
|
|
* then it also is deleted.
|
|
*
|
|
* @param {string} property The property to compare between characters and text.
|
|
*/
|
|
cleanStyle(property) {
|
|
if (!this.styles) {
|
|
return false;
|
|
}
|
|
const obj = this.styles;
|
|
let stylesCount = 0,
|
|
letterCount,
|
|
stylePropertyValue,
|
|
allStyleObjectPropertiesMatch = true,
|
|
graphemeCount = 0;
|
|
for (const p1 in obj) {
|
|
letterCount = 0;
|
|
for (const p2 in obj[p1]) {
|
|
const styleObject = obj[p1][p2] || {},
|
|
stylePropertyHasBeenSet = styleObject[property] !== undefined;
|
|
stylesCount++;
|
|
if (stylePropertyHasBeenSet) {
|
|
if (!stylePropertyValue) {
|
|
stylePropertyValue = styleObject[property];
|
|
} else if (styleObject[property] !== stylePropertyValue) {
|
|
allStyleObjectPropertiesMatch = false;
|
|
}
|
|
if (styleObject[property] === this[property]) {
|
|
delete styleObject[property];
|
|
}
|
|
} else {
|
|
allStyleObjectPropertiesMatch = false;
|
|
}
|
|
if (Object.keys(styleObject).length !== 0) {
|
|
letterCount++;
|
|
} else {
|
|
delete obj[p1][p2];
|
|
}
|
|
}
|
|
if (letterCount === 0) {
|
|
delete obj[p1];
|
|
}
|
|
}
|
|
// if every grapheme has the same style set then
|
|
// delete those styles and set it on the parent
|
|
for (let i = 0; i < this._textLines.length; i++) {
|
|
graphemeCount += this._textLines[i].length;
|
|
}
|
|
if (allStyleObjectPropertiesMatch && stylesCount === graphemeCount) {
|
|
// @ts-expect-error conspiracy theory of TS
|
|
this[property] = stylePropertyValue;
|
|
this.removeStyle(property);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a style property or properties from all individual character styles
|
|
* in a text object. Deletes the character style object if it contains no other style
|
|
* props. Deletes a line style object if it contains no other character styles.
|
|
*
|
|
* @param {String} props The property to remove from character styles.
|
|
*/
|
|
removeStyle(property) {
|
|
if (!this.styles) {
|
|
return;
|
|
}
|
|
const obj = this.styles;
|
|
let line, lineNum, charNum;
|
|
for (lineNum in obj) {
|
|
line = obj[lineNum];
|
|
for (charNum in line) {
|
|
delete line[charNum][property];
|
|
if (Object.keys(line[charNum]).length === 0) {
|
|
delete line[charNum];
|
|
}
|
|
}
|
|
if (Object.keys(line).length === 0) {
|
|
delete obj[lineNum];
|
|
}
|
|
}
|
|
}
|
|
_extendStyles(index, style) {
|
|
const {
|
|
lineIndex,
|
|
charIndex
|
|
} = this.get2DCursorLocation(index);
|
|
if (!this._getLineStyle(lineIndex)) {
|
|
this._setLineStyle(lineIndex);
|
|
}
|
|
const newStyle = pickBy(_objectSpread2(_objectSpread2({}, this._getStyleDeclaration(lineIndex, charIndex)), style), value => value !== undefined);
|
|
|
|
// finally assign to the old position the new style
|
|
this._setStyleDeclaration(lineIndex, charIndex, newStyle);
|
|
}
|
|
|
|
/**
|
|
* Gets style of a current selection/cursor (at the start position)
|
|
* @param {Number} startIndex Start index to get styles at
|
|
* @param {Number} endIndex End index to get styles at, if not specified startIndex + 1
|
|
* @param {Boolean} [complete] get full style or not
|
|
* @return {Array} styles an array with one, zero or more Style objects
|
|
*/
|
|
getSelectionStyles(startIndex, endIndex, complete) {
|
|
const styles = [];
|
|
for (let i = startIndex; i < (endIndex || startIndex); i++) {
|
|
styles.push(this.getStyleAtPosition(i, complete));
|
|
}
|
|
return styles;
|
|
}
|
|
|
|
/**
|
|
* Gets style of a current selection/cursor position
|
|
* @param {Number} position to get styles at
|
|
* @param {Boolean} [complete] full style if true
|
|
* @return {Object} style Style object at a specified index
|
|
* @private
|
|
*/
|
|
getStyleAtPosition(position, complete) {
|
|
const {
|
|
lineIndex,
|
|
charIndex
|
|
} = this.get2DCursorLocation(position);
|
|
return complete ? this.getCompleteStyleDeclaration(lineIndex, charIndex) : this._getStyleDeclaration(lineIndex, charIndex);
|
|
}
|
|
|
|
/**
|
|
* Sets style of a current selection, if no selection exist, do not set anything.
|
|
* @param {Object} styles Styles object
|
|
* @param {Number} startIndex Start index to get styles at
|
|
* @param {Number} [endIndex] End index to get styles at, if not specified startIndex + 1
|
|
*/
|
|
setSelectionStyles(styles, startIndex, endIndex) {
|
|
for (let i = startIndex; i < (endIndex || startIndex); i++) {
|
|
this._extendStyles(i, styles);
|
|
}
|
|
/* not included in _extendStyles to avoid clearing cache more than once */
|
|
this._forceClearCache = true;
|
|
}
|
|
|
|
/**
|
|
* Get a reference, not a clone, to the style object for a given character,
|
|
* if no style is set for a line or char, return a new empty object.
|
|
* This is tricky and confusing because when you get an empty object you can't
|
|
* determine if it is a reference or a new one.
|
|
* @TODO this should always return a reference or always a clone or undefined when necessary.
|
|
* @protected
|
|
* @param {Number} lineIndex
|
|
* @param {Number} charIndex
|
|
* @return {TextStyleDeclaration} a style object reference to the existing one or a new empty object when undefined
|
|
*/
|
|
_getStyleDeclaration(lineIndex, charIndex) {
|
|
var _lineStyle$charIndex;
|
|
const lineStyle = this.styles && this.styles[lineIndex];
|
|
return lineStyle ? (_lineStyle$charIndex = lineStyle[charIndex]) !== null && _lineStyle$charIndex !== void 0 ? _lineStyle$charIndex : {} : {};
|
|
}
|
|
|
|
/**
|
|
* return a new object that contains all the style property for a character
|
|
* the object returned is newly created
|
|
* @param {Number} lineIndex of the line where the character is
|
|
* @param {Number} charIndex position of the character on the line
|
|
* @return {Object} style object
|
|
*/
|
|
getCompleteStyleDeclaration(lineIndex, charIndex) {
|
|
return _objectSpread2(_objectSpread2({}, pick(this, this.constructor._styleProperties)), this._getStyleDeclaration(lineIndex, charIndex));
|
|
}
|
|
|
|
/**
|
|
* @param {Number} lineIndex
|
|
* @param {Number} charIndex
|
|
* @param {Object} style
|
|
* @private
|
|
*/
|
|
_setStyleDeclaration(lineIndex, charIndex, style) {
|
|
this.styles[lineIndex][charIndex] = style;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Number} lineIndex
|
|
* @param {Number} charIndex
|
|
* @private
|
|
*/
|
|
_deleteStyleDeclaration(lineIndex, charIndex) {
|
|
delete this.styles[lineIndex][charIndex];
|
|
}
|
|
|
|
/**
|
|
* @param {Number} lineIndex
|
|
* @return {Boolean} if the line exists or not
|
|
* @private
|
|
*/
|
|
_getLineStyle(lineIndex) {
|
|
return !!this.styles[lineIndex];
|
|
}
|
|
|
|
/**
|
|
* Set the line style to an empty object so that is initialized
|
|
* @param {Number} lineIndex
|
|
* @private
|
|
*/
|
|
_setLineStyle(lineIndex) {
|
|
this.styles[lineIndex] = {};
|
|
}
|
|
_deleteLineStyle(lineIndex) {
|
|
delete this.styles[lineIndex];
|
|
}
|
|
}
|
|
_defineProperty(StyledText, "_styleProperties", styleProperties);
|
|
|
|
const multipleSpacesRegex = / +/g;
|
|
const dblQuoteRegex = /"/g;
|
|
function createSVGInlineRect(color, left, top, width, height) {
|
|
return "\t\t".concat(createSVGRect(color, {
|
|
left,
|
|
top,
|
|
width,
|
|
height
|
|
}), "\n");
|
|
}
|
|
class TextSVGExportMixin extends FabricObjectSVGExportMixin {
|
|
_toSVG() {
|
|
const offsets = this._getSVGLeftTopOffsets(),
|
|
textAndBg = this._getSVGTextAndBg(offsets.textTop, offsets.textLeft);
|
|
return this._wrapSVGTextAndBg(textAndBg);
|
|
}
|
|
toSVG(reviver) {
|
|
return this._createBaseSVGMarkup(this._toSVG(), {
|
|
reviver,
|
|
noStyle: true,
|
|
withShadow: true
|
|
});
|
|
}
|
|
_getSVGLeftTopOffsets() {
|
|
return {
|
|
textLeft: -this.width / 2,
|
|
textTop: -this.height / 2,
|
|
lineTop: this.getHeightOfLine(0)
|
|
};
|
|
}
|
|
_wrapSVGTextAndBg(_ref) {
|
|
let {
|
|
textBgRects,
|
|
textSpans
|
|
} = _ref;
|
|
const noShadow = true,
|
|
textDecoration = this.getSvgTextDecoration(this);
|
|
return [textBgRects.join(''), '\t\t<text xml:space="preserve" ', this.fontFamily ? "font-family=\"".concat(this.fontFamily.replace(dblQuoteRegex, "'"), "\" ") : '', this.fontSize ? "font-size=\"".concat(this.fontSize, "\" ") : '', this.fontStyle ? "font-style=\"".concat(this.fontStyle, "\" ") : '', this.fontWeight ? "font-weight=\"".concat(this.fontWeight, "\" ") : '', textDecoration ? "text-decoration=\"".concat(textDecoration, "\" ") : '', this.direction === 'rtl' ? "direction=\"".concat(this.direction, "\" ") : '', 'style="', this.getSvgStyles(noShadow), '"', this.addPaintOrder(), ' >', textSpans.join(''), '</text>\n'];
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Number} textTopOffset Text top offset
|
|
* @param {Number} textLeftOffset Text left offset
|
|
* @return {Object}
|
|
*/
|
|
_getSVGTextAndBg(textTopOffset, textLeftOffset) {
|
|
const textSpans = [],
|
|
textBgRects = [];
|
|
let height = textTopOffset,
|
|
lineOffset;
|
|
|
|
// bounding-box background
|
|
this.backgroundColor && textBgRects.push(...createSVGInlineRect(this.backgroundColor, -this.width / 2, -this.height / 2, this.width, this.height));
|
|
|
|
// text and text-background
|
|
for (let i = 0, len = this._textLines.length; i < len; i++) {
|
|
lineOffset = this._getLineLeftOffset(i);
|
|
if (this.direction === 'rtl') {
|
|
lineOffset += this.width;
|
|
}
|
|
if (this.textBackgroundColor || this.styleHas('textBackgroundColor', i)) {
|
|
this._setSVGTextLineBg(textBgRects, i, textLeftOffset + lineOffset, height);
|
|
}
|
|
this._setSVGTextLineText(textSpans, i, textLeftOffset + lineOffset, height);
|
|
height += this.getHeightOfLine(i);
|
|
}
|
|
return {
|
|
textSpans,
|
|
textBgRects
|
|
};
|
|
}
|
|
_createTextCharSpan(char, styleDecl, left, top) {
|
|
const styleProps = this.getSvgSpanStyles(styleDecl, char !== char.trim() || !!char.match(multipleSpacesRegex)),
|
|
fillStyles = styleProps ? "style=\"".concat(styleProps, "\"") : '',
|
|
dy = styleDecl.deltaY,
|
|
dySpan = dy ? " dy=\"".concat(toFixed(dy, config.NUM_FRACTION_DIGITS), "\" ") : '';
|
|
return "<tspan x=\"".concat(toFixed(left, config.NUM_FRACTION_DIGITS), "\" y=\"").concat(toFixed(top, config.NUM_FRACTION_DIGITS), "\" ").concat(dySpan).concat(fillStyles, ">").concat(escapeXml(char), "</tspan>");
|
|
}
|
|
_setSVGTextLineText(textSpans, lineIndex, textLeftOffset, textTopOffset) {
|
|
const lineHeight = this.getHeightOfLine(lineIndex),
|
|
isJustify = this.textAlign.includes(JUSTIFY),
|
|
line = this._textLines[lineIndex];
|
|
let actualStyle,
|
|
nextStyle,
|
|
charsToRender = '',
|
|
charBox,
|
|
style,
|
|
boxWidth = 0,
|
|
timeToRender;
|
|
textTopOffset += lineHeight * (1 - this._fontSizeFraction) / this.lineHeight;
|
|
for (let i = 0, len = line.length - 1; i <= len; i++) {
|
|
timeToRender = i === len || this.charSpacing;
|
|
charsToRender += line[i];
|
|
charBox = this.__charBounds[lineIndex][i];
|
|
if (boxWidth === 0) {
|
|
textLeftOffset += charBox.kernedWidth - charBox.width;
|
|
boxWidth += charBox.width;
|
|
} else {
|
|
boxWidth += charBox.kernedWidth;
|
|
}
|
|
if (isJustify && !timeToRender) {
|
|
if (this._reSpaceAndTab.test(line[i])) {
|
|
timeToRender = true;
|
|
}
|
|
}
|
|
if (!timeToRender) {
|
|
// if we have charSpacing, we render char by char
|
|
actualStyle = actualStyle || this.getCompleteStyleDeclaration(lineIndex, i);
|
|
nextStyle = this.getCompleteStyleDeclaration(lineIndex, i + 1);
|
|
timeToRender = hasStyleChanged(actualStyle, nextStyle, true);
|
|
}
|
|
if (timeToRender) {
|
|
style = this._getStyleDeclaration(lineIndex, i);
|
|
textSpans.push(this._createTextCharSpan(charsToRender, style, textLeftOffset, textTopOffset));
|
|
charsToRender = '';
|
|
actualStyle = nextStyle;
|
|
if (this.direction === 'rtl') {
|
|
textLeftOffset -= boxWidth;
|
|
} else {
|
|
textLeftOffset += boxWidth;
|
|
}
|
|
boxWidth = 0;
|
|
}
|
|
}
|
|
}
|
|
_setSVGTextLineBg(textBgRects, i, leftOffset, textTopOffset) {
|
|
const line = this._textLines[i],
|
|
heightOfLine = this.getHeightOfLine(i) / this.lineHeight;
|
|
let boxWidth = 0,
|
|
boxStart = 0,
|
|
currentColor,
|
|
lastColor = this.getValueOfPropertyAt(i, 0, 'textBackgroundColor');
|
|
for (let j = 0; j < line.length; j++) {
|
|
const {
|
|
left,
|
|
width,
|
|
kernedWidth
|
|
} = this.__charBounds[i][j];
|
|
currentColor = this.getValueOfPropertyAt(i, j, 'textBackgroundColor');
|
|
if (currentColor !== lastColor) {
|
|
lastColor && textBgRects.push(...createSVGInlineRect(lastColor, leftOffset + boxStart, textTopOffset, boxWidth, heightOfLine));
|
|
boxStart = left;
|
|
boxWidth = width;
|
|
lastColor = currentColor;
|
|
} else {
|
|
boxWidth += kernedWidth;
|
|
}
|
|
}
|
|
currentColor && textBgRects.push(...createSVGInlineRect(lastColor, leftOffset + boxStart, textTopOffset, boxWidth, heightOfLine));
|
|
}
|
|
|
|
/**
|
|
* @deprecated unused
|
|
*/
|
|
_getSVGLineTopOffset(lineIndex) {
|
|
let lineTopOffset = 0,
|
|
j;
|
|
for (j = 0; j < lineIndex; j++) {
|
|
lineTopOffset += this.getHeightOfLine(j);
|
|
}
|
|
const lastHeight = this.getHeightOfLine(j);
|
|
return {
|
|
lineTop: lineTopOffset,
|
|
offset: (this._fontSizeMult - this._fontSizeFraction) * lastHeight / (this.lineHeight * this._fontSizeMult)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns styles-string for svg-export
|
|
* @param {Boolean} skipShadow a boolean to skip shadow filter output
|
|
* @return {String}
|
|
*/
|
|
getSvgStyles(skipShadow) {
|
|
return "".concat(super.getSvgStyles(skipShadow), " white-space: pre;");
|
|
}
|
|
|
|
/**
|
|
* Returns styles-string for svg-export
|
|
* @param {Object} style the object from which to retrieve style properties
|
|
* @param {Boolean} useWhiteSpace a boolean to include an additional attribute in the style.
|
|
* @return {String}
|
|
*/
|
|
getSvgSpanStyles(style, useWhiteSpace) {
|
|
const {
|
|
fontFamily,
|
|
strokeWidth,
|
|
stroke,
|
|
fill,
|
|
fontSize,
|
|
fontStyle,
|
|
fontWeight,
|
|
deltaY
|
|
} = style;
|
|
const textDecoration = this.getSvgTextDecoration(style);
|
|
return [stroke ? colorPropToSVG(STROKE, stroke) : '', strokeWidth ? "stroke-width: ".concat(strokeWidth, "; ") : '', fontFamily ? "font-family: ".concat(!fontFamily.includes("'") && !fontFamily.includes('"') ? "'".concat(fontFamily, "'") : fontFamily, "; ") : '', fontSize ? "font-size: ".concat(fontSize, "px; ") : '', fontStyle ? "font-style: ".concat(fontStyle, "; ") : '', fontWeight ? "font-weight: ".concat(fontWeight, "; ") : '', textDecoration ? "text-decoration: ".concat(textDecoration, "; ") : textDecoration, fill ? colorPropToSVG(FILL, fill) : '', deltaY ? "baseline-shift: ".concat(-deltaY, "; ") : '', useWhiteSpace ? 'white-space: pre; ' : ''].join('');
|
|
}
|
|
|
|
/**
|
|
* Returns text-decoration property for svg-export
|
|
* @param {Object} style the object from which to retrieve style properties
|
|
* @return {String}
|
|
*/
|
|
getSvgTextDecoration(style) {
|
|
return ['overline', 'underline', 'line-through'].filter(decoration => style[decoration.replace('-', '')]).join(' ');
|
|
}
|
|
}
|
|
|
|
const _excluded$3 = ["textAnchor", "textDecoration", "dx", "dy", "top", "left", "fontSize", "strokeWidth"];
|
|
let measuringContext;
|
|
|
|
/**
|
|
* Return a context for measurement of text string.
|
|
* if created it gets stored for reuse
|
|
*/
|
|
function getMeasuringContext() {
|
|
if (!measuringContext) {
|
|
const canvas = createCanvasElement();
|
|
canvas.width = canvas.height = 0;
|
|
measuringContext = canvas.getContext('2d');
|
|
}
|
|
return measuringContext;
|
|
}
|
|
|
|
/**
|
|
* Measure and return the info of a single grapheme.
|
|
* needs the the info of previous graphemes already filled
|
|
* Override to customize measuring
|
|
*/
|
|
|
|
// @TODO this is not complete
|
|
|
|
/**
|
|
* Text class
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-2#text}
|
|
*/
|
|
class FabricText extends StyledText {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), FabricText.ownDefaults);
|
|
}
|
|
constructor(text, options) {
|
|
super();
|
|
/**
|
|
* contains characters bounding boxes
|
|
* This variable is considered to be protected.
|
|
* But for how mixins are implemented right now, we can't leave it private
|
|
* @protected
|
|
*/
|
|
_defineProperty(this, "__charBounds", []);
|
|
Object.assign(this, FabricText.ownDefaults);
|
|
this.setOptions(options);
|
|
if (!this.styles) {
|
|
this.styles = {};
|
|
}
|
|
this.text = text;
|
|
this.initialized = true;
|
|
if (this.path) {
|
|
this.setPathInfo();
|
|
}
|
|
this.initDimensions();
|
|
this.setCoords();
|
|
}
|
|
|
|
/**
|
|
* If text has a path, it will add the extra information needed
|
|
* for path and text calculations
|
|
*/
|
|
setPathInfo() {
|
|
const path = this.path;
|
|
if (path) {
|
|
path.segmentsInfo = getPathSegmentsInfo(path.path);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Divides text into lines of text and lines of graphemes.
|
|
*/
|
|
_splitText() {
|
|
const newLines = this._splitTextIntoLines(this.text);
|
|
this.textLines = newLines.lines;
|
|
this._textLines = newLines.graphemeLines;
|
|
this._unwrappedTextLines = newLines._unwrappedLines;
|
|
this._text = newLines.graphemeText;
|
|
return newLines;
|
|
}
|
|
|
|
/**
|
|
* Initialize or update text dimensions.
|
|
* Updates this.width and this.height with the proper values.
|
|
* Does not return dimensions.
|
|
*/
|
|
initDimensions() {
|
|
this._splitText();
|
|
this._clearCache();
|
|
this.dirty = true;
|
|
if (this.path) {
|
|
this.width = this.path.width;
|
|
this.height = this.path.height;
|
|
} else {
|
|
this.width = this.calcTextWidth() || this.cursorWidth || this.MIN_TEXT_WIDTH;
|
|
this.height = this.calcTextHeight();
|
|
}
|
|
if (this.textAlign.includes(JUSTIFY)) {
|
|
// once text is measured we need to make space fatter to make justified text.
|
|
this.enlargeSpaces();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enlarge space boxes and shift the others
|
|
*/
|
|
enlargeSpaces() {
|
|
let diffSpace, currentLineWidth, numberOfSpaces, accumulatedSpace, line, charBound, spaces;
|
|
for (let i = 0, len = this._textLines.length; i < len; i++) {
|
|
if (this.textAlign !== JUSTIFY && (i === len - 1 || this.isEndOfWrapping(i))) {
|
|
continue;
|
|
}
|
|
accumulatedSpace = 0;
|
|
line = this._textLines[i];
|
|
currentLineWidth = this.getLineWidth(i);
|
|
if (currentLineWidth < this.width && (spaces = this.textLines[i].match(this._reSpacesAndTabs))) {
|
|
numberOfSpaces = spaces.length;
|
|
diffSpace = (this.width - currentLineWidth) / numberOfSpaces;
|
|
for (let j = 0; j <= line.length; j++) {
|
|
charBound = this.__charBounds[i][j];
|
|
if (this._reSpaceAndTab.test(line[j])) {
|
|
charBound.width += diffSpace;
|
|
charBound.kernedWidth += diffSpace;
|
|
charBound.left += accumulatedSpace;
|
|
accumulatedSpace += diffSpace;
|
|
} else {
|
|
charBound.left += accumulatedSpace;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect if the text line is ended with an hard break
|
|
* text and itext do not have wrapping, return false
|
|
* @return {Boolean}
|
|
*/
|
|
isEndOfWrapping(lineIndex) {
|
|
return lineIndex === this._textLines.length - 1;
|
|
}
|
|
|
|
/**
|
|
* Detect if a line has a linebreak and so we need to account for it when moving
|
|
* and counting style.
|
|
* It return always 1 for text and Itext. Textbox has its own implementation
|
|
* @return Number
|
|
*/
|
|
|
|
missingNewlineOffset(_lineIndex) {
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Returns 2d representation (lineIndex and charIndex) of cursor
|
|
* @param {Number} selectionStart
|
|
* @param {Boolean} [skipWrapping] consider the location for unwrapped lines. useful to manage styles.
|
|
*/
|
|
get2DCursorLocation(selectionStart, skipWrapping) {
|
|
const lines = skipWrapping ? this._unwrappedTextLines : this._textLines;
|
|
let i;
|
|
for (i = 0; i < lines.length; i++) {
|
|
if (selectionStart <= lines[i].length) {
|
|
return {
|
|
lineIndex: i,
|
|
charIndex: selectionStart
|
|
};
|
|
}
|
|
selectionStart -= lines[i].length + this.missingNewlineOffset(i, skipWrapping);
|
|
}
|
|
return {
|
|
lineIndex: i - 1,
|
|
charIndex: lines[i - 1].length < selectionStart ? lines[i - 1].length : selectionStart
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns string representation of an instance
|
|
* @return {String} String representation of text object
|
|
*/
|
|
toString() {
|
|
return "#<Text (".concat(this.complexity(), "): { \"text\": \"").concat(this.text, "\", \"fontFamily\": \"").concat(this.fontFamily, "\" }>");
|
|
}
|
|
|
|
/**
|
|
* Return the dimension and the zoom level needed to create a cache canvas
|
|
* big enough to host the object to be cached.
|
|
* @private
|
|
* @param {Object} dim.x width of object to be cached
|
|
* @param {Object} dim.y height of object to be cached
|
|
* @return {Object}.width width of canvas
|
|
* @return {Object}.height height of canvas
|
|
* @return {Object}.zoomX zoomX zoom value to unscale the canvas before drawing cache
|
|
* @return {Object}.zoomY zoomY zoom value to unscale the canvas before drawing cache
|
|
*/
|
|
_getCacheCanvasDimensions() {
|
|
const dims = super._getCacheCanvasDimensions();
|
|
const fontSize = this.fontSize;
|
|
dims.width += fontSize * dims.zoomX;
|
|
dims.height += fontSize * dims.zoomY;
|
|
return dims;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_render(ctx) {
|
|
const path = this.path;
|
|
path && !path.isNotVisible() && path._render(ctx);
|
|
this._setTextStyles(ctx);
|
|
this._renderTextLinesBackground(ctx);
|
|
this._renderTextDecoration(ctx, 'underline');
|
|
this._renderText(ctx);
|
|
this._renderTextDecoration(ctx, 'overline');
|
|
this._renderTextDecoration(ctx, 'linethrough');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderText(ctx) {
|
|
if (this.paintFirst === STROKE) {
|
|
this._renderTextStroke(ctx);
|
|
this._renderTextFill(ctx);
|
|
} else {
|
|
this._renderTextFill(ctx);
|
|
this._renderTextStroke(ctx);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the font parameter of the context with the object properties or with charStyle
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {Object} [charStyle] object with font style properties
|
|
* @param {String} [charStyle.fontFamily] Font Family
|
|
* @param {Number} [charStyle.fontSize] Font size in pixels. ( without px suffix )
|
|
* @param {String} [charStyle.fontWeight] Font weight
|
|
* @param {String} [charStyle.fontStyle] Font style (italic|normal)
|
|
*/
|
|
_setTextStyles(ctx, charStyle, forMeasuring) {
|
|
ctx.textBaseline = 'alphabetic';
|
|
if (this.path) {
|
|
switch (this.pathAlign) {
|
|
case CENTER:
|
|
ctx.textBaseline = 'middle';
|
|
break;
|
|
case 'ascender':
|
|
ctx.textBaseline = TOP;
|
|
break;
|
|
case 'descender':
|
|
ctx.textBaseline = BOTTOM;
|
|
break;
|
|
}
|
|
}
|
|
ctx.font = this._getFontDeclaration(charStyle, forMeasuring);
|
|
}
|
|
|
|
/**
|
|
* calculate and return the text Width measuring each line.
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @return {Number} Maximum width of Text object
|
|
*/
|
|
calcTextWidth() {
|
|
let maxWidth = this.getLineWidth(0);
|
|
for (let i = 1, len = this._textLines.length; i < len; i++) {
|
|
const currentLineWidth = this.getLineWidth(i);
|
|
if (currentLineWidth > maxWidth) {
|
|
maxWidth = currentLineWidth;
|
|
}
|
|
}
|
|
return maxWidth;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} method Method name ("fillText" or "strokeText")
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {String} line Text to render
|
|
* @param {Number} left Left position of text
|
|
* @param {Number} top Top position of text
|
|
* @param {Number} lineIndex Index of a line in a text
|
|
*/
|
|
_renderTextLine(method, ctx, line, left, top, lineIndex) {
|
|
this._renderChars(method, ctx, line, left, top, lineIndex);
|
|
}
|
|
|
|
/**
|
|
* Renders the text background for lines, taking care of style
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderTextLinesBackground(ctx) {
|
|
if (!this.textBackgroundColor && !this.styleHas('textBackgroundColor')) {
|
|
return;
|
|
}
|
|
const originalFill = ctx.fillStyle,
|
|
leftOffset = this._getLeftOffset();
|
|
let lineTopOffset = this._getTopOffset();
|
|
for (let i = 0, len = this._textLines.length; i < len; i++) {
|
|
const heightOfLine = this.getHeightOfLine(i);
|
|
if (!this.textBackgroundColor && !this.styleHas('textBackgroundColor', i)) {
|
|
lineTopOffset += heightOfLine;
|
|
continue;
|
|
}
|
|
const jlen = this._textLines[i].length;
|
|
const lineLeftOffset = this._getLineLeftOffset(i);
|
|
let boxWidth = 0;
|
|
let boxStart = 0;
|
|
let drawStart;
|
|
let currentColor;
|
|
let lastColor = this.getValueOfPropertyAt(i, 0, 'textBackgroundColor');
|
|
for (let j = 0; j < jlen; j++) {
|
|
// at this point charbox are either standard or full with pathInfo if there is a path.
|
|
const charBox = this.__charBounds[i][j];
|
|
currentColor = this.getValueOfPropertyAt(i, j, 'textBackgroundColor');
|
|
if (this.path) {
|
|
ctx.save();
|
|
ctx.translate(charBox.renderLeft, charBox.renderTop);
|
|
ctx.rotate(charBox.angle);
|
|
ctx.fillStyle = currentColor;
|
|
currentColor && ctx.fillRect(-charBox.width / 2, -heightOfLine / this.lineHeight * (1 - this._fontSizeFraction), charBox.width, heightOfLine / this.lineHeight);
|
|
ctx.restore();
|
|
} else if (currentColor !== lastColor) {
|
|
drawStart = leftOffset + lineLeftOffset + boxStart;
|
|
if (this.direction === 'rtl') {
|
|
drawStart = this.width - drawStart - boxWidth;
|
|
}
|
|
ctx.fillStyle = lastColor;
|
|
lastColor && ctx.fillRect(drawStart, lineTopOffset, boxWidth, heightOfLine / this.lineHeight);
|
|
boxStart = charBox.left;
|
|
boxWidth = charBox.width;
|
|
lastColor = currentColor;
|
|
} else {
|
|
boxWidth += charBox.kernedWidth;
|
|
}
|
|
}
|
|
if (currentColor && !this.path) {
|
|
drawStart = leftOffset + lineLeftOffset + boxStart;
|
|
if (this.direction === 'rtl') {
|
|
drawStart = this.width - drawStart - boxWidth;
|
|
}
|
|
ctx.fillStyle = currentColor;
|
|
ctx.fillRect(drawStart, lineTopOffset, boxWidth, heightOfLine / this.lineHeight);
|
|
}
|
|
lineTopOffset += heightOfLine;
|
|
}
|
|
ctx.fillStyle = originalFill;
|
|
// if there is text background color no
|
|
// other shadows should be casted
|
|
this._removeShadow(ctx);
|
|
}
|
|
|
|
/**
|
|
* measure and return the width of a single character.
|
|
* possibly overridden to accommodate different measure logic or
|
|
* to hook some external lib for character measurement
|
|
* @private
|
|
* @param {String} _char, char to be measured
|
|
* @param {Object} charStyle style of char to be measured
|
|
* @param {String} [previousChar] previous char
|
|
* @param {Object} [prevCharStyle] style of previous char
|
|
*/
|
|
_measureChar(_char, charStyle, previousChar, prevCharStyle) {
|
|
const fontCache = cache.getFontCache(charStyle),
|
|
fontDeclaration = this._getFontDeclaration(charStyle),
|
|
couple = previousChar + _char,
|
|
stylesAreEqual = previousChar && fontDeclaration === this._getFontDeclaration(prevCharStyle),
|
|
fontMultiplier = charStyle.fontSize / this.CACHE_FONT_SIZE;
|
|
let width, coupleWidth, previousWidth, kernedWidth;
|
|
if (previousChar && fontCache[previousChar] !== undefined) {
|
|
previousWidth = fontCache[previousChar];
|
|
}
|
|
if (fontCache[_char] !== undefined) {
|
|
kernedWidth = width = fontCache[_char];
|
|
}
|
|
if (stylesAreEqual && fontCache[couple] !== undefined) {
|
|
coupleWidth = fontCache[couple];
|
|
kernedWidth = coupleWidth - previousWidth;
|
|
}
|
|
if (width === undefined || previousWidth === undefined || coupleWidth === undefined) {
|
|
const ctx = getMeasuringContext();
|
|
// send a TRUE to specify measuring font size CACHE_FONT_SIZE
|
|
this._setTextStyles(ctx, charStyle, true);
|
|
if (width === undefined) {
|
|
kernedWidth = width = ctx.measureText(_char).width;
|
|
fontCache[_char] = width;
|
|
}
|
|
if (previousWidth === undefined && stylesAreEqual && previousChar) {
|
|
previousWidth = ctx.measureText(previousChar).width;
|
|
fontCache[previousChar] = previousWidth;
|
|
}
|
|
if (stylesAreEqual && coupleWidth === undefined) {
|
|
// we can measure the kerning couple and subtract the width of the previous character
|
|
coupleWidth = ctx.measureText(couple).width;
|
|
fontCache[couple] = coupleWidth;
|
|
// safe to use the non-null since if undefined we defined it before.
|
|
kernedWidth = coupleWidth - previousWidth;
|
|
}
|
|
}
|
|
return {
|
|
width: width * fontMultiplier,
|
|
kernedWidth: kernedWidth * fontMultiplier
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Computes height of character at given position
|
|
* @param {Number} line the line index number
|
|
* @param {Number} _char the character index number
|
|
* @return {Number} fontSize of the character
|
|
*/
|
|
getHeightOfChar(line, _char) {
|
|
return this.getValueOfPropertyAt(line, _char, 'fontSize');
|
|
}
|
|
|
|
/**
|
|
* measure a text line measuring all characters.
|
|
* @param {Number} lineIndex line number
|
|
*/
|
|
measureLine(lineIndex) {
|
|
const lineInfo = this._measureLine(lineIndex);
|
|
if (this.charSpacing !== 0) {
|
|
lineInfo.width -= this._getWidthOfCharSpacing();
|
|
}
|
|
if (lineInfo.width < 0) {
|
|
lineInfo.width = 0;
|
|
}
|
|
return lineInfo;
|
|
}
|
|
|
|
/**
|
|
* measure every grapheme of a line, populating __charBounds
|
|
* @param {Number} lineIndex
|
|
* @return {Object} object.width total width of characters
|
|
* @return {Object} object.numOfSpaces length of chars that match this._reSpacesAndTabs
|
|
*/
|
|
_measureLine(lineIndex) {
|
|
let width = 0,
|
|
prevGrapheme,
|
|
graphemeInfo;
|
|
const reverse = this.pathSide === RIGHT,
|
|
path = this.path,
|
|
line = this._textLines[lineIndex],
|
|
llength = line.length,
|
|
lineBounds = new Array(llength);
|
|
this.__charBounds[lineIndex] = lineBounds;
|
|
for (let i = 0; i < llength; i++) {
|
|
const grapheme = line[i];
|
|
graphemeInfo = this._getGraphemeBox(grapheme, lineIndex, i, prevGrapheme);
|
|
lineBounds[i] = graphemeInfo;
|
|
width += graphemeInfo.kernedWidth;
|
|
prevGrapheme = grapheme;
|
|
}
|
|
// this latest bound box represent the last character of the line
|
|
// to simplify cursor handling in interactive mode.
|
|
lineBounds[llength] = {
|
|
left: graphemeInfo ? graphemeInfo.left + graphemeInfo.width : 0,
|
|
width: 0,
|
|
kernedWidth: 0,
|
|
height: this.fontSize,
|
|
deltaY: 0
|
|
};
|
|
if (path && path.segmentsInfo) {
|
|
let positionInPath = 0;
|
|
const totalPathLength = path.segmentsInfo[path.segmentsInfo.length - 1].length;
|
|
switch (this.textAlign) {
|
|
case LEFT:
|
|
positionInPath = reverse ? totalPathLength - width : 0;
|
|
break;
|
|
case CENTER:
|
|
positionInPath = (totalPathLength - width) / 2;
|
|
break;
|
|
case RIGHT:
|
|
positionInPath = reverse ? 0 : totalPathLength - width;
|
|
break;
|
|
//todo - add support for justify
|
|
}
|
|
positionInPath += this.pathStartOffset * (reverse ? -1 : 1);
|
|
for (let i = reverse ? llength - 1 : 0; reverse ? i >= 0 : i < llength; reverse ? i-- : i++) {
|
|
graphemeInfo = lineBounds[i];
|
|
if (positionInPath > totalPathLength) {
|
|
positionInPath %= totalPathLength;
|
|
} else if (positionInPath < 0) {
|
|
positionInPath += totalPathLength;
|
|
}
|
|
// it would probably much faster to send all the grapheme position for a line
|
|
// and calculate path position/angle at once.
|
|
this._setGraphemeOnPath(positionInPath, graphemeInfo);
|
|
positionInPath += graphemeInfo.kernedWidth;
|
|
}
|
|
}
|
|
return {
|
|
width: width,
|
|
numOfSpaces: 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate the angle and the left,top position of the char that follow a path.
|
|
* It appends it to graphemeInfo to be reused later at rendering
|
|
* @private
|
|
* @param {Number} positionInPath to be measured
|
|
* @param {GraphemeBBox} graphemeInfo current grapheme box information
|
|
* @param {Object} startingPoint position of the point
|
|
*/
|
|
_setGraphemeOnPath(positionInPath, graphemeInfo) {
|
|
const centerPosition = positionInPath + graphemeInfo.kernedWidth / 2,
|
|
path = this.path;
|
|
|
|
// we are at currentPositionOnPath. we want to know what point on the path is.
|
|
const info = getPointOnPath(path.path, centerPosition, path.segmentsInfo);
|
|
graphemeInfo.renderLeft = info.x - path.pathOffset.x;
|
|
graphemeInfo.renderTop = info.y - path.pathOffset.y;
|
|
graphemeInfo.angle = info.angle + (this.pathSide === RIGHT ? Math.PI : 0);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {String} grapheme to be measured
|
|
* @param {Number} lineIndex index of the line where the char is
|
|
* @param {Number} charIndex position in the line
|
|
* @param {String} [prevGrapheme] character preceding the one to be measured
|
|
* @returns {GraphemeBBox} grapheme bbox
|
|
*/
|
|
_getGraphemeBox(grapheme, lineIndex, charIndex, prevGrapheme, skipLeft) {
|
|
const style = this.getCompleteStyleDeclaration(lineIndex, charIndex),
|
|
prevStyle = prevGrapheme ? this.getCompleteStyleDeclaration(lineIndex, charIndex - 1) : {},
|
|
info = this._measureChar(grapheme, style, prevGrapheme, prevStyle);
|
|
let kernedWidth = info.kernedWidth,
|
|
width = info.width,
|
|
charSpacing;
|
|
if (this.charSpacing !== 0) {
|
|
charSpacing = this._getWidthOfCharSpacing();
|
|
width += charSpacing;
|
|
kernedWidth += charSpacing;
|
|
}
|
|
const box = {
|
|
width,
|
|
left: 0,
|
|
height: style.fontSize,
|
|
kernedWidth,
|
|
deltaY: style.deltaY
|
|
};
|
|
if (charIndex > 0 && !skipLeft) {
|
|
const previousBox = this.__charBounds[lineIndex][charIndex - 1];
|
|
box.left = previousBox.left + previousBox.width + info.kernedWidth - info.width;
|
|
}
|
|
return box;
|
|
}
|
|
|
|
/**
|
|
* Calculate height of line at 'lineIndex'
|
|
* @param {Number} lineIndex index of line to calculate
|
|
* @return {Number}
|
|
*/
|
|
getHeightOfLine(lineIndex) {
|
|
if (this.__lineHeights[lineIndex]) {
|
|
return this.__lineHeights[lineIndex];
|
|
}
|
|
|
|
// char 0 is measured before the line cycle because it needs to char
|
|
// emptylines
|
|
let maxHeight = this.getHeightOfChar(lineIndex, 0);
|
|
for (let i = 1, len = this._textLines[lineIndex].length; i < len; i++) {
|
|
maxHeight = Math.max(this.getHeightOfChar(lineIndex, i), maxHeight);
|
|
}
|
|
return this.__lineHeights[lineIndex] = maxHeight * this.lineHeight * this._fontSizeMult;
|
|
}
|
|
|
|
/**
|
|
* Calculate text box height
|
|
*/
|
|
calcTextHeight() {
|
|
let lineHeight,
|
|
height = 0;
|
|
for (let i = 0, len = this._textLines.length; i < len; i++) {
|
|
lineHeight = this.getHeightOfLine(i);
|
|
height += i === len - 1 ? lineHeight / this.lineHeight : lineHeight;
|
|
}
|
|
return height;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @return {Number} Left offset
|
|
*/
|
|
_getLeftOffset() {
|
|
return this.direction === 'ltr' ? -this.width / 2 : this.width / 2;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @return {Number} Top offset
|
|
*/
|
|
_getTopOffset() {
|
|
return -this.height / 2;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {String} method Method name ("fillText" or "strokeText")
|
|
*/
|
|
_renderTextCommon(ctx, method) {
|
|
ctx.save();
|
|
let lineHeights = 0;
|
|
const left = this._getLeftOffset(),
|
|
top = this._getTopOffset();
|
|
for (let i = 0, len = this._textLines.length; i < len; i++) {
|
|
const heightOfLine = this.getHeightOfLine(i),
|
|
maxHeight = heightOfLine / this.lineHeight,
|
|
leftOffset = this._getLineLeftOffset(i);
|
|
this._renderTextLine(method, ctx, this._textLines[i], left + leftOffset, top + lineHeights + maxHeight, i);
|
|
lineHeights += heightOfLine;
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderTextFill(ctx) {
|
|
if (!this.fill && !this.styleHas(FILL)) {
|
|
return;
|
|
}
|
|
this._renderTextCommon(ctx, 'fillText');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderTextStroke(ctx) {
|
|
if ((!this.stroke || this.strokeWidth === 0) && this.isEmptyStyles()) {
|
|
return;
|
|
}
|
|
if (this.shadow && !this.shadow.affectStroke) {
|
|
this._removeShadow(ctx);
|
|
}
|
|
ctx.save();
|
|
this._setLineDash(ctx, this.strokeDashArray);
|
|
ctx.beginPath();
|
|
this._renderTextCommon(ctx, 'strokeText');
|
|
ctx.closePath();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} method fillText or strokeText.
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {Array} line Content of the line, splitted in an array by grapheme
|
|
* @param {Number} left
|
|
* @param {Number} top
|
|
* @param {Number} lineIndex
|
|
*/
|
|
_renderChars(method, ctx, line, left, top, lineIndex) {
|
|
const lineHeight = this.getHeightOfLine(lineIndex),
|
|
isJustify = this.textAlign.includes(JUSTIFY),
|
|
path = this.path,
|
|
shortCut = !isJustify && this.charSpacing === 0 && this.isEmptyStyles(lineIndex) && !path,
|
|
isLtr = this.direction === 'ltr',
|
|
sign = this.direction === 'ltr' ? 1 : -1,
|
|
// this was changed in the PR #7674
|
|
// currentDirection = ctx.canvas.getAttribute('dir');
|
|
currentDirection = ctx.direction;
|
|
let actualStyle,
|
|
nextStyle,
|
|
charsToRender = '',
|
|
charBox,
|
|
boxWidth = 0,
|
|
timeToRender,
|
|
drawingLeft;
|
|
ctx.save();
|
|
if (currentDirection !== this.direction) {
|
|
ctx.canvas.setAttribute('dir', isLtr ? 'ltr' : 'rtl');
|
|
ctx.direction = isLtr ? 'ltr' : 'rtl';
|
|
ctx.textAlign = isLtr ? LEFT : RIGHT;
|
|
}
|
|
top -= lineHeight * this._fontSizeFraction / this.lineHeight;
|
|
if (shortCut) {
|
|
// render all the line in one pass without checking
|
|
// drawingLeft = isLtr ? left : left - this.getLineWidth(lineIndex);
|
|
this._renderChar(method, ctx, lineIndex, 0, line.join(''), left, top);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
for (let i = 0, len = line.length - 1; i <= len; i++) {
|
|
timeToRender = i === len || this.charSpacing || path;
|
|
charsToRender += line[i];
|
|
charBox = this.__charBounds[lineIndex][i];
|
|
if (boxWidth === 0) {
|
|
left += sign * (charBox.kernedWidth - charBox.width);
|
|
boxWidth += charBox.width;
|
|
} else {
|
|
boxWidth += charBox.kernedWidth;
|
|
}
|
|
if (isJustify && !timeToRender) {
|
|
if (this._reSpaceAndTab.test(line[i])) {
|
|
timeToRender = true;
|
|
}
|
|
}
|
|
if (!timeToRender) {
|
|
// if we have charSpacing, we render char by char
|
|
actualStyle = actualStyle || this.getCompleteStyleDeclaration(lineIndex, i);
|
|
nextStyle = this.getCompleteStyleDeclaration(lineIndex, i + 1);
|
|
timeToRender = hasStyleChanged(actualStyle, nextStyle, false);
|
|
}
|
|
if (timeToRender) {
|
|
if (path) {
|
|
ctx.save();
|
|
ctx.translate(charBox.renderLeft, charBox.renderTop);
|
|
ctx.rotate(charBox.angle);
|
|
this._renderChar(method, ctx, lineIndex, i, charsToRender, -boxWidth / 2, 0);
|
|
ctx.restore();
|
|
} else {
|
|
drawingLeft = left;
|
|
this._renderChar(method, ctx, lineIndex, i, charsToRender, drawingLeft, top);
|
|
}
|
|
charsToRender = '';
|
|
actualStyle = nextStyle;
|
|
left += sign * boxWidth;
|
|
boxWidth = 0;
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* This function try to patch the missing gradientTransform on canvas gradients.
|
|
* transforming a context to transform the gradient, is going to transform the stroke too.
|
|
* we want to transform the gradient but not the stroke operation, so we create
|
|
* a transformed gradient on a pattern and then we use the pattern instead of the gradient.
|
|
* this method has drawbacks: is slow, is in low resolution, needs a patch for when the size
|
|
* is limited.
|
|
* @private
|
|
* @param {TFiller} filler a fabric gradient instance
|
|
* @return {CanvasPattern} a pattern to use as fill/stroke style
|
|
*/
|
|
_applyPatternGradientTransformText(filler) {
|
|
const pCanvas = createCanvasElement(),
|
|
// TODO: verify compatibility with strokeUniform
|
|
width = this.width + this.strokeWidth,
|
|
height = this.height + this.strokeWidth,
|
|
pCtx = pCanvas.getContext('2d');
|
|
pCanvas.width = width;
|
|
pCanvas.height = height;
|
|
pCtx.beginPath();
|
|
pCtx.moveTo(0, 0);
|
|
pCtx.lineTo(width, 0);
|
|
pCtx.lineTo(width, height);
|
|
pCtx.lineTo(0, height);
|
|
pCtx.closePath();
|
|
pCtx.translate(width / 2, height / 2);
|
|
pCtx.fillStyle = filler.toLive(pCtx);
|
|
this._applyPatternGradientTransform(pCtx, filler);
|
|
pCtx.fill();
|
|
return pCtx.createPattern(pCanvas, 'no-repeat');
|
|
}
|
|
handleFiller(ctx, property, filler) {
|
|
let offsetX, offsetY;
|
|
if (isFiller(filler)) {
|
|
if (filler.gradientUnits === 'percentage' || filler.gradientTransform || filler.patternTransform) {
|
|
// need to transform gradient in a pattern.
|
|
// this is a slow process. If you are hitting this codepath, and the object
|
|
// is not using caching, you should consider switching it on.
|
|
// we need a canvas as big as the current object caching canvas.
|
|
offsetX = -this.width / 2;
|
|
offsetY = -this.height / 2;
|
|
ctx.translate(offsetX, offsetY);
|
|
ctx[property] = this._applyPatternGradientTransformText(filler);
|
|
return {
|
|
offsetX,
|
|
offsetY
|
|
};
|
|
} else {
|
|
// is a simple gradient or pattern
|
|
ctx[property] = filler.toLive(ctx);
|
|
return this._applyPatternGradientTransform(ctx, filler);
|
|
}
|
|
} else {
|
|
// is a color
|
|
ctx[property] = filler;
|
|
}
|
|
return {
|
|
offsetX: 0,
|
|
offsetY: 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* This function prepare the canvas for a stroke style, and stroke and strokeWidth
|
|
* need to be sent in as defined
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {CompleteTextStyleDeclaration} style with stroke and strokeWidth defined
|
|
* @returns
|
|
*/
|
|
_setStrokeStyles(ctx, _ref) {
|
|
let {
|
|
stroke,
|
|
strokeWidth
|
|
} = _ref;
|
|
ctx.lineWidth = strokeWidth;
|
|
ctx.lineCap = this.strokeLineCap;
|
|
ctx.lineDashOffset = this.strokeDashOffset;
|
|
ctx.lineJoin = this.strokeLineJoin;
|
|
ctx.miterLimit = this.strokeMiterLimit;
|
|
return this.handleFiller(ctx, 'strokeStyle', stroke);
|
|
}
|
|
|
|
/**
|
|
* This function prepare the canvas for a ill style, and fill
|
|
* need to be sent in as defined
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {CompleteTextStyleDeclaration} style with ill defined
|
|
* @returns
|
|
*/
|
|
_setFillStyles(ctx, _ref2) {
|
|
let {
|
|
fill
|
|
} = _ref2;
|
|
return this.handleFiller(ctx, 'fillStyle', fill);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} method
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {Number} lineIndex
|
|
* @param {Number} charIndex
|
|
* @param {String} _char
|
|
* @param {Number} left Left coordinate
|
|
* @param {Number} top Top coordinate
|
|
* @param {Number} lineHeight Height of the line
|
|
*/
|
|
_renderChar(method, ctx, lineIndex, charIndex, _char, left, top) {
|
|
const decl = this._getStyleDeclaration(lineIndex, charIndex),
|
|
fullDecl = this.getCompleteStyleDeclaration(lineIndex, charIndex),
|
|
shouldFill = method === 'fillText' && fullDecl.fill,
|
|
shouldStroke = method === 'strokeText' && fullDecl.stroke && fullDecl.strokeWidth;
|
|
if (!shouldStroke && !shouldFill) {
|
|
return;
|
|
}
|
|
ctx.save();
|
|
ctx.font = this._getFontDeclaration(fullDecl);
|
|
if (decl.textBackgroundColor) {
|
|
this._removeShadow(ctx);
|
|
}
|
|
if (decl.deltaY) {
|
|
top += decl.deltaY;
|
|
}
|
|
if (shouldFill) {
|
|
const fillOffsets = this._setFillStyles(ctx, fullDecl);
|
|
ctx.fillText(_char, left - fillOffsets.offsetX, top - fillOffsets.offsetY);
|
|
}
|
|
if (shouldStroke) {
|
|
const strokeOffsets = this._setStrokeStyles(ctx, fullDecl);
|
|
ctx.strokeText(_char, left - strokeOffsets.offsetX, top - strokeOffsets.offsetY);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Turns the character into a 'superior figure' (i.e. 'superscript')
|
|
* @param {Number} start selection start
|
|
* @param {Number} end selection end
|
|
*/
|
|
setSuperscript(start, end) {
|
|
this._setScript(start, end, this.superscript);
|
|
}
|
|
|
|
/**
|
|
* Turns the character into an 'inferior figure' (i.e. 'subscript')
|
|
* @param {Number} start selection start
|
|
* @param {Number} end selection end
|
|
*/
|
|
setSubscript(start, end) {
|
|
this._setScript(start, end, this.subscript);
|
|
}
|
|
|
|
/**
|
|
* Applies 'schema' at given position
|
|
* @private
|
|
* @param {Number} start selection start
|
|
* @param {Number} end selection end
|
|
* @param {Number} schema
|
|
*/
|
|
_setScript(start, end, schema) {
|
|
const loc = this.get2DCursorLocation(start, true),
|
|
fontSize = this.getValueOfPropertyAt(loc.lineIndex, loc.charIndex, 'fontSize'),
|
|
dy = this.getValueOfPropertyAt(loc.lineIndex, loc.charIndex, 'deltaY'),
|
|
style = {
|
|
fontSize: fontSize * schema.size,
|
|
deltaY: dy + fontSize * schema.baseline
|
|
};
|
|
this.setSelectionStyles(style, start, end);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Number} lineIndex index text line
|
|
* @return {Number} Line left offset
|
|
*/
|
|
_getLineLeftOffset(lineIndex) {
|
|
const lineWidth = this.getLineWidth(lineIndex),
|
|
lineDiff = this.width - lineWidth,
|
|
textAlign = this.textAlign,
|
|
direction = this.direction,
|
|
isEndOfWrapping = this.isEndOfWrapping(lineIndex);
|
|
let leftOffset = 0;
|
|
if (textAlign === JUSTIFY || textAlign === JUSTIFY_CENTER && !isEndOfWrapping || textAlign === JUSTIFY_RIGHT && !isEndOfWrapping || textAlign === JUSTIFY_LEFT && !isEndOfWrapping) {
|
|
return 0;
|
|
}
|
|
if (textAlign === CENTER) {
|
|
leftOffset = lineDiff / 2;
|
|
}
|
|
if (textAlign === RIGHT) {
|
|
leftOffset = lineDiff;
|
|
}
|
|
if (textAlign === JUSTIFY_CENTER) {
|
|
leftOffset = lineDiff / 2;
|
|
}
|
|
if (textAlign === JUSTIFY_RIGHT) {
|
|
leftOffset = lineDiff;
|
|
}
|
|
if (direction === 'rtl') {
|
|
if (textAlign === RIGHT || textAlign === JUSTIFY || textAlign === JUSTIFY_RIGHT) {
|
|
leftOffset = 0;
|
|
} else if (textAlign === LEFT || textAlign === JUSTIFY_LEFT) {
|
|
leftOffset = -lineDiff;
|
|
} else if (textAlign === CENTER || textAlign === JUSTIFY_CENTER) {
|
|
leftOffset = -lineDiff / 2;
|
|
}
|
|
}
|
|
return leftOffset;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_clearCache() {
|
|
this._forceClearCache = false;
|
|
this.__lineWidths = [];
|
|
this.__lineHeights = [];
|
|
this.__charBounds = [];
|
|
}
|
|
|
|
/**
|
|
* Measure a single line given its index. Used to calculate the initial
|
|
* text bounding box. The values are calculated and stored in __lineWidths cache.
|
|
* @private
|
|
* @param {Number} lineIndex line number
|
|
* @return {Number} Line width
|
|
*/
|
|
getLineWidth(lineIndex) {
|
|
if (this.__lineWidths[lineIndex] !== undefined) {
|
|
return this.__lineWidths[lineIndex];
|
|
}
|
|
const {
|
|
width
|
|
} = this.measureLine(lineIndex);
|
|
this.__lineWidths[lineIndex] = width;
|
|
return width;
|
|
}
|
|
_getWidthOfCharSpacing() {
|
|
if (this.charSpacing !== 0) {
|
|
return this.fontSize * this.charSpacing / 1000;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the value of property at given character position
|
|
* @param {Number} lineIndex the line number
|
|
* @param {Number} charIndex the character number
|
|
* @param {String} property the property name
|
|
* @returns the value of 'property'
|
|
*/
|
|
getValueOfPropertyAt(lineIndex, charIndex, property) {
|
|
var _charStyle$property;
|
|
const charStyle = this._getStyleDeclaration(lineIndex, charIndex);
|
|
return (_charStyle$property = charStyle[property]) !== null && _charStyle$property !== void 0 ? _charStyle$property : this[property];
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_renderTextDecoration(ctx, type) {
|
|
if (!this[type] && !this.styleHas(type)) {
|
|
return;
|
|
}
|
|
let topOffset = this._getTopOffset();
|
|
const leftOffset = this._getLeftOffset(),
|
|
path = this.path,
|
|
charSpacing = this._getWidthOfCharSpacing(),
|
|
offsetY = this.offsets[type];
|
|
for (let i = 0, len = this._textLines.length; i < len; i++) {
|
|
const heightOfLine = this.getHeightOfLine(i);
|
|
if (!this[type] && !this.styleHas(type, i)) {
|
|
topOffset += heightOfLine;
|
|
continue;
|
|
}
|
|
const line = this._textLines[i];
|
|
const maxHeight = heightOfLine / this.lineHeight;
|
|
const lineLeftOffset = this._getLineLeftOffset(i);
|
|
let boxStart = 0;
|
|
let boxWidth = 0;
|
|
let lastDecoration = this.getValueOfPropertyAt(i, 0, type);
|
|
let lastFill = this.getValueOfPropertyAt(i, 0, FILL);
|
|
let currentDecoration;
|
|
let currentFill;
|
|
const top = topOffset + maxHeight * (1 - this._fontSizeFraction);
|
|
let size = this.getHeightOfChar(i, 0);
|
|
let dy = this.getValueOfPropertyAt(i, 0, 'deltaY');
|
|
for (let j = 0, jlen = line.length; j < jlen; j++) {
|
|
const charBox = this.__charBounds[i][j];
|
|
currentDecoration = this.getValueOfPropertyAt(i, j, type);
|
|
currentFill = this.getValueOfPropertyAt(i, j, FILL);
|
|
const currentSize = this.getHeightOfChar(i, j);
|
|
const currentDy = this.getValueOfPropertyAt(i, j, 'deltaY');
|
|
if (path && currentDecoration && currentFill) {
|
|
ctx.save();
|
|
// bug? verify lastFill is a valid fill here.
|
|
ctx.fillStyle = lastFill;
|
|
ctx.translate(charBox.renderLeft, charBox.renderTop);
|
|
ctx.rotate(charBox.angle);
|
|
ctx.fillRect(-charBox.kernedWidth / 2, offsetY * currentSize + currentDy, charBox.kernedWidth, this.fontSize / 15);
|
|
ctx.restore();
|
|
} else if ((currentDecoration !== lastDecoration || currentFill !== lastFill || currentSize !== size || currentDy !== dy) && boxWidth > 0) {
|
|
let drawStart = leftOffset + lineLeftOffset + boxStart;
|
|
if (this.direction === 'rtl') {
|
|
drawStart = this.width - drawStart - boxWidth;
|
|
}
|
|
if (lastDecoration && lastFill) {
|
|
// bug? verify lastFill is a valid fill here.
|
|
ctx.fillStyle = lastFill;
|
|
ctx.fillRect(drawStart, top + offsetY * size + dy, boxWidth, this.fontSize / 15);
|
|
}
|
|
boxStart = charBox.left;
|
|
boxWidth = charBox.width;
|
|
lastDecoration = currentDecoration;
|
|
lastFill = currentFill;
|
|
size = currentSize;
|
|
dy = currentDy;
|
|
} else {
|
|
boxWidth += charBox.kernedWidth;
|
|
}
|
|
}
|
|
let drawStart = leftOffset + lineLeftOffset + boxStart;
|
|
if (this.direction === 'rtl') {
|
|
drawStart = this.width - drawStart - boxWidth;
|
|
}
|
|
ctx.fillStyle = currentFill;
|
|
currentDecoration && currentFill && ctx.fillRect(drawStart, top + offsetY * size + dy, boxWidth - charSpacing, this.fontSize / 15);
|
|
topOffset += heightOfLine;
|
|
}
|
|
// if there is text background color no
|
|
// other shadows should be casted
|
|
this._removeShadow(ctx);
|
|
}
|
|
|
|
/**
|
|
* return font declaration string for canvas context
|
|
* @param {Object} [styleObject] object
|
|
* @returns {String} font declaration formatted for canvas context.
|
|
*/
|
|
_getFontDeclaration() {
|
|
let {
|
|
fontFamily = this.fontFamily,
|
|
fontStyle = this.fontStyle,
|
|
fontWeight = this.fontWeight,
|
|
fontSize = this.fontSize
|
|
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
let forMeasuring = arguments.length > 1 ? arguments[1] : undefined;
|
|
const parsedFontFamily = fontFamily.includes("'") || fontFamily.includes('"') || fontFamily.includes(',') || FabricText.genericFonts.includes(fontFamily.toLowerCase()) ? fontFamily : "\"".concat(fontFamily, "\"");
|
|
return [fontStyle, fontWeight, "".concat(forMeasuring ? this.CACHE_FONT_SIZE : fontSize, "px"), parsedFontFamily].join(' ');
|
|
}
|
|
|
|
/**
|
|
* Renders text instance on a specified context
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
render(ctx) {
|
|
if (!this.visible) {
|
|
return;
|
|
}
|
|
if (this.canvas && this.canvas.skipOffscreen && !this.group && !this.isOnScreen()) {
|
|
return;
|
|
}
|
|
if (this._forceClearCache) {
|
|
this.initDimensions();
|
|
}
|
|
super.render(ctx);
|
|
}
|
|
|
|
/**
|
|
* Override this method to customize grapheme splitting
|
|
* @todo the util `graphemeSplit` needs to be injectable in some way.
|
|
* is more comfortable to inject the correct util rather than having to override text
|
|
* in the middle of the prototype chain
|
|
* @param {string} value
|
|
* @returns {string[]} array of graphemes
|
|
*/
|
|
graphemeSplit(value) {
|
|
return graphemeSplit(value);
|
|
}
|
|
|
|
/**
|
|
* Returns the text as an array of lines.
|
|
* @param {String} text text to split
|
|
* @returns Lines in the text
|
|
*/
|
|
_splitTextIntoLines(text) {
|
|
const lines = text.split(this._reNewline),
|
|
newLines = new Array(lines.length),
|
|
newLine = ['\n'];
|
|
let newText = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
newLines[i] = this.graphemeSplit(lines[i]);
|
|
newText = newText.concat(newLines[i], newLine);
|
|
}
|
|
newText.pop();
|
|
return {
|
|
_unwrappedLines: newLines,
|
|
lines: lines,
|
|
graphemeText: newText,
|
|
graphemeLines: newLines
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} Object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
return _objectSpread2(_objectSpread2({}, super.toObject([...additionalProps, ...propertiesToInclude])), {}, {
|
|
styles: stylesToArray(this.styles, this.text)
|
|
}, this.path ? {
|
|
path: this.path.toObject()
|
|
} : {});
|
|
}
|
|
set(key, value) {
|
|
const {
|
|
textLayoutProperties
|
|
} = this.constructor;
|
|
super.set(key, value);
|
|
let needsDims = false;
|
|
let isAddingPath = false;
|
|
if (typeof key === 'object') {
|
|
for (const _key in key) {
|
|
if (_key === 'path') {
|
|
this.setPathInfo();
|
|
}
|
|
needsDims = needsDims || textLayoutProperties.includes(_key);
|
|
isAddingPath = isAddingPath || _key === 'path';
|
|
}
|
|
} else {
|
|
needsDims = textLayoutProperties.includes(key);
|
|
isAddingPath = key === 'path';
|
|
}
|
|
if (isAddingPath) {
|
|
this.setPathInfo();
|
|
}
|
|
if (needsDims && this.initialized) {
|
|
this.initDimensions();
|
|
this.setCoords();
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Returns complexity of an instance
|
|
* @return {Number} complexity
|
|
*/
|
|
complexity() {
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* List of generic font families
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#generic-name
|
|
*/
|
|
|
|
/**
|
|
* Returns FabricText instance from an SVG element (<b>not yet implemented</b>)
|
|
* @static
|
|
* @memberOf Text
|
|
* @param {HTMLElement} element Element to parse
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
static async fromElement(element, options, cssRules) {
|
|
const parsedAttributes = parseAttributes(element, FabricText.ATTRIBUTE_NAMES, cssRules);
|
|
const _options$parsedAttrib = _objectSpread2(_objectSpread2({}, options), parsedAttributes),
|
|
{
|
|
textAnchor = LEFT,
|
|
textDecoration = '',
|
|
dx = 0,
|
|
dy = 0,
|
|
top = 0,
|
|
left = 0,
|
|
fontSize = DEFAULT_SVG_FONT_SIZE,
|
|
strokeWidth = 1
|
|
} = _options$parsedAttrib,
|
|
restOfOptions = _objectWithoutProperties(_options$parsedAttrib, _excluded$3);
|
|
const textContent = (element.textContent || '').replace(/^\s+|\s+$|\n+/g, '').replace(/\s+/g, ' ');
|
|
|
|
// this code here is probably the usual issue for SVG center find
|
|
// this can later looked at again and probably removed.
|
|
|
|
const text = new this(textContent, _objectSpread2({
|
|
left: left + dx,
|
|
top: top + dy,
|
|
underline: textDecoration.includes('underline'),
|
|
overline: textDecoration.includes('overline'),
|
|
linethrough: textDecoration.includes('line-through'),
|
|
// we initialize this as 0
|
|
strokeWidth: 0,
|
|
fontSize
|
|
}, restOfOptions)),
|
|
textHeightScaleFactor = text.getScaledHeight() / text.height,
|
|
lineHeightDiff = (text.height + text.strokeWidth) * text.lineHeight - text.height,
|
|
scaledDiff = lineHeightDiff * textHeightScaleFactor,
|
|
textHeight = text.getScaledHeight() + scaledDiff;
|
|
let offX = 0;
|
|
/*
|
|
Adjust positioning:
|
|
x/y attributes in SVG correspond to the bottom-left corner of text bounding box
|
|
fabric output by default at top, left.
|
|
*/
|
|
if (textAnchor === CENTER) {
|
|
offX = text.getScaledWidth() / 2;
|
|
}
|
|
if (textAnchor === RIGHT) {
|
|
offX = text.getScaledWidth();
|
|
}
|
|
text.set({
|
|
left: text.left - offX,
|
|
top: text.top - (textHeight - text.fontSize * (0.07 + text._fontSizeFraction)) / text.lineHeight,
|
|
strokeWidth
|
|
});
|
|
return text;
|
|
}
|
|
|
|
/* _FROM_SVG_END_ */
|
|
|
|
/**
|
|
* Returns FabricText instance from an object representation
|
|
* @param {Object} object plain js Object to create an instance from
|
|
* @returns {Promise<FabricText>}
|
|
*/
|
|
static fromObject(object) {
|
|
return this._fromObject(_objectSpread2(_objectSpread2({}, object), {}, {
|
|
styles: stylesFromArray(object.styles || {}, object.text)
|
|
}), {
|
|
extraParam: 'text'
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Properties that requires a text layout recalculation when changed
|
|
* @type string[]
|
|
* @protected
|
|
*/
|
|
_defineProperty(FabricText, "textLayoutProperties", textLayoutProperties);
|
|
_defineProperty(FabricText, "cacheProperties", [...cacheProperties, ...additionalProps]);
|
|
_defineProperty(FabricText, "ownDefaults", textDefaultValues);
|
|
_defineProperty(FabricText, "type", 'Text');
|
|
_defineProperty(FabricText, "genericFonts", ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji', 'fangsong']);
|
|
/* _FROM_SVG_START_ */
|
|
/**
|
|
* List of attribute names to account for when parsing SVG element (used by {@link FabricText.fromElement})
|
|
* @static
|
|
* @memberOf Text
|
|
* @see: http://www.w3.org/TR/SVG/text.html#TextElement
|
|
*/
|
|
_defineProperty(FabricText, "ATTRIBUTE_NAMES", SHARED_ATTRIBUTES.concat('x', 'y', 'dx', 'dy', 'font-family', 'font-style', 'font-weight', 'font-size', 'letter-spacing', 'text-decoration', 'text-anchor'));
|
|
applyMixins(FabricText, [TextSVGExportMixin]);
|
|
classRegistry.setClass(FabricText);
|
|
classRegistry.setSVGClass(FabricText);
|
|
|
|
/**
|
|
* #### Dragging IText/Textbox Lifecycle
|
|
* - {@link start} is called from `mousedown` {@link IText#_mouseDownHandler} and determines if dragging should start by testing {@link isPointerOverSelection}
|
|
* - if true `mousedown` {@link IText#_mouseDownHandler} is blocked to keep selection
|
|
* - if the pointer moves, canvas fires numerous mousemove {@link Canvas#_onMouseMove} that we make sure **aren't** prevented ({@link IText#shouldStartDragging}) in order for the window to start a drag session
|
|
* - once/if the session starts canvas calls {@link onDragStart} on the active object to determine if dragging should occur
|
|
* - canvas fires relevant drag events that are handled by the handlers defined in this scope
|
|
* - {@link end} is called from `mouseup` {@link IText#mouseUpHandler}, blocking IText default click behavior
|
|
* - in case the drag session didn't occur, {@link end} handles a click, since logic to do so was blocked during `mousedown`
|
|
*/
|
|
class DraggableTextDelegate {
|
|
constructor(target) {
|
|
_defineProperty(this, "target", void 0);
|
|
_defineProperty(this, "__mouseDownInPlace", false);
|
|
_defineProperty(this, "__dragStartFired", false);
|
|
_defineProperty(this, "__isDraggingOver", false);
|
|
_defineProperty(this, "__dragStartSelection", void 0);
|
|
_defineProperty(this, "__dragImageDisposer", void 0);
|
|
_defineProperty(this, "_dispose", void 0);
|
|
this.target = target;
|
|
const disposers = [this.target.on('dragenter', this.dragEnterHandler.bind(this)), this.target.on('dragover', this.dragOverHandler.bind(this)), this.target.on('dragleave', this.dragLeaveHandler.bind(this)), this.target.on('dragend', this.dragEndHandler.bind(this)), this.target.on('drop', this.dropHandler.bind(this))];
|
|
this._dispose = () => {
|
|
disposers.forEach(d => d());
|
|
this._dispose = undefined;
|
|
};
|
|
}
|
|
isPointerOverSelection(e) {
|
|
const target = this.target;
|
|
const newSelection = target.getSelectionStartFromPointer(e);
|
|
return target.isEditing && newSelection >= target.selectionStart && newSelection <= target.selectionEnd && target.selectionStart < target.selectionEnd;
|
|
}
|
|
|
|
/**
|
|
* @public override this method to disable dragging and default to mousedown logic
|
|
*/
|
|
start(e) {
|
|
return this.__mouseDownInPlace = this.isPointerOverSelection(e);
|
|
}
|
|
|
|
/**
|
|
* @public override this method to disable dragging without discarding selection
|
|
*/
|
|
isActive() {
|
|
return this.__mouseDownInPlace;
|
|
}
|
|
|
|
/**
|
|
* Ends interaction and sets cursor in case of a click
|
|
* @returns true if was active
|
|
*/
|
|
end(e) {
|
|
const active = this.isActive();
|
|
if (active && !this.__dragStartFired) {
|
|
// mousedown has been blocked since `active` is true => cursor has not been set.
|
|
// `__dragStartFired` is false => dragging didn't occur, pointer didn't move and is over selection.
|
|
// meaning this is actually a click, `active` is a false positive.
|
|
this.target.setCursorByClick(e);
|
|
this.target.initDelayedCursor(true);
|
|
}
|
|
this.__mouseDownInPlace = false;
|
|
this.__dragStartFired = false;
|
|
this.__isDraggingOver = false;
|
|
return active;
|
|
}
|
|
getDragStartSelection() {
|
|
return this.__dragStartSelection;
|
|
}
|
|
|
|
/**
|
|
* Override to customize the drag image
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/setDragImage
|
|
*/
|
|
setDragImage(e, _ref) {
|
|
var _e$dataTransfer;
|
|
let {
|
|
selectionStart,
|
|
selectionEnd
|
|
} = _ref;
|
|
const target = this.target;
|
|
const canvas = target.canvas;
|
|
const flipFactor = new Point(target.flipX ? -1 : 1, target.flipY ? -1 : 1);
|
|
const boundaries = target._getCursorBoundaries(selectionStart);
|
|
const selectionPosition = new Point(boundaries.left + boundaries.leftOffset, boundaries.top + boundaries.topOffset).multiply(flipFactor);
|
|
const pos = selectionPosition.transform(target.calcTransformMatrix());
|
|
const pointer = canvas.getScenePoint(e);
|
|
const diff = pointer.subtract(pos);
|
|
const retinaScaling = target.getCanvasRetinaScaling();
|
|
const bbox = target.getBoundingRect();
|
|
const correction = pos.subtract(new Point(bbox.left, bbox.top));
|
|
const vpt = canvas.viewportTransform;
|
|
const offset = correction.add(diff).transform(vpt, true);
|
|
// prepare instance for drag image snapshot by making all non selected text invisible
|
|
const bgc = target.backgroundColor;
|
|
const styles = cloneStyles(target.styles);
|
|
target.backgroundColor = '';
|
|
const styleOverride = {
|
|
stroke: 'transparent',
|
|
fill: 'transparent',
|
|
textBackgroundColor: 'transparent'
|
|
};
|
|
target.setSelectionStyles(styleOverride, 0, selectionStart);
|
|
target.setSelectionStyles(styleOverride, selectionEnd, target.text.length);
|
|
target.dirty = true;
|
|
const dragImage = target.toCanvasElement({
|
|
enableRetinaScaling: canvas.enableRetinaScaling,
|
|
viewportTransform: true
|
|
});
|
|
// restore values
|
|
target.backgroundColor = bgc;
|
|
target.styles = styles;
|
|
target.dirty = true;
|
|
// position drag image offscreen
|
|
setStyle(dragImage, {
|
|
position: 'fixed',
|
|
left: "".concat(-dragImage.width, "px"),
|
|
border: NONE,
|
|
width: "".concat(dragImage.width / retinaScaling, "px"),
|
|
height: "".concat(dragImage.height / retinaScaling, "px")
|
|
});
|
|
this.__dragImageDisposer && this.__dragImageDisposer();
|
|
this.__dragImageDisposer = () => {
|
|
dragImage.remove();
|
|
};
|
|
getDocumentFromElement(e.target || this.target.hiddenTextarea).body.appendChild(dragImage);
|
|
(_e$dataTransfer = e.dataTransfer) === null || _e$dataTransfer === void 0 || _e$dataTransfer.setDragImage(dragImage, offset.x, offset.y);
|
|
}
|
|
|
|
/**
|
|
* @returns {boolean} determines whether {@link target} should/shouldn't become a drag source
|
|
*/
|
|
onDragStart(e) {
|
|
this.__dragStartFired = true;
|
|
const target = this.target;
|
|
const active = this.isActive();
|
|
if (active && e.dataTransfer) {
|
|
const selection = this.__dragStartSelection = {
|
|
selectionStart: target.selectionStart,
|
|
selectionEnd: target.selectionEnd
|
|
};
|
|
const value = target._text.slice(selection.selectionStart, selection.selectionEnd).join('');
|
|
const data = _objectSpread2({
|
|
text: target.text,
|
|
value
|
|
}, selection);
|
|
e.dataTransfer.setData('text/plain', value);
|
|
e.dataTransfer.setData('application/fabric', JSON.stringify({
|
|
value: value,
|
|
styles: target.getSelectionStyles(selection.selectionStart, selection.selectionEnd, true)
|
|
}));
|
|
e.dataTransfer.effectAllowed = 'copyMove';
|
|
this.setDragImage(e, data);
|
|
}
|
|
target.abortCursorAnimation();
|
|
return active;
|
|
}
|
|
|
|
/**
|
|
* use {@link targetCanDrop} to respect overriding
|
|
* @returns {boolean} determines whether {@link target} should/shouldn't become a drop target
|
|
*/
|
|
canDrop(e) {
|
|
if (this.target.editable && !this.target.getActiveControl() && !e.defaultPrevented) {
|
|
if (this.isActive() && this.__dragStartSelection) {
|
|
// drag source trying to drop over itself
|
|
// allow dropping only outside of drag start selection
|
|
const index = this.target.getSelectionStartFromPointer(e);
|
|
const dragStartSelection = this.__dragStartSelection;
|
|
return index < dragStartSelection.selectionStart || index > dragStartSelection.selectionEnd;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* in order to respect overriding {@link IText#canDrop} we call that instead of calling {@link canDrop} directly
|
|
*/
|
|
targetCanDrop(e) {
|
|
return this.target.canDrop(e);
|
|
}
|
|
dragEnterHandler(_ref2) {
|
|
let {
|
|
e
|
|
} = _ref2;
|
|
const canDrop = this.targetCanDrop(e);
|
|
if (!this.__isDraggingOver && canDrop) {
|
|
this.__isDraggingOver = true;
|
|
}
|
|
}
|
|
dragOverHandler(ev) {
|
|
const {
|
|
e
|
|
} = ev;
|
|
const canDrop = this.targetCanDrop(e);
|
|
if (!this.__isDraggingOver && canDrop) {
|
|
this.__isDraggingOver = true;
|
|
} else if (this.__isDraggingOver && !canDrop) {
|
|
// drop state has changed
|
|
this.__isDraggingOver = false;
|
|
}
|
|
if (this.__isDraggingOver) {
|
|
// can be dropped, inform browser
|
|
e.preventDefault();
|
|
// inform event subscribers
|
|
ev.canDrop = true;
|
|
ev.dropTarget = this.target;
|
|
}
|
|
}
|
|
dragLeaveHandler() {
|
|
if (this.__isDraggingOver || this.isActive()) {
|
|
this.__isDraggingOver = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Override the `text/plain | application/fabric` types of {@link DragEvent#dataTransfer}
|
|
* in order to change the drop value or to customize styling respectively, by listening to the `drop:before` event
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#performing_a_drop
|
|
*/
|
|
dropHandler(ev) {
|
|
var _e$dataTransfer2;
|
|
const {
|
|
e
|
|
} = ev;
|
|
const didDrop = e.defaultPrevented;
|
|
this.__isDraggingOver = false;
|
|
// inform browser that the drop has been accepted
|
|
e.preventDefault();
|
|
let insert = (_e$dataTransfer2 = e.dataTransfer) === null || _e$dataTransfer2 === void 0 ? void 0 : _e$dataTransfer2.getData('text/plain');
|
|
if (insert && !didDrop) {
|
|
const target = this.target;
|
|
const canvas = target.canvas;
|
|
let insertAt = target.getSelectionStartFromPointer(e);
|
|
const {
|
|
styles
|
|
} = e.dataTransfer.types.includes('application/fabric') ? JSON.parse(e.dataTransfer.getData('application/fabric')) : {};
|
|
const trailing = insert[Math.max(0, insert.length - 1)];
|
|
const selectionStartOffset = 0;
|
|
// drag and drop in same instance
|
|
if (this.__dragStartSelection) {
|
|
const selectionStart = this.__dragStartSelection.selectionStart;
|
|
const selectionEnd = this.__dragStartSelection.selectionEnd;
|
|
if (insertAt > selectionStart && insertAt <= selectionEnd) {
|
|
insertAt = selectionStart;
|
|
} else if (insertAt > selectionEnd) {
|
|
insertAt -= selectionEnd - selectionStart;
|
|
}
|
|
target.removeChars(selectionStart, selectionEnd);
|
|
// prevent `dragend` from handling event
|
|
delete this.__dragStartSelection;
|
|
}
|
|
// remove redundant line break
|
|
if (target._reNewline.test(trailing) && (target._reNewline.test(target._text[insertAt]) || insertAt === target._text.length)) {
|
|
insert = insert.trimEnd();
|
|
}
|
|
// inform subscribers
|
|
ev.didDrop = true;
|
|
ev.dropTarget = target;
|
|
// finalize
|
|
target.insertChars(insert, styles, insertAt);
|
|
// can this part be moved in an outside event? andrea to check.
|
|
canvas.setActiveObject(target);
|
|
target.enterEditing(e);
|
|
target.selectionStart = Math.min(insertAt + selectionStartOffset, target._text.length);
|
|
target.selectionEnd = Math.min(target.selectionStart + insert.length, target._text.length);
|
|
target.hiddenTextarea.value = target.text;
|
|
target._updateTextarea();
|
|
target.hiddenTextarea.focus();
|
|
target.fire(CHANGED, {
|
|
index: insertAt + selectionStartOffset,
|
|
action: 'drop'
|
|
});
|
|
canvas.fire('text:changed', {
|
|
target
|
|
});
|
|
canvas.contextTopDirty = true;
|
|
canvas.requestRenderAll();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* fired only on the drag source after drop (if occurred)
|
|
* handle changes to the drag source in case of a drop on another object or a cancellation
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#finishing_a_drag
|
|
*/
|
|
dragEndHandler(_ref3) {
|
|
let {
|
|
e
|
|
} = _ref3;
|
|
if (this.isActive() && this.__dragStartFired) {
|
|
// once the drop event finishes we check if we need to change the drag source
|
|
// if the drag source received the drop we bail out since the drop handler has already handled logic
|
|
if (this.__dragStartSelection) {
|
|
var _e$dataTransfer3;
|
|
const target = this.target;
|
|
const canvas = this.target.canvas;
|
|
const {
|
|
selectionStart,
|
|
selectionEnd
|
|
} = this.__dragStartSelection;
|
|
const dropEffect = ((_e$dataTransfer3 = e.dataTransfer) === null || _e$dataTransfer3 === void 0 ? void 0 : _e$dataTransfer3.dropEffect) || NONE;
|
|
if (dropEffect === NONE) {
|
|
// pointer is back over selection
|
|
target.selectionStart = selectionStart;
|
|
target.selectionEnd = selectionEnd;
|
|
target._updateTextarea();
|
|
target.hiddenTextarea.focus();
|
|
} else {
|
|
target.clearContextTop();
|
|
if (dropEffect === 'move') {
|
|
target.removeChars(selectionStart, selectionEnd);
|
|
target.selectionStart = target.selectionEnd = selectionStart;
|
|
target.hiddenTextarea && (target.hiddenTextarea.value = target.text);
|
|
target._updateTextarea();
|
|
target.fire(CHANGED, {
|
|
index: selectionStart,
|
|
action: 'dragend'
|
|
});
|
|
canvas.fire('text:changed', {
|
|
target
|
|
});
|
|
canvas.requestRenderAll();
|
|
}
|
|
target.exitEditing();
|
|
}
|
|
}
|
|
}
|
|
this.__dragImageDisposer && this.__dragImageDisposer();
|
|
delete this.__dragImageDisposer;
|
|
delete this.__dragStartSelection;
|
|
this.__isDraggingOver = false;
|
|
}
|
|
dispose() {
|
|
this._dispose && this._dispose();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* extend this regex to support non english languages
|
|
*
|
|
* - ` ` Matches a SPACE character (char code 32).
|
|
* - `\n` Matches a LINE FEED character (char code 10).
|
|
* - `\.` Matches a "." character (char code 46).
|
|
* - `,` Matches a "," character (char code 44).
|
|
* - `;` Matches a ";" character (char code 59).
|
|
* - `!` Matches a "!" character (char code 33).
|
|
* - `\?` Matches a "?" character (char code 63).
|
|
* - `\-` Matches a "-" character (char code 45).
|
|
*/
|
|
// eslint-disable-next-line no-useless-escape
|
|
const reNonWord = /[ \n\.,;!\?\-]/;
|
|
class ITextBehavior extends FabricText {
|
|
constructor() {
|
|
super(...arguments);
|
|
/**
|
|
* Helps determining when the text is in composition, so that the cursor
|
|
* rendering is altered.
|
|
*/
|
|
_defineProperty(this, "_currentCursorOpacity", 1);
|
|
}
|
|
/**
|
|
* Initializes all the interactive behavior of IText
|
|
*/
|
|
initBehavior() {
|
|
this._tick = this._tick.bind(this);
|
|
this._onTickComplete = this._onTickComplete.bind(this);
|
|
this.updateSelectionOnMouseMove = this.updateSelectionOnMouseMove.bind(this);
|
|
}
|
|
onDeselect(options) {
|
|
this.isEditing && this.exitEditing();
|
|
this.selected = false;
|
|
return super.onDeselect(options);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_animateCursor(_ref) {
|
|
let {
|
|
toValue,
|
|
duration,
|
|
delay,
|
|
onComplete
|
|
} = _ref;
|
|
return animate({
|
|
startValue: this._currentCursorOpacity,
|
|
endValue: toValue,
|
|
duration,
|
|
delay,
|
|
onComplete,
|
|
abort: () => !this.canvas ||
|
|
// we do not want to animate a selection, only cursor
|
|
this.selectionStart !== this.selectionEnd,
|
|
onChange: value => {
|
|
this._currentCursorOpacity = value;
|
|
this.renderCursorOrSelection();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* changes the cursor from visible to invisible
|
|
*/
|
|
_tick(delay) {
|
|
this._currentTickState = this._animateCursor({
|
|
toValue: 0,
|
|
duration: this.cursorDuration / 2,
|
|
delay: Math.max(delay || 0, 100),
|
|
onComplete: this._onTickComplete
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Changes the cursor from invisible to visible
|
|
*/
|
|
_onTickComplete() {
|
|
var _this$_currentTickCom;
|
|
(_this$_currentTickCom = this._currentTickCompleteState) === null || _this$_currentTickCom === void 0 || _this$_currentTickCom.abort();
|
|
this._currentTickCompleteState = this._animateCursor({
|
|
toValue: 1,
|
|
duration: this.cursorDuration,
|
|
onComplete: this._tick
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initializes delayed cursor
|
|
*/
|
|
initDelayedCursor(restart) {
|
|
this.abortCursorAnimation();
|
|
this._tick(restart ? 0 : this.cursorDelay);
|
|
}
|
|
|
|
/**
|
|
* Aborts cursor animation, clears all timeouts and clear textarea context if necessary
|
|
*/
|
|
abortCursorAnimation() {
|
|
let shouldClear = false;
|
|
[this._currentTickState, this._currentTickCompleteState].forEach(cursorAnimation => {
|
|
if (cursorAnimation && !cursorAnimation.isDone()) {
|
|
shouldClear = true;
|
|
cursorAnimation.abort();
|
|
}
|
|
});
|
|
this._currentCursorOpacity = 1;
|
|
|
|
// make sure we clear context even if instance is not editing
|
|
if (shouldClear) {
|
|
this.clearContextTop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restart tue cursor animation if either is in complete state ( between animations )
|
|
* or if it never started before
|
|
*/
|
|
restartCursorIfNeeded() {
|
|
if ([this._currentTickState, this._currentTickCompleteState].some(cursorAnimation => !cursorAnimation || cursorAnimation.isDone())) {
|
|
this.initDelayedCursor();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Selects entire text
|
|
*/
|
|
selectAll() {
|
|
this.selectionStart = 0;
|
|
this.selectionEnd = this._text.length;
|
|
this._fireSelectionChanged();
|
|
this._updateTextarea();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Returns selected text
|
|
* @return {String}
|
|
*/
|
|
getSelectedText() {
|
|
return this._text.slice(this.selectionStart, this.selectionEnd).join('');
|
|
}
|
|
|
|
/**
|
|
* Find new selection index representing start of current word according to current selection index
|
|
* @param {Number} startFrom Current selection index
|
|
* @return {Number} New selection index
|
|
*/
|
|
findWordBoundaryLeft(startFrom) {
|
|
let offset = 0,
|
|
index = startFrom - 1;
|
|
|
|
// remove space before cursor first
|
|
if (this._reSpace.test(this._text[index])) {
|
|
while (this._reSpace.test(this._text[index])) {
|
|
offset++;
|
|
index--;
|
|
}
|
|
}
|
|
while (/\S/.test(this._text[index]) && index > -1) {
|
|
offset++;
|
|
index--;
|
|
}
|
|
return startFrom - offset;
|
|
}
|
|
|
|
/**
|
|
* Find new selection index representing end of current word according to current selection index
|
|
* @param {Number} startFrom Current selection index
|
|
* @return {Number} New selection index
|
|
*/
|
|
findWordBoundaryRight(startFrom) {
|
|
let offset = 0,
|
|
index = startFrom;
|
|
|
|
// remove space after cursor first
|
|
if (this._reSpace.test(this._text[index])) {
|
|
while (this._reSpace.test(this._text[index])) {
|
|
offset++;
|
|
index++;
|
|
}
|
|
}
|
|
while (/\S/.test(this._text[index]) && index < this._text.length) {
|
|
offset++;
|
|
index++;
|
|
}
|
|
return startFrom + offset;
|
|
}
|
|
|
|
/**
|
|
* Find new selection index representing start of current line according to current selection index
|
|
* @param {Number} startFrom Current selection index
|
|
* @return {Number} New selection index
|
|
*/
|
|
findLineBoundaryLeft(startFrom) {
|
|
let offset = 0,
|
|
index = startFrom - 1;
|
|
while (!/\n/.test(this._text[index]) && index > -1) {
|
|
offset++;
|
|
index--;
|
|
}
|
|
return startFrom - offset;
|
|
}
|
|
|
|
/**
|
|
* Find new selection index representing end of current line according to current selection index
|
|
* @param {Number} startFrom Current selection index
|
|
* @return {Number} New selection index
|
|
*/
|
|
findLineBoundaryRight(startFrom) {
|
|
let offset = 0,
|
|
index = startFrom;
|
|
while (!/\n/.test(this._text[index]) && index < this._text.length) {
|
|
offset++;
|
|
index++;
|
|
}
|
|
return startFrom + offset;
|
|
}
|
|
|
|
/**
|
|
* Finds index corresponding to beginning or end of a word
|
|
* @param {Number} selectionStart Index of a character
|
|
* @param {Number} direction 1 or -1
|
|
* @return {Number} Index of the beginning or end of a word
|
|
*/
|
|
searchWordBoundary(selectionStart, direction) {
|
|
const text = this._text;
|
|
// if we land on a space we move the cursor backwards
|
|
// if we are searching boundary end we move the cursor backwards ONLY if we don't land on a line break
|
|
let index = selectionStart > 0 && this._reSpace.test(text[selectionStart]) && (direction === -1 || !reNewline.test(text[selectionStart - 1])) ? selectionStart - 1 : selectionStart,
|
|
_char = text[index];
|
|
while (index > 0 && index < text.length && !reNonWord.test(_char)) {
|
|
index += direction;
|
|
_char = text[index];
|
|
}
|
|
if (direction === -1 && reNonWord.test(_char)) {
|
|
index++;
|
|
}
|
|
return index;
|
|
}
|
|
|
|
/**
|
|
* TODO fix: selectionStart set as 0 will be ignored?
|
|
* Selects a word based on the index
|
|
* @param {Number} selectionStart Index of a character
|
|
*/
|
|
selectWord(selectionStart) {
|
|
selectionStart = selectionStart || this.selectionStart;
|
|
// search backwards
|
|
const newSelectionStart = this.searchWordBoundary(selectionStart, -1),
|
|
// search forward
|
|
newSelectionEnd = Math.max(newSelectionStart, this.searchWordBoundary(selectionStart, 1));
|
|
this.selectionStart = newSelectionStart;
|
|
this.selectionEnd = newSelectionEnd;
|
|
this._fireSelectionChanged();
|
|
this._updateTextarea();
|
|
this.renderCursorOrSelection();
|
|
}
|
|
|
|
/**
|
|
* TODO fix: selectionStart set as 0 will be ignored?
|
|
* Selects a line based on the index
|
|
* @param {Number} selectionStart Index of a character
|
|
*/
|
|
selectLine(selectionStart) {
|
|
selectionStart = selectionStart || this.selectionStart;
|
|
const newSelectionStart = this.findLineBoundaryLeft(selectionStart),
|
|
newSelectionEnd = this.findLineBoundaryRight(selectionStart);
|
|
this.selectionStart = newSelectionStart;
|
|
this.selectionEnd = newSelectionEnd;
|
|
this._fireSelectionChanged();
|
|
this._updateTextarea();
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Enters editing state
|
|
*/
|
|
enterEditing(e) {
|
|
if (this.isEditing || !this.editable) {
|
|
return;
|
|
}
|
|
if (this.canvas) {
|
|
this.canvas.calcOffset();
|
|
this.canvas.textEditingManager.exitTextEditing();
|
|
}
|
|
this.isEditing = true;
|
|
this.initHiddenTextarea();
|
|
this.hiddenTextarea.focus();
|
|
this.hiddenTextarea.value = this.text;
|
|
this._updateTextarea();
|
|
this._saveEditingProps();
|
|
this._setEditingProps();
|
|
this._textBeforeEdit = this.text;
|
|
this._tick();
|
|
this.fire('editing:entered', e ? {
|
|
e
|
|
} : undefined);
|
|
this._fireSelectionChanged();
|
|
if (this.canvas) {
|
|
this.canvas.fire('text:editing:entered', {
|
|
target: this,
|
|
e
|
|
});
|
|
this.canvas.requestRenderAll();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* called by {@link Canvas#textEditingManager}
|
|
*/
|
|
updateSelectionOnMouseMove(e) {
|
|
if (this.getActiveControl()) {
|
|
return;
|
|
}
|
|
const el = this.hiddenTextarea;
|
|
// regain focus
|
|
getDocumentFromElement(el).activeElement !== el && el.focus();
|
|
const newSelectionStart = this.getSelectionStartFromPointer(e),
|
|
currentStart = this.selectionStart,
|
|
currentEnd = this.selectionEnd;
|
|
if ((newSelectionStart !== this.__selectionStartOnMouseDown || currentStart === currentEnd) && (currentStart === newSelectionStart || currentEnd === newSelectionStart)) {
|
|
return;
|
|
}
|
|
if (newSelectionStart > this.__selectionStartOnMouseDown) {
|
|
this.selectionStart = this.__selectionStartOnMouseDown;
|
|
this.selectionEnd = newSelectionStart;
|
|
} else {
|
|
this.selectionStart = newSelectionStart;
|
|
this.selectionEnd = this.__selectionStartOnMouseDown;
|
|
}
|
|
if (this.selectionStart !== currentStart || this.selectionEnd !== currentEnd) {
|
|
this._fireSelectionChanged();
|
|
this._updateTextarea();
|
|
this.renderCursorOrSelection();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_setEditingProps() {
|
|
this.hoverCursor = 'text';
|
|
if (this.canvas) {
|
|
this.canvas.defaultCursor = this.canvas.moveCursor = 'text';
|
|
}
|
|
this.borderColor = this.editingBorderColor;
|
|
this.hasControls = this.selectable = false;
|
|
this.lockMovementX = this.lockMovementY = true;
|
|
}
|
|
|
|
/**
|
|
* convert from textarea to grapheme indexes
|
|
*/
|
|
fromStringToGraphemeSelection(start, end, text) {
|
|
const smallerTextStart = text.slice(0, start),
|
|
graphemeStart = this.graphemeSplit(smallerTextStart).length;
|
|
if (start === end) {
|
|
return {
|
|
selectionStart: graphemeStart,
|
|
selectionEnd: graphemeStart
|
|
};
|
|
}
|
|
const smallerTextEnd = text.slice(start, end),
|
|
graphemeEnd = this.graphemeSplit(smallerTextEnd).length;
|
|
return {
|
|
selectionStart: graphemeStart,
|
|
selectionEnd: graphemeStart + graphemeEnd
|
|
};
|
|
}
|
|
|
|
/**
|
|
* convert from fabric to textarea values
|
|
*/
|
|
fromGraphemeToStringSelection(start, end, graphemes) {
|
|
const smallerTextStart = graphemes.slice(0, start),
|
|
graphemeStart = smallerTextStart.join('').length;
|
|
if (start === end) {
|
|
return {
|
|
selectionStart: graphemeStart,
|
|
selectionEnd: graphemeStart
|
|
};
|
|
}
|
|
const smallerTextEnd = graphemes.slice(start, end),
|
|
graphemeEnd = smallerTextEnd.join('').length;
|
|
return {
|
|
selectionStart: graphemeStart,
|
|
selectionEnd: graphemeStart + graphemeEnd
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_updateTextarea() {
|
|
this.cursorOffsetCache = {};
|
|
if (!this.hiddenTextarea) {
|
|
return;
|
|
}
|
|
if (!this.inCompositionMode) {
|
|
const newSelection = this.fromGraphemeToStringSelection(this.selectionStart, this.selectionEnd, this._text);
|
|
this.hiddenTextarea.selectionStart = newSelection.selectionStart;
|
|
this.hiddenTextarea.selectionEnd = newSelection.selectionEnd;
|
|
}
|
|
this.updateTextareaPosition();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
updateFromTextArea() {
|
|
if (!this.hiddenTextarea) {
|
|
return;
|
|
}
|
|
this.cursorOffsetCache = {};
|
|
const textarea = this.hiddenTextarea;
|
|
this.text = textarea.value;
|
|
this.set('dirty', true);
|
|
this.initDimensions();
|
|
this.setCoords();
|
|
const newSelection = this.fromStringToGraphemeSelection(textarea.selectionStart, textarea.selectionEnd, textarea.value);
|
|
this.selectionEnd = this.selectionStart = newSelection.selectionEnd;
|
|
if (!this.inCompositionMode) {
|
|
this.selectionStart = newSelection.selectionStart;
|
|
}
|
|
this.updateTextareaPosition();
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
updateTextareaPosition() {
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
const style = this._calcTextareaPosition();
|
|
this.hiddenTextarea.style.left = style.left;
|
|
this.hiddenTextarea.style.top = style.top;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @return {Object} style contains style for hiddenTextarea
|
|
*/
|
|
_calcTextareaPosition() {
|
|
if (!this.canvas) {
|
|
return {
|
|
left: '1px',
|
|
top: '1px'
|
|
};
|
|
}
|
|
const desiredPosition = this.inCompositionMode ? this.compositionStart : this.selectionStart,
|
|
boundaries = this._getCursorBoundaries(desiredPosition),
|
|
cursorLocation = this.get2DCursorLocation(desiredPosition),
|
|
lineIndex = cursorLocation.lineIndex,
|
|
charIndex = cursorLocation.charIndex,
|
|
charHeight = this.getValueOfPropertyAt(lineIndex, charIndex, 'fontSize') * this.lineHeight,
|
|
leftOffset = boundaries.leftOffset,
|
|
retinaScaling = this.getCanvasRetinaScaling(),
|
|
upperCanvas = this.canvas.upperCanvasEl,
|
|
upperCanvasWidth = upperCanvas.width / retinaScaling,
|
|
upperCanvasHeight = upperCanvas.height / retinaScaling,
|
|
maxWidth = upperCanvasWidth - charHeight,
|
|
maxHeight = upperCanvasHeight - charHeight;
|
|
const p = new Point(boundaries.left + leftOffset, boundaries.top + boundaries.topOffset + charHeight).transform(this.calcTransformMatrix()).transform(this.canvas.viewportTransform).multiply(new Point(upperCanvas.clientWidth / upperCanvasWidth, upperCanvas.clientHeight / upperCanvasHeight));
|
|
if (p.x < 0) {
|
|
p.x = 0;
|
|
}
|
|
if (p.x > maxWidth) {
|
|
p.x = maxWidth;
|
|
}
|
|
if (p.y < 0) {
|
|
p.y = 0;
|
|
}
|
|
if (p.y > maxHeight) {
|
|
p.y = maxHeight;
|
|
}
|
|
|
|
// add canvas offset on document
|
|
p.x += this.canvas._offset.left;
|
|
p.y += this.canvas._offset.top;
|
|
return {
|
|
left: "".concat(p.x, "px"),
|
|
top: "".concat(p.y, "px"),
|
|
fontSize: "".concat(charHeight, "px"),
|
|
charHeight: charHeight
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_saveEditingProps() {
|
|
this._savedProps = {
|
|
hasControls: this.hasControls,
|
|
borderColor: this.borderColor,
|
|
lockMovementX: this.lockMovementX,
|
|
lockMovementY: this.lockMovementY,
|
|
hoverCursor: this.hoverCursor,
|
|
selectable: this.selectable,
|
|
defaultCursor: this.canvas && this.canvas.defaultCursor,
|
|
moveCursor: this.canvas && this.canvas.moveCursor
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_restoreEditingProps() {
|
|
if (!this._savedProps) {
|
|
return;
|
|
}
|
|
this.hoverCursor = this._savedProps.hoverCursor;
|
|
this.hasControls = this._savedProps.hasControls;
|
|
this.borderColor = this._savedProps.borderColor;
|
|
this.selectable = this._savedProps.selectable;
|
|
this.lockMovementX = this._savedProps.lockMovementX;
|
|
this.lockMovementY = this._savedProps.lockMovementY;
|
|
if (this.canvas) {
|
|
this.canvas.defaultCursor = this._savedProps.defaultCursor || this.canvas.defaultCursor;
|
|
this.canvas.moveCursor = this._savedProps.moveCursor || this.canvas.moveCursor;
|
|
}
|
|
delete this._savedProps;
|
|
}
|
|
|
|
/**
|
|
* runs the actual logic that exits from editing state, see {@link exitEditing}
|
|
*/
|
|
_exitEditing() {
|
|
const hiddenTextarea = this.hiddenTextarea;
|
|
this.selected = false;
|
|
this.isEditing = false;
|
|
if (hiddenTextarea) {
|
|
hiddenTextarea.blur && hiddenTextarea.blur();
|
|
hiddenTextarea.parentNode && hiddenTextarea.parentNode.removeChild(hiddenTextarea);
|
|
}
|
|
this.hiddenTextarea = null;
|
|
this.abortCursorAnimation();
|
|
this.selectionStart !== this.selectionEnd && this.clearContextTop();
|
|
}
|
|
|
|
/**
|
|
* Exits from editing state and fires relevant events
|
|
*/
|
|
exitEditing() {
|
|
const isTextChanged = this._textBeforeEdit !== this.text;
|
|
this._exitEditing();
|
|
this.selectionEnd = this.selectionStart;
|
|
this._restoreEditingProps();
|
|
if (this._forceClearCache) {
|
|
this.initDimensions();
|
|
this.setCoords();
|
|
}
|
|
this.fire('editing:exited');
|
|
isTextChanged && this.fire(MODIFIED);
|
|
if (this.canvas) {
|
|
this.canvas.fire('text:editing:exited', {
|
|
target: this
|
|
});
|
|
// todo: evaluate add an action to this event
|
|
isTextChanged && this.canvas.fire('object:modified', {
|
|
target: this
|
|
});
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_removeExtraneousStyles() {
|
|
for (const prop in this.styles) {
|
|
if (!this._textLines[prop]) {
|
|
delete this.styles[prop];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* remove and reflow a style block from start to end.
|
|
* @param {Number} start linear start position for removal (included in removal)
|
|
* @param {Number} end linear end position for removal ( excluded from removal )
|
|
*/
|
|
removeStyleFromTo(start, end) {
|
|
const {
|
|
lineIndex: lineStart,
|
|
charIndex: charStart
|
|
} = this.get2DCursorLocation(start, true),
|
|
{
|
|
lineIndex: lineEnd,
|
|
charIndex: charEnd
|
|
} = this.get2DCursorLocation(end, true);
|
|
if (lineStart !== lineEnd) {
|
|
// step1 remove the trailing of lineStart
|
|
if (this.styles[lineStart]) {
|
|
for (let i = charStart; i < this._unwrappedTextLines[lineStart].length; i++) {
|
|
delete this.styles[lineStart][i];
|
|
}
|
|
}
|
|
// step2 move the trailing of lineEnd to lineStart if needed
|
|
if (this.styles[lineEnd]) {
|
|
for (let i = charEnd; i < this._unwrappedTextLines[lineEnd].length; i++) {
|
|
const styleObj = this.styles[lineEnd][i];
|
|
if (styleObj) {
|
|
this.styles[lineStart] || (this.styles[lineStart] = {});
|
|
this.styles[lineStart][charStart + i - charEnd] = styleObj;
|
|
}
|
|
}
|
|
}
|
|
// step3 detects lines will be completely removed.
|
|
for (let i = lineStart + 1; i <= lineEnd; i++) {
|
|
delete this.styles[i];
|
|
}
|
|
// step4 shift remaining lines.
|
|
this.shiftLineStyles(lineEnd, lineStart - lineEnd);
|
|
} else {
|
|
// remove and shift left on the same line
|
|
if (this.styles[lineStart]) {
|
|
const styleObj = this.styles[lineStart];
|
|
const diff = charEnd - charStart;
|
|
for (let i = charStart; i < charEnd; i++) {
|
|
delete styleObj[i];
|
|
}
|
|
for (const char in this.styles[lineStart]) {
|
|
const numericChar = parseInt(char, 10);
|
|
if (numericChar >= charEnd) {
|
|
styleObj[numericChar - diff] = styleObj[char];
|
|
delete styleObj[char];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shifts line styles up or down
|
|
* @param {Number} lineIndex Index of a line
|
|
* @param {Number} offset Can any number?
|
|
*/
|
|
shiftLineStyles(lineIndex, offset) {
|
|
const clonedStyles = Object.assign({}, this.styles);
|
|
for (const line in this.styles) {
|
|
const numericLine = parseInt(line, 10);
|
|
if (numericLine > lineIndex) {
|
|
this.styles[numericLine + offset] = clonedStyles[numericLine];
|
|
if (!clonedStyles[numericLine - offset]) {
|
|
delete this.styles[numericLine];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle insertion of more consecutive style lines for when one or more
|
|
* newlines gets added to the text. Since current style needs to be shifted
|
|
* first we shift the current style of the number lines needed, then we add
|
|
* new lines from the last to the first.
|
|
* @param {Number} lineIndex Index of a line
|
|
* @param {Number} charIndex Index of a char
|
|
* @param {Number} qty number of lines to add
|
|
* @param {Array} copiedStyle Array of objects styles
|
|
*/
|
|
insertNewlineStyleObject(lineIndex, charIndex, qty, copiedStyle) {
|
|
const newLineStyles = {};
|
|
const originalLineLength = this._unwrappedTextLines[lineIndex].length;
|
|
const isEndOfLine = originalLineLength === charIndex;
|
|
let someStyleIsCarryingOver = false;
|
|
qty || (qty = 1);
|
|
this.shiftLineStyles(lineIndex, qty);
|
|
const currentCharStyle = this.styles[lineIndex] ? this.styles[lineIndex][charIndex === 0 ? charIndex : charIndex - 1] : undefined;
|
|
|
|
// we clone styles of all chars
|
|
// after cursor onto the current line
|
|
for (const index in this.styles[lineIndex]) {
|
|
const numIndex = parseInt(index, 10);
|
|
if (numIndex >= charIndex) {
|
|
someStyleIsCarryingOver = true;
|
|
newLineStyles[numIndex - charIndex] = this.styles[lineIndex][index];
|
|
// remove lines from the previous line since they're on a new line now
|
|
if (!(isEndOfLine && charIndex === 0)) {
|
|
delete this.styles[lineIndex][index];
|
|
}
|
|
}
|
|
}
|
|
let styleCarriedOver = false;
|
|
if (someStyleIsCarryingOver && !isEndOfLine) {
|
|
// if is end of line, the extra style we copied
|
|
// is probably not something we want
|
|
this.styles[lineIndex + qty] = newLineStyles;
|
|
styleCarriedOver = true;
|
|
}
|
|
if (styleCarriedOver || originalLineLength > charIndex) {
|
|
// skip the last line of since we already prepared it.
|
|
// or contains text without style that we don't want to style
|
|
// just because it changed lines
|
|
qty--;
|
|
}
|
|
// for the all the lines or all the other lines
|
|
// we clone current char style onto the next (otherwise empty) line
|
|
while (qty > 0) {
|
|
if (copiedStyle && copiedStyle[qty - 1]) {
|
|
this.styles[lineIndex + qty] = {
|
|
0: _objectSpread2({}, copiedStyle[qty - 1])
|
|
};
|
|
} else if (currentCharStyle) {
|
|
this.styles[lineIndex + qty] = {
|
|
0: _objectSpread2({}, currentCharStyle)
|
|
};
|
|
} else {
|
|
delete this.styles[lineIndex + qty];
|
|
}
|
|
qty--;
|
|
}
|
|
this._forceClearCache = true;
|
|
}
|
|
|
|
/**
|
|
* Inserts style object for a given line/char index
|
|
* @param {Number} lineIndex Index of a line
|
|
* @param {Number} charIndex Index of a char
|
|
* @param {Number} quantity number Style object to insert, if given
|
|
* @param {Array} copiedStyle array of style objects
|
|
*/
|
|
insertCharStyleObject(lineIndex, charIndex, quantity, copiedStyle) {
|
|
if (!this.styles) {
|
|
this.styles = {};
|
|
}
|
|
const currentLineStyles = this.styles[lineIndex],
|
|
currentLineStylesCloned = currentLineStyles ? _objectSpread2({}, currentLineStyles) : {};
|
|
quantity || (quantity = 1);
|
|
// shift all char styles by quantity forward
|
|
// 0,1,2,3 -> (charIndex=2) -> 0,1,3,4 -> (insert 2) -> 0,1,2,3,4
|
|
for (const index in currentLineStylesCloned) {
|
|
const numericIndex = parseInt(index, 10);
|
|
if (numericIndex >= charIndex) {
|
|
currentLineStyles[numericIndex + quantity] = currentLineStylesCloned[numericIndex];
|
|
// only delete the style if there was nothing moved there
|
|
if (!currentLineStylesCloned[numericIndex - quantity]) {
|
|
delete currentLineStyles[numericIndex];
|
|
}
|
|
}
|
|
}
|
|
this._forceClearCache = true;
|
|
if (copiedStyle) {
|
|
while (quantity--) {
|
|
if (!Object.keys(copiedStyle[quantity]).length) {
|
|
continue;
|
|
}
|
|
if (!this.styles[lineIndex]) {
|
|
this.styles[lineIndex] = {};
|
|
}
|
|
this.styles[lineIndex][charIndex + quantity] = _objectSpread2({}, copiedStyle[quantity]);
|
|
}
|
|
return;
|
|
}
|
|
if (!currentLineStyles) {
|
|
return;
|
|
}
|
|
const newStyle = currentLineStyles[charIndex ? charIndex - 1 : 1];
|
|
while (newStyle && quantity--) {
|
|
this.styles[lineIndex][charIndex + quantity] = _objectSpread2({}, newStyle);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inserts style object(s)
|
|
* @param {Array} insertedText Characters at the location where style is inserted
|
|
* @param {Number} start cursor index for inserting style
|
|
* @param {Array} [copiedStyle] array of style objects to insert.
|
|
*/
|
|
insertNewStyleBlock(insertedText, start, copiedStyle) {
|
|
const cursorLoc = this.get2DCursorLocation(start, true),
|
|
addedLines = [0];
|
|
let linesLength = 0;
|
|
// get an array of how many char per lines are being added.
|
|
for (let i = 0; i < insertedText.length; i++) {
|
|
if (insertedText[i] === '\n') {
|
|
linesLength++;
|
|
addedLines[linesLength] = 0;
|
|
} else {
|
|
addedLines[linesLength]++;
|
|
}
|
|
}
|
|
// for the first line copy the style from the current char position.
|
|
if (addedLines[0] > 0) {
|
|
this.insertCharStyleObject(cursorLoc.lineIndex, cursorLoc.charIndex, addedLines[0], copiedStyle);
|
|
copiedStyle = copiedStyle && copiedStyle.slice(addedLines[0] + 1);
|
|
}
|
|
linesLength && this.insertNewlineStyleObject(cursorLoc.lineIndex, cursorLoc.charIndex + addedLines[0], linesLength);
|
|
let i;
|
|
for (i = 1; i < linesLength; i++) {
|
|
if (addedLines[i] > 0) {
|
|
this.insertCharStyleObject(cursorLoc.lineIndex + i, 0, addedLines[i], copiedStyle);
|
|
} else if (copiedStyle) {
|
|
// this test is required in order to close #6841
|
|
// when a pasted buffer begins with a newline then
|
|
// this.styles[cursorLoc.lineIndex + i] and copiedStyle[0]
|
|
// may be undefined for some reason
|
|
if (this.styles[cursorLoc.lineIndex + i] && copiedStyle[0]) {
|
|
this.styles[cursorLoc.lineIndex + i][0] = copiedStyle[0];
|
|
}
|
|
}
|
|
copiedStyle = copiedStyle && copiedStyle.slice(addedLines[i] + 1);
|
|
}
|
|
if (addedLines[i] > 0) {
|
|
this.insertCharStyleObject(cursorLoc.lineIndex + i, 0, addedLines[i], copiedStyle);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes characters from start/end
|
|
* start/end ar per grapheme position in _text array.
|
|
*
|
|
* @param {Number} start
|
|
* @param {Number} end default to start + 1
|
|
*/
|
|
removeChars(start) {
|
|
let end = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : start + 1;
|
|
this.removeStyleFromTo(start, end);
|
|
this._text.splice(start, end - start);
|
|
this.text = this._text.join('');
|
|
this.set('dirty', true);
|
|
this.initDimensions();
|
|
this.setCoords();
|
|
this._removeExtraneousStyles();
|
|
}
|
|
|
|
/**
|
|
* insert characters at start position, before start position.
|
|
* start equal 1 it means the text get inserted between actual grapheme 0 and 1
|
|
* if style array is provided, it must be as the same length of text in graphemes
|
|
* if end is provided and is bigger than start, old text is replaced.
|
|
* start/end ar per grapheme position in _text array.
|
|
*
|
|
* @param {String} text text to insert
|
|
* @param {Array} style array of style objects
|
|
* @param {Number} start
|
|
* @param {Number} end default to start + 1
|
|
*/
|
|
insertChars(text, style, start) {
|
|
let end = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : start;
|
|
if (end > start) {
|
|
this.removeStyleFromTo(start, end);
|
|
}
|
|
const graphemes = this.graphemeSplit(text);
|
|
this.insertNewStyleBlock(graphemes, start, style);
|
|
this._text = [...this._text.slice(0, start), ...graphemes, ...this._text.slice(end)];
|
|
this.text = this._text.join('');
|
|
this.set('dirty', true);
|
|
this.initDimensions();
|
|
this.setCoords();
|
|
this._removeExtraneousStyles();
|
|
}
|
|
|
|
/**
|
|
* Set the selectionStart and selectionEnd according to the new position of cursor
|
|
* mimic the key - mouse navigation when shift is pressed.
|
|
*/
|
|
setSelectionStartEndWithShift(start, end, newSelection) {
|
|
if (newSelection <= start) {
|
|
if (end === start) {
|
|
this._selectionDirection = LEFT;
|
|
} else if (this._selectionDirection === RIGHT) {
|
|
this._selectionDirection = LEFT;
|
|
this.selectionEnd = start;
|
|
}
|
|
this.selectionStart = newSelection;
|
|
} else if (newSelection > start && newSelection < end) {
|
|
if (this._selectionDirection === RIGHT) {
|
|
this.selectionEnd = newSelection;
|
|
} else {
|
|
this.selectionStart = newSelection;
|
|
}
|
|
} else {
|
|
// newSelection is > selection start and end
|
|
if (end === start) {
|
|
this._selectionDirection = RIGHT;
|
|
} else if (this._selectionDirection === LEFT) {
|
|
this._selectionDirection = RIGHT;
|
|
this.selectionStart = end;
|
|
}
|
|
this.selectionEnd = newSelection;
|
|
}
|
|
}
|
|
}
|
|
|
|
class ITextKeyBehavior extends ITextBehavior {
|
|
/**
|
|
* For functionalities on keyDown
|
|
* Map a special key to a function of the instance/prototype
|
|
* If you need different behavior for ESC or TAB or arrows, you have to change
|
|
* this map setting the name of a function that you build on the IText or
|
|
* your prototype.
|
|
* the map change will affect all Instances unless you need for only some text Instances
|
|
* in that case you have to clone this object and assign your Instance.
|
|
* this.keysMap = Object.assign({}, this.keysMap);
|
|
* The function must be in IText.prototype.myFunction And will receive event as args[0]
|
|
*/
|
|
|
|
/**
|
|
* For functionalities on keyUp + ctrl || cmd
|
|
*/
|
|
|
|
/**
|
|
* For functionalities on keyDown + ctrl || cmd
|
|
*/
|
|
|
|
/**
|
|
* DOM container to append the hiddenTextarea.
|
|
* An alternative to attaching to the document.body.
|
|
* Useful to reduce laggish redraw of the full document.body tree and
|
|
* also with modals event capturing that won't let the textarea take focus.
|
|
* @type HTMLElement
|
|
* @default
|
|
*/
|
|
|
|
/**
|
|
* Initializes hidden textarea (needed to bring up keyboard in iOS)
|
|
*/
|
|
initHiddenTextarea() {
|
|
const doc = this.canvas && getDocumentFromElement(this.canvas.getElement()) || getFabricDocument();
|
|
const textarea = doc.createElement('textarea');
|
|
Object.entries({
|
|
autocapitalize: 'off',
|
|
autocorrect: 'off',
|
|
autocomplete: 'off',
|
|
spellcheck: 'false',
|
|
'data-fabric': 'textarea',
|
|
wrap: 'off'
|
|
}).map(_ref => {
|
|
let [attribute, value] = _ref;
|
|
return textarea.setAttribute(attribute, value);
|
|
});
|
|
const {
|
|
top,
|
|
left,
|
|
fontSize
|
|
} = this._calcTextareaPosition();
|
|
// line-height: 1px; was removed from the style to fix this:
|
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=870966
|
|
textarea.style.cssText = "position: absolute; top: ".concat(top, "; left: ").concat(left, "; z-index: -999; opacity: 0; width: 1px; height: 1px; font-size: 1px; padding-top: ").concat(fontSize, ";");
|
|
(this.hiddenTextareaContainer || doc.body).appendChild(textarea);
|
|
Object.entries({
|
|
blur: 'blur',
|
|
keydown: 'onKeyDown',
|
|
keyup: 'onKeyUp',
|
|
input: 'onInput',
|
|
copy: 'copy',
|
|
cut: 'copy',
|
|
paste: 'paste',
|
|
compositionstart: 'onCompositionStart',
|
|
compositionupdate: 'onCompositionUpdate',
|
|
compositionend: 'onCompositionEnd'
|
|
}).map(_ref2 => {
|
|
let [eventName, handler] = _ref2;
|
|
return textarea.addEventListener(eventName, this[handler].bind(this));
|
|
});
|
|
this.hiddenTextarea = textarea;
|
|
}
|
|
|
|
/**
|
|
* Override this method to customize cursor behavior on textbox blur
|
|
*/
|
|
blur() {
|
|
this.abortCursorAnimation();
|
|
}
|
|
|
|
/**
|
|
* Handles keydown event
|
|
* only used for arrows and combination of modifier keys.
|
|
* @param {KeyboardEvent} e Event object
|
|
*/
|
|
onKeyDown(e) {
|
|
if (!this.isEditing) {
|
|
return;
|
|
}
|
|
const keyMap = this.direction === 'rtl' ? this.keysMapRtl : this.keysMap;
|
|
if (e.keyCode in keyMap) {
|
|
// @ts-expect-error legacy method calling pattern
|
|
this[keyMap[e.keyCode]](e);
|
|
} else if (e.keyCode in this.ctrlKeysMapDown && (e.ctrlKey || e.metaKey)) {
|
|
// @ts-expect-error legacy method calling pattern
|
|
this[this.ctrlKeysMapDown[e.keyCode]](e);
|
|
} else {
|
|
return;
|
|
}
|
|
e.stopImmediatePropagation();
|
|
e.preventDefault();
|
|
if (e.keyCode >= 33 && e.keyCode <= 40) {
|
|
// if i press an arrow key just update selection
|
|
this.inCompositionMode = false;
|
|
this.clearContextTop();
|
|
this.renderCursorOrSelection();
|
|
} else {
|
|
this.canvas && this.canvas.requestRenderAll();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles keyup event
|
|
* We handle KeyUp because ie11 and edge have difficulties copy/pasting
|
|
* if a copy/cut event fired, keyup is dismissed
|
|
* @param {KeyboardEvent} e Event object
|
|
*/
|
|
onKeyUp(e) {
|
|
if (!this.isEditing || this._copyDone || this.inCompositionMode) {
|
|
this._copyDone = false;
|
|
return;
|
|
}
|
|
if (e.keyCode in this.ctrlKeysMapUp && (e.ctrlKey || e.metaKey)) {
|
|
// @ts-expect-error legacy method calling pattern
|
|
this[this.ctrlKeysMapUp[e.keyCode]](e);
|
|
} else {
|
|
return;
|
|
}
|
|
e.stopImmediatePropagation();
|
|
e.preventDefault();
|
|
this.canvas && this.canvas.requestRenderAll();
|
|
}
|
|
|
|
/**
|
|
* Handles onInput event
|
|
* @param {Event} e Event object
|
|
*/
|
|
onInput(e) {
|
|
const fromPaste = this.fromPaste;
|
|
this.fromPaste = false;
|
|
e && e.stopPropagation();
|
|
if (!this.isEditing) {
|
|
return;
|
|
}
|
|
const updateAndFire = () => {
|
|
this.updateFromTextArea();
|
|
this.fire(CHANGED);
|
|
if (this.canvas) {
|
|
this.canvas.fire('text:changed', {
|
|
target: this
|
|
});
|
|
this.canvas.requestRenderAll();
|
|
}
|
|
};
|
|
if (this.hiddenTextarea.value === '') {
|
|
this.styles = {};
|
|
updateAndFire();
|
|
return;
|
|
}
|
|
// decisions about style changes.
|
|
const nextText = this._splitTextIntoLines(this.hiddenTextarea.value).graphemeText,
|
|
charCount = this._text.length,
|
|
nextCharCount = nextText.length,
|
|
selectionStart = this.selectionStart,
|
|
selectionEnd = this.selectionEnd,
|
|
selection = selectionStart !== selectionEnd;
|
|
let copiedStyle,
|
|
removedText,
|
|
charDiff = nextCharCount - charCount,
|
|
removeFrom,
|
|
removeTo;
|
|
const textareaSelection = this.fromStringToGraphemeSelection(this.hiddenTextarea.selectionStart, this.hiddenTextarea.selectionEnd, this.hiddenTextarea.value);
|
|
const backDelete = selectionStart > textareaSelection.selectionStart;
|
|
if (selection) {
|
|
removedText = this._text.slice(selectionStart, selectionEnd);
|
|
charDiff += selectionEnd - selectionStart;
|
|
} else if (nextCharCount < charCount) {
|
|
if (backDelete) {
|
|
removedText = this._text.slice(selectionEnd + charDiff, selectionEnd);
|
|
} else {
|
|
removedText = this._text.slice(selectionStart, selectionStart - charDiff);
|
|
}
|
|
}
|
|
const insertedText = nextText.slice(textareaSelection.selectionEnd - charDiff, textareaSelection.selectionEnd);
|
|
if (removedText && removedText.length) {
|
|
if (insertedText.length) {
|
|
// let's copy some style before deleting.
|
|
// we want to copy the style before the cursor OR the style at the cursor if selection
|
|
// is bigger than 0.
|
|
copiedStyle = this.getSelectionStyles(selectionStart, selectionStart + 1, false);
|
|
// now duplicate the style one for each inserted text.
|
|
copiedStyle = insertedText.map(() =>
|
|
// this return an array of references, but that is fine since we are
|
|
// copying the style later.
|
|
copiedStyle[0]);
|
|
}
|
|
if (selection) {
|
|
removeFrom = selectionStart;
|
|
removeTo = selectionEnd;
|
|
} else if (backDelete) {
|
|
// detect differences between forwardDelete and backDelete
|
|
removeFrom = selectionEnd - removedText.length;
|
|
removeTo = selectionEnd;
|
|
} else {
|
|
removeFrom = selectionEnd;
|
|
removeTo = selectionEnd + removedText.length;
|
|
}
|
|
this.removeStyleFromTo(removeFrom, removeTo);
|
|
}
|
|
if (insertedText.length) {
|
|
const {
|
|
copyPasteData
|
|
} = getEnv();
|
|
if (fromPaste && insertedText.join('') === copyPasteData.copiedText && !config.disableStyleCopyPaste) {
|
|
copiedStyle = copyPasteData.copiedTextStyle;
|
|
}
|
|
this.insertNewStyleBlock(insertedText, selectionStart, copiedStyle);
|
|
}
|
|
updateAndFire();
|
|
}
|
|
|
|
/**
|
|
* Composition start
|
|
*/
|
|
onCompositionStart() {
|
|
this.inCompositionMode = true;
|
|
}
|
|
|
|
/**
|
|
* Composition end
|
|
*/
|
|
onCompositionEnd() {
|
|
this.inCompositionMode = false;
|
|
}
|
|
onCompositionUpdate(_ref3) {
|
|
let {
|
|
target
|
|
} = _ref3;
|
|
const {
|
|
selectionStart,
|
|
selectionEnd
|
|
} = target;
|
|
this.compositionStart = selectionStart;
|
|
this.compositionEnd = selectionEnd;
|
|
this.updateTextareaPosition();
|
|
}
|
|
|
|
/**
|
|
* Copies selected text
|
|
*/
|
|
copy() {
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
//do not cut-copy if no selection
|
|
return;
|
|
}
|
|
const {
|
|
copyPasteData
|
|
} = getEnv();
|
|
copyPasteData.copiedText = this.getSelectedText();
|
|
if (!config.disableStyleCopyPaste) {
|
|
copyPasteData.copiedTextStyle = this.getSelectionStyles(this.selectionStart, this.selectionEnd, true);
|
|
} else {
|
|
copyPasteData.copiedTextStyle = undefined;
|
|
}
|
|
this._copyDone = true;
|
|
}
|
|
|
|
/**
|
|
* Pastes text
|
|
*/
|
|
paste() {
|
|
this.fromPaste = true;
|
|
}
|
|
|
|
/**
|
|
* Finds the width in pixels before the cursor on the same line
|
|
* @private
|
|
* @param {Number} lineIndex
|
|
* @param {Number} charIndex
|
|
* @return {Number} widthBeforeCursor width before cursor
|
|
*/
|
|
_getWidthBeforeCursor(lineIndex, charIndex) {
|
|
let widthBeforeCursor = this._getLineLeftOffset(lineIndex),
|
|
bound;
|
|
if (charIndex > 0) {
|
|
bound = this.__charBounds[lineIndex][charIndex - 1];
|
|
widthBeforeCursor += bound.left + bound.width;
|
|
}
|
|
return widthBeforeCursor;
|
|
}
|
|
|
|
/**
|
|
* Gets start offset of a selection
|
|
* @param {KeyboardEvent} e Event object
|
|
* @param {Boolean} isRight
|
|
* @return {Number}
|
|
*/
|
|
getDownCursorOffset(e, isRight) {
|
|
const selectionProp = this._getSelectionForOffset(e, isRight),
|
|
cursorLocation = this.get2DCursorLocation(selectionProp),
|
|
lineIndex = cursorLocation.lineIndex;
|
|
// if on last line, down cursor goes to end of line
|
|
if (lineIndex === this._textLines.length - 1 || e.metaKey || e.keyCode === 34) {
|
|
// move to the end of a text
|
|
return this._text.length - selectionProp;
|
|
}
|
|
const charIndex = cursorLocation.charIndex,
|
|
widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex),
|
|
indexOnOtherLine = this._getIndexOnLine(lineIndex + 1, widthBeforeCursor),
|
|
textAfterCursor = this._textLines[lineIndex].slice(charIndex);
|
|
return textAfterCursor.length + indexOnOtherLine + 1 + this.missingNewlineOffset(lineIndex);
|
|
}
|
|
|
|
/**
|
|
* private
|
|
* Helps finding if the offset should be counted from Start or End
|
|
* @param {KeyboardEvent} e Event object
|
|
* @param {Boolean} isRight
|
|
* @return {Number}
|
|
*/
|
|
_getSelectionForOffset(e, isRight) {
|
|
if (e.shiftKey && this.selectionStart !== this.selectionEnd && isRight) {
|
|
return this.selectionEnd;
|
|
} else {
|
|
return this.selectionStart;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {KeyboardEvent} e Event object
|
|
* @param {Boolean} isRight
|
|
* @return {Number}
|
|
*/
|
|
getUpCursorOffset(e, isRight) {
|
|
const selectionProp = this._getSelectionForOffset(e, isRight),
|
|
cursorLocation = this.get2DCursorLocation(selectionProp),
|
|
lineIndex = cursorLocation.lineIndex;
|
|
if (lineIndex === 0 || e.metaKey || e.keyCode === 33) {
|
|
// if on first line, up cursor goes to start of line
|
|
return -selectionProp;
|
|
}
|
|
const charIndex = cursorLocation.charIndex,
|
|
widthBeforeCursor = this._getWidthBeforeCursor(lineIndex, charIndex),
|
|
indexOnOtherLine = this._getIndexOnLine(lineIndex - 1, widthBeforeCursor),
|
|
textBeforeCursor = this._textLines[lineIndex].slice(0, charIndex),
|
|
missingNewlineOffset = this.missingNewlineOffset(lineIndex - 1);
|
|
// return a negative offset
|
|
return -this._textLines[lineIndex - 1].length + indexOnOtherLine - textBeforeCursor.length + (1 - missingNewlineOffset);
|
|
}
|
|
|
|
/**
|
|
* for a given width it founds the matching character.
|
|
* @private
|
|
*/
|
|
_getIndexOnLine(lineIndex, width) {
|
|
const line = this._textLines[lineIndex],
|
|
lineLeftOffset = this._getLineLeftOffset(lineIndex);
|
|
let widthOfCharsOnLine = lineLeftOffset,
|
|
indexOnLine = 0,
|
|
charWidth,
|
|
foundMatch;
|
|
for (let j = 0, jlen = line.length; j < jlen; j++) {
|
|
charWidth = this.__charBounds[lineIndex][j].width;
|
|
widthOfCharsOnLine += charWidth;
|
|
if (widthOfCharsOnLine > width) {
|
|
foundMatch = true;
|
|
const leftEdge = widthOfCharsOnLine - charWidth,
|
|
rightEdge = widthOfCharsOnLine,
|
|
offsetFromLeftEdge = Math.abs(leftEdge - width),
|
|
offsetFromRightEdge = Math.abs(rightEdge - width);
|
|
indexOnLine = offsetFromRightEdge < offsetFromLeftEdge ? j : j - 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// reached end
|
|
if (!foundMatch) {
|
|
indexOnLine = line.length - 1;
|
|
}
|
|
return indexOnLine;
|
|
}
|
|
|
|
/**
|
|
* Moves cursor down
|
|
* @param {KeyboardEvent} e Event object
|
|
*/
|
|
moveCursorDown(e) {
|
|
if (this.selectionStart >= this._text.length && this.selectionEnd >= this._text.length) {
|
|
return;
|
|
}
|
|
this._moveCursorUpOrDown('Down', e);
|
|
}
|
|
|
|
/**
|
|
* Moves cursor up
|
|
* @param {KeyboardEvent} e Event object
|
|
*/
|
|
moveCursorUp(e) {
|
|
if (this.selectionStart === 0 && this.selectionEnd === 0) {
|
|
return;
|
|
}
|
|
this._moveCursorUpOrDown('Up', e);
|
|
}
|
|
|
|
/**
|
|
* Moves cursor up or down, fires the events
|
|
* @param {String} direction 'Up' or 'Down'
|
|
* @param {KeyboardEvent} e Event object
|
|
*/
|
|
_moveCursorUpOrDown(direction, e) {
|
|
const offset = this["get".concat(direction, "CursorOffset")](e, this._selectionDirection === RIGHT);
|
|
if (e.shiftKey) {
|
|
this.moveCursorWithShift(offset);
|
|
} else {
|
|
this.moveCursorWithoutShift(offset);
|
|
}
|
|
if (offset !== 0) {
|
|
const max = this.text.length;
|
|
this.selectionStart = capValue(0, this.selectionStart, max);
|
|
this.selectionEnd = capValue(0, this.selectionEnd, max);
|
|
// TODO fix: abort and init should be an alternative depending
|
|
// on selectionStart/End being equal or different
|
|
this.abortCursorAnimation();
|
|
this.initDelayedCursor();
|
|
this._fireSelectionChanged();
|
|
this._updateTextarea();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves cursor with shift
|
|
* @param {Number} offset
|
|
*/
|
|
moveCursorWithShift(offset) {
|
|
const newSelection = this._selectionDirection === LEFT ? this.selectionStart + offset : this.selectionEnd + offset;
|
|
this.setSelectionStartEndWithShift(this.selectionStart, this.selectionEnd, newSelection);
|
|
return offset !== 0;
|
|
}
|
|
|
|
/**
|
|
* Moves cursor up without shift
|
|
* @param {Number} offset
|
|
*/
|
|
moveCursorWithoutShift(offset) {
|
|
if (offset < 0) {
|
|
this.selectionStart += offset;
|
|
this.selectionEnd = this.selectionStart;
|
|
} else {
|
|
this.selectionEnd += offset;
|
|
this.selectionStart = this.selectionEnd;
|
|
}
|
|
return offset !== 0;
|
|
}
|
|
|
|
/**
|
|
* Moves cursor left
|
|
* @param {KeyboardEvent} e Event object
|
|
*/
|
|
moveCursorLeft(e) {
|
|
if (this.selectionStart === 0 && this.selectionEnd === 0) {
|
|
return;
|
|
}
|
|
this._moveCursorLeftOrRight('Left', e);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @return {Boolean} true if a change happened
|
|
*
|
|
* @todo refactor not to use method name composition
|
|
*/
|
|
_move(e, prop, direction) {
|
|
let newValue;
|
|
if (e.altKey) {
|
|
newValue = this["findWordBoundary".concat(direction)](this[prop]);
|
|
} else if (e.metaKey || e.keyCode === 35 || e.keyCode === 36) {
|
|
newValue = this["findLineBoundary".concat(direction)](this[prop]);
|
|
} else {
|
|
this[prop] += direction === 'Left' ? -1 : 1;
|
|
return true;
|
|
}
|
|
if (typeof newValue !== 'undefined' && this[prop] !== newValue) {
|
|
this[prop] = newValue;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_moveLeft(e, prop) {
|
|
return this._move(e, prop, 'Left');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_moveRight(e, prop) {
|
|
return this._move(e, prop, 'Right');
|
|
}
|
|
|
|
/**
|
|
* Moves cursor left without keeping selection
|
|
* @param {KeyboardEvent} e
|
|
*/
|
|
moveCursorLeftWithoutShift(e) {
|
|
let change = true;
|
|
this._selectionDirection = LEFT;
|
|
|
|
// only move cursor when there is no selection,
|
|
// otherwise we discard it, and leave cursor on same place
|
|
if (this.selectionEnd === this.selectionStart && this.selectionStart !== 0) {
|
|
change = this._moveLeft(e, 'selectionStart');
|
|
}
|
|
this.selectionEnd = this.selectionStart;
|
|
return change;
|
|
}
|
|
|
|
/**
|
|
* Moves cursor left while keeping selection
|
|
* @param {KeyboardEvent} e
|
|
*/
|
|
moveCursorLeftWithShift(e) {
|
|
if (this._selectionDirection === RIGHT && this.selectionStart !== this.selectionEnd) {
|
|
return this._moveLeft(e, 'selectionEnd');
|
|
} else if (this.selectionStart !== 0) {
|
|
this._selectionDirection = LEFT;
|
|
return this._moveLeft(e, 'selectionStart');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves cursor right
|
|
* @param {KeyboardEvent} e Event object
|
|
*/
|
|
moveCursorRight(e) {
|
|
if (this.selectionStart >= this._text.length && this.selectionEnd >= this._text.length) {
|
|
return;
|
|
}
|
|
this._moveCursorLeftOrRight('Right', e);
|
|
}
|
|
|
|
/**
|
|
* Moves cursor right or Left, fires event
|
|
* @param {String} direction 'Left', 'Right'
|
|
* @param {KeyboardEvent} e Event object
|
|
*/
|
|
_moveCursorLeftOrRight(direction, e) {
|
|
const actionName = "moveCursor".concat(direction).concat(e.shiftKey ? 'WithShift' : 'WithoutShift');
|
|
this._currentCursorOpacity = 1;
|
|
if (this[actionName](e)) {
|
|
// TODO fix: abort and init should be an alternative depending
|
|
// on selectionStart/End being equal or different
|
|
this.abortCursorAnimation();
|
|
this.initDelayedCursor();
|
|
this._fireSelectionChanged();
|
|
this._updateTextarea();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves cursor right while keeping selection
|
|
* @param {KeyboardEvent} e
|
|
*/
|
|
moveCursorRightWithShift(e) {
|
|
if (this._selectionDirection === LEFT && this.selectionStart !== this.selectionEnd) {
|
|
return this._moveRight(e, 'selectionStart');
|
|
} else if (this.selectionEnd !== this._text.length) {
|
|
this._selectionDirection = RIGHT;
|
|
return this._moveRight(e, 'selectionEnd');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves cursor right without keeping selection
|
|
* @param {KeyboardEvent} e Event object
|
|
*/
|
|
moveCursorRightWithoutShift(e) {
|
|
let changed = true;
|
|
this._selectionDirection = RIGHT;
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
changed = this._moveRight(e, 'selectionStart');
|
|
this.selectionEnd = this.selectionStart;
|
|
} else {
|
|
this.selectionStart = this.selectionEnd;
|
|
}
|
|
return changed;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `LEFT_CLICK === 0`
|
|
*/
|
|
const notALeftClick = e => !!e.button;
|
|
class ITextClickBehavior extends ITextKeyBehavior {
|
|
constructor() {
|
|
super(...arguments);
|
|
_defineProperty(this, "draggableTextDelegate", void 0);
|
|
}
|
|
initBehavior() {
|
|
// Initializes event handlers related to cursor or selection
|
|
this.on('mousedown', this._mouseDownHandler);
|
|
this.on('mousedown:before', this._mouseDownHandlerBefore);
|
|
this.on('mouseup', this.mouseUpHandler);
|
|
this.on('mousedblclick', this.doubleClickHandler);
|
|
this.on('tripleclick', this.tripleClickHandler);
|
|
|
|
// Initializes "dbclick" event handler
|
|
this.__lastClickTime = +new Date();
|
|
// for triple click
|
|
this.__lastLastClickTime = +new Date();
|
|
this.__lastPointer = {};
|
|
this.on('mousedown', this.onMouseDown);
|
|
|
|
// @ts-expect-error in reality it is an IText instance
|
|
this.draggableTextDelegate = new DraggableTextDelegate(this);
|
|
super.initBehavior();
|
|
}
|
|
|
|
/**
|
|
* If this method returns true a mouse move operation over a text selection
|
|
* will not prevent the native mouse event allowing the browser to start a drag operation.
|
|
* shouldStartDragging can be read 'do not prevent default for mouse move event'
|
|
* To prevent drag and drop between objects both shouldStartDragging and onDragStart should return false
|
|
* @returns
|
|
*/
|
|
shouldStartDragging() {
|
|
return this.draggableTextDelegate.isActive();
|
|
}
|
|
|
|
/**
|
|
* @public override this method to control whether instance should/shouldn't become a drag source,
|
|
* @see also {@link DraggableTextDelegate#isActive}
|
|
* To prevent drag and drop between objects both shouldStartDragging and onDragStart should return false
|
|
* @returns {boolean} should handle event
|
|
*/
|
|
onDragStart(e) {
|
|
return this.draggableTextDelegate.onDragStart(e);
|
|
}
|
|
|
|
/**
|
|
* @public override this method to control whether instance should/shouldn't become a drop target
|
|
*/
|
|
canDrop(e) {
|
|
return this.draggableTextDelegate.canDrop(e);
|
|
}
|
|
|
|
/**
|
|
* Default event handler to simulate triple click
|
|
* @private
|
|
*/
|
|
onMouseDown(options) {
|
|
if (!this.canvas) {
|
|
return;
|
|
}
|
|
this.__newClickTime = +new Date();
|
|
const newPointer = options.pointer;
|
|
if (this.isTripleClick(newPointer)) {
|
|
this.fire('tripleclick', options);
|
|
stopEvent(options.e);
|
|
}
|
|
this.__lastLastClickTime = this.__lastClickTime;
|
|
this.__lastClickTime = this.__newClickTime;
|
|
this.__lastPointer = newPointer;
|
|
this.__lastSelected = this.selected && !this.getActiveControl();
|
|
}
|
|
isTripleClick(newPointer) {
|
|
return this.__newClickTime - this.__lastClickTime < 500 && this.__lastClickTime - this.__lastLastClickTime < 500 && this.__lastPointer.x === newPointer.x && this.__lastPointer.y === newPointer.y;
|
|
}
|
|
|
|
/**
|
|
* Default handler for double click, select a word
|
|
*/
|
|
doubleClickHandler(options) {
|
|
if (!this.isEditing) {
|
|
return;
|
|
}
|
|
this.selectWord(this.getSelectionStartFromPointer(options.e));
|
|
}
|
|
|
|
/**
|
|
* Default handler for triple click, select a line
|
|
*/
|
|
tripleClickHandler(options) {
|
|
if (!this.isEditing) {
|
|
return;
|
|
}
|
|
this.selectLine(this.getSelectionStartFromPointer(options.e));
|
|
}
|
|
|
|
/**
|
|
* Default event handler for the basic functionalities needed on _mouseDown
|
|
* can be overridden to do something different.
|
|
* Scope of this implementation is: find the click position, set selectionStart
|
|
* find selectionEnd, initialize the drawing of either cursor or selection area
|
|
* initializing a mousedDown on a text area will cancel fabricjs knowledge of
|
|
* current compositionMode. It will be set to false.
|
|
*/
|
|
_mouseDownHandler(_ref) {
|
|
let {
|
|
e
|
|
} = _ref;
|
|
if (!this.canvas || !this.editable || notALeftClick(e) || this.getActiveControl()) {
|
|
return;
|
|
}
|
|
if (this.draggableTextDelegate.start(e)) {
|
|
return;
|
|
}
|
|
this.canvas.textEditingManager.register(this);
|
|
if (this.selected) {
|
|
this.inCompositionMode = false;
|
|
this.setCursorByClick(e);
|
|
}
|
|
if (this.isEditing) {
|
|
this.__selectionStartOnMouseDown = this.selectionStart;
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
this.abortCursorAnimation();
|
|
}
|
|
this.renderCursorOrSelection();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Default event handler for the basic functionalities needed on mousedown:before
|
|
* can be overridden to do something different.
|
|
* Scope of this implementation is: verify the object is already selected when mousing down
|
|
*/
|
|
_mouseDownHandlerBefore(_ref2) {
|
|
let {
|
|
e
|
|
} = _ref2;
|
|
if (!this.canvas || !this.editable || notALeftClick(e)) {
|
|
return;
|
|
}
|
|
// we want to avoid that an object that was selected and then becomes unselectable,
|
|
// may trigger editing mode in some way.
|
|
this.selected = this === this.canvas._activeObject;
|
|
}
|
|
|
|
/**
|
|
* standard handler for mouse up, overridable
|
|
* @private
|
|
*/
|
|
mouseUpHandler(_ref3) {
|
|
let {
|
|
e,
|
|
transform
|
|
} = _ref3;
|
|
const didDrag = this.draggableTextDelegate.end(e);
|
|
if (this.canvas) {
|
|
this.canvas.textEditingManager.unregister(this);
|
|
const activeObject = this.canvas._activeObject;
|
|
if (activeObject && activeObject !== this) {
|
|
// avoid running this logic when there is an active object
|
|
// this because is possible with shift click and fast clicks,
|
|
// to rapidly deselect and reselect this object and trigger an enterEdit
|
|
return;
|
|
}
|
|
}
|
|
if (!this.editable || this.group && !this.group.interactive || transform && transform.actionPerformed || notALeftClick(e) || didDrag) {
|
|
return;
|
|
}
|
|
if (this.__lastSelected && !this.getActiveControl()) {
|
|
this.selected = false;
|
|
this.__lastSelected = false;
|
|
this.enterEditing(e);
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
this.initDelayedCursor(true);
|
|
} else {
|
|
this.renderCursorOrSelection();
|
|
}
|
|
} else {
|
|
this.selected = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Changes cursor location in a text depending on passed pointer (x/y) object
|
|
* @param {TPointerEvent} e Event object
|
|
*/
|
|
setCursorByClick(e) {
|
|
const newSelection = this.getSelectionStartFromPointer(e),
|
|
start = this.selectionStart,
|
|
end = this.selectionEnd;
|
|
if (e.shiftKey) {
|
|
this.setSelectionStartEndWithShift(start, end, newSelection);
|
|
} else {
|
|
this.selectionStart = newSelection;
|
|
this.selectionEnd = newSelection;
|
|
}
|
|
if (this.isEditing) {
|
|
this._fireSelectionChanged();
|
|
this._updateTextarea();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns index of a character corresponding to where an object was clicked
|
|
* @param {TPointerEvent} e Event object
|
|
* @return {Number} Index of a character
|
|
*/
|
|
getSelectionStartFromPointer(e) {
|
|
const mouseOffset = this.canvas.getScenePoint(e).transform(invertTransform(this.calcTransformMatrix())).add(new Point(-this._getLeftOffset(), -this._getTopOffset()));
|
|
let height = 0,
|
|
charIndex = 0,
|
|
lineIndex = 0;
|
|
for (let i = 0; i < this._textLines.length; i++) {
|
|
if (height <= mouseOffset.y) {
|
|
height += this.getHeightOfLine(i);
|
|
lineIndex = i;
|
|
if (i > 0) {
|
|
charIndex += this._textLines[i - 1].length + this.missingNewlineOffset(i - 1);
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
const lineLeftOffset = Math.abs(this._getLineLeftOffset(lineIndex));
|
|
let width = lineLeftOffset;
|
|
const charLength = this._textLines[lineIndex].length;
|
|
const chars = this.__charBounds[lineIndex];
|
|
for (let j = 0; j < charLength; j++) {
|
|
// i removed something about flipX here, check.
|
|
const charWidth = chars[j].kernedWidth;
|
|
const widthAfter = width + charWidth;
|
|
if (mouseOffset.x <= widthAfter) {
|
|
// if the pointer is closer to the end of the char we increment charIndex
|
|
// in order to position the cursor after the char
|
|
if (Math.abs(mouseOffset.x - widthAfter) <= Math.abs(mouseOffset.x - width)) {
|
|
charIndex++;
|
|
}
|
|
break;
|
|
}
|
|
width = widthAfter;
|
|
charIndex++;
|
|
}
|
|
return Math.min(
|
|
// if object is horizontally flipped, mirror cursor location from the end
|
|
this.flipX ? charLength - charIndex : charIndex, this._text.length);
|
|
}
|
|
}
|
|
|
|
const MOVE_CURSOR_UP = 'moveCursorUp';
|
|
const MOVE_CURSOR_DOWN = 'moveCursorDown';
|
|
const MOVE_CURSOR_LEFT = 'moveCursorLeft';
|
|
const MOVE_CURSOR_RIGHT = 'moveCursorRight';
|
|
const EXIT_EDITING = 'exitEditing';
|
|
|
|
// @TODO look into import { Key } from 'ts-key-enum';
|
|
// and transition from keyCode to Key
|
|
// also reduce string duplication
|
|
const keysMap = {
|
|
9: EXIT_EDITING,
|
|
27: EXIT_EDITING,
|
|
33: MOVE_CURSOR_UP,
|
|
34: MOVE_CURSOR_DOWN,
|
|
35: MOVE_CURSOR_RIGHT,
|
|
36: MOVE_CURSOR_LEFT,
|
|
37: MOVE_CURSOR_LEFT,
|
|
38: MOVE_CURSOR_UP,
|
|
39: MOVE_CURSOR_RIGHT,
|
|
40: MOVE_CURSOR_DOWN
|
|
};
|
|
const keysMapRtl = {
|
|
9: EXIT_EDITING,
|
|
27: EXIT_EDITING,
|
|
33: MOVE_CURSOR_UP,
|
|
34: MOVE_CURSOR_DOWN,
|
|
35: MOVE_CURSOR_LEFT,
|
|
36: MOVE_CURSOR_RIGHT,
|
|
37: MOVE_CURSOR_RIGHT,
|
|
38: MOVE_CURSOR_UP,
|
|
39: MOVE_CURSOR_LEFT,
|
|
40: MOVE_CURSOR_DOWN
|
|
};
|
|
|
|
/**
|
|
* For functionalities on keyUp + ctrl || cmd
|
|
*/
|
|
const ctrlKeysMapUp = {
|
|
67: 'copy',
|
|
// there was a reason this wasn't deleted. for now leave it here
|
|
88: 'cut'
|
|
};
|
|
|
|
/**
|
|
* For functionalities on keyDown + ctrl || cmd
|
|
*/
|
|
const ctrlKeysMapDown = {
|
|
65: 'selectAll'
|
|
};
|
|
|
|
// Declare IText protected properties to workaround TS
|
|
const protectedDefaultValues = {
|
|
_selectionDirection: null,
|
|
_reSpace: /\s|\r?\n/,
|
|
inCompositionMode: false
|
|
};
|
|
const iTextDefaultValues = _objectSpread2({
|
|
selectionStart: 0,
|
|
selectionEnd: 0,
|
|
selectionColor: 'rgba(17,119,255,0.3)',
|
|
isEditing: false,
|
|
editable: true,
|
|
editingBorderColor: 'rgba(102,153,255,0.25)',
|
|
cursorWidth: 2,
|
|
cursorColor: '',
|
|
cursorDelay: 1000,
|
|
cursorDuration: 600,
|
|
caching: true,
|
|
hiddenTextareaContainer: null,
|
|
keysMap,
|
|
keysMapRtl,
|
|
ctrlKeysMapDown,
|
|
ctrlKeysMapUp
|
|
}, protectedDefaultValues);
|
|
|
|
// @TODO this is not complete
|
|
|
|
/**
|
|
* @fires changed
|
|
* @fires selection:changed
|
|
* @fires editing:entered
|
|
* @fires editing:exited
|
|
* @fires dragstart
|
|
* @fires drag drag event firing on the drag source
|
|
* @fires dragend
|
|
* @fires copy
|
|
* @fires cut
|
|
* @fires paste
|
|
*
|
|
* #### Supported key combinations
|
|
* ```
|
|
* Move cursor: left, right, up, down
|
|
* Select character: shift + left, shift + right
|
|
* Select text vertically: shift + up, shift + down
|
|
* Move cursor by word: alt + left, alt + right
|
|
* Select words: shift + alt + left, shift + alt + right
|
|
* Move cursor to line start/end: cmd + left, cmd + right or home, end
|
|
* Select till start/end of line: cmd + shift + left, cmd + shift + right or shift + home, shift + end
|
|
* Jump to start/end of text: cmd + up, cmd + down
|
|
* Select till start/end of text: cmd + shift + up, cmd + shift + down or shift + pgUp, shift + pgDown
|
|
* Delete character: backspace
|
|
* Delete word: alt + backspace
|
|
* Delete line: cmd + backspace
|
|
* Forward delete: delete
|
|
* Copy text: ctrl/cmd + c
|
|
* Paste text: ctrl/cmd + v
|
|
* Cut text: ctrl/cmd + x
|
|
* Select entire text: ctrl/cmd + a
|
|
* Quit editing tab or esc
|
|
* ```
|
|
*
|
|
* #### Supported mouse/touch combination
|
|
* ```
|
|
* Position cursor: click/touch
|
|
* Create selection: click/touch & drag
|
|
* Create selection: click & shift + click
|
|
* Select word: double click
|
|
* Select line: triple click
|
|
* ```
|
|
*/
|
|
class IText extends ITextClickBehavior {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), IText.ownDefaults);
|
|
}
|
|
get type() {
|
|
const type = super.type;
|
|
// backward compatibility
|
|
return type === 'itext' ? 'i-text' : type;
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {String} text Text string
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor(text, options) {
|
|
super(text, _objectSpread2(_objectSpread2({}, IText.ownDefaults), options));
|
|
this.initBehavior();
|
|
}
|
|
|
|
/**
|
|
* While editing handle differently
|
|
* @private
|
|
* @param {string} key
|
|
* @param {*} value
|
|
*/
|
|
_set(key, value) {
|
|
if (this.isEditing && this._savedProps && key in this._savedProps) {
|
|
// @ts-expect-error irritating TS
|
|
this._savedProps[key] = value;
|
|
return this;
|
|
}
|
|
if (key === 'canvas') {
|
|
this.canvas instanceof Canvas && this.canvas.textEditingManager.remove(this);
|
|
value instanceof Canvas && value.textEditingManager.add(this);
|
|
}
|
|
return super._set(key, value);
|
|
}
|
|
|
|
/**
|
|
* Sets selection start (left boundary of a selection)
|
|
* @param {Number} index Index to set selection start to
|
|
*/
|
|
setSelectionStart(index) {
|
|
index = Math.max(index, 0);
|
|
this._updateAndFire('selectionStart', index);
|
|
}
|
|
|
|
/**
|
|
* Sets selection end (right boundary of a selection)
|
|
* @param {Number} index Index to set selection end to
|
|
*/
|
|
setSelectionEnd(index) {
|
|
index = Math.min(index, this.text.length);
|
|
this._updateAndFire('selectionEnd', index);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {String} property 'selectionStart' or 'selectionEnd'
|
|
* @param {Number} index new position of property
|
|
*/
|
|
_updateAndFire(property, index) {
|
|
if (this[property] !== index) {
|
|
this._fireSelectionChanged();
|
|
this[property] = index;
|
|
}
|
|
this._updateTextarea();
|
|
}
|
|
|
|
/**
|
|
* Fires the even of selection changed
|
|
* @private
|
|
*/
|
|
_fireSelectionChanged() {
|
|
this.fire('selection:changed');
|
|
this.canvas && this.canvas.fire('text:selection:changed', {
|
|
target: this
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize text dimensions. Render all text on given context
|
|
* or on a offscreen canvas to get the text width with measureText.
|
|
* Updates this.width and this.height with the proper values.
|
|
* Does not return dimensions.
|
|
* @private
|
|
*/
|
|
initDimensions() {
|
|
this.isEditing && this.initDelayedCursor();
|
|
super.initDimensions();
|
|
}
|
|
|
|
/**
|
|
* Gets style of a current selection/cursor (at the start position)
|
|
* if startIndex or endIndex are not provided, selectionStart or selectionEnd will be used.
|
|
* @param {Number} startIndex Start index to get styles at
|
|
* @param {Number} endIndex End index to get styles at, if not specified selectionEnd or startIndex + 1
|
|
* @param {Boolean} [complete] get full style or not
|
|
* @return {Array} styles an array with one, zero or more Style objects
|
|
*/
|
|
getSelectionStyles() {
|
|
let startIndex = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.selectionStart || 0;
|
|
let endIndex = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : this.selectionEnd;
|
|
let complete = arguments.length > 2 ? arguments[2] : undefined;
|
|
return super.getSelectionStyles(startIndex, endIndex, complete);
|
|
}
|
|
|
|
/**
|
|
* Sets style of a current selection, if no selection exist, do not set anything.
|
|
* @param {Object} [styles] Styles object
|
|
* @param {Number} [startIndex] Start index to get styles at
|
|
* @param {Number} [endIndex] End index to get styles at, if not specified selectionEnd or startIndex + 1
|
|
*/
|
|
setSelectionStyles(styles) {
|
|
let startIndex = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : this.selectionStart || 0;
|
|
let endIndex = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : this.selectionEnd;
|
|
return super.setSelectionStyles(styles, startIndex, endIndex);
|
|
}
|
|
|
|
/**
|
|
* Returns 2d representation (lineIndex and charIndex) of cursor (or selection start)
|
|
* @param {Number} [selectionStart] Optional index. When not given, current selectionStart is used.
|
|
* @param {Boolean} [skipWrapping] consider the location for unwrapped lines. useful to manage styles.
|
|
*/
|
|
get2DCursorLocation() {
|
|
let selectionStart = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.selectionStart;
|
|
let skipWrapping = arguments.length > 1 ? arguments[1] : undefined;
|
|
return super.get2DCursorLocation(selectionStart, skipWrapping);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
render(ctx) {
|
|
super.render(ctx);
|
|
// clear the cursorOffsetCache, so we ensure to calculate once per renderCursor
|
|
// the correct position but not at every cursor animation.
|
|
this.cursorOffsetCache = {};
|
|
this.renderCursorOrSelection();
|
|
}
|
|
|
|
/**
|
|
* @override block cursor/selection logic while rendering the exported canvas
|
|
* @todo this workaround should be replaced with a more robust solution
|
|
*/
|
|
toCanvasElement(options) {
|
|
const isEditing = this.isEditing;
|
|
this.isEditing = false;
|
|
const canvas = super.toCanvasElement(options);
|
|
this.isEditing = isEditing;
|
|
return canvas;
|
|
}
|
|
|
|
/**
|
|
* Renders cursor or selection (depending on what exists)
|
|
* it does on the contextTop. If contextTop is not available, do nothing.
|
|
*/
|
|
renderCursorOrSelection() {
|
|
if (!this.isEditing) {
|
|
return;
|
|
}
|
|
const ctx = this.clearContextTop(true);
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
const boundaries = this._getCursorBoundaries();
|
|
if (this.selectionStart === this.selectionEnd) {
|
|
this.renderCursor(ctx, boundaries);
|
|
} else {
|
|
this.renderSelection(ctx, boundaries);
|
|
}
|
|
this.canvas.contextTopDirty = true;
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Returns cursor boundaries (left, top, leftOffset, topOffset)
|
|
* left/top are left/top of entire text box
|
|
* leftOffset/topOffset are offset from that left/top point of a text box
|
|
* @private
|
|
* @param {number} [index] index from start
|
|
* @param {boolean} [skipCaching]
|
|
*/
|
|
_getCursorBoundaries() {
|
|
let index = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.selectionStart;
|
|
let skipCaching = arguments.length > 1 ? arguments[1] : undefined;
|
|
const left = this._getLeftOffset(),
|
|
top = this._getTopOffset(),
|
|
offsets = this._getCursorBoundariesOffsets(index, skipCaching);
|
|
return {
|
|
left: left,
|
|
top: top,
|
|
leftOffset: offsets.left,
|
|
topOffset: offsets.top
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Caches and returns cursor left/top offset relative to instance's center point
|
|
* @private
|
|
* @param {number} index index from start
|
|
* @param {boolean} [skipCaching]
|
|
*/
|
|
_getCursorBoundariesOffsets(index, skipCaching) {
|
|
if (skipCaching) {
|
|
return this.__getCursorBoundariesOffsets(index);
|
|
}
|
|
if (this.cursorOffsetCache && 'top' in this.cursorOffsetCache) {
|
|
return this.cursorOffsetCache;
|
|
}
|
|
return this.cursorOffsetCache = this.__getCursorBoundariesOffsets(index);
|
|
}
|
|
|
|
/**
|
|
* Calculates cursor left/top offset relative to instance's center point
|
|
* @private
|
|
* @param {number} index index from start
|
|
*/
|
|
__getCursorBoundariesOffsets(index) {
|
|
let topOffset = 0,
|
|
leftOffset = 0;
|
|
const {
|
|
charIndex,
|
|
lineIndex
|
|
} = this.get2DCursorLocation(index);
|
|
for (let i = 0; i < lineIndex; i++) {
|
|
topOffset += this.getHeightOfLine(i);
|
|
}
|
|
const lineLeftOffset = this._getLineLeftOffset(lineIndex);
|
|
const bound = this.__charBounds[lineIndex][charIndex];
|
|
bound && (leftOffset = bound.left);
|
|
if (this.charSpacing !== 0 && charIndex === this._textLines[lineIndex].length) {
|
|
leftOffset -= this._getWidthOfCharSpacing();
|
|
}
|
|
const boundaries = {
|
|
top: topOffset,
|
|
left: lineLeftOffset + (leftOffset > 0 ? leftOffset : 0)
|
|
};
|
|
if (this.direction === 'rtl') {
|
|
if (this.textAlign === RIGHT || this.textAlign === JUSTIFY || this.textAlign === JUSTIFY_RIGHT) {
|
|
boundaries.left *= -1;
|
|
} else if (this.textAlign === LEFT || this.textAlign === JUSTIFY_LEFT) {
|
|
boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0);
|
|
} else if (this.textAlign === CENTER || this.textAlign === JUSTIFY_CENTER) {
|
|
boundaries.left = lineLeftOffset - (leftOffset > 0 ? leftOffset : 0);
|
|
}
|
|
}
|
|
return boundaries;
|
|
}
|
|
|
|
/**
|
|
* Renders cursor on context Top, outside the animation cycle, on request
|
|
* Used for the drag/drop effect.
|
|
* If contextTop is not available, do nothing.
|
|
*/
|
|
renderCursorAt(selectionStart) {
|
|
const boundaries = this._getCursorBoundaries(selectionStart, true);
|
|
this._renderCursor(this.canvas.contextTop, boundaries, selectionStart);
|
|
}
|
|
|
|
/**
|
|
* Renders cursor
|
|
* @param {Object} boundaries
|
|
* @param {CanvasRenderingContext2D} ctx transformed context to draw on
|
|
*/
|
|
renderCursor(ctx, boundaries) {
|
|
this._renderCursor(ctx, boundaries, this.selectionStart);
|
|
}
|
|
_renderCursor(ctx, boundaries, selectionStart) {
|
|
const cursorLocation = this.get2DCursorLocation(selectionStart),
|
|
lineIndex = cursorLocation.lineIndex,
|
|
charIndex = cursorLocation.charIndex > 0 ? cursorLocation.charIndex - 1 : 0,
|
|
charHeight = this.getValueOfPropertyAt(lineIndex, charIndex, 'fontSize'),
|
|
multiplier = this.getObjectScaling().x * this.canvas.getZoom(),
|
|
cursorWidth = this.cursorWidth / multiplier,
|
|
dy = this.getValueOfPropertyAt(lineIndex, charIndex, 'deltaY'),
|
|
topOffset = boundaries.topOffset + (1 - this._fontSizeFraction) * this.getHeightOfLine(lineIndex) / this.lineHeight - charHeight * (1 - this._fontSizeFraction);
|
|
if (this.inCompositionMode) {
|
|
// TODO: investigate why there isn't a return inside the if,
|
|
// and why can't happen at the top of the function
|
|
this.renderSelection(ctx, boundaries);
|
|
}
|
|
ctx.fillStyle = this.cursorColor || this.getValueOfPropertyAt(lineIndex, charIndex, FILL);
|
|
ctx.globalAlpha = this._currentCursorOpacity;
|
|
ctx.fillRect(boundaries.left + boundaries.leftOffset - cursorWidth / 2, topOffset + boundaries.top + dy, cursorWidth, charHeight);
|
|
}
|
|
|
|
/**
|
|
* Renders text selection
|
|
* @param {Object} boundaries Object with left/top/leftOffset/topOffset
|
|
* @param {CanvasRenderingContext2D} ctx transformed context to draw on
|
|
*/
|
|
renderSelection(ctx, boundaries) {
|
|
const selection = {
|
|
selectionStart: this.inCompositionMode ? this.hiddenTextarea.selectionStart : this.selectionStart,
|
|
selectionEnd: this.inCompositionMode ? this.hiddenTextarea.selectionEnd : this.selectionEnd
|
|
};
|
|
this._renderSelection(ctx, selection, boundaries);
|
|
}
|
|
|
|
/**
|
|
* Renders drag start text selection
|
|
*/
|
|
renderDragSourceEffect() {
|
|
const dragStartSelection = this.draggableTextDelegate.getDragStartSelection();
|
|
this._renderSelection(this.canvas.contextTop, dragStartSelection, this._getCursorBoundaries(dragStartSelection.selectionStart, true));
|
|
}
|
|
renderDropTargetEffect(e) {
|
|
const dragSelection = this.getSelectionStartFromPointer(e);
|
|
this.renderCursorAt(dragSelection);
|
|
}
|
|
|
|
/**
|
|
* Renders text selection
|
|
* @private
|
|
* @param {{ selectionStart: number, selectionEnd: number }} selection
|
|
* @param {Object} boundaries Object with left/top/leftOffset/topOffset
|
|
* @param {CanvasRenderingContext2D} ctx transformed context to draw on
|
|
*/
|
|
_renderSelection(ctx, selection, boundaries) {
|
|
const selectionStart = selection.selectionStart,
|
|
selectionEnd = selection.selectionEnd,
|
|
isJustify = this.textAlign.includes(JUSTIFY),
|
|
start = this.get2DCursorLocation(selectionStart),
|
|
end = this.get2DCursorLocation(selectionEnd),
|
|
startLine = start.lineIndex,
|
|
endLine = end.lineIndex,
|
|
startChar = start.charIndex < 0 ? 0 : start.charIndex,
|
|
endChar = end.charIndex < 0 ? 0 : end.charIndex;
|
|
for (let i = startLine; i <= endLine; i++) {
|
|
const lineOffset = this._getLineLeftOffset(i) || 0;
|
|
let lineHeight = this.getHeightOfLine(i),
|
|
realLineHeight = 0,
|
|
boxStart = 0,
|
|
boxEnd = 0;
|
|
if (i === startLine) {
|
|
boxStart = this.__charBounds[startLine][startChar].left;
|
|
}
|
|
if (i >= startLine && i < endLine) {
|
|
boxEnd = isJustify && !this.isEndOfWrapping(i) ? this.width : this.getLineWidth(i) || 5; // WTF is this 5?
|
|
} else if (i === endLine) {
|
|
if (endChar === 0) {
|
|
boxEnd = this.__charBounds[endLine][endChar].left;
|
|
} else {
|
|
const charSpacing = this._getWidthOfCharSpacing();
|
|
boxEnd = this.__charBounds[endLine][endChar - 1].left + this.__charBounds[endLine][endChar - 1].width - charSpacing;
|
|
}
|
|
}
|
|
realLineHeight = lineHeight;
|
|
if (this.lineHeight < 1 || i === endLine && this.lineHeight > 1) {
|
|
lineHeight /= this.lineHeight;
|
|
}
|
|
let drawStart = boundaries.left + lineOffset + boxStart,
|
|
drawHeight = lineHeight,
|
|
extraTop = 0;
|
|
const drawWidth = boxEnd - boxStart;
|
|
if (this.inCompositionMode) {
|
|
ctx.fillStyle = this.compositionColor || 'black';
|
|
drawHeight = 1;
|
|
extraTop = lineHeight;
|
|
} else {
|
|
ctx.fillStyle = this.selectionColor;
|
|
}
|
|
if (this.direction === 'rtl') {
|
|
if (this.textAlign === RIGHT || this.textAlign === JUSTIFY || this.textAlign === JUSTIFY_RIGHT) {
|
|
drawStart = this.width - drawStart - drawWidth;
|
|
} else if (this.textAlign === LEFT || this.textAlign === JUSTIFY_LEFT) {
|
|
drawStart = boundaries.left + lineOffset - boxEnd;
|
|
} else if (this.textAlign === CENTER || this.textAlign === JUSTIFY_CENTER) {
|
|
drawStart = boundaries.left + lineOffset - boxEnd;
|
|
}
|
|
}
|
|
ctx.fillRect(drawStart, boundaries.top + boundaries.topOffset + extraTop, drawWidth, drawHeight);
|
|
boundaries.topOffset += realLineHeight;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* High level function to know the height of the cursor.
|
|
* the currentChar is the one that precedes the cursor
|
|
* Returns fontSize of char at the current cursor
|
|
* Unused from the library, is for the end user
|
|
* @return {Number} Character font size
|
|
*/
|
|
getCurrentCharFontSize() {
|
|
const cp = this._getCurrentCharIndex();
|
|
return this.getValueOfPropertyAt(cp.l, cp.c, 'fontSize');
|
|
}
|
|
|
|
/**
|
|
* High level function to know the color of the cursor.
|
|
* the currentChar is the one that precedes the cursor
|
|
* Returns color (fill) of char at the current cursor
|
|
* if the text object has a pattern or gradient for filler, it will return that.
|
|
* Unused by the library, is for the end user
|
|
* @return {String | TFiller} Character color (fill)
|
|
*/
|
|
getCurrentCharColor() {
|
|
const cp = this._getCurrentCharIndex();
|
|
return this.getValueOfPropertyAt(cp.l, cp.c, FILL);
|
|
}
|
|
|
|
/**
|
|
* Returns the cursor position for the getCurrent.. functions
|
|
* @private
|
|
*/
|
|
_getCurrentCharIndex() {
|
|
const cursorPosition = this.get2DCursorLocation(this.selectionStart, true),
|
|
charIndex = cursorPosition.charIndex > 0 ? cursorPosition.charIndex - 1 : 0;
|
|
return {
|
|
l: cursorPosition.lineIndex,
|
|
c: charIndex
|
|
};
|
|
}
|
|
dispose() {
|
|
this._exitEditing();
|
|
this.draggableTextDelegate.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|
|
/**
|
|
* Index where text selection starts (or where cursor is when there is no selection)
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Index where text selection ends
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Color of text selection
|
|
* @type String
|
|
* @default
|
|
*/
|
|
/**
|
|
* Indicates whether text is in editing mode
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
/**
|
|
* Indicates whether a text can be edited
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
/**
|
|
* Border color of text object while it's in editing mode
|
|
* @type String
|
|
* @default
|
|
*/
|
|
/**
|
|
* Width of cursor (in px)
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Color of text cursor color in editing mode.
|
|
* if not set (default) will take color from the text.
|
|
* if set to a color value that fabric can understand, it will
|
|
* be used instead of the color of the text at the current position.
|
|
* @type String
|
|
* @default
|
|
*/
|
|
/**
|
|
* Delay between cursor blink (in ms)
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Duration of cursor fade in (in ms)
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Indicates whether internal text char widths can be cached
|
|
* @type Boolean
|
|
* @default
|
|
*/
|
|
_defineProperty(IText, "ownDefaults", iTextDefaultValues);
|
|
_defineProperty(IText, "type", 'IText');
|
|
classRegistry.setClass(IText);
|
|
// legacy
|
|
classRegistry.setClass(IText, 'i-text');
|
|
|
|
// @TODO: Many things here are configuration related and shouldn't be on the class nor prototype
|
|
// regexes, list of properties that are not suppose to change by instances, magic consts.
|
|
// this will be a separated effort
|
|
const textboxDefaultValues = {
|
|
minWidth: 20,
|
|
dynamicMinWidth: 2,
|
|
lockScalingFlip: true,
|
|
noScaleCache: false,
|
|
_wordJoiners: /[ \t\r]/,
|
|
splitByGrapheme: false
|
|
};
|
|
|
|
// @TODO this is not complete
|
|
|
|
/**
|
|
* Textbox class, based on IText, allows the user to resize the text rectangle
|
|
* and wraps lines automatically. Textboxes have their Y scaling locked, the
|
|
* user can only change width. Height is adjusted automatically based on the
|
|
* wrapping of lines.
|
|
*/
|
|
class Textbox extends IText {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), Textbox.ownDefaults);
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {String} text Text string
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor(text, options) {
|
|
super(text, _objectSpread2(_objectSpread2({}, Textbox.ownDefaults), options));
|
|
}
|
|
|
|
/**
|
|
* Creates the default control object.
|
|
* If you prefer to have on instance of controls shared among all objects
|
|
* make this function return an empty object and add controls to the ownDefaults object
|
|
*/
|
|
static createControls() {
|
|
return {
|
|
controls: createTextboxDefaultControls()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Unlike superclass's version of this function, Textbox does not update
|
|
* its width.
|
|
* @private
|
|
* @override
|
|
*/
|
|
initDimensions() {
|
|
if (!this.initialized) {
|
|
return;
|
|
}
|
|
this.isEditing && this.initDelayedCursor();
|
|
this._clearCache();
|
|
// clear dynamicMinWidth as it will be different after we re-wrap line
|
|
this.dynamicMinWidth = 0;
|
|
// wrap lines
|
|
this._styleMap = this._generateStyleMap(this._splitText());
|
|
// if after wrapping, the width is smaller than dynamicMinWidth, change the width and re-wrap
|
|
if (this.dynamicMinWidth > this.width) {
|
|
this._set('width', this.dynamicMinWidth);
|
|
}
|
|
if (this.textAlign.includes(JUSTIFY)) {
|
|
// once text is measured we need to make space fatter to make justified text.
|
|
this.enlargeSpaces();
|
|
}
|
|
// clear cache and re-calculate height
|
|
this.height = this.calcTextHeight();
|
|
}
|
|
|
|
/**
|
|
* Generate an object that translates the style object so that it is
|
|
* broken up by visual lines (new lines and automatic wrapping).
|
|
* The original text styles object is broken up by actual lines (new lines only),
|
|
* which is only sufficient for Text / IText
|
|
* @private
|
|
*/
|
|
_generateStyleMap(textInfo) {
|
|
let realLineCount = 0,
|
|
realLineCharCount = 0,
|
|
charCount = 0;
|
|
const map = {};
|
|
for (let i = 0; i < textInfo.graphemeLines.length; i++) {
|
|
if (textInfo.graphemeText[charCount] === '\n' && i > 0) {
|
|
realLineCharCount = 0;
|
|
charCount++;
|
|
realLineCount++;
|
|
} else if (!this.splitByGrapheme && this._reSpaceAndTab.test(textInfo.graphemeText[charCount]) && i > 0) {
|
|
// this case deals with space's that are removed from end of lines when wrapping
|
|
realLineCharCount++;
|
|
charCount++;
|
|
}
|
|
map[i] = {
|
|
line: realLineCount,
|
|
offset: realLineCharCount
|
|
};
|
|
charCount += textInfo.graphemeLines[i].length;
|
|
realLineCharCount += textInfo.graphemeLines[i].length;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Returns true if object has a style property or has it on a specified line
|
|
* @param {Number} lineIndex
|
|
* @return {Boolean}
|
|
*/
|
|
styleHas(property, lineIndex) {
|
|
if (this._styleMap && !this.isWrapping) {
|
|
const map = this._styleMap[lineIndex];
|
|
if (map) {
|
|
lineIndex = map.line;
|
|
}
|
|
}
|
|
return super.styleHas(property, lineIndex);
|
|
}
|
|
|
|
/**
|
|
* Returns true if object has no styling or no styling in a line
|
|
* @param {Number} lineIndex , lineIndex is on wrapped lines.
|
|
* @return {Boolean}
|
|
*/
|
|
isEmptyStyles(lineIndex) {
|
|
if (!this.styles) {
|
|
return true;
|
|
}
|
|
let offset = 0,
|
|
nextLineIndex = lineIndex + 1,
|
|
nextOffset,
|
|
shouldLimit = false;
|
|
const map = this._styleMap[lineIndex],
|
|
mapNextLine = this._styleMap[lineIndex + 1];
|
|
if (map) {
|
|
lineIndex = map.line;
|
|
offset = map.offset;
|
|
}
|
|
if (mapNextLine) {
|
|
nextLineIndex = mapNextLine.line;
|
|
shouldLimit = nextLineIndex === lineIndex;
|
|
nextOffset = mapNextLine.offset;
|
|
}
|
|
const obj = typeof lineIndex === 'undefined' ? this.styles : {
|
|
line: this.styles[lineIndex]
|
|
};
|
|
for (const p1 in obj) {
|
|
for (const p2 in obj[p1]) {
|
|
const p2Number = parseInt(p2, 10);
|
|
if (p2Number >= offset && (!shouldLimit || p2Number < nextOffset)) {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for (const p3 in obj[p1][p2]) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @protected
|
|
* @param {Number} lineIndex
|
|
* @param {Number} charIndex
|
|
* @return {TextStyleDeclaration} a style object reference to the existing one or a new empty object when undefined
|
|
*/
|
|
_getStyleDeclaration(lineIndex, charIndex) {
|
|
if (this._styleMap && !this.isWrapping) {
|
|
const map = this._styleMap[lineIndex];
|
|
if (!map) {
|
|
return {};
|
|
}
|
|
lineIndex = map.line;
|
|
charIndex = map.offset + charIndex;
|
|
}
|
|
return super._getStyleDeclaration(lineIndex, charIndex);
|
|
}
|
|
|
|
/**
|
|
* @param {Number} lineIndex
|
|
* @param {Number} charIndex
|
|
* @param {Object} style
|
|
* @private
|
|
*/
|
|
_setStyleDeclaration(lineIndex, charIndex, style) {
|
|
const map = this._styleMap[lineIndex];
|
|
super._setStyleDeclaration(map.line, map.offset + charIndex, style);
|
|
}
|
|
|
|
/**
|
|
* @param {Number} lineIndex
|
|
* @param {Number} charIndex
|
|
* @private
|
|
*/
|
|
_deleteStyleDeclaration(lineIndex, charIndex) {
|
|
const map = this._styleMap[lineIndex];
|
|
super._deleteStyleDeclaration(map.line, map.offset + charIndex);
|
|
}
|
|
|
|
/**
|
|
* probably broken need a fix
|
|
* Returns the real style line that correspond to the wrapped lineIndex line
|
|
* Used just to verify if the line does exist or not.
|
|
* @param {Number} lineIndex
|
|
* @returns {Boolean} if the line exists or not
|
|
* @private
|
|
*/
|
|
_getLineStyle(lineIndex) {
|
|
const map = this._styleMap[lineIndex];
|
|
return !!this.styles[map.line];
|
|
}
|
|
|
|
/**
|
|
* Set the line style to an empty object so that is initialized
|
|
* @param {Number} lineIndex
|
|
* @param {Object} style
|
|
* @private
|
|
*/
|
|
_setLineStyle(lineIndex) {
|
|
const map = this._styleMap[lineIndex];
|
|
super._setLineStyle(map.line);
|
|
}
|
|
|
|
/**
|
|
* Wraps text using the 'width' property of Textbox. First this function
|
|
* splits text on newlines, so we preserve newlines entered by the user.
|
|
* Then it wraps each line using the width of the Textbox by calling
|
|
* _wrapLine().
|
|
* @param {Array} lines The string array of text that is split into lines
|
|
* @param {Number} desiredWidth width you want to wrap to
|
|
* @returns {Array} Array of lines
|
|
*/
|
|
_wrapText(lines, desiredWidth) {
|
|
this.isWrapping = true;
|
|
// extract all thewords and the widths to optimally wrap lines.
|
|
const data = this.getGraphemeDataForRender(lines);
|
|
const wrapped = [];
|
|
for (let i = 0; i < data.wordsData.length; i++) {
|
|
wrapped.push(...this._wrapLine(i, desiredWidth, data));
|
|
}
|
|
this.isWrapping = false;
|
|
return wrapped;
|
|
}
|
|
|
|
/**
|
|
* For each line of text terminated by an hard line stop,
|
|
* measure each word width and extract the largest word from all.
|
|
* The returned words here are the one that at the end will be rendered.
|
|
* @param {string[]} lines the lines we need to measure
|
|
*
|
|
*/
|
|
getGraphemeDataForRender(lines) {
|
|
const splitByGrapheme = this.splitByGrapheme,
|
|
infix = splitByGrapheme ? '' : ' ';
|
|
let largestWordWidth = 0;
|
|
const data = lines.map((line, lineIndex) => {
|
|
let offset = 0;
|
|
const wordsOrGraphemes = splitByGrapheme ? this.graphemeSplit(line) : this.wordSplit(line);
|
|
if (wordsOrGraphemes.length === 0) {
|
|
return [{
|
|
word: [],
|
|
width: 0
|
|
}];
|
|
}
|
|
return wordsOrGraphemes.map(word => {
|
|
// if using splitByGrapheme words are already in graphemes.
|
|
const graphemeArray = splitByGrapheme ? [word] : this.graphemeSplit(word);
|
|
const width = this._measureWord(graphemeArray, lineIndex, offset);
|
|
largestWordWidth = Math.max(width, largestWordWidth);
|
|
offset += graphemeArray.length + infix.length;
|
|
return {
|
|
word: graphemeArray,
|
|
width
|
|
};
|
|
});
|
|
});
|
|
return {
|
|
wordsData: data,
|
|
largestWordWidth
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper function to measure a string of text, given its lineIndex and charIndex offset
|
|
* It gets called when charBounds are not available yet.
|
|
* Override if necessary
|
|
* Use with {@link Textbox#wordSplit}
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {String} text
|
|
* @param {number} lineIndex
|
|
* @param {number} charOffset
|
|
* @returns {number}
|
|
*/
|
|
_measureWord(word, lineIndex) {
|
|
let charOffset = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0;
|
|
let width = 0,
|
|
prevGrapheme;
|
|
const skipLeft = true;
|
|
for (let i = 0, len = word.length; i < len; i++) {
|
|
const box = this._getGraphemeBox(word[i], lineIndex, i + charOffset, prevGrapheme, skipLeft);
|
|
width += box.kernedWidth;
|
|
prevGrapheme = word[i];
|
|
}
|
|
return width;
|
|
}
|
|
|
|
/**
|
|
* Override this method to customize word splitting
|
|
* Use with {@link Textbox#_measureWord}
|
|
* @param {string} value
|
|
* @returns {string[]} array of words
|
|
*/
|
|
wordSplit(value) {
|
|
return value.split(this._wordJoiners);
|
|
}
|
|
|
|
/**
|
|
* Wraps a line of text using the width of the Textbox as desiredWidth
|
|
* and leveraging the known width o words from GraphemeData
|
|
* @private
|
|
* @param {Number} lineIndex
|
|
* @param {Number} desiredWidth width you want to wrap the line to
|
|
* @param {GraphemeData} graphemeData an object containing all the lines' words width.
|
|
* @param {Number} reservedSpace space to remove from wrapping for custom functionalities
|
|
* @returns {Array} Array of line(s) into which the given text is wrapped
|
|
* to.
|
|
*/
|
|
_wrapLine(lineIndex, desiredWidth, _ref) {
|
|
let {
|
|
largestWordWidth,
|
|
wordsData
|
|
} = _ref;
|
|
let reservedSpace = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0;
|
|
const additionalSpace = this._getWidthOfCharSpacing(),
|
|
splitByGrapheme = this.splitByGrapheme,
|
|
graphemeLines = [],
|
|
infix = splitByGrapheme ? '' : ' ';
|
|
let lineWidth = 0,
|
|
line = [],
|
|
// spaces in different languages?
|
|
offset = 0,
|
|
infixWidth = 0,
|
|
lineJustStarted = true;
|
|
desiredWidth -= reservedSpace;
|
|
const maxWidth = Math.max(desiredWidth, largestWordWidth, this.dynamicMinWidth);
|
|
// layout words
|
|
const data = wordsData[lineIndex];
|
|
offset = 0;
|
|
let i;
|
|
for (i = 0; i < data.length; i++) {
|
|
const {
|
|
word,
|
|
width: wordWidth
|
|
} = data[i];
|
|
offset += word.length;
|
|
lineWidth += infixWidth + wordWidth - additionalSpace;
|
|
if (lineWidth > maxWidth && !lineJustStarted) {
|
|
graphemeLines.push(line);
|
|
line = [];
|
|
lineWidth = wordWidth;
|
|
lineJustStarted = true;
|
|
} else {
|
|
lineWidth += additionalSpace;
|
|
}
|
|
if (!lineJustStarted && !splitByGrapheme) {
|
|
line.push(infix);
|
|
}
|
|
line = line.concat(word);
|
|
infixWidth = splitByGrapheme ? 0 : this._measureWord([infix], lineIndex, offset);
|
|
offset++;
|
|
lineJustStarted = false;
|
|
}
|
|
i && graphemeLines.push(line);
|
|
|
|
// TODO: this code is probably not necessary anymore.
|
|
// it can be moved out of this function since largestWordWidth is now
|
|
// known in advance
|
|
if (largestWordWidth + reservedSpace > this.dynamicMinWidth) {
|
|
this.dynamicMinWidth = largestWordWidth - additionalSpace + reservedSpace;
|
|
}
|
|
return graphemeLines;
|
|
}
|
|
|
|
/**
|
|
* Detect if the text line is ended with an hard break
|
|
* text and itext do not have wrapping, return false
|
|
* @param {Number} lineIndex text to split
|
|
* @return {Boolean}
|
|
*/
|
|
isEndOfWrapping(lineIndex) {
|
|
if (!this._styleMap[lineIndex + 1]) {
|
|
// is last line, return true;
|
|
return true;
|
|
}
|
|
if (this._styleMap[lineIndex + 1].line !== this._styleMap[lineIndex].line) {
|
|
// this is last line before a line break, return true;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Detect if a line has a linebreak and so we need to account for it when moving
|
|
* and counting style.
|
|
* This is important only for splitByGrapheme at the end of wrapping.
|
|
* If we are not wrapping the offset is always 1
|
|
* @return Number
|
|
*/
|
|
missingNewlineOffset(lineIndex, skipWrapping) {
|
|
if (this.splitByGrapheme && !skipWrapping) {
|
|
return this.isEndOfWrapping(lineIndex) ? 1 : 0;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Gets lines of text to render in the Textbox. This function calculates
|
|
* text wrapping on the fly every time it is called.
|
|
* @param {String} text text to split
|
|
* @returns {Array} Array of lines in the Textbox.
|
|
* @override
|
|
*/
|
|
_splitTextIntoLines(text) {
|
|
const newText = super._splitTextIntoLines(text),
|
|
graphemeLines = this._wrapText(newText.lines, this.width),
|
|
lines = new Array(graphemeLines.length);
|
|
for (let i = 0; i < graphemeLines.length; i++) {
|
|
lines[i] = graphemeLines[i].join('');
|
|
}
|
|
newText.lines = lines;
|
|
newText.graphemeLines = graphemeLines;
|
|
return newText;
|
|
}
|
|
getMinWidth() {
|
|
return Math.max(this.minWidth, this.dynamicMinWidth);
|
|
}
|
|
_removeExtraneousStyles() {
|
|
const linesToKeep = new Map();
|
|
for (const prop in this._styleMap) {
|
|
const propNumber = parseInt(prop, 10);
|
|
if (this._textLines[propNumber]) {
|
|
const lineIndex = this._styleMap[prop].line;
|
|
linesToKeep.set("".concat(lineIndex), true);
|
|
}
|
|
}
|
|
for (const prop in this.styles) {
|
|
if (!linesToKeep.has(prop)) {
|
|
delete this.styles[prop];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @method toObject
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
return super.toObject(['minWidth', 'splitByGrapheme', ...propertiesToInclude]);
|
|
}
|
|
}
|
|
/**
|
|
* Minimum width of textbox, in pixels.
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Minimum calculated width of a textbox, in pixels.
|
|
* fixed to 2 so that an empty textbox cannot go to 0
|
|
* and is still selectable without text.
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
/**
|
|
* Use this boolean property in order to split strings that have no white space concept.
|
|
* this is a cheap way to help with chinese/japanese
|
|
* @type Boolean
|
|
* @since 2.6.0
|
|
*/
|
|
_defineProperty(Textbox, "type", 'Textbox');
|
|
_defineProperty(Textbox, "textLayoutProperties", [...IText.textLayoutProperties, 'width']);
|
|
_defineProperty(Textbox, "ownDefaults", textboxDefaultValues);
|
|
classRegistry.setClass(Textbox);
|
|
|
|
/**
|
|
* Layout will adjust the bounding box to match the clip path bounding box.
|
|
*/
|
|
class ClipPathLayout extends LayoutStrategy {
|
|
shouldPerformLayout(context) {
|
|
return !!context.target.clipPath && super.shouldPerformLayout(context);
|
|
}
|
|
shouldLayoutClipPath() {
|
|
return false;
|
|
}
|
|
calcLayoutResult(context, objects) {
|
|
const {
|
|
target
|
|
} = context;
|
|
const {
|
|
clipPath,
|
|
group
|
|
} = target;
|
|
if (!clipPath || !this.shouldPerformLayout(context)) {
|
|
return;
|
|
}
|
|
// TODO: remove stroke calculation from this case
|
|
const {
|
|
width,
|
|
height
|
|
} = makeBoundingBoxFromPoints(getObjectBounds(target, clipPath));
|
|
const size = new Point(width, height);
|
|
if (clipPath.absolutePositioned) {
|
|
// we want the center point to exist in group's containing plane
|
|
const clipPathCenter = sendPointToPlane(clipPath.getRelativeCenterPoint(), undefined, group ? group.calcTransformMatrix() : undefined);
|
|
return {
|
|
center: clipPathCenter,
|
|
size
|
|
};
|
|
} else {
|
|
// we want the center point to exist in group's containing plane, so we send it upwards
|
|
const clipPathCenter = clipPath.getRelativeCenterPoint().transform(target.calcOwnMatrix(), true);
|
|
if (this.shouldPerformLayout(context)) {
|
|
// the clip path is positioned relative to the group's center which is affected by the bbox
|
|
// so we first calculate the bbox
|
|
const {
|
|
center = new Point(),
|
|
correction = new Point()
|
|
} = this.calcBoundingBox(objects, context) || {};
|
|
return {
|
|
center: center.add(clipPathCenter),
|
|
correction: correction.subtract(clipPathCenter),
|
|
size
|
|
};
|
|
} else {
|
|
return {
|
|
center: target.getRelativeCenterPoint().add(clipPathCenter),
|
|
size
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_defineProperty(ClipPathLayout, "type", 'clip-path');
|
|
classRegistry.setClass(ClipPathLayout);
|
|
|
|
/**
|
|
* Layout will keep target's initial size.
|
|
*/
|
|
class FixedLayout extends LayoutStrategy {
|
|
/**
|
|
* @override respect target's initial size
|
|
*/
|
|
getInitialSize(_ref, _ref2) {
|
|
let {
|
|
target
|
|
} = _ref;
|
|
let {
|
|
size
|
|
} = _ref2;
|
|
return new Point(target.width || size.x, target.height || size.y);
|
|
}
|
|
}
|
|
_defineProperty(FixedLayout, "type", 'fixed');
|
|
classRegistry.setClass(FixedLayout);
|
|
|
|
/**
|
|
* Today the LayoutManager class also takes care of subscribing event handlers
|
|
* to update the group layout when the group is interactive and a transform is applied
|
|
* to a child object.
|
|
* The ActiveSelection is never interactive, but it could contain objects from
|
|
* groups that are.
|
|
* The standard LayoutManager would subscribe the children of the activeSelection to
|
|
* perform layout changes to the active selection itself, what we need instead is that
|
|
* the transformation applied to the active selection will trigger changes to the
|
|
* original group of the children ( the one referenced under the parent property )
|
|
* This subclass of the LayoutManager has a single duty to fill the gap of this difference.`
|
|
*/
|
|
class ActiveSelectionLayoutManager extends LayoutManager {
|
|
subscribeTargets(context) {
|
|
const activeSelection = context.target;
|
|
const parents = context.targets.reduce((parents, target) => {
|
|
target.parent && parents.add(target.parent);
|
|
return parents;
|
|
}, new Set());
|
|
parents.forEach(parent => {
|
|
parent.layoutManager.subscribeTargets({
|
|
target: parent,
|
|
targets: [activeSelection]
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* unsubscribe from parent only if all its children were deselected
|
|
*/
|
|
unsubscribeTargets(context) {
|
|
const activeSelection = context.target;
|
|
const selectedObjects = activeSelection.getObjects();
|
|
const parents = context.targets.reduce((parents, target) => {
|
|
target.parent && parents.add(target.parent);
|
|
return parents;
|
|
}, new Set());
|
|
parents.forEach(parent => {
|
|
!selectedObjects.some(object => object.parent === parent) && parent.layoutManager.unsubscribeTargets({
|
|
target: parent,
|
|
targets: [activeSelection]
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
const activeSelectionDefaultValues = {
|
|
multiSelectionStacking: 'canvas-stacking'
|
|
};
|
|
|
|
/**
|
|
* Used by Canvas to manage selection.
|
|
*
|
|
* @example
|
|
* class MyActiveSelection extends ActiveSelection {
|
|
* ...
|
|
* }
|
|
*
|
|
* // override the default `ActiveSelection` class
|
|
* classRegistry.setClass(MyActiveSelection)
|
|
*/
|
|
class ActiveSelection extends Group {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), ActiveSelection.ownDefaults);
|
|
}
|
|
|
|
/**
|
|
* The ActiveSelection needs to use the ActiveSelectionLayoutManager
|
|
* or selections on interactive groups may be broken
|
|
*/
|
|
|
|
/**
|
|
* controls how selected objects are added during a multiselection event
|
|
* - `canvas-stacking` adds the selected object to the active selection while respecting canvas object stacking order
|
|
* - `selection-order` adds the selected object to the top of the stack,
|
|
* meaning that the stack is ordered by the order in which objects were selected
|
|
* @default `canvas-stacking`
|
|
*/
|
|
|
|
constructor() {
|
|
let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
super();
|
|
Object.assign(this, ActiveSelection.ownDefaults);
|
|
this.setOptions(options);
|
|
const {
|
|
left,
|
|
top,
|
|
layoutManager
|
|
} = options;
|
|
this.groupInit(objects, {
|
|
left,
|
|
top,
|
|
layoutManager: layoutManager !== null && layoutManager !== void 0 ? layoutManager : new ActiveSelectionLayoutManager()
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_shouldSetNestedCoords() {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @override we don't want the selection monitor to be active
|
|
*/
|
|
__objectSelectionMonitor() {
|
|
// noop
|
|
}
|
|
|
|
/**
|
|
* Adds objects with respect to {@link multiSelectionStacking}
|
|
* @param targets object to add to selection
|
|
*/
|
|
multiSelectAdd() {
|
|
for (var _len = arguments.length, targets = new Array(_len), _key = 0; _key < _len; _key++) {
|
|
targets[_key] = arguments[_key];
|
|
}
|
|
if (this.multiSelectionStacking === 'selection-order') {
|
|
this.add(...targets);
|
|
} else {
|
|
// respect object stacking as it is on canvas
|
|
// perf enhancement for large ActiveSelection: consider a binary search of `isInFrontOf`
|
|
targets.forEach(target => {
|
|
const index = this._objects.findIndex(obj => obj.isInFrontOf(target));
|
|
const insertAt = index === -1 ?
|
|
// `target` is in front of all other objects
|
|
this.size() : index;
|
|
this.insertAt(insertAt, target);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @override block ancestors/descendants of selected objects from being selected to prevent a circular object tree
|
|
*/
|
|
canEnterGroup(object) {
|
|
if (this.getObjects().some(o => o.isDescendantOf(object) || object.isDescendantOf(o))) {
|
|
// prevent circular object tree
|
|
log('error', 'ActiveSelection: circular object trees are not supported, this call has no effect');
|
|
return false;
|
|
}
|
|
return super.canEnterGroup(object);
|
|
}
|
|
|
|
/**
|
|
* Change an object so that it can be part of an active selection.
|
|
* this method is called by multiselectAdd from canvas code.
|
|
* @private
|
|
* @param {FabricObject} object
|
|
* @param {boolean} [removeParentTransform] true if object is in canvas coordinate plane
|
|
*/
|
|
enterGroup(object, removeParentTransform) {
|
|
// This condition check that the object has currently a group, and the group
|
|
// is also its parent, meaning that is not in an active selection, but is
|
|
// in a normal group.
|
|
if (object.parent && object.parent === object.group) {
|
|
// Disconnect the object from the group functionalities, but keep the ref parent intact
|
|
// for later re-enter
|
|
object.parent._exitGroup(object);
|
|
// in this case the object is probably inside an active selection.
|
|
} else if (object.group && object.parent !== object.group) {
|
|
// in this case group.remove will also clear the old parent reference.
|
|
object.group.remove(object);
|
|
}
|
|
// enter the active selection from a render perspective
|
|
// the object will be in the objects array of both the ActiveSelection and the Group
|
|
// but referenced in the group's _activeObjects so that it won't be rendered twice.
|
|
this._enterGroup(object, removeParentTransform);
|
|
}
|
|
|
|
/**
|
|
* we want objects to retain their canvas ref when exiting instance
|
|
* @private
|
|
* @param {FabricObject} object
|
|
* @param {boolean} [removeParentTransform] true if object should exit group without applying group's transform to it
|
|
*/
|
|
exitGroup(object, removeParentTransform) {
|
|
this._exitGroup(object, removeParentTransform);
|
|
// return to parent
|
|
object.parent && object.parent._enterGroup(object, true);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {'added'|'removed'} type
|
|
* @param {FabricObject[]} targets
|
|
*/
|
|
_onAfterObjectsChange(type, targets) {
|
|
super._onAfterObjectsChange(type, targets);
|
|
const groups = new Set();
|
|
targets.forEach(object => {
|
|
const {
|
|
parent
|
|
} = object;
|
|
parent && groups.add(parent);
|
|
});
|
|
if (type === LAYOUT_TYPE_REMOVED) {
|
|
// invalidate groups' layout and mark as dirty
|
|
groups.forEach(group => {
|
|
group._onAfterObjectsChange(LAYOUT_TYPE_ADDED, targets);
|
|
});
|
|
} else {
|
|
// mark groups as dirty
|
|
groups.forEach(group => {
|
|
group._set('dirty', true);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @override remove all objects
|
|
*/
|
|
onDeselect() {
|
|
this.removeAll();
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns string representation of a group
|
|
* @return {String}
|
|
*/
|
|
toString() {
|
|
return "#<ActiveSelection: (".concat(this.complexity(), ")>");
|
|
}
|
|
|
|
/**
|
|
* Decide if the object should cache or not. Create its own cache level
|
|
* objectCaching is a global flag, wins over everything
|
|
* needsItsOwnCache should be used when the object drawing method requires
|
|
* a cache step. None of the fabric classes requires it.
|
|
* Generally you do not cache objects in groups because the group outside is cached.
|
|
* @return {Boolean}
|
|
*/
|
|
shouldCache() {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if this group or its parent group are caching, recursively up
|
|
* @return {Boolean}
|
|
*/
|
|
isOnACache() {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Renders controls and borders for the object
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
* @param {Object} [styleOverride] properties to override the object style
|
|
* @param {Object} [childrenOverride] properties to override the children overrides
|
|
*/
|
|
_renderControls(ctx, styleOverride, childrenOverride) {
|
|
ctx.save();
|
|
ctx.globalAlpha = this.isMoving ? this.borderOpacityWhenMoving : 1;
|
|
const options = _objectSpread2(_objectSpread2({
|
|
hasControls: false
|
|
}, childrenOverride), {}, {
|
|
forActiveSelection: true
|
|
});
|
|
for (let i = 0; i < this._objects.length; i++) {
|
|
this._objects[i]._renderControls(ctx, options);
|
|
}
|
|
super._renderControls(ctx, styleOverride);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
_defineProperty(ActiveSelection, "type", 'ActiveSelection');
|
|
_defineProperty(ActiveSelection, "ownDefaults", activeSelectionDefaultValues);
|
|
classRegistry.setClass(ActiveSelection);
|
|
classRegistry.setClass(ActiveSelection, 'activeSelection');
|
|
|
|
/**
|
|
* Canvas 2D filter backend.
|
|
*/
|
|
|
|
class Canvas2dFilterBackend {
|
|
constructor() {
|
|
/**
|
|
* Experimental. This object is a sort of repository of help layers used to avoid
|
|
* of recreating them during frequent filtering. If you are previewing a filter with
|
|
* a slider you probably do not want to create help layers every filter step.
|
|
* in this object there will be appended some canvases, created once, resized sometimes
|
|
* cleared never. Clearing is left to the developer.
|
|
**/
|
|
_defineProperty(this, "resources", {});
|
|
}
|
|
/**
|
|
* Apply a set of filters against a source image and draw the filtered output
|
|
* to the provided destination canvas.
|
|
*
|
|
* @param {EnhancedFilter} filters The filter to apply.
|
|
* @param {HTMLImageElement|HTMLCanvasElement} sourceElement The source to be filtered.
|
|
* @param {Number} sourceWidth The width of the source input.
|
|
* @param {Number} sourceHeight The height of the source input.
|
|
* @param {HTMLCanvasElement} targetCanvas The destination for filtered output to be drawn.
|
|
*/
|
|
applyFilters(filters, sourceElement, sourceWidth, sourceHeight, targetCanvas) {
|
|
const ctx = targetCanvas.getContext('2d');
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
ctx.drawImage(sourceElement, 0, 0, sourceWidth, sourceHeight);
|
|
const imageData = ctx.getImageData(0, 0, sourceWidth, sourceHeight);
|
|
const originalImageData = ctx.getImageData(0, 0, sourceWidth, sourceHeight);
|
|
const pipelineState = {
|
|
sourceWidth,
|
|
sourceHeight,
|
|
imageData,
|
|
originalEl: sourceElement,
|
|
originalImageData,
|
|
canvasEl: targetCanvas,
|
|
ctx,
|
|
filterBackend: this
|
|
};
|
|
filters.forEach(filter => {
|
|
filter.applyTo(pipelineState);
|
|
});
|
|
const {
|
|
imageData: imageDataPostFilter
|
|
} = pipelineState;
|
|
if (imageDataPostFilter.width !== sourceWidth || imageDataPostFilter.height !== sourceHeight) {
|
|
targetCanvas.width = imageDataPostFilter.width;
|
|
targetCanvas.height = imageDataPostFilter.height;
|
|
}
|
|
ctx.putImageData(imageDataPostFilter, 0, 0);
|
|
return pipelineState;
|
|
}
|
|
}
|
|
|
|
class WebGLFilterBackend {
|
|
constructor() {
|
|
let {
|
|
tileSize = config.textureSize
|
|
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
/**
|
|
* Define ...
|
|
**/
|
|
_defineProperty(this, "aPosition", new Float32Array([0, 0, 0, 1, 1, 0, 1, 1]));
|
|
/**
|
|
* Experimental. This object is a sort of repository of help layers used to avoid
|
|
* of recreating them during frequent filtering. If you are previewing a filter with
|
|
* a slider you probably do not want to create help layers every filter step.
|
|
* in this object there will be appended some canvases, created once, resized sometimes
|
|
* cleared never. Clearing is left to the developer.
|
|
**/
|
|
_defineProperty(this, "resources", {});
|
|
this.tileSize = tileSize;
|
|
this.setupGLContext(tileSize, tileSize);
|
|
this.captureGPUInfo();
|
|
}
|
|
|
|
/**
|
|
* Setup a WebGL context suitable for filtering, and bind any needed event handlers.
|
|
*/
|
|
setupGLContext(width, height) {
|
|
this.dispose();
|
|
this.createWebGLCanvas(width, height);
|
|
}
|
|
|
|
/**
|
|
* Create a canvas element and associated WebGL context and attaches them as
|
|
* class properties to the GLFilterBackend class.
|
|
*/
|
|
createWebGLCanvas(width, height) {
|
|
const canvas = createCanvasElement();
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const glOptions = {
|
|
alpha: true,
|
|
premultipliedAlpha: false,
|
|
depth: false,
|
|
stencil: false,
|
|
antialias: false
|
|
},
|
|
gl = canvas.getContext('webgl', glOptions);
|
|
if (!gl) {
|
|
return;
|
|
}
|
|
gl.clearColor(0, 0, 0, 0);
|
|
// this canvas can fire webglcontextlost and webglcontextrestored
|
|
this.canvas = canvas;
|
|
this.gl = gl;
|
|
}
|
|
|
|
/**
|
|
* Attempts to apply the requested filters to the source provided, drawing the filtered output
|
|
* to the provided target canvas.
|
|
*
|
|
* @param {Array} filters The filters to apply.
|
|
* @param {TexImageSource} source The source to be filtered.
|
|
* @param {Number} width The width of the source input.
|
|
* @param {Number} height The height of the source input.
|
|
* @param {HTMLCanvasElement} targetCanvas The destination for filtered output to be drawn.
|
|
* @param {String|undefined} cacheKey A key used to cache resources related to the source. If
|
|
* omitted, caching will be skipped.
|
|
*/
|
|
applyFilters(filters, source, width, height, targetCanvas, cacheKey) {
|
|
const gl = this.gl;
|
|
const ctx = targetCanvas.getContext('2d');
|
|
if (!gl || !ctx) {
|
|
return;
|
|
}
|
|
let cachedTexture;
|
|
if (cacheKey) {
|
|
cachedTexture = this.getCachedTexture(cacheKey, source);
|
|
}
|
|
const pipelineState = {
|
|
originalWidth: source.width ||
|
|
// @ts-expect-error is this a bug? should this be naturalWidth? or is this the pipeline state?
|
|
source.originalWidth || 0,
|
|
originalHeight: source.height ||
|
|
// @ts-expect-error is this a bug? should this be naturalHeight? or is this the pipeline state?
|
|
source.originalHeight || 0,
|
|
sourceWidth: width,
|
|
sourceHeight: height,
|
|
destinationWidth: width,
|
|
destinationHeight: height,
|
|
context: gl,
|
|
sourceTexture: this.createTexture(gl, width, height, !cachedTexture ? source : undefined),
|
|
targetTexture: this.createTexture(gl, width, height),
|
|
originalTexture: cachedTexture || this.createTexture(gl, width, height, !cachedTexture ? source : undefined),
|
|
passes: filters.length,
|
|
webgl: true,
|
|
aPosition: this.aPosition,
|
|
programCache: this.programCache,
|
|
pass: 0,
|
|
filterBackend: this,
|
|
targetCanvas: targetCanvas
|
|
};
|
|
const tempFbo = gl.createFramebuffer();
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, tempFbo);
|
|
filters.forEach(filter => {
|
|
filter && filter.applyTo(pipelineState);
|
|
});
|
|
resizeCanvasIfNeeded(pipelineState);
|
|
this.copyGLTo2D(gl, pipelineState);
|
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
gl.deleteTexture(pipelineState.sourceTexture);
|
|
gl.deleteTexture(pipelineState.targetTexture);
|
|
gl.deleteFramebuffer(tempFbo);
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
return pipelineState;
|
|
}
|
|
|
|
/**
|
|
* Detach event listeners, remove references, and clean up caches.
|
|
*/
|
|
dispose() {
|
|
if (this.canvas) {
|
|
// we are disposing, we don't care about the fact
|
|
// that the canvas shouldn't be null.
|
|
// @ts-expect-error disposing
|
|
this.canvas = null;
|
|
// @ts-expect-error disposing
|
|
this.gl = null;
|
|
}
|
|
this.clearWebGLCaches();
|
|
}
|
|
|
|
/**
|
|
* Wipe out WebGL-related caches.
|
|
*/
|
|
clearWebGLCaches() {
|
|
this.programCache = {};
|
|
this.textureCache = {};
|
|
}
|
|
|
|
/**
|
|
* Create a WebGL texture object.
|
|
*
|
|
* Accepts specific dimensions to initialize the texture to or a source image.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL context to use for creating the texture.
|
|
* @param {number} width The width to initialize the texture at.
|
|
* @param {number} height The height to initialize the texture.
|
|
* @param {TexImageSource} textureImageSource A source for the texture data.
|
|
* @param {number} filter gl.NEAREST default or gl.LINEAR filters for the texture.
|
|
* This filter is very useful for LUTs filters. If you need interpolation use gl.LINEAR
|
|
* @returns {WebGLTexture}
|
|
*/
|
|
createTexture(gl, width, height, textureImageSource, filter) {
|
|
const {
|
|
NEAREST,
|
|
TEXTURE_2D,
|
|
RGBA,
|
|
UNSIGNED_BYTE,
|
|
CLAMP_TO_EDGE,
|
|
TEXTURE_MAG_FILTER,
|
|
TEXTURE_MIN_FILTER,
|
|
TEXTURE_WRAP_S,
|
|
TEXTURE_WRAP_T
|
|
} = gl;
|
|
const texture = gl.createTexture();
|
|
gl.bindTexture(TEXTURE_2D, texture);
|
|
gl.texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, filter || NEAREST);
|
|
gl.texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, filter || NEAREST);
|
|
gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE);
|
|
gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE);
|
|
if (textureImageSource) {
|
|
gl.texImage2D(TEXTURE_2D, 0, RGBA, RGBA, UNSIGNED_BYTE, textureImageSource);
|
|
} else {
|
|
gl.texImage2D(TEXTURE_2D, 0, RGBA, width, height, 0, RGBA, UNSIGNED_BYTE, null);
|
|
}
|
|
return texture;
|
|
}
|
|
|
|
/**
|
|
* Can be optionally used to get a texture from the cache array
|
|
*
|
|
* If an existing texture is not found, a new texture is created and cached.
|
|
*
|
|
* @param {String} uniqueId A cache key to use to find an existing texture.
|
|
* @param {HTMLImageElement|HTMLCanvasElement} textureImageSource A source to use to create the
|
|
* texture cache entry if one does not already exist.
|
|
*/
|
|
getCachedTexture(uniqueId, textureImageSource, filter) {
|
|
const {
|
|
textureCache
|
|
} = this;
|
|
if (textureCache[uniqueId]) {
|
|
return textureCache[uniqueId];
|
|
} else {
|
|
const texture = this.createTexture(this.gl, textureImageSource.width, textureImageSource.height, textureImageSource, filter);
|
|
if (texture) {
|
|
textureCache[uniqueId] = texture;
|
|
}
|
|
return texture;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear out cached resources related to a source image that has been
|
|
* filtered previously.
|
|
*
|
|
* @param {String} cacheKey The cache key provided when the source image was filtered.
|
|
*/
|
|
evictCachesForKey(cacheKey) {
|
|
if (this.textureCache[cacheKey]) {
|
|
this.gl.deleteTexture(this.textureCache[cacheKey]);
|
|
delete this.textureCache[cacheKey];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copy an input WebGL canvas on to an output 2D canvas.
|
|
*
|
|
* The WebGL canvas is assumed to be upside down, with the top-left pixel of the
|
|
* desired output image appearing in the bottom-left corner of the WebGL canvas.
|
|
*
|
|
* @param {WebGLRenderingContext} sourceContext The WebGL context to copy from.
|
|
* @param {Object} pipelineState The 2D target canvas to copy on to.
|
|
*/
|
|
copyGLTo2D(gl, pipelineState) {
|
|
const glCanvas = gl.canvas,
|
|
targetCanvas = pipelineState.targetCanvas,
|
|
ctx = targetCanvas.getContext('2d');
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
ctx.translate(0, targetCanvas.height); // move it down again
|
|
ctx.scale(1, -1); // vertical flip
|
|
// where is my image on the big glcanvas?
|
|
const sourceY = glCanvas.height - targetCanvas.height;
|
|
ctx.drawImage(glCanvas, 0, sourceY, targetCanvas.width, targetCanvas.height, 0, 0, targetCanvas.width, targetCanvas.height);
|
|
}
|
|
|
|
/**
|
|
* Copy an input WebGL canvas on to an output 2D canvas using 2d canvas' putImageData
|
|
* API. Measurably faster than using ctx.drawImage in Firefox (version 54 on OSX Sierra).
|
|
*
|
|
* @param {WebGLRenderingContext} sourceContext The WebGL context to copy from.
|
|
* @param {HTMLCanvasElement} targetCanvas The 2D target canvas to copy on to.
|
|
* @param {Object} pipelineState The 2D target canvas to copy on to.
|
|
*/
|
|
copyGLTo2DPutImageData(gl, pipelineState) {
|
|
const targetCanvas = pipelineState.targetCanvas,
|
|
ctx = targetCanvas.getContext('2d'),
|
|
dWidth = pipelineState.destinationWidth,
|
|
dHeight = pipelineState.destinationHeight,
|
|
numBytes = dWidth * dHeight * 4;
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
const u8 = new Uint8Array(this.imageBuffer, 0, numBytes);
|
|
const u8Clamped = new Uint8ClampedArray(this.imageBuffer, 0, numBytes);
|
|
gl.readPixels(0, 0, dWidth, dHeight, gl.RGBA, gl.UNSIGNED_BYTE, u8);
|
|
const imgData = new ImageData(u8Clamped, dWidth, dHeight);
|
|
ctx.putImageData(imgData, 0, 0);
|
|
}
|
|
|
|
/**
|
|
* Attempt to extract GPU information strings from a WebGL context.
|
|
*
|
|
* Useful information when debugging or blacklisting specific GPUs.
|
|
*
|
|
* @returns {Object} A GPU info object with renderer and vendor strings.
|
|
*/
|
|
captureGPUInfo() {
|
|
if (this.gpuInfo) {
|
|
return this.gpuInfo;
|
|
}
|
|
const gl = this.gl,
|
|
gpuInfo = {
|
|
renderer: '',
|
|
vendor: ''
|
|
};
|
|
if (!gl) {
|
|
return gpuInfo;
|
|
}
|
|
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
|
if (ext) {
|
|
const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
|
|
const vendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL);
|
|
if (renderer) {
|
|
gpuInfo.renderer = renderer.toLowerCase();
|
|
}
|
|
if (vendor) {
|
|
gpuInfo.vendor = vendor.toLowerCase();
|
|
}
|
|
}
|
|
this.gpuInfo = gpuInfo;
|
|
return gpuInfo;
|
|
}
|
|
}
|
|
function resizeCanvasIfNeeded(pipelineState) {
|
|
const targetCanvas = pipelineState.targetCanvas,
|
|
width = targetCanvas.width,
|
|
height = targetCanvas.height,
|
|
dWidth = pipelineState.destinationWidth,
|
|
dHeight = pipelineState.destinationHeight;
|
|
if (width !== dWidth || height !== dHeight) {
|
|
targetCanvas.width = dWidth;
|
|
targetCanvas.height = dHeight;
|
|
}
|
|
}
|
|
|
|
let filterBackend;
|
|
|
|
/**
|
|
* Verifies if it is possible to initialize webgl or fallback on a canvas2d filtering backend
|
|
*/
|
|
function initFilterBackend() {
|
|
const {
|
|
WebGLProbe
|
|
} = getEnv();
|
|
WebGLProbe.queryWebGL(createCanvasElement());
|
|
if (config.enableGLFiltering && WebGLProbe.isSupported(config.textureSize)) {
|
|
return new WebGLFilterBackend({
|
|
tileSize: config.textureSize
|
|
});
|
|
} else {
|
|
return new Canvas2dFilterBackend();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current fabricJS filter backend or initialize one if not available yet
|
|
* @param [strict] pass `true` to create the backend if it wasn't created yet (default behavior),
|
|
* pass `false` to get the backend ref without mutating it
|
|
*/
|
|
function getFilterBackend() {
|
|
let strict = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
|
|
if (!filterBackend && strict) {
|
|
filterBackend = initFilterBackend();
|
|
}
|
|
return filterBackend;
|
|
}
|
|
function setFilterBackend(backend) {
|
|
filterBackend = backend;
|
|
}
|
|
|
|
const _excluded$2 = ["filters", "resizeFilter", "src", "crossOrigin", "type"];
|
|
|
|
// @todo Would be nice to have filtering code not imported directly.
|
|
|
|
const imageDefaultValues = {
|
|
strokeWidth: 0,
|
|
srcFromAttribute: false,
|
|
minimumScaleTrigger: 0.5,
|
|
cropX: 0,
|
|
cropY: 0,
|
|
imageSmoothing: true
|
|
};
|
|
const IMAGE_PROPS = ['cropX', 'cropY'];
|
|
|
|
/**
|
|
* @tutorial {@link http://fabricjs.com/fabric-intro-part-1#images}
|
|
*/
|
|
class FabricImage extends FabricObject {
|
|
static getDefaults() {
|
|
return _objectSpread2(_objectSpread2({}, super.getDefaults()), FabricImage.ownDefaults);
|
|
}
|
|
/**
|
|
* Constructor
|
|
* Image can be initialized with any canvas drawable or a string.
|
|
* The string should be a url and will be loaded as an image.
|
|
* Canvas and Image element work out of the box, while videos require extra code to work.
|
|
* Please check video element events for seeking.
|
|
* @param {ImageSource | string} element Image element
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
|
|
constructor(arg0, options) {
|
|
super();
|
|
/**
|
|
* When calling {@link FabricImage.getSrc}, return value from element src with `element.getAttribute('src')`.
|
|
* This allows for relative urls as image src.
|
|
* @since 2.7.0
|
|
* @type Boolean
|
|
* @default false
|
|
*/
|
|
/**
|
|
* private
|
|
* contains last value of scaleX to detect
|
|
* if the Image got resized after the last Render
|
|
* @type Number
|
|
*/
|
|
_defineProperty(this, "_lastScaleX", 1);
|
|
/**
|
|
* private
|
|
* contains last value of scaleY to detect
|
|
* if the Image got resized after the last Render
|
|
* @type Number
|
|
*/
|
|
_defineProperty(this, "_lastScaleY", 1);
|
|
/**
|
|
* private
|
|
* contains last value of scaling applied by the apply filter chain
|
|
* @type Number
|
|
*/
|
|
_defineProperty(this, "_filterScalingX", 1);
|
|
/**
|
|
* private
|
|
* contains last value of scaling applied by the apply filter chain
|
|
* @type Number
|
|
*/
|
|
_defineProperty(this, "_filterScalingY", 1);
|
|
this.filters = [];
|
|
Object.assign(this, FabricImage.ownDefaults);
|
|
this.setOptions(options);
|
|
this.cacheKey = "texture".concat(uid());
|
|
this.setElement(typeof arg0 === 'string' ? (this.canvas && getDocumentFromElement(this.canvas.getElement()) || getFabricDocument()).getElementById(arg0) : arg0, options);
|
|
}
|
|
|
|
/**
|
|
* Returns image element which this instance if based on
|
|
*/
|
|
getElement() {
|
|
return this._element;
|
|
}
|
|
|
|
/**
|
|
* Sets image element for this instance to a specified one.
|
|
* If filters defined they are applied to new image.
|
|
* You might need to call `canvas.renderAll` and `object.setCoords` after replacing, to render new image and update controls area.
|
|
* @param {HTMLImageElement} element
|
|
* @param {Partial<TSize>} [size] Options object
|
|
*/
|
|
setElement(element) {
|
|
let size = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
this.removeTexture(this.cacheKey);
|
|
this.removeTexture("".concat(this.cacheKey, "_filtered"));
|
|
this._element = element;
|
|
this._originalElement = element;
|
|
this._setWidthHeight(size);
|
|
element.classList.add(FabricImage.CSS_CANVAS);
|
|
if (this.filters.length !== 0) {
|
|
this.applyFilters();
|
|
}
|
|
// resizeFilters work on the already filtered copy.
|
|
// we need to apply resizeFilters AFTER normal filters.
|
|
// applyResizeFilters is run more often than normal filters
|
|
// and is triggered by user interactions rather than dev code
|
|
if (this.resizeFilter) {
|
|
this.applyResizeFilters();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a single texture if in webgl mode
|
|
*/
|
|
removeTexture(key) {
|
|
const backend = getFilterBackend(false);
|
|
if (backend instanceof WebGLFilterBackend) {
|
|
backend.evictCachesForKey(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete textures, reference to elements and eventually JSDOM cleanup
|
|
*/
|
|
dispose() {
|
|
super.dispose();
|
|
this.removeTexture(this.cacheKey);
|
|
this.removeTexture("".concat(this.cacheKey, "_filtered"));
|
|
this._cacheContext = null;
|
|
['_originalElement', '_element', '_filteredEl', '_cacheCanvas'].forEach(elementKey => {
|
|
const el = this[elementKey];
|
|
el && getEnv().dispose(el);
|
|
// @ts-expect-error disposing
|
|
this[elementKey] = undefined;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the crossOrigin value (of the corresponding image element)
|
|
*/
|
|
getCrossOrigin() {
|
|
return this._originalElement && (this._originalElement.crossOrigin || null);
|
|
}
|
|
|
|
/**
|
|
* Returns original size of an image
|
|
*/
|
|
getOriginalSize() {
|
|
const element = this.getElement();
|
|
if (!element) {
|
|
return {
|
|
width: 0,
|
|
height: 0
|
|
};
|
|
}
|
|
return {
|
|
width: element.naturalWidth || element.width,
|
|
height: element.naturalHeight || element.height
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_stroke(ctx) {
|
|
if (!this.stroke || this.strokeWidth === 0) {
|
|
return;
|
|
}
|
|
const w = this.width / 2,
|
|
h = this.height / 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-w, -h);
|
|
ctx.lineTo(w, -h);
|
|
ctx.lineTo(w, h);
|
|
ctx.lineTo(-w, h);
|
|
ctx.lineTo(-w, -h);
|
|
ctx.closePath();
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
|
|
* @return {Object} Object representation of an instance
|
|
*/
|
|
toObject() {
|
|
let propertiesToInclude = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
|
|
const filters = [];
|
|
this.filters.forEach(filterObj => {
|
|
filterObj && filters.push(filterObj.toObject());
|
|
});
|
|
return _objectSpread2(_objectSpread2({}, super.toObject([...IMAGE_PROPS, ...propertiesToInclude])), {}, {
|
|
src: this.getSrc(),
|
|
crossOrigin: this.getCrossOrigin(),
|
|
filters
|
|
}, this.resizeFilter ? {
|
|
resizeFilter: this.resizeFilter.toObject()
|
|
} : {});
|
|
}
|
|
|
|
/**
|
|
* Returns true if an image has crop applied, inspecting values of cropX,cropY,width,height.
|
|
* @return {Boolean}
|
|
*/
|
|
hasCrop() {
|
|
return !!this.cropX || !!this.cropY || this.width < this._element.width || this.height < this._element.height;
|
|
}
|
|
|
|
/**
|
|
* Returns svg representation of an instance
|
|
* @return {string[]} an array of strings with the specific svg representation
|
|
* of the instance
|
|
*/
|
|
_toSVG() {
|
|
const imageMarkup = [],
|
|
element = this._element,
|
|
x = -this.width / 2,
|
|
y = -this.height / 2;
|
|
let svgString = [],
|
|
strokeSvg = [],
|
|
clipPath = '',
|
|
imageRendering = '';
|
|
if (!element) {
|
|
return [];
|
|
}
|
|
if (this.hasCrop()) {
|
|
const clipPathId = uid();
|
|
svgString.push('<clipPath id="imageCrop_' + clipPathId + '">\n', '\t<rect x="' + x + '" y="' + y + '" width="' + this.width + '" height="' + this.height + '" />\n', '</clipPath>\n');
|
|
clipPath = ' clip-path="url(#imageCrop_' + clipPathId + ')" ';
|
|
}
|
|
if (!this.imageSmoothing) {
|
|
imageRendering = ' image-rendering="optimizeSpeed"';
|
|
}
|
|
imageMarkup.push('\t<image ', 'COMMON_PARTS', "xlink:href=\"".concat(this.getSvgSrc(true), "\" x=\"").concat(x - this.cropX, "\" y=\"").concat(y - this.cropY
|
|
// we're essentially moving origin of transformation from top/left corner to the center of the shape
|
|
// by wrapping it in container <g> element with actual transformation, then offsetting object to the top/left
|
|
// so that object's center aligns with container's left/top
|
|
, "\" width=\"").concat(element.width || element.naturalWidth, "\" height=\"").concat(element.height || element.naturalHeight, "\"").concat(imageRendering).concat(clipPath, "></image>\n"));
|
|
if (this.stroke || this.strokeDashArray) {
|
|
const origFill = this.fill;
|
|
this.fill = null;
|
|
strokeSvg = ["\t<rect x=\"".concat(x, "\" y=\"").concat(y, "\" width=\"").concat(this.width, "\" height=\"").concat(this.height, "\" style=\"").concat(this.getSvgStyles(), "\" />\n")];
|
|
this.fill = origFill;
|
|
}
|
|
if (this.paintFirst !== FILL) {
|
|
svgString = svgString.concat(strokeSvg, imageMarkup);
|
|
} else {
|
|
svgString = svgString.concat(imageMarkup, strokeSvg);
|
|
}
|
|
return svgString;
|
|
}
|
|
|
|
/**
|
|
* Returns source of an image
|
|
* @param {Boolean} filtered indicates if the src is needed for svg
|
|
* @return {String} Source of an image
|
|
*/
|
|
getSrc(filtered) {
|
|
const element = filtered ? this._element : this._originalElement;
|
|
if (element) {
|
|
if (element.toDataURL) {
|
|
return element.toDataURL();
|
|
}
|
|
if (this.srcFromAttribute) {
|
|
return element.getAttribute('src') || '';
|
|
} else {
|
|
return element.src;
|
|
}
|
|
} else {
|
|
return this.src || '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Alias for getSrc
|
|
* @param filtered
|
|
* @deprecated
|
|
*/
|
|
getSvgSrc(filtered) {
|
|
return this.getSrc(filtered);
|
|
}
|
|
|
|
/**
|
|
* Loads and sets source of an image\
|
|
* **IMPORTANT**: It is recommended to abort loading tasks before calling this method to prevent race conditions and unnecessary networking
|
|
* @param {String} src Source string (URL)
|
|
* @param {LoadImageOptions} [options] Options object
|
|
*/
|
|
setSrc(src) {
|
|
let {
|
|
crossOrigin,
|
|
signal
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
return loadImage(src, {
|
|
crossOrigin,
|
|
signal
|
|
}).then(img => {
|
|
typeof crossOrigin !== 'undefined' && this.set({
|
|
crossOrigin
|
|
});
|
|
this.setElement(img);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns string representation of an instance
|
|
* @return {String} String representation of an instance
|
|
*/
|
|
toString() {
|
|
return "#<Image: { src: \"".concat(this.getSrc(), "\" }>");
|
|
}
|
|
applyResizeFilters() {
|
|
const filter = this.resizeFilter,
|
|
minimumScale = this.minimumScaleTrigger,
|
|
objectScale = this.getTotalObjectScaling(),
|
|
scaleX = objectScale.x,
|
|
scaleY = objectScale.y,
|
|
elementToFilter = this._filteredEl || this._originalElement;
|
|
if (this.group) {
|
|
this.set('dirty', true);
|
|
}
|
|
if (!filter || scaleX > minimumScale && scaleY > minimumScale) {
|
|
this._element = elementToFilter;
|
|
this._filterScalingX = 1;
|
|
this._filterScalingY = 1;
|
|
this._lastScaleX = scaleX;
|
|
this._lastScaleY = scaleY;
|
|
return;
|
|
}
|
|
const canvasEl = createCanvasElement(),
|
|
sourceWidth = elementToFilter.width,
|
|
sourceHeight = elementToFilter.height;
|
|
canvasEl.width = sourceWidth;
|
|
canvasEl.height = sourceHeight;
|
|
this._element = canvasEl;
|
|
this._lastScaleX = filter.scaleX = scaleX;
|
|
this._lastScaleY = filter.scaleY = scaleY;
|
|
getFilterBackend().applyFilters([filter], elementToFilter, sourceWidth, sourceHeight, this._element);
|
|
this._filterScalingX = canvasEl.width / this._originalElement.width;
|
|
this._filterScalingY = canvasEl.height / this._originalElement.height;
|
|
}
|
|
|
|
/**
|
|
* Applies filters assigned to this image (from "filters" array) or from filter param
|
|
* @method applyFilters
|
|
* @param {Array} filters to be applied
|
|
* @param {Boolean} forResizing specify if the filter operation is a resize operation
|
|
*/
|
|
applyFilters() {
|
|
let filters = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.filters || [];
|
|
filters = filters.filter(filter => filter && !filter.isNeutralState());
|
|
this.set('dirty', true);
|
|
|
|
// needs to clear out or WEBGL will not resize correctly
|
|
this.removeTexture("".concat(this.cacheKey, "_filtered"));
|
|
if (filters.length === 0) {
|
|
this._element = this._originalElement;
|
|
// this is unsafe and needs to be rethinkend
|
|
this._filteredEl = undefined;
|
|
this._filterScalingX = 1;
|
|
this._filterScalingY = 1;
|
|
return;
|
|
}
|
|
const imgElement = this._originalElement,
|
|
sourceWidth = imgElement.naturalWidth || imgElement.width,
|
|
sourceHeight = imgElement.naturalHeight || imgElement.height;
|
|
if (this._element === this._originalElement) {
|
|
// if the _element a reference to _originalElement
|
|
// we need to create a new element to host the filtered pixels
|
|
const canvasEl = createCanvasElement();
|
|
canvasEl.width = sourceWidth;
|
|
canvasEl.height = sourceHeight;
|
|
this._element = canvasEl;
|
|
this._filteredEl = canvasEl;
|
|
} else if (this._filteredEl) {
|
|
// if the _element is it own element,
|
|
// and we also have a _filteredEl, then we clean up _filteredEl
|
|
// and we assign it to _element.
|
|
// in this way we invalidate the eventual old resize filtered element
|
|
this._element = this._filteredEl;
|
|
this._filteredEl.getContext('2d').clearRect(0, 0, sourceWidth, sourceHeight);
|
|
// we also need to resize again at next renderAll, so remove saved _lastScaleX/Y
|
|
this._lastScaleX = 1;
|
|
this._lastScaleY = 1;
|
|
}
|
|
getFilterBackend().applyFilters(filters, this._originalElement, sourceWidth, sourceHeight, this._element);
|
|
if (this._originalElement.width !== this._element.width || this._originalElement.height !== this._element.height) {
|
|
this._filterScalingX = this._element.width / this._originalElement.width;
|
|
this._filterScalingY = this._element.height / this._originalElement.height;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
_render(ctx) {
|
|
ctx.imageSmoothingEnabled = this.imageSmoothing;
|
|
if (this.isMoving !== true && this.resizeFilter && this._needsResize()) {
|
|
this.applyResizeFilters();
|
|
}
|
|
this._stroke(ctx);
|
|
this._renderPaintInOrder(ctx);
|
|
}
|
|
|
|
/**
|
|
* Paint the cached copy of the object on the target context.
|
|
* it will set the imageSmoothing for the draw operation
|
|
* @param {CanvasRenderingContext2D} ctx Context to render on
|
|
*/
|
|
drawCacheOnCanvas(ctx) {
|
|
ctx.imageSmoothingEnabled = this.imageSmoothing;
|
|
super.drawCacheOnCanvas(ctx);
|
|
}
|
|
|
|
/**
|
|
* Decide if the object should cache or not. Create its own cache level
|
|
* needsItsOwnCache should be used when the object drawing method requires
|
|
* a cache step. None of the fabric classes requires it.
|
|
* Generally you do not cache objects in groups because the group outside is cached.
|
|
* This is the special image version where we would like to avoid caching where possible.
|
|
* Essentially images do not benefit from caching. They may require caching, and in that
|
|
* case we do it. Also caching an image usually ends in a loss of details.
|
|
* A full performance audit should be done.
|
|
* @return {Boolean}
|
|
*/
|
|
shouldCache() {
|
|
return this.needsItsOwnCache();
|
|
}
|
|
_renderFill(ctx) {
|
|
const elementToDraw = this._element;
|
|
if (!elementToDraw) {
|
|
return;
|
|
}
|
|
const scaleX = this._filterScalingX,
|
|
scaleY = this._filterScalingY,
|
|
w = this.width,
|
|
h = this.height,
|
|
// crop values cannot be lesser than 0.
|
|
cropX = Math.max(this.cropX, 0),
|
|
cropY = Math.max(this.cropY, 0),
|
|
elWidth = elementToDraw.naturalWidth || elementToDraw.width,
|
|
elHeight = elementToDraw.naturalHeight || elementToDraw.height,
|
|
sX = cropX * scaleX,
|
|
sY = cropY * scaleY,
|
|
// the width height cannot exceed element width/height, starting from the crop offset.
|
|
sW = Math.min(w * scaleX, elWidth - sX),
|
|
sH = Math.min(h * scaleY, elHeight - sY),
|
|
x = -w / 2,
|
|
y = -h / 2,
|
|
maxDestW = Math.min(w, elWidth / scaleX - cropX),
|
|
maxDestH = Math.min(h, elHeight / scaleY - cropY);
|
|
elementToDraw && ctx.drawImage(elementToDraw, sX, sY, sW, sH, x, y, maxDestW, maxDestH);
|
|
}
|
|
|
|
/**
|
|
* needed to check if image needs resize
|
|
* @private
|
|
*/
|
|
_needsResize() {
|
|
const scale = this.getTotalObjectScaling();
|
|
return scale.x !== this._lastScaleX || scale.y !== this._lastScaleY;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @deprecated unused
|
|
*/
|
|
_resetWidthHeight() {
|
|
this.set(this.getOriginalSize());
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* Set the width and the height of the image object, using the element or the
|
|
* options.
|
|
*/
|
|
_setWidthHeight() {
|
|
let {
|
|
width,
|
|
height
|
|
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
const size = this.getOriginalSize();
|
|
this.width = width || size.width;
|
|
this.height = height || size.height;
|
|
}
|
|
|
|
/**
|
|
* Calculate offset for center and scale factor for the image in order to respect
|
|
* the preserveAspectRatio attribute
|
|
* @private
|
|
*/
|
|
parsePreserveAspectRatioAttribute() {
|
|
const pAR = parsePreserveAspectRatioAttribute(this.preserveAspectRatio || ''),
|
|
pWidth = this.width,
|
|
pHeight = this.height,
|
|
parsedAttributes = {
|
|
width: pWidth,
|
|
height: pHeight
|
|
};
|
|
let rWidth = this._element.width,
|
|
rHeight = this._element.height,
|
|
scaleX = 1,
|
|
scaleY = 1,
|
|
offsetLeft = 0,
|
|
offsetTop = 0,
|
|
cropX = 0,
|
|
cropY = 0,
|
|
offset;
|
|
if (pAR && (pAR.alignX !== NONE || pAR.alignY !== NONE)) {
|
|
if (pAR.meetOrSlice === 'meet') {
|
|
scaleX = scaleY = findScaleToFit(this._element, parsedAttributes);
|
|
offset = (pWidth - rWidth * scaleX) / 2;
|
|
if (pAR.alignX === 'Min') {
|
|
offsetLeft = -offset;
|
|
}
|
|
if (pAR.alignX === 'Max') {
|
|
offsetLeft = offset;
|
|
}
|
|
offset = (pHeight - rHeight * scaleY) / 2;
|
|
if (pAR.alignY === 'Min') {
|
|
offsetTop = -offset;
|
|
}
|
|
if (pAR.alignY === 'Max') {
|
|
offsetTop = offset;
|
|
}
|
|
}
|
|
if (pAR.meetOrSlice === 'slice') {
|
|
scaleX = scaleY = findScaleToCover(this._element, parsedAttributes);
|
|
offset = rWidth - pWidth / scaleX;
|
|
if (pAR.alignX === 'Mid') {
|
|
cropX = offset / 2;
|
|
}
|
|
if (pAR.alignX === 'Max') {
|
|
cropX = offset;
|
|
}
|
|
offset = rHeight - pHeight / scaleY;
|
|
if (pAR.alignY === 'Mid') {
|
|
cropY = offset / 2;
|
|
}
|
|
if (pAR.alignY === 'Max') {
|
|
cropY = offset;
|
|
}
|
|
rWidth = pWidth / scaleX;
|
|
rHeight = pHeight / scaleY;
|
|
}
|
|
} else {
|
|
scaleX = pWidth / rWidth;
|
|
scaleY = pHeight / rHeight;
|
|
}
|
|
return {
|
|
width: rWidth,
|
|
height: rHeight,
|
|
scaleX,
|
|
scaleY,
|
|
offsetLeft,
|
|
offsetTop,
|
|
cropX,
|
|
cropY
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Default CSS class name for canvas
|
|
* @static
|
|
* @type String
|
|
* @default
|
|
*/
|
|
|
|
/**
|
|
* Creates an instance of FabricImage from its object representation
|
|
* @static
|
|
* @param {Object} object Object to create an instance from
|
|
* @param {object} [options] Options object
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @returns {Promise<FabricImage>}
|
|
*/
|
|
static fromObject(_ref, options) {
|
|
let {
|
|
filters: f,
|
|
resizeFilter: rf,
|
|
src,
|
|
crossOrigin,
|
|
type
|
|
} = _ref,
|
|
object = _objectWithoutProperties(_ref, _excluded$2);
|
|
return Promise.all([loadImage(src, _objectSpread2(_objectSpread2({}, options), {}, {
|
|
crossOrigin
|
|
})), f && enlivenObjects(f, options),
|
|
// TODO: redundant - handled by enlivenObjectEnlivables
|
|
rf && enlivenObjects([rf], options), enlivenObjectEnlivables(object, options)]).then(_ref2 => {
|
|
let [el, filters = [], [resizeFilter] = [], hydratedProps = {}] = _ref2;
|
|
return new this(el, _objectSpread2(_objectSpread2({}, object), {}, {
|
|
// TODO: this creates a difference between image creation and restoring from JSON
|
|
src,
|
|
filters,
|
|
resizeFilter
|
|
}, hydratedProps));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates an instance of Image from an URL string
|
|
* @static
|
|
* @param {String} url URL to create an image from
|
|
* @param {LoadImageOptions} [options] Options object
|
|
* @returns {Promise<FabricImage>}
|
|
*/
|
|
static fromURL(url) {
|
|
let {
|
|
crossOrigin = null,
|
|
signal
|
|
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
let imageOptions = arguments.length > 2 ? arguments[2] : undefined;
|
|
return loadImage(url, {
|
|
crossOrigin,
|
|
signal
|
|
}).then(img => new this(img, imageOptions));
|
|
}
|
|
|
|
/**
|
|
* Returns {@link FabricImage} instance from an SVG element
|
|
* @static
|
|
* @param {HTMLElement} element Element to parse
|
|
* @param {Object} [options] Options object
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @param {Function} callback Callback to execute when Image object is created
|
|
*/
|
|
static async fromElement(element) {
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
let cssRules = arguments.length > 2 ? arguments[2] : undefined;
|
|
const parsedAttributes = parseAttributes(element, this.ATTRIBUTE_NAMES, cssRules);
|
|
return this.fromURL(parsedAttributes['xlink:href'], options, parsedAttributes).catch(err => {
|
|
log('log', 'Unable to parse Image', err);
|
|
return null;
|
|
});
|
|
}
|
|
}
|
|
_defineProperty(FabricImage, "type", 'Image');
|
|
_defineProperty(FabricImage, "cacheProperties", [...cacheProperties, ...IMAGE_PROPS]);
|
|
_defineProperty(FabricImage, "ownDefaults", imageDefaultValues);
|
|
_defineProperty(FabricImage, "CSS_CANVAS", 'canvas-img');
|
|
/**
|
|
* List of attribute names to account for when parsing SVG element (used by {@link FabricImage.fromElement})
|
|
* @static
|
|
* @see {@link http://www.w3.org/TR/SVG/struct.html#ImageElement}
|
|
*/
|
|
_defineProperty(FabricImage, "ATTRIBUTE_NAMES", [...SHARED_ATTRIBUTES, 'x', 'y', 'width', 'height', 'preserveAspectRatio', 'xlink:href', 'crossOrigin', 'image-rendering']);
|
|
classRegistry.setClass(FabricImage);
|
|
classRegistry.setSVGClass(FabricImage);
|
|
|
|
/**
|
|
* Add a <g> element that envelop all child elements and makes the viewbox transformMatrix descend on all elements
|
|
*/
|
|
function applyViewboxTransform(element) {
|
|
if (!svgViewBoxElementsRegEx.test(element.nodeName)) {
|
|
return {};
|
|
}
|
|
const viewBoxAttr = element.getAttribute('viewBox');
|
|
let scaleX = 1;
|
|
let scaleY = 1;
|
|
let minX = 0;
|
|
let minY = 0;
|
|
let matrix;
|
|
let el;
|
|
const widthAttr = element.getAttribute('width');
|
|
const heightAttr = element.getAttribute('height');
|
|
const x = element.getAttribute('x') || 0;
|
|
const y = element.getAttribute('y') || 0;
|
|
const goodViewbox = viewBoxAttr && reViewBoxAttrValue.test(viewBoxAttr);
|
|
const missingViewBox = !goodViewbox;
|
|
const missingDimAttr = !widthAttr || !heightAttr || widthAttr === '100%' || heightAttr === '100%';
|
|
let translateMatrix = '';
|
|
let widthDiff = 0;
|
|
let heightDiff = 0;
|
|
if (missingViewBox) {
|
|
if ((x || y) && element.parentNode && element.parentNode.nodeName !== '#document') {
|
|
translateMatrix = ' translate(' + parseUnit(x || '0') + ' ' + parseUnit(y || '0') + ') ';
|
|
matrix = (element.getAttribute('transform') || '') + translateMatrix;
|
|
element.setAttribute('transform', matrix);
|
|
element.removeAttribute('x');
|
|
element.removeAttribute('y');
|
|
}
|
|
}
|
|
if (missingViewBox && missingDimAttr) {
|
|
return {
|
|
width: 0,
|
|
height: 0
|
|
};
|
|
}
|
|
const parsedDim = {
|
|
width: 0,
|
|
height: 0
|
|
};
|
|
if (missingViewBox) {
|
|
parsedDim.width = parseUnit(widthAttr);
|
|
parsedDim.height = parseUnit(heightAttr);
|
|
// set a transform for elements that have x y and are inner(only) SVGs
|
|
return parsedDim;
|
|
}
|
|
const pasedViewBox = viewBoxAttr.match(reViewBoxAttrValue);
|
|
minX = -parseFloat(pasedViewBox[1]);
|
|
minY = -parseFloat(pasedViewBox[2]);
|
|
const viewBoxWidth = parseFloat(pasedViewBox[3]);
|
|
const viewBoxHeight = parseFloat(pasedViewBox[4]);
|
|
parsedDim.minX = minX;
|
|
parsedDim.minY = minY;
|
|
parsedDim.viewBoxWidth = viewBoxWidth;
|
|
parsedDim.viewBoxHeight = viewBoxHeight;
|
|
if (!missingDimAttr) {
|
|
parsedDim.width = parseUnit(widthAttr);
|
|
parsedDim.height = parseUnit(heightAttr);
|
|
scaleX = parsedDim.width / viewBoxWidth;
|
|
scaleY = parsedDim.height / viewBoxHeight;
|
|
} else {
|
|
parsedDim.width = viewBoxWidth;
|
|
parsedDim.height = viewBoxHeight;
|
|
}
|
|
|
|
// default is to preserve aspect ratio
|
|
const preserveAspectRatio = parsePreserveAspectRatioAttribute(element.getAttribute('preserveAspectRatio') || '');
|
|
if (preserveAspectRatio.alignX !== NONE) {
|
|
//translate all container for the effect of Mid, Min, Max
|
|
if (preserveAspectRatio.meetOrSlice === 'meet') {
|
|
scaleY = scaleX = scaleX > scaleY ? scaleY : scaleX;
|
|
// calculate additional translation to move the viewbox
|
|
}
|
|
if (preserveAspectRatio.meetOrSlice === 'slice') {
|
|
scaleY = scaleX = scaleX > scaleY ? scaleX : scaleY;
|
|
// calculate additional translation to move the viewbox
|
|
}
|
|
widthDiff = parsedDim.width - viewBoxWidth * scaleX;
|
|
heightDiff = parsedDim.height - viewBoxHeight * scaleX;
|
|
if (preserveAspectRatio.alignX === 'Mid') {
|
|
widthDiff /= 2;
|
|
}
|
|
if (preserveAspectRatio.alignY === 'Mid') {
|
|
heightDiff /= 2;
|
|
}
|
|
if (preserveAspectRatio.alignX === 'Min') {
|
|
widthDiff = 0;
|
|
}
|
|
if (preserveAspectRatio.alignY === 'Min') {
|
|
heightDiff = 0;
|
|
}
|
|
}
|
|
if (scaleX === 1 && scaleY === 1 && minX === 0 && minY === 0 && x === 0 && y === 0) {
|
|
return parsedDim;
|
|
}
|
|
if ((x || y) && element.parentNode.nodeName !== '#document') {
|
|
translateMatrix = ' translate(' + parseUnit(x || '0') + ' ' + parseUnit(y || '0') + ') ';
|
|
}
|
|
matrix = translateMatrix + ' matrix(' + scaleX + ' 0' + ' 0 ' + scaleY + ' ' + (minX * scaleX + widthDiff) + ' ' + (minY * scaleY + heightDiff) + ') ';
|
|
// seems unused.
|
|
// parsedDim.viewboxTransform = parseTransformAttribute(matrix);
|
|
if (element.nodeName === 'svg') {
|
|
el = element.ownerDocument.createElementNS(svgNS, 'g');
|
|
// element.firstChild != null
|
|
while (element.firstChild) {
|
|
el.appendChild(element.firstChild);
|
|
}
|
|
element.appendChild(el);
|
|
} else {
|
|
el = element;
|
|
el.removeAttribute('x');
|
|
el.removeAttribute('y');
|
|
matrix = el.getAttribute('transform') + matrix;
|
|
}
|
|
el.setAttribute('transform', matrix);
|
|
return parsedDim;
|
|
}
|
|
|
|
const getTagName = node => node.tagName.replace('svg:', '');
|
|
|
|
const svgInvalidAncestorsRegEx = getSvgRegex(svgInvalidAncestors);
|
|
function hasInvalidAncestor(element) {
|
|
let _element = element;
|
|
while (_element && (_element = _element.parentElement)) {
|
|
if (_element && _element.nodeName && svgInvalidAncestorsRegEx.test(getTagName(_element)) && !_element.getAttribute('instantiated_by_use')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getMultipleNodes(doc, nodeNames) {
|
|
let nodeName,
|
|
nodeArray = [],
|
|
nodeList,
|
|
i,
|
|
len;
|
|
for (i = 0, len = nodeNames.length; i < len; i++) {
|
|
nodeName = nodeNames[i];
|
|
nodeList = doc.getElementsByTagNameNS('http://www.w3.org/2000/svg', nodeName);
|
|
nodeArray = nodeArray.concat(Array.from(nodeList));
|
|
}
|
|
return nodeArray;
|
|
}
|
|
|
|
function parseUseDirectives(doc) {
|
|
const nodelist = getMultipleNodes(doc, ['use', 'svg:use']);
|
|
const skipAttributes = ['x', 'y', 'xlink:href', 'href', 'transform'];
|
|
for (const useElement of nodelist) {
|
|
const useAttributes = useElement.attributes;
|
|
const useAttrMap = {};
|
|
for (const attr of useAttributes) {
|
|
attr.value && (useAttrMap[attr.name] = attr.value);
|
|
}
|
|
const xlink = (useAttrMap['xlink:href'] || useAttrMap.href || '').slice(1);
|
|
if (xlink === '') {
|
|
return;
|
|
}
|
|
const referencedElement = doc.getElementById(xlink);
|
|
if (referencedElement === null) {
|
|
// if we can't find the target of the xlink, consider this use tag bad, similar to no xlink
|
|
return;
|
|
}
|
|
let clonedOriginal = referencedElement.cloneNode(true);
|
|
const originalAttributes = clonedOriginal.attributes;
|
|
const originalAttrMap = {};
|
|
for (const attr of originalAttributes) {
|
|
attr.value && (originalAttrMap[attr.name] = attr.value);
|
|
}
|
|
|
|
// Transform attribute needs to be merged in a particular way
|
|
const {
|
|
x = 0,
|
|
y = 0,
|
|
transform = ''
|
|
} = useAttrMap;
|
|
const currentTrans = "".concat(transform, " ").concat(originalAttrMap.transform || '', " translate(").concat(x, ", ").concat(y, ")");
|
|
applyViewboxTransform(clonedOriginal);
|
|
if (/^svg$/i.test(clonedOriginal.nodeName)) {
|
|
// if is an SVG, create a group and apply all the attributes on top of it
|
|
const el3 = clonedOriginal.ownerDocument.createElementNS(svgNS, 'g');
|
|
Object.entries(originalAttrMap).forEach(_ref => {
|
|
let [name, value] = _ref;
|
|
return el3.setAttributeNS(svgNS, name, value);
|
|
});
|
|
el3.append(...clonedOriginal.childNodes);
|
|
clonedOriginal = el3;
|
|
}
|
|
for (const attr of useAttributes) {
|
|
if (!attr) {
|
|
continue;
|
|
}
|
|
const {
|
|
name,
|
|
value
|
|
} = attr;
|
|
if (skipAttributes.includes(name)) {
|
|
continue;
|
|
}
|
|
if (name === 'style') {
|
|
// when use has a style, merge the two styles, with the ref being priority (not use)
|
|
// priority is by feature. an attribute for fill on the original element
|
|
// will overwrite the fill in style or attribute for tha use
|
|
const styleRecord = {};
|
|
parseStyleString(value, styleRecord);
|
|
// cleanup styleRecord from attributes of original
|
|
Object.entries(originalAttrMap).forEach(_ref2 => {
|
|
let [name, value] = _ref2;
|
|
styleRecord[name] = value;
|
|
});
|
|
// now we can put in the style of the original that will overwrite the original attributes
|
|
parseStyleString(originalAttrMap.style || '', styleRecord);
|
|
const mergedStyles = Object.entries(styleRecord).map(entry => entry.join(':')).join(';');
|
|
clonedOriginal.setAttribute(name, mergedStyles);
|
|
} else {
|
|
// set the attribute from use element only if the original does not have it already
|
|
!originalAttrMap[name] && clonedOriginal.setAttribute(name, value);
|
|
}
|
|
}
|
|
clonedOriginal.setAttribute('transform', currentTrans);
|
|
clonedOriginal.setAttribute('instantiated_by_use', '1');
|
|
clonedOriginal.removeAttribute('id');
|
|
useElement.parentNode.replaceChild(clonedOriginal, useElement);
|
|
}
|
|
}
|
|
|
|
const gradientsAttrs = ['gradientTransform', 'x1', 'x2', 'y1', 'y2', 'gradientUnits', 'cx', 'cy', 'r', 'fx', 'fy'];
|
|
const xlinkAttr = 'xlink:href';
|
|
function recursivelyParseGradientsXlink(doc, gradient) {
|
|
var _gradient$getAttribut;
|
|
const xLink = ((_gradient$getAttribut = gradient.getAttribute(xlinkAttr)) === null || _gradient$getAttribut === void 0 ? void 0 : _gradient$getAttribut.slice(1)) || '',
|
|
referencedGradient = doc.getElementById(xLink);
|
|
if (referencedGradient && referencedGradient.getAttribute(xlinkAttr)) {
|
|
recursivelyParseGradientsXlink(doc, referencedGradient);
|
|
}
|
|
if (referencedGradient) {
|
|
gradientsAttrs.forEach(attr => {
|
|
const value = referencedGradient.getAttribute(attr);
|
|
if (!gradient.hasAttribute(attr) && value) {
|
|
gradient.setAttribute(attr, value);
|
|
}
|
|
});
|
|
if (!gradient.children.length) {
|
|
const referenceClone = referencedGradient.cloneNode(true);
|
|
while (referenceClone.firstChild) {
|
|
gradient.appendChild(referenceClone.firstChild);
|
|
}
|
|
}
|
|
}
|
|
gradient.removeAttribute(xlinkAttr);
|
|
}
|
|
|
|
const tagArray = ['linearGradient', 'radialGradient', 'svg:linearGradient', 'svg:radialGradient'];
|
|
|
|
/**
|
|
* Parses an SVG document, returning all of the gradient declarations found in it
|
|
* @param {SVGDocument} doc SVG document to parse
|
|
* @return {Object} Gradient definitions; key corresponds to element id, value -- to gradient definition element
|
|
*/
|
|
function getGradientDefs(doc) {
|
|
const elList = getMultipleNodes(doc, tagArray);
|
|
const gradientDefs = {};
|
|
let j = elList.length;
|
|
while (j--) {
|
|
const el = elList[j];
|
|
if (el.getAttribute('xlink:href')) {
|
|
recursivelyParseGradientsXlink(doc, el);
|
|
}
|
|
const id = el.getAttribute('id');
|
|
if (id) {
|
|
gradientDefs[id] = el;
|
|
}
|
|
}
|
|
return gradientDefs;
|
|
}
|
|
|
|
/**
|
|
* Returns CSS rules for a given SVG document
|
|
* @param {HTMLElement} doc SVG document to parse
|
|
* @return {Object} CSS rules of this document
|
|
*/
|
|
function getCSSRules(doc) {
|
|
const styles = doc.getElementsByTagName('style');
|
|
let i;
|
|
let len;
|
|
const allRules = {};
|
|
|
|
// very crude parsing of style contents
|
|
for (i = 0, len = styles.length; i < len; i++) {
|
|
const styleContents = (styles[i].textContent || '').replace(
|
|
// remove comments
|
|
/\/\*[\s\S]*?\*\//g, '');
|
|
if (styleContents.trim() === '') {
|
|
continue;
|
|
}
|
|
// recovers all the rule in this form `body { style code... }`
|
|
// rules = styleContents.match(/[^{]*\{[\s\S]*?\}/g);
|
|
styleContents.split('}')
|
|
// remove empty rules and remove everything if we didn't split in at least 2 pieces
|
|
.filter((rule, index, array) => array.length > 1 && rule.trim())
|
|
// at this point we have hopefully an array of rules `body { style code... `
|
|
.forEach(rule => {
|
|
// if there is more than one opening bracket and the rule starts with '@', it is likely
|
|
// a nested at-rule like @media, @supports, @scope, etc. Ignore these as the code below
|
|
// can not handle it.
|
|
if ((rule.match(/{/g) || []).length > 1 && rule.trim().startsWith('@')) {
|
|
return;
|
|
}
|
|
const match = rule.split('{'),
|
|
ruleObj = {},
|
|
declaration = match[1].trim(),
|
|
propertyValuePairs = declaration.split(';').filter(function (pair) {
|
|
return pair.trim();
|
|
});
|
|
for (i = 0, len = propertyValuePairs.length; i < len; i++) {
|
|
const pair = propertyValuePairs[i].split(':'),
|
|
property = pair[0].trim(),
|
|
value = pair[1].trim();
|
|
ruleObj[property] = value;
|
|
}
|
|
rule = match[0].trim();
|
|
rule.split(',').forEach(_rule => {
|
|
_rule = _rule.replace(/^svg/i, '').trim();
|
|
if (_rule === '') {
|
|
return;
|
|
}
|
|
allRules[_rule] = _objectSpread2(_objectSpread2({}, allRules[_rule] || {}), ruleObj);
|
|
});
|
|
});
|
|
}
|
|
return allRules;
|
|
}
|
|
|
|
const findTag = el => classRegistry.getSVGClass(getTagName(el).toLowerCase());
|
|
class ElementsParser {
|
|
constructor(elements, options, reviver, doc, clipPaths) {
|
|
this.elements = elements;
|
|
this.options = options;
|
|
this.reviver = reviver;
|
|
this.regexUrl = /^url\(['"]?#([^'"]+)['"]?\)/g;
|
|
this.doc = doc;
|
|
this.clipPaths = clipPaths;
|
|
this.gradientDefs = getGradientDefs(doc);
|
|
this.cssRules = getCSSRules(doc);
|
|
}
|
|
parse() {
|
|
return Promise.all(this.elements.map(element => this.createObject(element)));
|
|
}
|
|
async createObject(el) {
|
|
const klass = findTag(el);
|
|
if (klass) {
|
|
const obj = await klass.fromElement(el, this.options, this.cssRules);
|
|
this.resolveGradient(obj, el, FILL);
|
|
this.resolveGradient(obj, el, STROKE);
|
|
if (obj instanceof FabricImage && obj._originalElement) {
|
|
removeTransformMatrixForSvgParsing(obj, obj.parsePreserveAspectRatioAttribute());
|
|
} else {
|
|
removeTransformMatrixForSvgParsing(obj);
|
|
}
|
|
await this.resolveClipPath(obj, el);
|
|
this.reviver && this.reviver(el, obj);
|
|
return obj;
|
|
}
|
|
return null;
|
|
}
|
|
extractPropertyDefinition(obj, property, storage) {
|
|
const value = obj[property],
|
|
regex = this.regexUrl;
|
|
if (!regex.test(value)) {
|
|
return undefined;
|
|
}
|
|
// verify: can we remove the 'g' flag? and remove lastIndex changes?
|
|
regex.lastIndex = 0;
|
|
// we passed the regex test, so we know is not null;
|
|
const id = regex.exec(value)[1];
|
|
regex.lastIndex = 0;
|
|
// @todo fix this
|
|
return storage[id];
|
|
}
|
|
resolveGradient(obj, el, property) {
|
|
const gradientDef = this.extractPropertyDefinition(obj, property, this.gradientDefs);
|
|
if (gradientDef) {
|
|
const opacityAttr = el.getAttribute(property + '-opacity');
|
|
const gradient = Gradient.fromElement(gradientDef, obj, _objectSpread2(_objectSpread2({}, this.options), {}, {
|
|
opacity: opacityAttr
|
|
}));
|
|
obj.set(property, gradient);
|
|
}
|
|
}
|
|
|
|
// TODO: resolveClipPath could be run once per clippath with minor work per object.
|
|
// is a refactor that i m not sure is worth on this code
|
|
async resolveClipPath(obj, usingElement) {
|
|
const clipPathElements = this.extractPropertyDefinition(obj, 'clipPath', this.clipPaths);
|
|
if (clipPathElements) {
|
|
const objTransformInv = invertTransform(obj.calcTransformMatrix());
|
|
const clipPathTag = clipPathElements[0].parentElement;
|
|
let clipPathOwner = usingElement;
|
|
while (clipPathOwner.parentElement && clipPathOwner.getAttribute('clip-path') !== obj.clipPath) {
|
|
clipPathOwner = clipPathOwner.parentElement;
|
|
}
|
|
// move the clipPath tag as sibling to the real element that is using it
|
|
clipPathOwner.parentElement.appendChild(clipPathTag);
|
|
|
|
// this multiplication order could be opposite.
|
|
// but i don't have an svg to test it
|
|
// at the first SVG that has a transform on both places and is misplaced
|
|
// try to invert this multiplication order
|
|
const finalTransform = parseTransformAttribute("".concat(clipPathOwner.getAttribute('transform') || '', " ").concat(clipPathTag.getAttribute('originalTransform') || ''));
|
|
clipPathTag.setAttribute('transform', "matrix(".concat(finalTransform.join(','), ")"));
|
|
const container = await Promise.all(clipPathElements.map(clipPathElement => {
|
|
return findTag(clipPathElement).fromElement(clipPathElement, this.options, this.cssRules).then(enlivedClippath => {
|
|
removeTransformMatrixForSvgParsing(enlivedClippath);
|
|
enlivedClippath.fillRule = enlivedClippath.clipRule;
|
|
delete enlivedClippath.clipRule;
|
|
return enlivedClippath;
|
|
});
|
|
}));
|
|
const clipPath = container.length === 1 ? container[0] : new Group(container);
|
|
const gTransform = multiplyTransformMatrices(objTransformInv, clipPath.calcTransformMatrix());
|
|
if (clipPath.clipPath) {
|
|
await this.resolveClipPath(clipPath, clipPathOwner);
|
|
}
|
|
const {
|
|
scaleX,
|
|
scaleY,
|
|
angle,
|
|
skewX,
|
|
translateX,
|
|
translateY
|
|
} = qrDecompose(gTransform);
|
|
clipPath.set({
|
|
flipX: false,
|
|
flipY: false
|
|
});
|
|
clipPath.set({
|
|
scaleX,
|
|
scaleY,
|
|
angle,
|
|
skewX,
|
|
skewY: 0
|
|
});
|
|
clipPath.setPositionByOrigin(new Point(translateX, translateY), CENTER, CENTER);
|
|
obj.clipPath = clipPath;
|
|
} else {
|
|
// if clip-path does not resolve to any element, delete the property.
|
|
delete obj.clipPath;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const isValidSvgTag = el => svgValidTagNamesRegEx.test(getTagName(el));
|
|
const createEmptyResponse = () => ({
|
|
objects: [],
|
|
elements: [],
|
|
options: {},
|
|
allElements: []
|
|
});
|
|
|
|
/**
|
|
* Parses an SVG document, converts it to an array of corresponding fabric.* instances and passes them to a callback
|
|
* @static
|
|
* @function
|
|
* @memberOf fabric
|
|
* @param {HTMLElement} doc SVG document to parse
|
|
* @param {TSvgParsedCallback} callback Invoked when the parsing is done, with null if parsing wasn't possible with the list of svg nodes.
|
|
* @param {TSvgReviverCallback} [reviver] Extra callback for further parsing of SVG elements, called after each fabric object has been created.
|
|
* Takes as input the original svg element and the generated `FabricObject` as arguments. Used to inspect extra properties not parsed by fabric,
|
|
* or extra custom manipulation
|
|
* @param {Object} [options] Object containing options for parsing
|
|
* @param {String} [options.crossOrigin] crossOrigin setting to use for external resources
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @return {SVGParsingOutput}
|
|
* {@link SVGParsingOutput} also receives `allElements` array as the last argument. This is the full list of svg nodes available in the document.
|
|
* You may want to use it if you are trying to regroup the objects as they were originally grouped in the SVG. ( This was the reason why it was added )
|
|
*/
|
|
async function parseSVGDocument(doc, reviver) {
|
|
let {
|
|
crossOrigin,
|
|
signal
|
|
} = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
|
|
if (signal && signal.aborted) {
|
|
log('log', new SignalAbortedError('parseSVGDocument'));
|
|
// this is an unhappy path, we dont care about speed
|
|
return createEmptyResponse();
|
|
}
|
|
const documentElement = doc.documentElement;
|
|
parseUseDirectives(doc);
|
|
const descendants = Array.from(documentElement.getElementsByTagName('*')),
|
|
options = _objectSpread2(_objectSpread2({}, applyViewboxTransform(documentElement)), {}, {
|
|
crossOrigin,
|
|
signal
|
|
});
|
|
const elements = descendants.filter(el => {
|
|
applyViewboxTransform(el);
|
|
return isValidSvgTag(el) && !hasInvalidAncestor(el); // http://www.w3.org/TR/SVG/struct.html#DefsElement
|
|
});
|
|
if (!elements || elements && !elements.length) {
|
|
return _objectSpread2(_objectSpread2({}, createEmptyResponse()), {}, {
|
|
options,
|
|
allElements: descendants
|
|
});
|
|
}
|
|
const localClipPaths = {};
|
|
descendants.filter(el => getTagName(el) === 'clipPath').forEach(el => {
|
|
el.setAttribute('originalTransform', el.getAttribute('transform') || '');
|
|
const id = el.getAttribute('id');
|
|
localClipPaths[id] = Array.from(el.getElementsByTagName('*')).filter(el => isValidSvgTag(el));
|
|
});
|
|
|
|
// Precedence of rules: style > class > attribute
|
|
const elementParser = new ElementsParser(elements, options, reviver, doc, localClipPaths);
|
|
const instances = await elementParser.parse();
|
|
return {
|
|
objects: instances,
|
|
elements,
|
|
options,
|
|
allElements: descendants
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Takes string corresponding to an SVG document, and parses it into a set of fabric objects
|
|
* @memberOf fabric
|
|
* @param {String} string representing the svg
|
|
* @param {TSvgParsedCallback} callback Invoked when the parsing is done, with null if parsing wasn't possible with the list of svg nodes.
|
|
* {@link TSvgParsedCallback} also receives `allElements` array as the last argument. This is the full list of svg nodes available in the document.
|
|
* You may want to use it if you are trying to regroup the objects as they were originally grouped in the SVG. ( This was the reason why it was added )
|
|
* @param {TSvgReviverCallback} [reviver] Extra callback for further parsing of SVG elements, called after each fabric object has been created.
|
|
* Takes as input the original svg element and the generated `FabricObject` as arguments. Used to inspect extra properties not parsed by fabric,
|
|
* or extra custom manipulation
|
|
* @param {Object} [options] Object containing options for parsing
|
|
* @param {String} [options.crossOrigin] crossOrigin setting to use for external resources
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
*/
|
|
function loadSVGFromString(string, reviver, options) {
|
|
const parser = new (getFabricWindow().DOMParser)(),
|
|
// should we use `image/svg+xml` here?
|
|
doc = parser.parseFromString(string.trim(), 'text/xml');
|
|
return parseSVGDocument(doc, reviver, options);
|
|
}
|
|
|
|
/**
|
|
* Takes url corresponding to an SVG document, and parses it into a set of fabric objects.
|
|
* Note that SVG is fetched via XMLHttpRequest, so it needs to conform to SOP (Same Origin Policy)
|
|
* @memberOf fabric
|
|
* @param {string} url where the SVG is
|
|
* @param {TSvgParsedCallback} callback Invoked when the parsing is done, with null if parsing wasn't possible with the list of svg nodes.
|
|
* {@link TSvgParsedCallback} also receives `allElements` array as the last argument. This is the full list of svg nodes available in the document.
|
|
* You may want to use it if you are trying to regroup the objects as they were originally grouped in the SVG. ( This was the reason why it was added )
|
|
* @param {TSvgReviverCallback} [reviver] Extra callback for further parsing of SVG elements, called after each fabric object has been created.
|
|
* Takes as input the original svg element and the generated `FabricObject` as arguments. Used to inspect extra properties not parsed by fabric,
|
|
* or extra custom manipulation
|
|
* @param {Object} [options] Object containing options for parsing
|
|
* @param {String} [options.crossOrigin] crossOrigin setting to use for external resources
|
|
* @param {AbortSignal} [options.signal] handle aborting, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
*/
|
|
function loadSVGFromURL(url, reviver) {
|
|
let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
|
|
// need to handle error properly
|
|
return new Promise((resolve, reject) => {
|
|
const onComplete = r => {
|
|
const xml = r.responseXML;
|
|
if (xml) {
|
|
resolve(xml);
|
|
}
|
|
reject();
|
|
};
|
|
request(url.replace(/^\n\s*/, '').trim(), {
|
|
onComplete,
|
|
signal: options.signal
|
|
});
|
|
}).then(parsedDoc => parseSVGDocument(parsedDoc, reviver, options)).catch(() => {
|
|
// this is an unhappy path, we dont care about speed
|
|
return createEmptyResponse();
|
|
});
|
|
}
|
|
|
|
const ACTION_NAME$1 = MODIFY_POLY;
|
|
/**
|
|
* This function locates the controls.
|
|
* It'll be used both for drawing and for interaction.
|
|
*/
|
|
const createPolyPositionHandler = pointIndex => {
|
|
return function (dim, finalMatrix, polyObject) {
|
|
const {
|
|
points,
|
|
pathOffset
|
|
} = polyObject;
|
|
return new Point(points[pointIndex]).subtract(pathOffset).transform(multiplyTransformMatrices(polyObject.getViewportTransform(), polyObject.calcTransformMatrix()));
|
|
};
|
|
};
|
|
|
|
/**
|
|
* This function defines what the control does.
|
|
* It'll be called on every mouse move after a control has been clicked and is being dragged.
|
|
* The function receives as argument the mouse event, the current transform object
|
|
* and the current position in canvas coordinate `transform.target` is a reference to the
|
|
* current object being transformed.
|
|
*/
|
|
const polyActionHandler = (eventData, transform, x, y) => {
|
|
const {
|
|
target,
|
|
pointIndex
|
|
} = transform;
|
|
const poly = target;
|
|
const mouseLocalPosition = sendPointToPlane(new Point(x, y), undefined, poly.calcOwnMatrix());
|
|
poly.points[pointIndex] = mouseLocalPosition.add(poly.pathOffset);
|
|
poly.setDimensions();
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Keep the polygon in the same position when we change its `width`/`height`/`top`/`left`.
|
|
*/
|
|
const factoryPolyActionHandler = (pointIndex, fn) => {
|
|
return function (eventData, transform, x, y) {
|
|
const poly = transform.target,
|
|
anchorPoint = new Point(poly.points[(pointIndex > 0 ? pointIndex : poly.points.length) - 1]),
|
|
anchorPointInParentPlane = anchorPoint.subtract(poly.pathOffset).transform(poly.calcOwnMatrix()),
|
|
actionPerformed = fn(eventData, _objectSpread2(_objectSpread2({}, transform), {}, {
|
|
pointIndex
|
|
}), x, y);
|
|
const newAnchorPointInParentPlane = anchorPoint.subtract(poly.pathOffset).transform(poly.calcOwnMatrix());
|
|
const diff = newAnchorPointInParentPlane.subtract(anchorPointInParentPlane);
|
|
poly.left -= diff.x;
|
|
poly.top -= diff.y;
|
|
return actionPerformed;
|
|
};
|
|
};
|
|
const createPolyActionHandler = pointIndex => wrapWithFireEvent(ACTION_NAME$1, factoryPolyActionHandler(pointIndex, polyActionHandler));
|
|
function createPolyControls(arg0) {
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const controls = {};
|
|
for (let idx = 0; idx < (typeof arg0 === 'number' ? arg0 : arg0.points.length); idx++) {
|
|
controls["p".concat(idx)] = new Control(_objectSpread2({
|
|
actionName: ACTION_NAME$1,
|
|
positionHandler: createPolyPositionHandler(idx),
|
|
actionHandler: createPolyActionHandler(idx)
|
|
}, options));
|
|
}
|
|
return controls;
|
|
}
|
|
|
|
const ACTION_NAME = 'modifyPath';
|
|
const calcPathPointPosition = (pathObject, commandIndex, pointIndex) => {
|
|
const {
|
|
path,
|
|
pathOffset
|
|
} = pathObject;
|
|
const command = path[commandIndex];
|
|
return new Point(command[pointIndex] - pathOffset.x, command[pointIndex + 1] - pathOffset.y).transform(multiplyTransformMatrices(pathObject.getViewportTransform(), pathObject.calcTransformMatrix()));
|
|
};
|
|
const movePathPoint = (pathObject, x, y, commandIndex, pointIndex) => {
|
|
const {
|
|
path,
|
|
pathOffset
|
|
} = pathObject;
|
|
const anchorCommand = path[(commandIndex > 0 ? commandIndex : path.length) - 1];
|
|
const anchorPoint = new Point(anchorCommand[pointIndex], anchorCommand[pointIndex + 1]);
|
|
const anchorPointInParentPlane = anchorPoint.subtract(pathOffset).transform(pathObject.calcOwnMatrix());
|
|
const mouseLocalPosition = sendPointToPlane(new Point(x, y), undefined, pathObject.calcOwnMatrix());
|
|
path[commandIndex][pointIndex] = mouseLocalPosition.x + pathOffset.x;
|
|
path[commandIndex][pointIndex + 1] = mouseLocalPosition.y + pathOffset.y;
|
|
pathObject.setDimensions();
|
|
const newAnchorPointInParentPlane = anchorPoint.subtract(pathObject.pathOffset).transform(pathObject.calcOwnMatrix());
|
|
const diff = newAnchorPointInParentPlane.subtract(anchorPointInParentPlane);
|
|
pathObject.left -= diff.x;
|
|
pathObject.top -= diff.y;
|
|
pathObject.set('dirty', true);
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* This function locates the controls.
|
|
* It'll be used both for drawing and for interaction.
|
|
*/
|
|
function pathPositionHandler(dim, finalMatrix, pathObject) {
|
|
const {
|
|
commandIndex,
|
|
pointIndex
|
|
} = this;
|
|
return calcPathPointPosition(pathObject, commandIndex, pointIndex);
|
|
}
|
|
|
|
/**
|
|
* This function defines what the control does.
|
|
* It'll be called on every mouse move after a control has been clicked and is being dragged.
|
|
* The function receives as argument the mouse event, the current transform object
|
|
* and the current position in canvas coordinate `transform.target` is a reference to the
|
|
* current object being transformed.
|
|
*/
|
|
function pathActionHandler(eventData, transform, x, y) {
|
|
const {
|
|
target
|
|
} = transform;
|
|
const {
|
|
commandIndex,
|
|
pointIndex
|
|
} = this;
|
|
const actionPerformed = movePathPoint(target, x, y, commandIndex, pointIndex);
|
|
{
|
|
fireEvent(this.actionName, _objectSpread2(_objectSpread2({}, commonEventInfo(eventData, transform, x, y)), {}, {
|
|
commandIndex,
|
|
pointIndex
|
|
}));
|
|
}
|
|
return actionPerformed;
|
|
}
|
|
const indexFromPrevCommand = previousCommandType => previousCommandType === 'C' ? 5 : previousCommandType === 'Q' ? 3 : 1;
|
|
class PathPointControl extends Control {
|
|
constructor(options) {
|
|
super(options);
|
|
}
|
|
render(ctx, left, top, styleOverride, fabricObject) {
|
|
const overrides = _objectSpread2(_objectSpread2({}, styleOverride), {}, {
|
|
cornerColor: this.controlFill,
|
|
cornerStrokeColor: this.controlStroke,
|
|
transparentCorners: !this.controlFill
|
|
});
|
|
super.render(ctx, left, top, overrides, fabricObject);
|
|
}
|
|
}
|
|
class PathControlPointControl extends PathPointControl {
|
|
constructor(options) {
|
|
super(options);
|
|
}
|
|
render(ctx, left, top, styleOverride, fabricObject) {
|
|
const {
|
|
path
|
|
} = fabricObject;
|
|
const {
|
|
commandIndex,
|
|
pointIndex,
|
|
connectToCommandIndex,
|
|
connectToPointIndex
|
|
} = this;
|
|
ctx.save();
|
|
ctx.strokeStyle = this.controlStroke;
|
|
if (this.connectionDashArray) {
|
|
ctx.setLineDash(this.connectionDashArray);
|
|
}
|
|
const [commandType] = path[commandIndex];
|
|
const point = calcPathPointPosition(fabricObject, connectToCommandIndex, connectToPointIndex);
|
|
if (commandType === 'Q') {
|
|
// one control point connects to 2 points
|
|
const point2 = calcPathPointPosition(fabricObject, commandIndex, pointIndex + 2);
|
|
ctx.moveTo(point2.x, point2.y);
|
|
ctx.lineTo(left, top);
|
|
} else {
|
|
ctx.moveTo(left, top);
|
|
}
|
|
ctx.lineTo(point.x, point.y);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
super.render(ctx, left, top, styleOverride, fabricObject);
|
|
}
|
|
}
|
|
const createControl = (commandIndexPos, pointIndexPos, isControlPoint, options, connectToCommandIndex, connectToPointIndex) => new (isControlPoint ? PathControlPointControl : PathPointControl)(_objectSpread2(_objectSpread2({
|
|
commandIndex: commandIndexPos,
|
|
pointIndex: pointIndexPos,
|
|
actionName: ACTION_NAME,
|
|
positionHandler: pathPositionHandler,
|
|
actionHandler: pathActionHandler,
|
|
connectToCommandIndex,
|
|
connectToPointIndex
|
|
}, options), isControlPoint ? options.controlPointStyle : options.pointStyle));
|
|
function createPathControls(path) {
|
|
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
const controls = {};
|
|
let previousCommandType = 'M';
|
|
path.path.forEach((command, commandIndex) => {
|
|
const commandType = command[0];
|
|
if (commandType !== 'Z') {
|
|
controls["c_".concat(commandIndex, "_").concat(commandType)] = createControl(commandIndex, command.length - 2, false, options);
|
|
}
|
|
switch (commandType) {
|
|
case 'C':
|
|
controls["c_".concat(commandIndex, "_C_CP_1")] = createControl(commandIndex, 1, true, options, commandIndex - 1, indexFromPrevCommand(previousCommandType));
|
|
controls["c_".concat(commandIndex, "_C_CP_2")] = createControl(commandIndex, 3, true, options, commandIndex, 5);
|
|
break;
|
|
case 'Q':
|
|
controls["c_".concat(commandIndex, "_Q_CP_1")] = createControl(commandIndex, 1, true, options, commandIndex, 3);
|
|
break;
|
|
}
|
|
previousCommandType = commandType;
|
|
});
|
|
return controls;
|
|
}
|
|
|
|
var index = /*#__PURE__*/Object.freeze({
|
|
__proto__: null,
|
|
changeWidth: changeWidth,
|
|
createObjectDefaultControls: createObjectDefaultControls,
|
|
createPathControls: createPathControls,
|
|
createPolyActionHandler: createPolyActionHandler,
|
|
createPolyControls: createPolyControls,
|
|
createPolyPositionHandler: createPolyPositionHandler,
|
|
createResizeControls: createResizeControls,
|
|
createTextboxDefaultControls: createTextboxDefaultControls,
|
|
dragHandler: dragHandler,
|
|
factoryPolyActionHandler: factoryPolyActionHandler,
|
|
getLocalPoint: getLocalPoint,
|
|
polyActionHandler: polyActionHandler,
|
|
renderCircleControl: renderCircleControl,
|
|
renderSquareControl: renderSquareControl,
|
|
rotationStyleHandler: rotationStyleHandler,
|
|
rotationWithSnapping: rotationWithSnapping,
|
|
scaleCursorStyleHandler: scaleCursorStyleHandler,
|
|
scaleOrSkewActionName: scaleOrSkewActionName,
|
|
scaleSkewCursorStyleHandler: scaleSkewCursorStyleHandler,
|
|
scalingEqually: scalingEqually,
|
|
scalingX: scalingX,
|
|
scalingXOrSkewingY: scalingXOrSkewingY,
|
|
scalingY: scalingY,
|
|
scalingYOrSkewingX: scalingYOrSkewingX,
|
|
skewCursorStyleHandler: skewCursorStyleHandler,
|
|
skewHandlerX: skewHandlerX,
|
|
skewHandlerY: skewHandlerY,
|
|
wrapWithFireEvent: wrapWithFireEvent,
|
|
wrapWithFixedAnchor: wrapWithFixedAnchor
|
|
});
|
|
|
|
const isWebGLPipelineState = options => {
|
|
return options.webgl !== undefined;
|
|
};
|
|
|
|
/**
|
|
* Pick a method to copy data from GL context to 2d canvas. In some browsers using
|
|
* drawImage should be faster, but is also bugged for a small combination of old hardware
|
|
* and drivers.
|
|
* putImageData is faster than drawImage for that specific operation.
|
|
*/
|
|
const isPutImageFaster = (width, height) => {
|
|
const targetCanvas = createCanvasElement();
|
|
const sourceCanvas = createCanvasElement();
|
|
const gl = sourceCanvas.getContext('webgl');
|
|
// eslint-disable-next-line no-undef
|
|
const imageBuffer = new ArrayBuffer(width * height * 4);
|
|
const testContext = {
|
|
imageBuffer: imageBuffer
|
|
};
|
|
const testPipelineState = {
|
|
destinationWidth: width,
|
|
destinationHeight: height,
|
|
targetCanvas: targetCanvas
|
|
};
|
|
let startTime;
|
|
targetCanvas.width = width;
|
|
targetCanvas.height = height;
|
|
startTime = getFabricWindow().performance.now();
|
|
WebGLFilterBackend.prototype.copyGLTo2D.call(testContext, gl, testPipelineState);
|
|
const drawImageTime = getFabricWindow().performance.now() - startTime;
|
|
startTime = getFabricWindow().performance.now();
|
|
WebGLFilterBackend.prototype.copyGLTo2DPutImageData.call(testContext, gl, testPipelineState);
|
|
const putImageDataTime = getFabricWindow().performance.now() - startTime;
|
|
return drawImageTime > putImageDataTime;
|
|
};
|
|
|
|
const highPsourceCode = "precision highp float";
|
|
const identityFragmentShader = "\n ".concat(highPsourceCode, ";\n varying vec2 vTexCoord;\n uniform sampler2D uTexture;\n void main() {\n gl_FragColor = texture2D(uTexture, vTexCoord);\n }");
|
|
const vertexSource$1 = "\n attribute vec2 aPosition;\n varying vec2 vTexCoord;\n void main() {\n vTexCoord = aPosition;\n gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0);\n }";
|
|
|
|
const _excluded$1 = ["type"],
|
|
_excluded2 = ["type"];
|
|
const regex = new RegExp(highPsourceCode, 'g');
|
|
class BaseFilter {
|
|
/**
|
|
* Filter type
|
|
* @param {String} type
|
|
* @default
|
|
*/
|
|
get type() {
|
|
return this.constructor.type;
|
|
}
|
|
|
|
/**
|
|
* The class type. Used to identify which class this is.
|
|
* This is used for serialization purposes and internally it can be used
|
|
* to identify classes. As a developer you could use `instance of Class`
|
|
* but to avoid importing all the code and blocking tree shaking we try
|
|
* to avoid doing that.
|
|
*/
|
|
|
|
/**
|
|
* Constructor
|
|
* @param {Object} [options] Options object
|
|
*/
|
|
constructor() {
|
|
let _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
|
|
options = _objectWithoutProperties(_ref, _excluded$1);
|
|
Object.assign(this, this.constructor.defaults, options);
|
|
}
|
|
getFragmentSource() {
|
|
return identityFragmentShader;
|
|
}
|
|
getVertexSource() {
|
|
return vertexSource$1;
|
|
}
|
|
|
|
/**
|
|
* Compile this filter's shader program.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context to use for shader compilation.
|
|
* @param {String} fragmentSource fragmentShader source for compilation
|
|
* @param {String} vertexSource vertexShader source for compilation
|
|
*/
|
|
createProgram(gl) {
|
|
let fragmentSource = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : this.getFragmentSource();
|
|
let vertexSource = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : this.getVertexSource();
|
|
const {
|
|
WebGLProbe: {
|
|
GLPrecision = 'highp'
|
|
}
|
|
} = getEnv();
|
|
if (GLPrecision !== 'highp') {
|
|
fragmentSource = fragmentSource.replace(regex, highPsourceCode.replace('highp', GLPrecision));
|
|
}
|
|
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
|
|
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
|
|
const program = gl.createProgram();
|
|
if (!vertexShader || !fragmentShader || !program) {
|
|
throw new FabricError('Vertex, fragment shader or program creation error');
|
|
}
|
|
gl.shaderSource(vertexShader, vertexSource);
|
|
gl.compileShader(vertexShader);
|
|
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
|
|
throw new FabricError("Vertex shader compile error for ".concat(this.type, ": ").concat(gl.getShaderInfoLog(vertexShader)));
|
|
}
|
|
gl.shaderSource(fragmentShader, fragmentSource);
|
|
gl.compileShader(fragmentShader);
|
|
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
|
|
throw new FabricError("Fragment shader compile error for ".concat(this.type, ": ").concat(gl.getShaderInfoLog(fragmentShader)));
|
|
}
|
|
gl.attachShader(program, vertexShader);
|
|
gl.attachShader(program, fragmentShader);
|
|
gl.linkProgram(program);
|
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
throw new FabricError("Shader link error for \"".concat(this.type, "\" ").concat(gl.getProgramInfoLog(program)));
|
|
}
|
|
const uniformLocations = this.getUniformLocations(gl, program) || {};
|
|
uniformLocations.uStepW = gl.getUniformLocation(program, 'uStepW');
|
|
uniformLocations.uStepH = gl.getUniformLocation(program, 'uStepH');
|
|
return {
|
|
program,
|
|
attributeLocations: this.getAttributeLocations(gl, program),
|
|
uniformLocations
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a map of attribute names to WebGLAttributeLocation objects.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The canvas context used to compile the shader program.
|
|
* @param {WebGLShaderProgram} program The shader program from which to take attribute locations.
|
|
* @returns {Object} A map of attribute names to attribute locations.
|
|
*/
|
|
getAttributeLocations(gl, program) {
|
|
return {
|
|
aPosition: gl.getAttribLocation(program, 'aPosition')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a map of uniform names to WebGLUniformLocation objects.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The canvas context used to compile the shader program.
|
|
* @param {WebGLShaderProgram} program The shader program from which to take uniform locations.
|
|
* @returns {Object} A map of uniform names to uniform locations.
|
|
*/
|
|
getUniformLocations(gl, program) {
|
|
const locations = this.constructor.uniformLocations;
|
|
const uniformLocations = {};
|
|
for (let i = 0; i < locations.length; i++) {
|
|
uniformLocations[locations[i]] = gl.getUniformLocation(program, locations[i]);
|
|
}
|
|
return uniformLocations;
|
|
}
|
|
|
|
/**
|
|
* Send attribute data from this filter to its shader program on the GPU.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The canvas context used to compile the shader program.
|
|
* @param {Object} attributeLocations A map of shader attribute names to their locations.
|
|
*/
|
|
sendAttributeData(gl, attributeLocations, aPositionData) {
|
|
const attributeLocation = attributeLocations.aPosition;
|
|
const buffer = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
gl.enableVertexAttribArray(attributeLocation);
|
|
gl.vertexAttribPointer(attributeLocation, 2, gl.FLOAT, false, 0, 0);
|
|
gl.bufferData(gl.ARRAY_BUFFER, aPositionData, gl.STATIC_DRAW);
|
|
}
|
|
_setupFrameBuffer(options) {
|
|
const gl = options.context;
|
|
if (options.passes > 1) {
|
|
const width = options.destinationWidth;
|
|
const height = options.destinationHeight;
|
|
if (options.sourceWidth !== width || options.sourceHeight !== height) {
|
|
gl.deleteTexture(options.targetTexture);
|
|
options.targetTexture = options.filterBackend.createTexture(gl, width, height);
|
|
}
|
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, options.targetTexture, 0);
|
|
} else {
|
|
// draw last filter on canvas and not to framebuffer.
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
gl.finish();
|
|
}
|
|
}
|
|
_swapTextures(options) {
|
|
options.passes--;
|
|
options.pass++;
|
|
const temp = options.targetTexture;
|
|
options.targetTexture = options.sourceTexture;
|
|
options.sourceTexture = temp;
|
|
}
|
|
|
|
/**
|
|
* Generic isNeutral implementation for one parameter based filters.
|
|
* Used only in image applyFilters to discard filters that will not have an effect
|
|
* on the image
|
|
* Other filters may need their own version ( ColorMatrix, HueRotation, gamma, ComposedFilter )
|
|
* @param {Object} options
|
|
**/
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
isNeutralState(options) {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Apply this filter to the input image data provided.
|
|
*
|
|
* Determines whether to use WebGL or Canvas2D based on the options.webgl flag.
|
|
*
|
|
* @param {Object} options
|
|
* @param {Number} options.passes The number of filters remaining to be executed
|
|
* @param {Boolean} options.webgl Whether to use webgl to render the filter.
|
|
* @param {WebGLTexture} options.sourceTexture The texture setup as the source to be filtered.
|
|
* @param {WebGLTexture} options.targetTexture The texture where filtered output should be drawn.
|
|
* @param {WebGLRenderingContext} options.context The GL context used for rendering.
|
|
* @param {Object} options.programCache A map of compiled shader programs, keyed by filter type.
|
|
*/
|
|
applyTo(options) {
|
|
if (isWebGLPipelineState(options)) {
|
|
this._setupFrameBuffer(options);
|
|
this.applyToWebGL(options);
|
|
this._swapTextures(options);
|
|
} else {
|
|
this.applyTo2d(options);
|
|
}
|
|
}
|
|
applyTo2d(_options) {
|
|
// override by subclass
|
|
}
|
|
|
|
/**
|
|
* Returns a string that represent the current selected shader code for the filter.
|
|
* Used to force recompilation when parameters change or to retrieve the shader from cache
|
|
* @type string
|
|
**/
|
|
getCacheKey() {
|
|
return this.type;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the cached shader.
|
|
* @param {Object} options
|
|
* @param {WebGLRenderingContext} options.context The GL context used for rendering.
|
|
* @param {Object} options.programCache A map of compiled shader programs, keyed by filter type.
|
|
* @return {WebGLProgram} the compiled program shader
|
|
*/
|
|
retrieveShader(options) {
|
|
const key = this.getCacheKey();
|
|
if (!options.programCache[key]) {
|
|
options.programCache[key] = this.createProgram(options.context);
|
|
}
|
|
return options.programCache[key];
|
|
}
|
|
|
|
/**
|
|
* Apply this filter using webgl.
|
|
*
|
|
* @param {Object} options
|
|
* @param {Number} options.passes The number of filters remaining to be executed
|
|
* @param {Boolean} options.webgl Whether to use webgl to render the filter.
|
|
* @param {WebGLTexture} options.originalTexture The texture of the original input image.
|
|
* @param {WebGLTexture} options.sourceTexture The texture setup as the source to be filtered.
|
|
* @param {WebGLTexture} options.targetTexture The texture where filtered output should be drawn.
|
|
* @param {WebGLRenderingContext} options.context The GL context used for rendering.
|
|
* @param {Object} options.programCache A map of compiled shader programs, keyed by filter type.
|
|
*/
|
|
applyToWebGL(options) {
|
|
const gl = options.context;
|
|
const shader = this.retrieveShader(options);
|
|
if (options.pass === 0 && options.originalTexture) {
|
|
gl.bindTexture(gl.TEXTURE_2D, options.originalTexture);
|
|
} else {
|
|
gl.bindTexture(gl.TEXTURE_2D, options.sourceTexture);
|
|
}
|
|
gl.useProgram(shader.program);
|
|
this.sendAttributeData(gl, shader.attributeLocations, options.aPosition);
|
|
gl.uniform1f(shader.uniformLocations.uStepW, 1 / options.sourceWidth);
|
|
gl.uniform1f(shader.uniformLocations.uStepH, 1 / options.sourceHeight);
|
|
this.sendUniformData(gl, shader.uniformLocations);
|
|
gl.viewport(0, 0, options.destinationWidth, options.destinationHeight);
|
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
}
|
|
bindAdditionalTexture(gl, texture, textureUnit) {
|
|
gl.activeTexture(textureUnit);
|
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
// reset active texture to 0 as usual
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
}
|
|
unbindAdditionalTexture(gl, textureUnit) {
|
|
gl.activeTexture(textureUnit);
|
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
}
|
|
|
|
/**
|
|
* Send uniform data from this filter to its shader program on the GPU.
|
|
*
|
|
* Intended to be overridden by subclasses.
|
|
*
|
|
* @param {WebGLRenderingContext} _gl The canvas context used to compile the shader program.
|
|
* @param {Object} _uniformLocations A map of shader uniform names to their locations.
|
|
*/
|
|
sendUniformData(_gl, _uniformLocations) {
|
|
// override by subclass
|
|
}
|
|
|
|
/**
|
|
* If needed by a 2d filter, this functions can create an helper canvas to be used
|
|
* remember that options.targetCanvas is available for use till end of chain.
|
|
*/
|
|
createHelpLayer(options) {
|
|
if (!options.helpLayer) {
|
|
const helpLayer = createCanvasElement();
|
|
helpLayer.width = options.sourceWidth;
|
|
helpLayer.height = options.sourceHeight;
|
|
options.helpLayer = helpLayer;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* It will automatically export the default values of a filter,
|
|
* stored in the static defaults property.
|
|
* @return {Object} Object representation of an instance
|
|
*/
|
|
toObject() {
|
|
const defaultKeys = Object.keys(this.constructor.defaults || {});
|
|
return _objectSpread2({
|
|
type: this.type
|
|
}, defaultKeys.reduce((acc, key) => {
|
|
acc[key] = this[key];
|
|
return acc;
|
|
}, {}));
|
|
}
|
|
|
|
/**
|
|
* Returns a JSON representation of an instance
|
|
* @return {Object} JSON
|
|
*/
|
|
toJSON() {
|
|
// delegate, not alias
|
|
return this.toObject();
|
|
}
|
|
static async fromObject(_ref2, _options) {
|
|
let filterOptions = _objectWithoutProperties(_ref2, _excluded2);
|
|
return new this(filterOptions);
|
|
}
|
|
}
|
|
_defineProperty(BaseFilter, "type", 'BaseFilter');
|
|
/**
|
|
* Contains the uniform locations for the fragment shader.
|
|
* uStepW and uStepH are handled by the BaseFilter, each filter class
|
|
* needs to specify all the one that are needed
|
|
*/
|
|
_defineProperty(BaseFilter, "uniformLocations", []);
|
|
|
|
const blendColorFragmentSource = {
|
|
multiply: 'gl_FragColor.rgb *= uColor.rgb;\n',
|
|
screen: 'gl_FragColor.rgb = 1.0 - (1.0 - gl_FragColor.rgb) * (1.0 - uColor.rgb);\n',
|
|
add: 'gl_FragColor.rgb += uColor.rgb;\n',
|
|
difference: 'gl_FragColor.rgb = abs(gl_FragColor.rgb - uColor.rgb);\n',
|
|
subtract: 'gl_FragColor.rgb -= uColor.rgb;\n',
|
|
lighten: 'gl_FragColor.rgb = max(gl_FragColor.rgb, uColor.rgb);\n',
|
|
darken: 'gl_FragColor.rgb = min(gl_FragColor.rgb, uColor.rgb);\n',
|
|
exclusion: 'gl_FragColor.rgb += uColor.rgb - 2.0 * (uColor.rgb * gl_FragColor.rgb);\n',
|
|
overlay: "\n if (uColor.r < 0.5) {\n gl_FragColor.r *= 2.0 * uColor.r;\n } else {\n gl_FragColor.r = 1.0 - 2.0 * (1.0 - gl_FragColor.r) * (1.0 - uColor.r);\n }\n if (uColor.g < 0.5) {\n gl_FragColor.g *= 2.0 * uColor.g;\n } else {\n gl_FragColor.g = 1.0 - 2.0 * (1.0 - gl_FragColor.g) * (1.0 - uColor.g);\n }\n if (uColor.b < 0.5) {\n gl_FragColor.b *= 2.0 * uColor.b;\n } else {\n gl_FragColor.b = 1.0 - 2.0 * (1.0 - gl_FragColor.b) * (1.0 - uColor.b);\n }\n ",
|
|
tint: "\n gl_FragColor.rgb *= (1.0 - uColor.a);\n gl_FragColor.rgb += uColor.rgb;\n "
|
|
};
|
|
|
|
const blendColorDefaultValues = {
|
|
color: '#F95C63',
|
|
mode: 'multiply',
|
|
alpha: 1
|
|
};
|
|
|
|
/**
|
|
* Color Blend filter class
|
|
* @example
|
|
* const filter = new BlendColor({
|
|
* color: '#000',
|
|
* mode: 'multiply'
|
|
* });
|
|
*
|
|
* const filter = new BlendImage({
|
|
* image: fabricImageObject,
|
|
* mode: 'multiply'
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
* canvas.renderAll();
|
|
*/
|
|
class BlendColor extends BaseFilter {
|
|
getCacheKey() {
|
|
return "".concat(this.type, "_").concat(this.mode);
|
|
}
|
|
getFragmentSource() {
|
|
return "\n precision highp float;\n uniform sampler2D uTexture;\n uniform vec4 uColor;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n gl_FragColor = color;\n if (color.a > 0.0) {\n ".concat(blendColorFragmentSource[this.mode], "\n }\n }\n ");
|
|
}
|
|
|
|
/**
|
|
* Apply the Blend operation to a Uint8ClampedArray representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8ClampedArray to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
const source = new Color(this.color).getSource();
|
|
const tr = source[0] * this.alpha;
|
|
const tg = source[1] * this.alpha;
|
|
const tb = source[2] * this.alpha;
|
|
const alpha1 = 1 - this.alpha;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const r = data[i];
|
|
const g = data[i + 1];
|
|
const b = data[i + 2];
|
|
switch (this.mode) {
|
|
case 'multiply':
|
|
data[i] = r * tr / 255;
|
|
data[i + 1] = g * tg / 255;
|
|
data[i + 2] = b * tb / 255;
|
|
break;
|
|
case 'screen':
|
|
data[i] = 255 - (255 - r) * (255 - tr) / 255;
|
|
data[i + 1] = 255 - (255 - g) * (255 - tg) / 255;
|
|
data[i + 2] = 255 - (255 - b) * (255 - tb) / 255;
|
|
break;
|
|
case 'add':
|
|
data[i] = r + tr;
|
|
data[i + 1] = g + tg;
|
|
data[i + 2] = b + tb;
|
|
break;
|
|
case 'difference':
|
|
data[i] = Math.abs(r - tr);
|
|
data[i + 1] = Math.abs(g - tg);
|
|
data[i + 2] = Math.abs(b - tb);
|
|
break;
|
|
case 'subtract':
|
|
data[i] = r - tr;
|
|
data[i + 1] = g - tg;
|
|
data[i + 2] = b - tb;
|
|
break;
|
|
case 'darken':
|
|
data[i] = Math.min(r, tr);
|
|
data[i + 1] = Math.min(g, tg);
|
|
data[i + 2] = Math.min(b, tb);
|
|
break;
|
|
case 'lighten':
|
|
data[i] = Math.max(r, tr);
|
|
data[i + 1] = Math.max(g, tg);
|
|
data[i + 2] = Math.max(b, tb);
|
|
break;
|
|
case 'overlay':
|
|
data[i] = tr < 128 ? 2 * r * tr / 255 : 255 - 2 * (255 - r) * (255 - tr) / 255;
|
|
data[i + 1] = tg < 128 ? 2 * g * tg / 255 : 255 - 2 * (255 - g) * (255 - tg) / 255;
|
|
data[i + 2] = tb < 128 ? 2 * b * tb / 255 : 255 - 2 * (255 - b) * (255 - tb) / 255;
|
|
break;
|
|
case 'exclusion':
|
|
data[i] = tr + r - 2 * tr * r / 255;
|
|
data[i + 1] = tg + g - 2 * tg * g / 255;
|
|
data[i + 2] = tb + b - 2 * tb * b / 255;
|
|
break;
|
|
case 'tint':
|
|
data[i] = tr + r * alpha1;
|
|
data[i + 1] = tg + g * alpha1;
|
|
data[i + 2] = tb + b * alpha1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
const source = new Color(this.color).getSource();
|
|
source[0] = this.alpha * source[0] / 255;
|
|
source[1] = this.alpha * source[1] / 255;
|
|
source[2] = this.alpha * source[2] / 255;
|
|
source[3] = this.alpha;
|
|
gl.uniform4fv(uniformLocations.uColor, source);
|
|
}
|
|
}
|
|
/**
|
|
* Color to make the blend operation with. default to a reddish color since black or white
|
|
* gives always strong result.
|
|
* @type String
|
|
* @default
|
|
**/
|
|
/**
|
|
* Blend mode for the filter: one of multiply, add, difference, screen, subtract,
|
|
* darken, lighten, overlay, exclusion, tint.
|
|
* @type String
|
|
* @default
|
|
**/
|
|
/**
|
|
* alpha value. represent the strength of the blend color operation.
|
|
* @type Number
|
|
* @default
|
|
**/
|
|
_defineProperty(BlendColor, "defaults", blendColorDefaultValues);
|
|
_defineProperty(BlendColor, "type", 'BlendColor');
|
|
_defineProperty(BlendColor, "uniformLocations", ['uColor']);
|
|
classRegistry.setClass(BlendColor);
|
|
|
|
const fragmentSource$c = {
|
|
multiply: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform sampler2D uImage;\n uniform vec4 uColor;\n varying vec2 vTexCoord;\n varying vec2 vTexCoord2;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n vec4 color2 = texture2D(uImage, vTexCoord2);\n color.rgba *= color2.rgba;\n gl_FragColor = color;\n }\n ",
|
|
mask: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform sampler2D uImage;\n uniform vec4 uColor;\n varying vec2 vTexCoord;\n varying vec2 vTexCoord2;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n vec4 color2 = texture2D(uImage, vTexCoord2);\n color.a = color2.a;\n gl_FragColor = color;\n }\n "
|
|
};
|
|
const vertexSource = "\n attribute vec2 aPosition;\n varying vec2 vTexCoord;\n varying vec2 vTexCoord2;\n uniform mat3 uTransformMatrix;\n void main() {\n vTexCoord = aPosition;\n vTexCoord2 = (uTransformMatrix * vec3(aPosition, 1.0)).xy;\n gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0);\n }\n ";
|
|
|
|
const _excluded = ["type", "image"];
|
|
const blendImageDefaultValues = {
|
|
mode: 'multiply',
|
|
alpha: 1
|
|
};
|
|
|
|
/**
|
|
* Image Blend filter class
|
|
* @example
|
|
* const filter = new filters.BlendColor({
|
|
* color: '#000',
|
|
* mode: 'multiply'
|
|
* });
|
|
*
|
|
* const filter = new BlendImage({
|
|
* image: fabricImageObject,
|
|
* mode: 'multiply'
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
* canvas.renderAll();
|
|
*/
|
|
class BlendImage extends BaseFilter {
|
|
getCacheKey() {
|
|
return "".concat(this.type, "_").concat(this.mode);
|
|
}
|
|
getFragmentSource() {
|
|
return fragmentSource$c[this.mode];
|
|
}
|
|
getVertexSource() {
|
|
return vertexSource;
|
|
}
|
|
applyToWebGL(options) {
|
|
const gl = options.context,
|
|
texture = this.createTexture(options.filterBackend, this.image);
|
|
this.bindAdditionalTexture(gl, texture, gl.TEXTURE1);
|
|
super.applyToWebGL(options);
|
|
this.unbindAdditionalTexture(gl, gl.TEXTURE1);
|
|
}
|
|
createTexture(backend, image) {
|
|
return backend.getCachedTexture(image.cacheKey, image.getElement());
|
|
}
|
|
|
|
/**
|
|
* Calculate a transformMatrix to adapt the image to blend over
|
|
* @param {Object} options
|
|
* @param {WebGLRenderingContext} options.context The GL context used for rendering.
|
|
* @param {Object} options.programCache A map of compiled shader programs, keyed by filter type.
|
|
*/
|
|
calculateMatrix() {
|
|
const image = this.image,
|
|
{
|
|
width,
|
|
height
|
|
} = image.getElement();
|
|
return [1 / image.scaleX, 0, 0, 0, 1 / image.scaleY, 0, -image.left / width, -image.top / height, 1];
|
|
}
|
|
|
|
/**
|
|
* Apply the Blend operation to a Uint8ClampedArray representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8ClampedArray to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data,
|
|
width,
|
|
height
|
|
},
|
|
filterBackend: {
|
|
resources
|
|
}
|
|
} = _ref;
|
|
const image = this.image;
|
|
if (!resources.blendImage) {
|
|
resources.blendImage = createCanvasElement();
|
|
}
|
|
const canvas1 = resources.blendImage;
|
|
const context = canvas1.getContext('2d');
|
|
if (canvas1.width !== width || canvas1.height !== height) {
|
|
canvas1.width = width;
|
|
canvas1.height = height;
|
|
} else {
|
|
context.clearRect(0, 0, width, height);
|
|
}
|
|
context.setTransform(image.scaleX, 0, 0, image.scaleY, image.left, image.top);
|
|
context.drawImage(image.getElement(), 0, 0, width, height);
|
|
const blendData = context.getImageData(0, 0, width, height).data;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const r = data[i];
|
|
const g = data[i + 1];
|
|
const b = data[i + 2];
|
|
const a = data[i + 3];
|
|
const tr = blendData[i];
|
|
const tg = blendData[i + 1];
|
|
const tb = blendData[i + 2];
|
|
const ta = blendData[i + 3];
|
|
switch (this.mode) {
|
|
case 'multiply':
|
|
data[i] = r * tr / 255;
|
|
data[i + 1] = g * tg / 255;
|
|
data[i + 2] = b * tb / 255;
|
|
data[i + 3] = a * ta / 255;
|
|
break;
|
|
case 'mask':
|
|
data[i + 3] = ta;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
const matrix = this.calculateMatrix();
|
|
gl.uniform1i(uniformLocations.uImage, 1); // texture unit 1.
|
|
gl.uniformMatrix3fv(uniformLocations.uTransformMatrix, false, matrix);
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* TODO: Handle the possibility of missing image better.
|
|
* As of now a BlendImage filter without image can't be used with fromObject
|
|
* @return {Object} Object representation of an instance
|
|
*/
|
|
toObject() {
|
|
return _objectSpread2(_objectSpread2({}, super.toObject()), {}, {
|
|
image: this.image && this.image.toObject()
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create filter instance from an object representation
|
|
* @static
|
|
* @param {object} object Object to create an instance from
|
|
* @param {object} [options]
|
|
* @param {AbortSignal} [options.signal] handle aborting image loading, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @returns {Promise<BlendImage>}
|
|
*/
|
|
static async fromObject(_ref2, options) {
|
|
let {
|
|
type,
|
|
image
|
|
} = _ref2,
|
|
filterOptions = _objectWithoutProperties(_ref2, _excluded);
|
|
return FabricImage.fromObject(image, options).then(enlivedImage => new this(_objectSpread2(_objectSpread2({}, filterOptions), {}, {
|
|
image: enlivedImage
|
|
})));
|
|
}
|
|
}
|
|
/**
|
|
* Image to make the blend operation with.
|
|
**/
|
|
/**
|
|
* Blend mode for the filter: either 'multiply' or 'mask'. 'multiply' will
|
|
* multiply the values of each channel (R, G, B, and A) of the filter image by
|
|
* their corresponding values in the base image. 'mask' will only look at the
|
|
* alpha channel of the filter image, and apply those values to the base
|
|
* image's alpha channel.
|
|
* @type String
|
|
* @default
|
|
**/
|
|
/**
|
|
* alpha value. represent the strength of the blend image operation.
|
|
* not implemented.
|
|
**/
|
|
_defineProperty(BlendImage, "type", 'BlendImage');
|
|
_defineProperty(BlendImage, "defaults", blendImageDefaultValues);
|
|
_defineProperty(BlendImage, "uniformLocations", ['uTransformMatrix', 'uImage']);
|
|
classRegistry.setClass(BlendImage);
|
|
|
|
const fragmentSource$b = "\n precision highp float;\n uniform sampler2D uTexture;\n uniform vec2 uDelta;\n varying vec2 vTexCoord;\n const float nSamples = 15.0;\n vec3 v3offset = vec3(12.9898, 78.233, 151.7182);\n float random(vec3 scale) {\n /* use the fragment position for a different seed per-pixel */\n return fract(sin(dot(gl_FragCoord.xyz, scale)) * 43758.5453);\n }\n void main() {\n vec4 color = vec4(0.0);\n float total = 0.0;\n float offset = random(v3offset);\n for (float t = -nSamples; t <= nSamples; t++) {\n float percent = (t + offset - 0.5) / nSamples;\n float weight = 1.0 - abs(percent);\n color += texture2D(uTexture, vTexCoord + uDelta * percent) * weight;\n total += weight;\n }\n gl_FragColor = color / total;\n }\n ";
|
|
|
|
const blurDefaultValues = {
|
|
blur: 0
|
|
};
|
|
|
|
/**
|
|
* Blur filter class
|
|
* @example
|
|
* const filter = new Blur({
|
|
* blur: 0.5
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
* canvas.renderAll();
|
|
*/
|
|
class Blur extends BaseFilter {
|
|
getFragmentSource() {
|
|
return fragmentSource$b;
|
|
}
|
|
applyTo(options) {
|
|
if (isWebGLPipelineState(options)) {
|
|
// this aspectRatio is used to give the same blur to vertical and horizontal
|
|
this.aspectRatio = options.sourceWidth / options.sourceHeight;
|
|
options.passes++;
|
|
this._setupFrameBuffer(options);
|
|
this.horizontal = true;
|
|
this.applyToWebGL(options);
|
|
this._swapTextures(options);
|
|
this._setupFrameBuffer(options);
|
|
this.horizontal = false;
|
|
this.applyToWebGL(options);
|
|
this._swapTextures(options);
|
|
} else {
|
|
this.applyTo2d(options);
|
|
}
|
|
}
|
|
applyTo2d(options) {
|
|
options.imageData = this.simpleBlur(options);
|
|
}
|
|
simpleBlur(_ref) {
|
|
let {
|
|
ctx,
|
|
imageData,
|
|
filterBackend: {
|
|
resources
|
|
}
|
|
} = _ref;
|
|
const {
|
|
width,
|
|
height
|
|
} = imageData;
|
|
if (!resources.blurLayer1) {
|
|
resources.blurLayer1 = createCanvasElement();
|
|
resources.blurLayer2 = createCanvasElement();
|
|
}
|
|
const canvas1 = resources.blurLayer1;
|
|
const canvas2 = resources.blurLayer2;
|
|
if (canvas1.width !== width || canvas1.height !== height) {
|
|
canvas2.width = canvas1.width = width;
|
|
canvas2.height = canvas1.height = height;
|
|
}
|
|
const ctx1 = canvas1.getContext('2d'),
|
|
ctx2 = canvas2.getContext('2d'),
|
|
nSamples = 15,
|
|
blur = this.blur * 0.06 * 0.5;
|
|
let random, percent, j, i;
|
|
|
|
// load first canvas
|
|
ctx1.putImageData(imageData, 0, 0);
|
|
ctx2.clearRect(0, 0, width, height);
|
|
for (i = -nSamples; i <= nSamples; i++) {
|
|
random = (Math.random() - 0.5) / 4;
|
|
percent = i / nSamples;
|
|
j = blur * percent * width + random;
|
|
ctx2.globalAlpha = 1 - Math.abs(percent);
|
|
ctx2.drawImage(canvas1, j, random);
|
|
ctx1.drawImage(canvas2, 0, 0);
|
|
ctx2.globalAlpha = 1;
|
|
ctx2.clearRect(0, 0, canvas2.width, canvas2.height);
|
|
}
|
|
for (i = -nSamples; i <= nSamples; i++) {
|
|
random = (Math.random() - 0.5) / 4;
|
|
percent = i / nSamples;
|
|
j = blur * percent * height + random;
|
|
ctx2.globalAlpha = 1 - Math.abs(percent);
|
|
ctx2.drawImage(canvas1, random, j);
|
|
ctx1.drawImage(canvas2, 0, 0);
|
|
ctx2.globalAlpha = 1;
|
|
ctx2.clearRect(0, 0, canvas2.width, canvas2.height);
|
|
}
|
|
ctx.drawImage(canvas1, 0, 0);
|
|
const newImageData = ctx.getImageData(0, 0, canvas1.width, canvas1.height);
|
|
ctx1.globalAlpha = 1;
|
|
ctx1.clearRect(0, 0, canvas1.width, canvas1.height);
|
|
return newImageData;
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
const delta = this.chooseRightDelta();
|
|
gl.uniform2fv(uniformLocations.uDelta, delta);
|
|
}
|
|
isNeutralState() {
|
|
return this.blur === 0;
|
|
}
|
|
|
|
/**
|
|
* choose right value of image percentage to blur with
|
|
* @returns {Array} a numeric array with delta values
|
|
*/
|
|
chooseRightDelta() {
|
|
let blurScale = 1;
|
|
const delta = [0, 0];
|
|
if (this.horizontal) {
|
|
if (this.aspectRatio > 1) {
|
|
// image is wide, i want to shrink radius horizontal
|
|
blurScale = 1 / this.aspectRatio;
|
|
}
|
|
} else {
|
|
if (this.aspectRatio < 1) {
|
|
// image is tall, i want to shrink radius vertical
|
|
blurScale = this.aspectRatio;
|
|
}
|
|
}
|
|
const blur = blurScale * this.blur * 0.12;
|
|
if (this.horizontal) {
|
|
delta[0] = blur;
|
|
} else {
|
|
delta[1] = blur;
|
|
}
|
|
return delta;
|
|
}
|
|
}
|
|
/**
|
|
* blur value, in percentage of image dimensions.
|
|
* specific to keep the image blur constant at different resolutions
|
|
* range between 0 and 1.
|
|
* @type Number
|
|
* @default
|
|
*/
|
|
_defineProperty(Blur, "type", 'Blur');
|
|
_defineProperty(Blur, "defaults", blurDefaultValues);
|
|
_defineProperty(Blur, "uniformLocations", ['uDelta']);
|
|
classRegistry.setClass(Blur);
|
|
|
|
const fragmentSource$a = "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uBrightness;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n color.rgb += uBrightness;\n gl_FragColor = color;\n }\n";
|
|
|
|
const brightnessDefaultValues = {
|
|
brightness: 0
|
|
};
|
|
|
|
/**
|
|
* Brightness filter class
|
|
* @example
|
|
* const filter = new Brightness({
|
|
* brightness: 0.05
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
*/
|
|
class Brightness extends BaseFilter {
|
|
getFragmentSource() {
|
|
return fragmentSource$a;
|
|
}
|
|
|
|
/**
|
|
* Apply the Brightness operation to a Uint8ClampedArray representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8ClampedArray to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
const brightness = Math.round(this.brightness * 255);
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
data[i] = data[i] + brightness;
|
|
data[i + 1] = data[i + 1] + brightness;
|
|
data[i + 2] = data[i + 2] + brightness;
|
|
}
|
|
}
|
|
isNeutralState() {
|
|
return this.brightness === 0;
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform1f(uniformLocations.uBrightness, this.brightness);
|
|
}
|
|
}
|
|
/**
|
|
* Brightness value, from -1 to 1.
|
|
* translated to -255 to 255 for 2d
|
|
* 0.0039215686 is the part of 1 that get translated to 1 in 2d
|
|
* @param {Number} brightness
|
|
* @default
|
|
*/
|
|
_defineProperty(Brightness, "type", 'Brightness');
|
|
_defineProperty(Brightness, "defaults", brightnessDefaultValues);
|
|
_defineProperty(Brightness, "uniformLocations", ['uBrightness']);
|
|
classRegistry.setClass(Brightness);
|
|
|
|
const fragmentSource$9 = "\n precision highp float;\n uniform sampler2D uTexture;\n varying vec2 vTexCoord;\n uniform mat4 uColorMatrix;\n uniform vec4 uConstants;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n color *= uColorMatrix;\n color += uConstants;\n gl_FragColor = color;\n }";
|
|
|
|
const colorMatrixDefaultValues = {
|
|
matrix: [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0],
|
|
colorsOnly: true
|
|
};
|
|
|
|
/**
|
|
* Color Matrix filter class
|
|
* @see {@link http://fabricjs.com/image-filters|ImageFilters demo}
|
|
* @see {@Link http://phoboslab.org/log/2013/11/fast-image-filters-with-webgl demo}
|
|
* @example <caption>Kodachrome filter</caption>
|
|
* const filter = new ColorMatrix({
|
|
* matrix: [
|
|
1.1285582396593525, -0.3967382283601348, -0.03992559172921793, 0, 63.72958762196502,
|
|
-0.16404339962244616, 1.0835251566291304, -0.05498805115633132, 0, 24.732407896706203,
|
|
-0.16786010706155763, -0.5603416277695248, 1.6014850761964943, 0, 35.62982807460946,
|
|
0, 0, 0, 1, 0
|
|
]
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
*/
|
|
class ColorMatrix extends BaseFilter {
|
|
getFragmentSource() {
|
|
return fragmentSource$9;
|
|
}
|
|
|
|
/**
|
|
* Apply the ColorMatrix operation to a Uint8Array representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8Array to be filtered.
|
|
*/
|
|
applyTo2d(options) {
|
|
const imageData = options.imageData,
|
|
data = imageData.data,
|
|
m = this.matrix,
|
|
colorsOnly = this.colorsOnly;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const r = data[i];
|
|
const g = data[i + 1];
|
|
const b = data[i + 2];
|
|
if (colorsOnly) {
|
|
data[i] = r * m[0] + g * m[1] + b * m[2] + m[4] * 255;
|
|
data[i + 1] = r * m[5] + g * m[6] + b * m[7] + m[9] * 255;
|
|
data[i + 2] = r * m[10] + g * m[11] + b * m[12] + m[14] * 255;
|
|
} else {
|
|
const a = data[i + 3];
|
|
data[i] = r * m[0] + g * m[1] + b * m[2] + a * m[3] + m[4] * 255;
|
|
data[i + 1] = r * m[5] + g * m[6] + b * m[7] + a * m[8] + m[9] * 255;
|
|
data[i + 2] = r * m[10] + g * m[11] + b * m[12] + a * m[13] + m[14] * 255;
|
|
data[i + 3] = r * m[15] + g * m[16] + b * m[17] + a * m[18] + m[19] * 255;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
const m = this.matrix,
|
|
matrix = [m[0], m[1], m[2], m[3], m[5], m[6], m[7], m[8], m[10], m[11], m[12], m[13], m[15], m[16], m[17], m[18]],
|
|
constants = [m[4], m[9], m[14], m[19]];
|
|
gl.uniformMatrix4fv(uniformLocations.uColorMatrix, false, matrix);
|
|
gl.uniform4fv(uniformLocations.uConstants, constants);
|
|
}
|
|
toObject() {
|
|
return _objectSpread2(_objectSpread2({}, super.toObject()), {}, {
|
|
matrix: [...this.matrix]
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Colormatrix for pixels.
|
|
* array of 20 floats. Numbers in positions 4, 9, 14, 19 loose meaning
|
|
* outside the -1, 1 range.
|
|
* 0.0039215686 is the part of 1 that get translated to 1 in 2d
|
|
* @param {Array} matrix array of 20 numbers.
|
|
* @default
|
|
*/
|
|
/**
|
|
* Lock the colormatrix on the color part, skipping alpha, mainly for non webgl scenario
|
|
* to save some calculation
|
|
* @type Boolean
|
|
* @default true
|
|
*/
|
|
_defineProperty(ColorMatrix, "type", 'ColorMatrix');
|
|
_defineProperty(ColorMatrix, "defaults", colorMatrixDefaultValues);
|
|
_defineProperty(ColorMatrix, "uniformLocations", ['uColorMatrix', 'uConstants']);
|
|
classRegistry.setClass(ColorMatrix);
|
|
|
|
function createColorMatrixFilter(key, matrix) {
|
|
var _Class;
|
|
const newClass = (_Class = class newClass extends ColorMatrix {
|
|
//@ts-expect-error TS wants matrix to be exported.
|
|
toObject() {
|
|
return {
|
|
type: this.type,
|
|
colorsOnly: this.colorsOnly
|
|
};
|
|
}
|
|
}, _defineProperty(_Class, "type", key), _defineProperty(_Class, "defaults", {
|
|
colorsOnly: false,
|
|
matrix
|
|
}), _Class);
|
|
classRegistry.setClass(newClass, key);
|
|
return newClass;
|
|
}
|
|
const Brownie = createColorMatrixFilter('Brownie', [0.5997, 0.34553, -0.27082, 0, 0.186, -0.0377, 0.86095, 0.15059, 0, -0.1449, 0.24113, -0.07441, 0.44972, 0, -0.02965, 0, 0, 0, 1, 0]);
|
|
const Vintage = createColorMatrixFilter('Vintage', [0.62793, 0.32021, -0.03965, 0, 0.03784, 0.02578, 0.64411, 0.03259, 0, 0.02926, 0.0466, -0.08512, 0.52416, 0, 0.02023, 0, 0, 0, 1, 0]);
|
|
const Kodachrome = createColorMatrixFilter('Kodachrome', [1.12855, -0.39673, -0.03992, 0, 0.24991, -0.16404, 1.08352, -0.05498, 0, 0.09698, -0.16786, -0.56034, 1.60148, 0, 0.13972, 0, 0, 0, 1, 0]);
|
|
const Technicolor = createColorMatrixFilter('Technicolor', [1.91252, -0.85453, -0.09155, 0, 0.04624, -0.30878, 1.76589, -0.10601, 0, -0.27589, -0.2311, -0.75018, 1.84759, 0, 0.12137, 0, 0, 0, 1, 0]);
|
|
const Polaroid = createColorMatrixFilter('Polaroid', [1.438, -0.062, -0.062, 0, 0, -0.122, 1.378, -0.122, 0, 0, -0.016, -0.016, 1.483, 0, 0, 0, 0, 0, 1, 0]);
|
|
const Sepia = createColorMatrixFilter('Sepia', [0.393, 0.769, 0.189, 0, 0, 0.349, 0.686, 0.168, 0, 0, 0.272, 0.534, 0.131, 0, 0, 0, 0, 0, 1, 0]);
|
|
const BlackWhite = createColorMatrixFilter('BlackWhite', [1.5, 1.5, 1.5, 0, -1, 1.5, 1.5, 1.5, 0, -1, 1.5, 1.5, 1.5, 0, -1, 0, 0, 0, 1, 0]);
|
|
|
|
/**
|
|
* A container class that knows how to apply a sequence of filters to an input image.
|
|
*/
|
|
class Composed extends BaseFilter {
|
|
constructor() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
super(options);
|
|
this.subFilters = options.subFilters || [];
|
|
}
|
|
|
|
/**
|
|
* Apply this container's filters to the input image provided.
|
|
*
|
|
* @param {Object} options
|
|
* @param {Number} options.passes The number of filters remaining to be applied.
|
|
*/
|
|
applyTo(options) {
|
|
if (isWebGLPipelineState(options)) {
|
|
options.passes += this.subFilters.length - 1;
|
|
}
|
|
this.subFilters.forEach(filter => {
|
|
filter.applyTo(options);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Serialize this filter into JSON.
|
|
* @returns {Object} A JSON representation of this filter.
|
|
*/
|
|
//@ts-expect-error TS doesn't like this toObject
|
|
toObject() {
|
|
return {
|
|
type: this.type,
|
|
subFilters: this.subFilters.map(filter => filter.toObject())
|
|
};
|
|
}
|
|
isNeutralState() {
|
|
return !this.subFilters.some(filter => !filter.isNeutralState());
|
|
}
|
|
|
|
/**
|
|
* Deserialize a JSON definition of a ComposedFilter into a concrete instance.
|
|
* @static
|
|
* @param {oject} object Object to create an instance from
|
|
* @param {object} [options]
|
|
* @param {AbortSignal} [options.signal] handle aborting `BlendImage` filter loading, see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
|
|
* @returns {Promise<Composed>}
|
|
*/
|
|
static fromObject(object, options) {
|
|
return Promise.all((object.subFilters || []).map(filter => classRegistry.getClass(filter.type).fromObject(filter, options))).then(enlivedFilters => new this({
|
|
subFilters: enlivedFilters
|
|
}));
|
|
}
|
|
}
|
|
/**
|
|
* A non sparse array of filters to apply
|
|
*/
|
|
_defineProperty(Composed, "type", 'Composed');
|
|
classRegistry.setClass(Composed);
|
|
|
|
const fragmentSource$8 = "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uContrast;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n float contrastF = 1.015 * (uContrast + 1.0) / (1.0 * (1.015 - uContrast));\n color.rgb = contrastF * (color.rgb - 0.5) + 0.5;\n gl_FragColor = color;\n }";
|
|
|
|
const contrastDefaultValues = {
|
|
contrast: 0
|
|
};
|
|
|
|
/**
|
|
* Contrast filter class
|
|
* @example
|
|
* const filter = new Contrast({
|
|
* contrast: 0.25
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
*/
|
|
class Contrast extends BaseFilter {
|
|
getFragmentSource() {
|
|
return fragmentSource$8;
|
|
}
|
|
isNeutralState() {
|
|
return this.contrast === 0;
|
|
}
|
|
|
|
/**
|
|
* Apply the Contrast operation to a Uint8Array representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8Array to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
const contrast = Math.floor(this.contrast * 255),
|
|
contrastF = 259 * (contrast + 255) / (255 * (259 - contrast));
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
data[i] = contrastF * (data[i] - 128) + 128;
|
|
data[i + 1] = contrastF * (data[i + 1] - 128) + 128;
|
|
data[i + 2] = contrastF * (data[i + 2] - 128) + 128;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform1f(uniformLocations.uContrast, this.contrast);
|
|
}
|
|
}
|
|
/**
|
|
* contrast value, range from -1 to 1.
|
|
* @param {Number} contrast
|
|
* @default 0
|
|
*/
|
|
_defineProperty(Contrast, "type", 'Contrast');
|
|
_defineProperty(Contrast, "defaults", contrastDefaultValues);
|
|
_defineProperty(Contrast, "uniformLocations", ['uContrast']);
|
|
classRegistry.setClass(Contrast);
|
|
|
|
const fragmentSource$7 = {
|
|
Convolute_3_1: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uMatrix[9];\n uniform float uStepW;\n uniform float uStepH;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = vec4(0, 0, 0, 0);\n for (float h = 0.0; h < 3.0; h+=1.0) {\n for (float w = 0.0; w < 3.0; w+=1.0) {\n vec2 matrixPos = vec2(uStepW * (w - 1), uStepH * (h - 1));\n color += texture2D(uTexture, vTexCoord + matrixPos) * uMatrix[int(h * 3.0 + w)];\n }\n }\n gl_FragColor = color;\n }\n ",
|
|
Convolute_3_0: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uMatrix[9];\n uniform float uStepW;\n uniform float uStepH;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = vec4(0, 0, 0, 1);\n for (float h = 0.0; h < 3.0; h+=1.0) {\n for (float w = 0.0; w < 3.0; w+=1.0) {\n vec2 matrixPos = vec2(uStepW * (w - 1.0), uStepH * (h - 1.0));\n color.rgb += texture2D(uTexture, vTexCoord + matrixPos).rgb * uMatrix[int(h * 3.0 + w)];\n }\n }\n float alpha = texture2D(uTexture, vTexCoord).a;\n gl_FragColor = color;\n gl_FragColor.a = alpha;\n }\n ",
|
|
Convolute_5_1: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uMatrix[25];\n uniform float uStepW;\n uniform float uStepH;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = vec4(0, 0, 0, 0);\n for (float h = 0.0; h < 5.0; h+=1.0) {\n for (float w = 0.0; w < 5.0; w+=1.0) {\n vec2 matrixPos = vec2(uStepW * (w - 2.0), uStepH * (h - 2.0));\n color += texture2D(uTexture, vTexCoord + matrixPos) * uMatrix[int(h * 5.0 + w)];\n }\n }\n gl_FragColor = color;\n }\n ",
|
|
Convolute_5_0: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uMatrix[25];\n uniform float uStepW;\n uniform float uStepH;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = vec4(0, 0, 0, 1);\n for (float h = 0.0; h < 5.0; h+=1.0) {\n for (float w = 0.0; w < 5.0; w+=1.0) {\n vec2 matrixPos = vec2(uStepW * (w - 2.0), uStepH * (h - 2.0));\n color.rgb += texture2D(uTexture, vTexCoord + matrixPos).rgb * uMatrix[int(h * 5.0 + w)];\n }\n }\n float alpha = texture2D(uTexture, vTexCoord).a;\n gl_FragColor = color;\n gl_FragColor.a = alpha;\n }\n ",
|
|
Convolute_7_1: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uMatrix[49];\n uniform float uStepW;\n uniform float uStepH;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = vec4(0, 0, 0, 0);\n for (float h = 0.0; h < 7.0; h+=1.0) {\n for (float w = 0.0; w < 7.0; w+=1.0) {\n vec2 matrixPos = vec2(uStepW * (w - 3.0), uStepH * (h - 3.0));\n color += texture2D(uTexture, vTexCoord + matrixPos) * uMatrix[int(h * 7.0 + w)];\n }\n }\n gl_FragColor = color;\n }\n ",
|
|
Convolute_7_0: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uMatrix[49];\n uniform float uStepW;\n uniform float uStepH;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = vec4(0, 0, 0, 1);\n for (float h = 0.0; h < 7.0; h+=1.0) {\n for (float w = 0.0; w < 7.0; w+=1.0) {\n vec2 matrixPos = vec2(uStepW * (w - 3.0), uStepH * (h - 3.0));\n color.rgb += texture2D(uTexture, vTexCoord + matrixPos).rgb * uMatrix[int(h * 7.0 + w)];\n }\n }\n float alpha = texture2D(uTexture, vTexCoord).a;\n gl_FragColor = color;\n gl_FragColor.a = alpha;\n }\n ",
|
|
Convolute_9_1: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uMatrix[81];\n uniform float uStepW;\n uniform float uStepH;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = vec4(0, 0, 0, 0);\n for (float h = 0.0; h < 9.0; h+=1.0) {\n for (float w = 0.0; w < 9.0; w+=1.0) {\n vec2 matrixPos = vec2(uStepW * (w - 4.0), uStepH * (h - 4.0));\n color += texture2D(uTexture, vTexCoord + matrixPos) * uMatrix[int(h * 9.0 + w)];\n }\n }\n gl_FragColor = color;\n }\n ",
|
|
Convolute_9_0: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uMatrix[81];\n uniform float uStepW;\n uniform float uStepH;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = vec4(0, 0, 0, 1);\n for (float h = 0.0; h < 9.0; h+=1.0) {\n for (float w = 0.0; w < 9.0; w+=1.0) {\n vec2 matrixPos = vec2(uStepW * (w - 4.0), uStepH * (h - 4.0));\n color.rgb += texture2D(uTexture, vTexCoord + matrixPos).rgb * uMatrix[int(h * 9.0 + w)];\n }\n }\n float alpha = texture2D(uTexture, vTexCoord).a;\n gl_FragColor = color;\n gl_FragColor.a = alpha;\n }\n "
|
|
};
|
|
|
|
const convoluteDefaultValues = {
|
|
opaque: false,
|
|
matrix: [0, 0, 0, 0, 1, 0, 0, 0, 0]
|
|
};
|
|
|
|
/**
|
|
* Adapted from <a href="http://www.html5rocks.com/en/tutorials/canvas/imagefilters/">html5rocks article</a>
|
|
* @example <caption>Sharpen filter</caption>
|
|
* const filter = new Convolute({
|
|
* matrix: [ 0, -1, 0,
|
|
* -1, 5, -1,
|
|
* 0, -1, 0 ]
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
* canvas.renderAll();
|
|
* @example <caption>Blur filter</caption>
|
|
* const filter = new Convolute({
|
|
* matrix: [ 1/9, 1/9, 1/9,
|
|
* 1/9, 1/9, 1/9,
|
|
* 1/9, 1/9, 1/9 ]
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
* canvas.renderAll();
|
|
* @example <caption>Emboss filter</caption>
|
|
* const filter = new Convolute({
|
|
* matrix: [ 1, 1, 1,
|
|
* 1, 0.7, -1,
|
|
* -1, -1, -1 ]
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
* canvas.renderAll();
|
|
* @example <caption>Emboss filter with opaqueness</caption>
|
|
* const filter = new Convolute({
|
|
* opaque: true,
|
|
* matrix: [ 1, 1, 1,
|
|
* 1, 0.7, -1,
|
|
* -1, -1, -1 ]
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
* canvas.renderAll();
|
|
*/
|
|
class Convolute extends BaseFilter {
|
|
getCacheKey() {
|
|
return "".concat(this.type, "_").concat(Math.sqrt(this.matrix.length), "_").concat(this.opaque ? 1 : 0);
|
|
}
|
|
getFragmentSource() {
|
|
return fragmentSource$7[this.getCacheKey()];
|
|
}
|
|
|
|
/**
|
|
* Apply the Brightness operation to a Uint8ClampedArray representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8ClampedArray to be filtered.
|
|
*/
|
|
applyTo2d(options) {
|
|
const imageData = options.imageData,
|
|
data = imageData.data,
|
|
weights = this.matrix,
|
|
side = Math.round(Math.sqrt(weights.length)),
|
|
halfSide = Math.floor(side / 2),
|
|
sw = imageData.width,
|
|
sh = imageData.height,
|
|
output = options.ctx.createImageData(sw, sh),
|
|
dst = output.data,
|
|
// go through the destination image pixels
|
|
alphaFac = this.opaque ? 1 : 0;
|
|
let r, g, b, a, dstOff, scx, scy, srcOff, wt, x, y, cx, cy;
|
|
for (y = 0; y < sh; y++) {
|
|
for (x = 0; x < sw; x++) {
|
|
dstOff = (y * sw + x) * 4;
|
|
// calculate the weighed sum of the source image pixels that
|
|
// fall under the convolution matrix
|
|
r = 0;
|
|
g = 0;
|
|
b = 0;
|
|
a = 0;
|
|
for (cy = 0; cy < side; cy++) {
|
|
for (cx = 0; cx < side; cx++) {
|
|
scy = y + cy - halfSide;
|
|
scx = x + cx - halfSide;
|
|
|
|
// eslint-disable-next-line max-depth
|
|
if (scy < 0 || scy >= sh || scx < 0 || scx >= sw) {
|
|
continue;
|
|
}
|
|
srcOff = (scy * sw + scx) * 4;
|
|
wt = weights[cy * side + cx];
|
|
r += data[srcOff] * wt;
|
|
g += data[srcOff + 1] * wt;
|
|
b += data[srcOff + 2] * wt;
|
|
// eslint-disable-next-line max-depth
|
|
if (!alphaFac) {
|
|
a += data[srcOff + 3] * wt;
|
|
}
|
|
}
|
|
}
|
|
dst[dstOff] = r;
|
|
dst[dstOff + 1] = g;
|
|
dst[dstOff + 2] = b;
|
|
if (!alphaFac) {
|
|
dst[dstOff + 3] = a;
|
|
} else {
|
|
dst[dstOff + 3] = data[dstOff + 3];
|
|
}
|
|
}
|
|
}
|
|
options.imageData = output;
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform1fv(uniformLocations.uMatrix, this.matrix);
|
|
}
|
|
|
|
/**
|
|
* Returns object representation of an instance
|
|
* @return {Object} Object representation of an instance
|
|
*/
|
|
toObject() {
|
|
return _objectSpread2(_objectSpread2({}, super.toObject()), {}, {
|
|
opaque: this.opaque,
|
|
matrix: [...this.matrix]
|
|
});
|
|
}
|
|
}
|
|
/*
|
|
* Opaque value (true/false)
|
|
*/
|
|
/*
|
|
* matrix for the filter, max 9x9
|
|
*/
|
|
_defineProperty(Convolute, "type", 'Convolute');
|
|
_defineProperty(Convolute, "defaults", convoluteDefaultValues);
|
|
_defineProperty(Convolute, "uniformLocations", ['uMatrix', 'uOpaque', 'uHalfSize', 'uSize']);
|
|
classRegistry.setClass(Convolute);
|
|
|
|
const fragmentSource$6 = "\n precision highp float;\n uniform sampler2D uTexture;\n uniform vec3 uGamma;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n vec3 correction = (1.0 / uGamma);\n color.r = pow(color.r, correction.r);\n color.g = pow(color.g, correction.g);\n color.b = pow(color.b, correction.b);\n gl_FragColor = color;\n gl_FragColor.rgb *= color.a;\n }\n";
|
|
|
|
const GAMMA = 'Gamma';
|
|
const gammaDefaultValues = {
|
|
gamma: [1, 1, 1]
|
|
};
|
|
|
|
/**
|
|
* Gamma filter class
|
|
* @example
|
|
* const filter = new Gamma({
|
|
* gamma: [1, 0.5, 2.1]
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
*/
|
|
class Gamma extends BaseFilter {
|
|
getFragmentSource() {
|
|
return fragmentSource$6;
|
|
}
|
|
constructor() {
|
|
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
super(options);
|
|
this.gamma = options.gamma || this.constructor.defaults.gamma.concat();
|
|
}
|
|
|
|
/**
|
|
* Apply the Gamma operation to a Uint8Array representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8Array to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
const gamma = this.gamma,
|
|
rInv = 1 / gamma[0],
|
|
gInv = 1 / gamma[1],
|
|
bInv = 1 / gamma[2];
|
|
if (!this.rgbValues) {
|
|
this.rgbValues = {
|
|
r: new Uint8Array(256),
|
|
g: new Uint8Array(256),
|
|
b: new Uint8Array(256)
|
|
};
|
|
}
|
|
|
|
// This is an optimization - pre-compute a look-up table for each color channel
|
|
// instead of performing these pow calls for each pixel in the image.
|
|
const rgb = this.rgbValues;
|
|
for (let i = 0; i < 256; i++) {
|
|
rgb.r[i] = Math.pow(i / 255, rInv) * 255;
|
|
rgb.g[i] = Math.pow(i / 255, gInv) * 255;
|
|
rgb.b[i] = Math.pow(i / 255, bInv) * 255;
|
|
}
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
data[i] = rgb.r[data[i]];
|
|
data[i + 1] = rgb.g[data[i + 1]];
|
|
data[i + 2] = rgb.b[data[i + 2]];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform3fv(uniformLocations.uGamma, this.gamma);
|
|
}
|
|
isNeutralState() {
|
|
const {
|
|
gamma
|
|
} = this;
|
|
return gamma[0] === 1 && gamma[1] === 1 && gamma[2] === 1;
|
|
}
|
|
toObject() {
|
|
return {
|
|
type: GAMMA,
|
|
gamma: this.gamma.concat()
|
|
};
|
|
}
|
|
}
|
|
/**
|
|
* Gamma array value, from 0.01 to 2.2.
|
|
* @param {Array} gamma
|
|
* @default
|
|
*/
|
|
_defineProperty(Gamma, "type", GAMMA);
|
|
_defineProperty(Gamma, "defaults", gammaDefaultValues);
|
|
_defineProperty(Gamma, "uniformLocations", ['uGamma']);
|
|
classRegistry.setClass(Gamma);
|
|
|
|
const fragmentSource$5 = {
|
|
average: "\n precision highp float;\n uniform sampler2D uTexture;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n float average = (color.r + color.b + color.g) / 3.0;\n gl_FragColor = vec4(average, average, average, color.a);\n }\n ",
|
|
lightness: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform int uMode;\n varying vec2 vTexCoord;\n void main() {\n vec4 col = texture2D(uTexture, vTexCoord);\n float average = (max(max(col.r, col.g),col.b) + min(min(col.r, col.g),col.b)) / 2.0;\n gl_FragColor = vec4(average, average, average, col.a);\n }\n ",
|
|
luminosity: "\n precision highp float;\n uniform sampler2D uTexture;\n uniform int uMode;\n varying vec2 vTexCoord;\n void main() {\n vec4 col = texture2D(uTexture, vTexCoord);\n float average = 0.21 * col.r + 0.72 * col.g + 0.07 * col.b;\n gl_FragColor = vec4(average, average, average, col.a);\n }\n "
|
|
};
|
|
|
|
const grayscaleDefaultValues = {
|
|
mode: 'average'
|
|
};
|
|
|
|
/**
|
|
* Grayscale image filter class
|
|
* @example
|
|
* const filter = new Grayscale();
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
*/
|
|
class Grayscale extends BaseFilter {
|
|
/**
|
|
* Apply the Grayscale operation to a Uint8Array representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8Array to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
for (let i = 0, value; i < data.length; i += 4) {
|
|
switch (this.mode) {
|
|
case 'average':
|
|
value = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
break;
|
|
case 'lightness':
|
|
value = (Math.min(data[i], data[i + 1], data[i + 2]) + Math.max(data[i], data[i + 1], data[i + 2])) / 2;
|
|
break;
|
|
case 'luminosity':
|
|
value = 0.21 * data[i] + 0.72 * data[i + 1] + 0.07 * data[i + 2];
|
|
break;
|
|
}
|
|
data[i] = value;
|
|
data[i + 1] = value;
|
|
data[i + 2] = value;
|
|
}
|
|
}
|
|
getCacheKey() {
|
|
return "".concat(this.type, "_").concat(this.mode);
|
|
}
|
|
getFragmentSource() {
|
|
return fragmentSource$5[this.mode];
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
const mode = 1;
|
|
gl.uniform1i(uniformLocations.uMode, mode);
|
|
}
|
|
|
|
/**
|
|
* Grayscale filter isNeutralState implementation
|
|
* The filter is never neutral
|
|
* on the image
|
|
**/
|
|
isNeutralState() {
|
|
return false;
|
|
}
|
|
}
|
|
_defineProperty(Grayscale, "type", 'Grayscale');
|
|
_defineProperty(Grayscale, "defaults", grayscaleDefaultValues);
|
|
_defineProperty(Grayscale, "uniformLocations", ['uMode']);
|
|
classRegistry.setClass(Grayscale);
|
|
|
|
const hueRotationDefaultValues = {
|
|
rotation: 0
|
|
};
|
|
|
|
/**
|
|
* HueRotation filter class
|
|
* @example
|
|
* const filter = new HueRotation({
|
|
* rotation: -0.5
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
*/
|
|
class HueRotation extends ColorMatrix {
|
|
calculateMatrix() {
|
|
const rad = this.rotation * Math.PI,
|
|
cosine = cos(rad),
|
|
sine = sin(rad),
|
|
aThird = 1 / 3,
|
|
aThirdSqtSin = Math.sqrt(aThird) * sine,
|
|
OneMinusCos = 1 - cosine;
|
|
this.matrix = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0];
|
|
this.matrix[0] = cosine + OneMinusCos / 3;
|
|
this.matrix[1] = aThird * OneMinusCos - aThirdSqtSin;
|
|
this.matrix[2] = aThird * OneMinusCos + aThirdSqtSin;
|
|
this.matrix[5] = aThird * OneMinusCos + aThirdSqtSin;
|
|
this.matrix[6] = cosine + aThird * OneMinusCos;
|
|
this.matrix[7] = aThird * OneMinusCos - aThirdSqtSin;
|
|
this.matrix[10] = aThird * OneMinusCos - aThirdSqtSin;
|
|
this.matrix[11] = aThird * OneMinusCos + aThirdSqtSin;
|
|
this.matrix[12] = cosine + aThird * OneMinusCos;
|
|
}
|
|
isNeutralState() {
|
|
return this.rotation === 0;
|
|
}
|
|
applyTo(options) {
|
|
this.calculateMatrix();
|
|
super.applyTo(options);
|
|
}
|
|
|
|
//@ts-expect-error TS and classes with different methods
|
|
toObject() {
|
|
return {
|
|
type: this.type,
|
|
rotation: this.rotation
|
|
};
|
|
}
|
|
}
|
|
/**
|
|
* HueRotation value, from -1 to 1.
|
|
*/
|
|
_defineProperty(HueRotation, "type", 'HueRotation');
|
|
_defineProperty(HueRotation, "defaults", hueRotationDefaultValues);
|
|
classRegistry.setClass(HueRotation);
|
|
|
|
const fragmentSource$4 = "\n precision highp float;\n uniform sampler2D uTexture;\n uniform int uInvert;\n uniform int uAlpha;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n if (uInvert == 1) {\n if (uAlpha == 1) {\n gl_FragColor = vec4(1.0 - color.r,1.0 -color.g,1.0 -color.b,1.0 -color.a);\n } else {\n gl_FragColor = vec4(1.0 - color.r,1.0 -color.g,1.0 -color.b,color.a);\n }\n } else {\n gl_FragColor = color;\n }\n }\n";
|
|
|
|
const invertDefaultValues = {
|
|
alpha: false,
|
|
invert: true
|
|
};
|
|
|
|
/**
|
|
* @example
|
|
* const filter = new Invert();
|
|
* object.filters.push(filter);
|
|
* object.applyFilters(canvas.renderAll.bind(canvas));
|
|
*/
|
|
class Invert extends BaseFilter {
|
|
/**
|
|
* Apply the Invert operation to a Uint8Array representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8Array to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
data[i] = 255 - data[i];
|
|
data[i + 1] = 255 - data[i + 1];
|
|
data[i + 2] = 255 - data[i + 2];
|
|
if (this.alpha) {
|
|
data[i + 3] = 255 - data[i + 3];
|
|
}
|
|
}
|
|
}
|
|
getFragmentSource() {
|
|
return fragmentSource$4;
|
|
}
|
|
|
|
/**
|
|
* Invert filter isNeutralState implementation
|
|
* Used only in image applyFilters to discard filters that will not have an effect
|
|
* on the image
|
|
* @param {Object} options
|
|
**/
|
|
isNeutralState() {
|
|
return !this.invert;
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform1i(uniformLocations.uInvert, Number(this.invert));
|
|
gl.uniform1i(uniformLocations.uAlpha, Number(this.alpha));
|
|
}
|
|
}
|
|
/**
|
|
* Invert also alpha.
|
|
* @param {Boolean} alpha
|
|
* @default
|
|
**/
|
|
/**
|
|
* Filter invert. if false, does nothing
|
|
* @param {Boolean} invert
|
|
* @default
|
|
*/
|
|
_defineProperty(Invert, "type", 'Invert');
|
|
_defineProperty(Invert, "defaults", invertDefaultValues);
|
|
_defineProperty(Invert, "uniformLocations", ['uInvert', 'uAlpha']);
|
|
classRegistry.setClass(Invert);
|
|
|
|
const fragmentSource$3 = "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uStepH;\n uniform float uNoise;\n uniform float uSeed;\n varying vec2 vTexCoord;\n float rand(vec2 co, float seed, float vScale) {\n return fract(sin(dot(co.xy * vScale ,vec2(12.9898 , 78.233))) * 43758.5453 * (seed + 0.01) / 2.0);\n }\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n color.rgb += (0.5 - rand(vTexCoord, uSeed, 0.1 / uStepH)) * uNoise;\n gl_FragColor = color;\n }\n";
|
|
|
|
const noiseDefaultValues = {
|
|
noise: 0
|
|
};
|
|
|
|
/**
|
|
* Noise filter class
|
|
* @example
|
|
* const filter = new Noise({
|
|
* noise: 700
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
* canvas.renderAll();
|
|
*/
|
|
class Noise extends BaseFilter {
|
|
getFragmentSource() {
|
|
return fragmentSource$3;
|
|
}
|
|
|
|
/**
|
|
* Apply the Brightness operation to a Uint8ClampedArray representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8ClampedArray to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
const noise = this.noise;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const rand = (0.5 - Math.random()) * noise;
|
|
data[i] += rand;
|
|
data[i + 1] += rand;
|
|
data[i + 2] += rand;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform1f(uniformLocations.uNoise, this.noise / 255);
|
|
gl.uniform1f(uniformLocations.uSeed, Math.random());
|
|
}
|
|
isNeutralState() {
|
|
return this.noise === 0;
|
|
}
|
|
}
|
|
/**
|
|
* Noise value, from
|
|
* @param {Number} noise
|
|
* @default
|
|
*/
|
|
_defineProperty(Noise, "type", 'Noise');
|
|
_defineProperty(Noise, "defaults", noiseDefaultValues);
|
|
_defineProperty(Noise, "uniformLocations", ['uNoise', 'uSeed']);
|
|
classRegistry.setClass(Noise);
|
|
|
|
const fragmentSource$2 = "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uBlocksize;\n uniform float uStepW;\n uniform float uStepH;\n varying vec2 vTexCoord;\n void main() {\n float blockW = uBlocksize * uStepW;\n float blockH = uBlocksize * uStepH;\n int posX = int(vTexCoord.x / blockW);\n int posY = int(vTexCoord.y / blockH);\n float fposX = float(posX);\n float fposY = float(posY);\n vec2 squareCoords = vec2(fposX * blockW, fposY * blockH);\n vec4 color = texture2D(uTexture, squareCoords);\n gl_FragColor = color;\n }\n";
|
|
|
|
const pixelateDefaultValues = {
|
|
blocksize: 4
|
|
};
|
|
|
|
/**
|
|
* Pixelate filter class
|
|
* @example
|
|
* const filter = new Pixelate({
|
|
* blocksize: 8
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
*/
|
|
class Pixelate extends BaseFilter {
|
|
/**
|
|
* Apply the Pixelate operation to a Uint8ClampedArray representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8ClampedArray to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data,
|
|
width,
|
|
height
|
|
}
|
|
} = _ref;
|
|
for (let i = 0; i < height; i += this.blocksize) {
|
|
for (let j = 0; j < width; j += this.blocksize) {
|
|
const index = i * 4 * width + j * 4;
|
|
const r = data[index];
|
|
const g = data[index + 1];
|
|
const b = data[index + 2];
|
|
const a = data[index + 3];
|
|
for (let _i = i; _i < Math.min(i + this.blocksize, height); _i++) {
|
|
for (let _j = j; _j < Math.min(j + this.blocksize, width); _j++) {
|
|
const index = _i * 4 * width + _j * 4;
|
|
data[index] = r;
|
|
data[index + 1] = g;
|
|
data[index + 2] = b;
|
|
data[index + 3] = a;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Indicate when the filter is not gonna apply changes to the image
|
|
**/
|
|
isNeutralState() {
|
|
return this.blocksize === 1;
|
|
}
|
|
getFragmentSource() {
|
|
return fragmentSource$2;
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform1f(uniformLocations.uBlocksize, this.blocksize);
|
|
}
|
|
}
|
|
_defineProperty(Pixelate, "type", 'Pixelate');
|
|
_defineProperty(Pixelate, "defaults", pixelateDefaultValues);
|
|
_defineProperty(Pixelate, "uniformLocations", ['uBlocksize']);
|
|
classRegistry.setClass(Pixelate);
|
|
|
|
const fragmentShader = "\nprecision highp float;\nuniform sampler2D uTexture;\nuniform vec4 uLow;\nuniform vec4 uHigh;\nvarying vec2 vTexCoord;\nvoid main() {\n gl_FragColor = texture2D(uTexture, vTexCoord);\n if(all(greaterThan(gl_FragColor.rgb,uLow.rgb)) && all(greaterThan(uHigh.rgb,gl_FragColor.rgb))) {\n gl_FragColor.a = 0.0;\n }\n}\n";
|
|
|
|
const removeColorDefaultValues = {
|
|
color: '#FFFFFF',
|
|
distance: 0.02,
|
|
useAlpha: false
|
|
};
|
|
|
|
/**
|
|
* Remove white filter class
|
|
* @example
|
|
* const filter = new RemoveColor({
|
|
* threshold: 0.2,
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
* canvas.renderAll();
|
|
*/
|
|
class RemoveColor extends BaseFilter {
|
|
getFragmentSource() {
|
|
return fragmentShader;
|
|
}
|
|
|
|
/**
|
|
* Applies filter to canvas element
|
|
* @param {Object} canvasEl Canvas element to apply filter to
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
const distance = this.distance * 255,
|
|
source = new Color(this.color).getSource(),
|
|
lowC = [source[0] - distance, source[1] - distance, source[2] - distance],
|
|
highC = [source[0] + distance, source[1] + distance, source[2] + distance];
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const r = data[i];
|
|
const g = data[i + 1];
|
|
const b = data[i + 2];
|
|
if (r > lowC[0] && g > lowC[1] && b > lowC[2] && r < highC[0] && g < highC[1] && b < highC[2]) {
|
|
data[i + 3] = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
const source = new Color(this.color).getSource(),
|
|
distance = this.distance,
|
|
lowC = [0 + source[0] / 255 - distance, 0 + source[1] / 255 - distance, 0 + source[2] / 255 - distance, 1],
|
|
highC = [source[0] / 255 + distance, source[1] / 255 + distance, source[2] / 255 + distance, 1];
|
|
gl.uniform4fv(uniformLocations.uLow, lowC);
|
|
gl.uniform4fv(uniformLocations.uHigh, highC);
|
|
}
|
|
}
|
|
/**
|
|
* Color to remove, in any format understood by {@link Color}.
|
|
* @param {String} type
|
|
* @default
|
|
*/
|
|
/**
|
|
* distance to actual color, as value up or down from each r,g,b
|
|
* between 0 and 1
|
|
**/
|
|
/**
|
|
* For color to remove inside distance, use alpha channel for a smoother deletion
|
|
* NOT IMPLEMENTED YET
|
|
**/
|
|
_defineProperty(RemoveColor, "type", 'RemoveColor');
|
|
_defineProperty(RemoveColor, "defaults", removeColorDefaultValues);
|
|
_defineProperty(RemoveColor, "uniformLocations", ['uLow', 'uHigh']);
|
|
classRegistry.setClass(RemoveColor);
|
|
|
|
const resizeDefaultValues = {
|
|
resizeType: 'hermite',
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
lanczosLobes: 3
|
|
};
|
|
/**
|
|
* Resize image filter class
|
|
* @example
|
|
* const filter = new Resize();
|
|
* object.filters.push(filter);
|
|
* object.applyFilters(canvas.renderAll.bind(canvas));
|
|
*/
|
|
class Resize extends BaseFilter {
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform2fv(uniformLocations.uDelta, this.horizontal ? [1 / this.width, 0] : [0, 1 / this.height]);
|
|
gl.uniform1fv(uniformLocations.uTaps, this.taps);
|
|
}
|
|
getFilterWindow() {
|
|
const scale = this.tempScale;
|
|
return Math.ceil(this.lanczosLobes / scale);
|
|
}
|
|
getCacheKey() {
|
|
const filterWindow = this.getFilterWindow();
|
|
return "".concat(this.type, "_").concat(filterWindow);
|
|
}
|
|
getFragmentSource() {
|
|
const filterWindow = this.getFilterWindow();
|
|
return this.generateShader(filterWindow);
|
|
}
|
|
getTaps() {
|
|
const lobeFunction = this.lanczosCreate(this.lanczosLobes),
|
|
scale = this.tempScale,
|
|
filterWindow = this.getFilterWindow(),
|
|
taps = new Array(filterWindow);
|
|
for (let i = 1; i <= filterWindow; i++) {
|
|
taps[i - 1] = lobeFunction(i * scale);
|
|
}
|
|
return taps;
|
|
}
|
|
|
|
/**
|
|
* Generate vertex and shader sources from the necessary steps numbers
|
|
* @param {Number} filterWindow
|
|
*/
|
|
generateShader(filterWindow) {
|
|
const offsets = new Array(filterWindow);
|
|
for (let i = 1; i <= filterWindow; i++) {
|
|
offsets[i - 1] = "".concat(i, ".0 * uDelta");
|
|
}
|
|
return "\n precision highp float;\n uniform sampler2D uTexture;\n uniform vec2 uDelta;\n varying vec2 vTexCoord;\n uniform float uTaps[".concat(filterWindow, "];\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n float sum = 1.0;\n ").concat(offsets.map((offset, i) => "\n color += texture2D(uTexture, vTexCoord + ".concat(offset, ") * uTaps[").concat(i, "] + texture2D(uTexture, vTexCoord - ").concat(offset, ") * uTaps[").concat(i, "];\n sum += 2.0 * uTaps[").concat(i, "];\n ")).join('\n'), "\n gl_FragColor = color / sum;\n }\n ");
|
|
}
|
|
applyToForWebgl(options) {
|
|
options.passes++;
|
|
this.width = options.sourceWidth;
|
|
this.horizontal = true;
|
|
this.dW = Math.round(this.width * this.scaleX);
|
|
this.dH = options.sourceHeight;
|
|
this.tempScale = this.dW / this.width;
|
|
this.taps = this.getTaps();
|
|
options.destinationWidth = this.dW;
|
|
super.applyTo(options);
|
|
options.sourceWidth = options.destinationWidth;
|
|
this.height = options.sourceHeight;
|
|
this.horizontal = false;
|
|
this.dH = Math.round(this.height * this.scaleY);
|
|
this.tempScale = this.dH / this.height;
|
|
this.taps = this.getTaps();
|
|
options.destinationHeight = this.dH;
|
|
super.applyTo(options);
|
|
options.sourceHeight = options.destinationHeight;
|
|
}
|
|
|
|
/**
|
|
* Apply the resize filter to the image
|
|
* Determines whether to use WebGL or Canvas2D based on the options.webgl flag.
|
|
*
|
|
* @param {Object} options
|
|
* @param {Number} options.passes The number of filters remaining to be executed
|
|
* @param {Boolean} options.webgl Whether to use webgl to render the filter.
|
|
* @param {WebGLTexture} options.sourceTexture The texture setup as the source to be filtered.
|
|
* @param {WebGLTexture} options.targetTexture The texture where filtered output should be drawn.
|
|
* @param {WebGLRenderingContext} options.context The GL context used for rendering.
|
|
* @param {Object} options.programCache A map of compiled shader programs, keyed by filter type.
|
|
*/
|
|
applyTo(options) {
|
|
if (isWebGLPipelineState(options)) {
|
|
this.applyToForWebgl(options);
|
|
} else {
|
|
this.applyTo2d(options);
|
|
}
|
|
}
|
|
isNeutralState() {
|
|
return this.scaleX === 1 && this.scaleY === 1;
|
|
}
|
|
lanczosCreate(lobes) {
|
|
return x => {
|
|
if (x >= lobes || x <= -lobes) {
|
|
return 0.0;
|
|
}
|
|
if (x < 1.1920929e-7 && x > -1.1920929e-7) {
|
|
return 1.0;
|
|
}
|
|
x *= Math.PI;
|
|
const xx = x / lobes;
|
|
return Math.sin(x) / x * Math.sin(xx) / xx;
|
|
};
|
|
}
|
|
applyTo2d(options) {
|
|
const imageData = options.imageData,
|
|
scaleX = this.scaleX,
|
|
scaleY = this.scaleY;
|
|
this.rcpScaleX = 1 / scaleX;
|
|
this.rcpScaleY = 1 / scaleY;
|
|
const oW = imageData.width;
|
|
const oH = imageData.height;
|
|
const dW = Math.round(oW * scaleX);
|
|
const dH = Math.round(oH * scaleY);
|
|
let newData;
|
|
if (this.resizeType === 'sliceHack') {
|
|
newData = this.sliceByTwo(options, oW, oH, dW, dH);
|
|
} else if (this.resizeType === 'hermite') {
|
|
newData = this.hermiteFastResize(options, oW, oH, dW, dH);
|
|
} else if (this.resizeType === 'bilinear') {
|
|
newData = this.bilinearFiltering(options, oW, oH, dW, dH);
|
|
} else if (this.resizeType === 'lanczos') {
|
|
newData = this.lanczosResize(options, oW, oH, dW, dH);
|
|
} else {
|
|
// this should never trigger, is here just for safety net.
|
|
newData = new ImageData(dW, dH);
|
|
}
|
|
options.imageData = newData;
|
|
}
|
|
|
|
/**
|
|
* Filter sliceByTwo
|
|
* @param {Object} canvasEl Canvas element to apply filter to
|
|
* @param {Number} oW Original Width
|
|
* @param {Number} oH Original Height
|
|
* @param {Number} dW Destination Width
|
|
* @param {Number} dH Destination Height
|
|
* @returns {ImageData}
|
|
*/
|
|
sliceByTwo(options, oW, oH, dW, dH) {
|
|
const imageData = options.imageData;
|
|
const mult = 0.5;
|
|
let doneW = false;
|
|
let doneH = false;
|
|
let stepW = oW * mult;
|
|
let stepH = oH * mult;
|
|
const resources = options.filterBackend.resources;
|
|
let sX = 0;
|
|
let sY = 0;
|
|
const dX = oW;
|
|
let dY = 0;
|
|
if (!resources.sliceByTwo) {
|
|
resources.sliceByTwo = createCanvasElement();
|
|
}
|
|
const tmpCanvas = resources.sliceByTwo;
|
|
if (tmpCanvas.width < oW * 1.5 || tmpCanvas.height < oH) {
|
|
tmpCanvas.width = oW * 1.5;
|
|
tmpCanvas.height = oH;
|
|
}
|
|
const ctx = tmpCanvas.getContext('2d');
|
|
ctx.clearRect(0, 0, oW * 1.5, oH);
|
|
ctx.putImageData(imageData, 0, 0);
|
|
dW = Math.floor(dW);
|
|
dH = Math.floor(dH);
|
|
while (!doneW || !doneH) {
|
|
oW = stepW;
|
|
oH = stepH;
|
|
if (dW < Math.floor(stepW * mult)) {
|
|
stepW = Math.floor(stepW * mult);
|
|
} else {
|
|
stepW = dW;
|
|
doneW = true;
|
|
}
|
|
if (dH < Math.floor(stepH * mult)) {
|
|
stepH = Math.floor(stepH * mult);
|
|
} else {
|
|
stepH = dH;
|
|
doneH = true;
|
|
}
|
|
ctx.drawImage(tmpCanvas, sX, sY, oW, oH, dX, dY, stepW, stepH);
|
|
sX = dX;
|
|
sY = dY;
|
|
dY += stepH;
|
|
}
|
|
return ctx.getImageData(sX, sY, dW, dH);
|
|
}
|
|
|
|
/**
|
|
* Filter lanczosResize
|
|
* @param {Object} canvasEl Canvas element to apply filter to
|
|
* @param {Number} oW Original Width
|
|
* @param {Number} oH Original Height
|
|
* @param {Number} dW Destination Width
|
|
* @param {Number} dH Destination Height
|
|
* @returns {ImageData}
|
|
*/
|
|
lanczosResize(options, oW, oH, dW, dH) {
|
|
function process(u) {
|
|
let v, i, weight, idx, a, red, green, blue, alpha, fX, fY;
|
|
center.x = (u + 0.5) * ratioX;
|
|
icenter.x = Math.floor(center.x);
|
|
for (v = 0; v < dH; v++) {
|
|
center.y = (v + 0.5) * ratioY;
|
|
icenter.y = Math.floor(center.y);
|
|
a = 0;
|
|
red = 0;
|
|
green = 0;
|
|
blue = 0;
|
|
alpha = 0;
|
|
for (i = icenter.x - range2X; i <= icenter.x + range2X; i++) {
|
|
if (i < 0 || i >= oW) {
|
|
continue;
|
|
}
|
|
fX = Math.floor(1000 * Math.abs(i - center.x));
|
|
if (!cacheLanc[fX]) {
|
|
cacheLanc[fX] = {};
|
|
}
|
|
for (let j = icenter.y - range2Y; j <= icenter.y + range2Y; j++) {
|
|
if (j < 0 || j >= oH) {
|
|
continue;
|
|
}
|
|
fY = Math.floor(1000 * Math.abs(j - center.y));
|
|
if (!cacheLanc[fX][fY]) {
|
|
cacheLanc[fX][fY] = lanczos(Math.sqrt(Math.pow(fX * rcpRatioX, 2) + Math.pow(fY * rcpRatioY, 2)) / 1000);
|
|
}
|
|
weight = cacheLanc[fX][fY];
|
|
if (weight > 0) {
|
|
idx = (j * oW + i) * 4;
|
|
a += weight;
|
|
red += weight * srcData[idx];
|
|
green += weight * srcData[idx + 1];
|
|
blue += weight * srcData[idx + 2];
|
|
alpha += weight * srcData[idx + 3];
|
|
}
|
|
}
|
|
}
|
|
idx = (v * dW + u) * 4;
|
|
destData[idx] = red / a;
|
|
destData[idx + 1] = green / a;
|
|
destData[idx + 2] = blue / a;
|
|
destData[idx + 3] = alpha / a;
|
|
}
|
|
if (++u < dW) {
|
|
return process(u);
|
|
} else {
|
|
return destImg;
|
|
}
|
|
}
|
|
const srcData = options.imageData.data,
|
|
destImg = options.ctx.createImageData(dW, dH),
|
|
destData = destImg.data,
|
|
lanczos = this.lanczosCreate(this.lanczosLobes),
|
|
ratioX = this.rcpScaleX,
|
|
ratioY = this.rcpScaleY,
|
|
rcpRatioX = 2 / this.rcpScaleX,
|
|
rcpRatioY = 2 / this.rcpScaleY,
|
|
range2X = Math.ceil(ratioX * this.lanczosLobes / 2),
|
|
range2Y = Math.ceil(ratioY * this.lanczosLobes / 2),
|
|
cacheLanc = {},
|
|
center = {
|
|
x: 0,
|
|
y: 0
|
|
},
|
|
icenter = {
|
|
x: 0,
|
|
y: 0
|
|
};
|
|
return process(0);
|
|
}
|
|
|
|
/**
|
|
* bilinearFiltering
|
|
* @param {Object} canvasEl Canvas element to apply filter to
|
|
* @param {Number} oW Original Width
|
|
* @param {Number} oH Original Height
|
|
* @param {Number} dW Destination Width
|
|
* @param {Number} dH Destination Height
|
|
* @returns {ImageData}
|
|
*/
|
|
bilinearFiltering(options, oW, oH, dW, dH) {
|
|
let a;
|
|
let b;
|
|
let c;
|
|
let d;
|
|
let x;
|
|
let y;
|
|
let i;
|
|
let j;
|
|
let xDiff;
|
|
let yDiff;
|
|
let chnl;
|
|
let color;
|
|
let offset = 0;
|
|
let origPix;
|
|
const ratioX = this.rcpScaleX;
|
|
const ratioY = this.rcpScaleY;
|
|
const w4 = 4 * (oW - 1);
|
|
const img = options.imageData;
|
|
const pixels = img.data;
|
|
const destImage = options.ctx.createImageData(dW, dH);
|
|
const destPixels = destImage.data;
|
|
for (i = 0; i < dH; i++) {
|
|
for (j = 0; j < dW; j++) {
|
|
x = Math.floor(ratioX * j);
|
|
y = Math.floor(ratioY * i);
|
|
xDiff = ratioX * j - x;
|
|
yDiff = ratioY * i - y;
|
|
origPix = 4 * (y * oW + x);
|
|
for (chnl = 0; chnl < 4; chnl++) {
|
|
a = pixels[origPix + chnl];
|
|
b = pixels[origPix + 4 + chnl];
|
|
c = pixels[origPix + w4 + chnl];
|
|
d = pixels[origPix + w4 + 4 + chnl];
|
|
color = a * (1 - xDiff) * (1 - yDiff) + b * xDiff * (1 - yDiff) + c * yDiff * (1 - xDiff) + d * xDiff * yDiff;
|
|
destPixels[offset++] = color;
|
|
}
|
|
}
|
|
}
|
|
return destImage;
|
|
}
|
|
|
|
/**
|
|
* hermiteFastResize
|
|
* @param {Object} canvasEl Canvas element to apply filter to
|
|
* @param {Number} oW Original Width
|
|
* @param {Number} oH Original Height
|
|
* @param {Number} dW Destination Width
|
|
* @param {Number} dH Destination Height
|
|
* @returns {ImageData}
|
|
*/
|
|
hermiteFastResize(options, oW, oH, dW, dH) {
|
|
const ratioW = this.rcpScaleX,
|
|
ratioH = this.rcpScaleY,
|
|
ratioWHalf = Math.ceil(ratioW / 2),
|
|
ratioHHalf = Math.ceil(ratioH / 2),
|
|
img = options.imageData,
|
|
data = img.data,
|
|
img2 = options.ctx.createImageData(dW, dH),
|
|
data2 = img2.data;
|
|
for (let j = 0; j < dH; j++) {
|
|
for (let i = 0; i < dW; i++) {
|
|
const x2 = (i + j * dW) * 4;
|
|
let weight = 0;
|
|
let weights = 0;
|
|
let weightsAlpha = 0;
|
|
let gxR = 0;
|
|
let gxG = 0;
|
|
let gxB = 0;
|
|
let gxA = 0;
|
|
const centerY = (j + 0.5) * ratioH;
|
|
for (let yy = Math.floor(j * ratioH); yy < (j + 1) * ratioH; yy++) {
|
|
const dy = Math.abs(centerY - (yy + 0.5)) / ratioHHalf,
|
|
centerX = (i + 0.5) * ratioW,
|
|
w0 = dy * dy;
|
|
for (let xx = Math.floor(i * ratioW); xx < (i + 1) * ratioW; xx++) {
|
|
let dx = Math.abs(centerX - (xx + 0.5)) / ratioWHalf;
|
|
const w = Math.sqrt(w0 + dx * dx);
|
|
/* eslint-disable max-depth */
|
|
if (w > 1 && w < -1) {
|
|
continue;
|
|
}
|
|
//hermite filter
|
|
weight = 2 * w * w * w - 3 * w * w + 1;
|
|
if (weight > 0) {
|
|
dx = 4 * (xx + yy * oW);
|
|
//alpha
|
|
gxA += weight * data[dx + 3];
|
|
weightsAlpha += weight;
|
|
//colors
|
|
if (data[dx + 3] < 255) {
|
|
weight = weight * data[dx + 3] / 250;
|
|
}
|
|
gxR += weight * data[dx];
|
|
gxG += weight * data[dx + 1];
|
|
gxB += weight * data[dx + 2];
|
|
weights += weight;
|
|
}
|
|
/* eslint-enable max-depth */
|
|
}
|
|
}
|
|
data2[x2] = gxR / weights;
|
|
data2[x2 + 1] = gxG / weights;
|
|
data2[x2 + 2] = gxB / weights;
|
|
data2[x2 + 3] = gxA / weightsAlpha;
|
|
}
|
|
}
|
|
return img2;
|
|
}
|
|
}
|
|
/**
|
|
* Resize type
|
|
* for webgl resizeType is just lanczos, for canvas2d can be:
|
|
* bilinear, hermite, sliceHack, lanczos.
|
|
* @default
|
|
*/
|
|
/**
|
|
* Scale factor for resizing, x axis
|
|
* @param {Number} scaleX
|
|
* @default
|
|
*/
|
|
/**
|
|
* Scale factor for resizing, y axis
|
|
* @param {Number} scaleY
|
|
* @default
|
|
*/
|
|
/**
|
|
* LanczosLobes parameter for lanczos filter, valid for resizeType lanczos
|
|
* @param {Number} lanczosLobes
|
|
* @default
|
|
*/
|
|
_defineProperty(Resize, "type", 'Resize');
|
|
_defineProperty(Resize, "defaults", resizeDefaultValues);
|
|
_defineProperty(Resize, "uniformLocations", ['uDelta', 'uTaps']);
|
|
classRegistry.setClass(Resize);
|
|
|
|
const fragmentSource$1 = "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uSaturation;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n float rgMax = max(color.r, color.g);\n float rgbMax = max(rgMax, color.b);\n color.r += rgbMax != color.r ? (rgbMax - color.r) * uSaturation : 0.00;\n color.g += rgbMax != color.g ? (rgbMax - color.g) * uSaturation : 0.00;\n color.b += rgbMax != color.b ? (rgbMax - color.b) * uSaturation : 0.00;\n gl_FragColor = color;\n }\n";
|
|
|
|
const saturationDefaultValues = {
|
|
saturation: 0
|
|
};
|
|
|
|
/**
|
|
* Saturate filter class
|
|
* @example
|
|
* const filter = new Saturation({
|
|
* saturation: 1
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
*/
|
|
class Saturation extends BaseFilter {
|
|
getFragmentSource() {
|
|
return fragmentSource$1;
|
|
}
|
|
|
|
/**
|
|
* Apply the Saturation operation to a Uint8ClampedArray representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8ClampedArray to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
const adjust = -this.saturation;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const max = Math.max(data[i], data[i + 1], data[i + 2]);
|
|
data[i] += max !== data[i] ? (max - data[i]) * adjust : 0;
|
|
data[i + 1] += max !== data[i + 1] ? (max - data[i + 1]) * adjust : 0;
|
|
data[i + 2] += max !== data[i + 2] ? (max - data[i + 2]) * adjust : 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {Object} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform1f(uniformLocations.uSaturation, -this.saturation);
|
|
}
|
|
isNeutralState() {
|
|
return this.saturation === 0;
|
|
}
|
|
}
|
|
/**
|
|
* Saturation value, from -1 to 1.
|
|
* Increases/decreases the color saturation.
|
|
* A value of 0 has no effect.
|
|
*
|
|
* @param {Number} saturation
|
|
* @default
|
|
*/
|
|
_defineProperty(Saturation, "type", 'Saturation');
|
|
_defineProperty(Saturation, "defaults", saturationDefaultValues);
|
|
_defineProperty(Saturation, "uniformLocations", ['uSaturation']);
|
|
classRegistry.setClass(Saturation);
|
|
|
|
const fragmentSource = "\n precision highp float;\n uniform sampler2D uTexture;\n uniform float uVibrance;\n varying vec2 vTexCoord;\n void main() {\n vec4 color = texture2D(uTexture, vTexCoord);\n float max = max(color.r, max(color.g, color.b));\n float avg = (color.r + color.g + color.b) / 3.0;\n float amt = (abs(max - avg) * 2.0) * uVibrance;\n color.r += max != color.r ? (max - color.r) * amt : 0.00;\n color.g += max != color.g ? (max - color.g) * amt : 0.00;\n color.b += max != color.b ? (max - color.b) * amt : 0.00;\n gl_FragColor = color;\n }\n";
|
|
|
|
const vibranceDefaultValues = {
|
|
vibrance: 0
|
|
};
|
|
|
|
/**
|
|
* Vibrance filter class
|
|
* @example
|
|
* const filter = new Vibrance({
|
|
* vibrance: 1
|
|
* });
|
|
* object.filters.push(filter);
|
|
* object.applyFilters();
|
|
*/
|
|
class Vibrance extends BaseFilter {
|
|
getFragmentSource() {
|
|
return fragmentSource;
|
|
}
|
|
|
|
/**
|
|
* Apply the Vibrance operation to a Uint8ClampedArray representing the pixels of an image.
|
|
*
|
|
* @param {Object} options
|
|
* @param {ImageData} options.imageData The Uint8ClampedArray to be filtered.
|
|
*/
|
|
applyTo2d(_ref) {
|
|
let {
|
|
imageData: {
|
|
data
|
|
}
|
|
} = _ref;
|
|
const adjust = -this.vibrance;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const max = Math.max(data[i], data[i + 1], data[i + 2]);
|
|
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
const amt = Math.abs(max - avg) * 2 / 255 * adjust;
|
|
data[i] += max !== data[i] ? (max - data[i]) * amt : 0;
|
|
data[i + 1] += max !== data[i + 1] ? (max - data[i + 1]) * amt : 0;
|
|
data[i + 2] += max !== data[i + 2] ? (max - data[i + 2]) * amt : 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send data from this filter to its shader program's uniforms.
|
|
*
|
|
* @param {WebGLRenderingContext} gl The GL canvas context used to compile this filter's shader.
|
|
* @param {TWebGLUniformLocationMap} uniformLocations A map of string uniform names to WebGLUniformLocation objects
|
|
*/
|
|
sendUniformData(gl, uniformLocations) {
|
|
gl.uniform1f(uniformLocations.uVibrance, -this.vibrance);
|
|
}
|
|
isNeutralState() {
|
|
return this.vibrance === 0;
|
|
}
|
|
}
|
|
/**
|
|
* Vibrance value, from -1 to 1.
|
|
* Increases/decreases the saturation of more muted colors with less effect on saturated colors.
|
|
* A value of 0 has no effect.
|
|
*
|
|
* @param {Number} vibrance
|
|
* @default
|
|
*/
|
|
_defineProperty(Vibrance, "type", 'Vibrance');
|
|
_defineProperty(Vibrance, "defaults", vibranceDefaultValues);
|
|
_defineProperty(Vibrance, "uniformLocations", ['uVibrance']);
|
|
classRegistry.setClass(Vibrance);
|
|
|
|
var filters = /*#__PURE__*/Object.freeze({
|
|
__proto__: null,
|
|
BaseFilter: BaseFilter,
|
|
BlackWhite: BlackWhite,
|
|
BlendColor: BlendColor,
|
|
BlendImage: BlendImage,
|
|
Blur: Blur,
|
|
Brightness: Brightness,
|
|
Brownie: Brownie,
|
|
ColorMatrix: ColorMatrix,
|
|
Composed: Composed,
|
|
Contrast: Contrast,
|
|
Convolute: Convolute,
|
|
Gamma: Gamma,
|
|
Grayscale: Grayscale,
|
|
HueRotation: HueRotation,
|
|
Invert: Invert,
|
|
Kodachrome: Kodachrome,
|
|
Noise: Noise,
|
|
Pixelate: Pixelate,
|
|
Polaroid: Polaroid,
|
|
RemoveColor: RemoveColor,
|
|
Resize: Resize,
|
|
Saturation: Saturation,
|
|
Sepia: Sepia,
|
|
Technicolor: Technicolor,
|
|
Vibrance: Vibrance,
|
|
Vintage: Vintage
|
|
});
|
|
|
|
exports.ActiveSelection = ActiveSelection;
|
|
exports.BaseBrush = BaseBrush;
|
|
exports.BaseFabricObject = FabricObject$1;
|
|
exports.Canvas = Canvas;
|
|
exports.Canvas2dFilterBackend = Canvas2dFilterBackend;
|
|
exports.CanvasDOMManager = CanvasDOMManager;
|
|
exports.Circle = Circle;
|
|
exports.CircleBrush = CircleBrush;
|
|
exports.ClipPathLayout = ClipPathLayout;
|
|
exports.Color = Color;
|
|
exports.Control = Control;
|
|
exports.Ellipse = Ellipse;
|
|
exports.FabricImage = FabricImage;
|
|
exports.FabricObject = FabricObject;
|
|
exports.FabricText = FabricText;
|
|
exports.FitContentLayout = FitContentLayout;
|
|
exports.FixedLayout = FixedLayout;
|
|
exports.Gradient = Gradient;
|
|
exports.Group = Group;
|
|
exports.IText = IText;
|
|
exports.Image = FabricImage;
|
|
exports.InteractiveFabricObject = InteractiveFabricObject;
|
|
exports.Intersection = Intersection;
|
|
exports.LayoutManager = LayoutManager;
|
|
exports.LayoutStrategy = LayoutStrategy;
|
|
exports.Line = Line;
|
|
exports.Object = FabricObject;
|
|
exports.Observable = Observable;
|
|
exports.Path = Path;
|
|
exports.Pattern = Pattern;
|
|
exports.PatternBrush = PatternBrush;
|
|
exports.PencilBrush = PencilBrush;
|
|
exports.Point = Point;
|
|
exports.Polygon = Polygon;
|
|
exports.Polyline = Polyline;
|
|
exports.Rect = Rect;
|
|
exports.Shadow = Shadow;
|
|
exports.SprayBrush = SprayBrush;
|
|
exports.StaticCanvas = StaticCanvas;
|
|
exports.StaticCanvasDOMManager = StaticCanvasDOMManager;
|
|
exports.Text = FabricText;
|
|
exports.Textbox = Textbox;
|
|
exports.Triangle = Triangle;
|
|
exports.WebGLFilterBackend = WebGLFilterBackend;
|
|
exports.cache = cache;
|
|
exports.classRegistry = classRegistry;
|
|
exports.config = config;
|
|
exports.controlsUtils = index;
|
|
exports.createCollectionMixin = createCollectionMixin;
|
|
exports.filters = filters;
|
|
exports.getCSSRules = getCSSRules;
|
|
exports.getEnv = getEnv;
|
|
exports.getFabricDocument = getFabricDocument;
|
|
exports.getFabricWindow = getFabricWindow;
|
|
exports.getFilterBackend = getFilterBackend;
|
|
exports.iMatrix = iMatrix;
|
|
exports.initFilterBackend = initFilterBackend;
|
|
exports.isPutImageFaster = isPutImageFaster;
|
|
exports.isWebGLPipelineState = isWebGLPipelineState;
|
|
exports.loadSVGFromString = loadSVGFromString;
|
|
exports.loadSVGFromURL = loadSVGFromURL;
|
|
exports.parseAttributes = parseAttributes;
|
|
exports.parseFontDeclaration = parseFontDeclaration;
|
|
exports.parsePointsAttribute = parsePointsAttribute;
|
|
exports.parseSVGDocument = parseSVGDocument;
|
|
exports.parseStyleAttribute = parseStyleAttribute;
|
|
exports.parseTransformAttribute = parseTransformAttribute;
|
|
exports.runningAnimations = runningAnimations;
|
|
exports.setEnv = setEnv;
|
|
exports.setFilterBackend = setFilterBackend;
|
|
exports.util = index$1;
|
|
exports.version = VERSION;
|
|
|
|
}));
|
|
//# sourceMappingURL=index.js.map |