fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
867 lines (803 loc) • 25.9 kB
text/typescript
import { getFabricDocument, getEnv } from '../env';
import type { BaseFilter } from '../filters/BaseFilter';
import { getFilterBackend } from '../filters/FilterBackend';
import { SHARED_ATTRIBUTES } from '../parser/attributes';
import { parseAttributes } from '../parser/parseAttributes';
import type {
TClassProperties,
TCrossOrigin,
TSize,
Abortable,
TOptions,
} from '../typedefs';
import { uid } from '../util/internals/uid';
import { createCanvasElementFor } from '../util/misc/dom';
import { findScaleToCover, findScaleToFit } from '../util/misc/findScaleTo';
import type { LoadImageOptions } from '../util/misc/objectEnlive';
import {
enlivenObjectEnlivables,
enlivenObjects,
loadImage,
} from '../util/misc/objectEnlive';
import { parsePreserveAspectRatioAttribute } from '../util/misc/svgParsing';
import { classRegistry } from '../ClassRegistry';
import { FabricObject, cacheProperties } from './Object/FabricObject';
import type { FabricObjectProps, SerializedObjectProps } from './Object/types';
import type { ObjectEvents } from '../EventTypeDefs';
import { WebGLFilterBackend } from '../filters/WebGLFilterBackend';
import { FILL, NONE } from '../constants';
import { getDocumentFromElement } from '../util/dom_misc';
import type { CSSRules } from '../parser/typedefs';
import type { Resize } from '../filters/Resize';
import type { TCachedFabricObject } from './Object/Object';
import { log } from '../util/internals/console';
// @todo Would be nice to have filtering code not imported directly.
export type ImageSource =
| HTMLImageElement
| HTMLVideoElement
| HTMLCanvasElement;
interface UniqueImageProps {
srcFromAttribute: boolean;
minimumScaleTrigger: number;
cropX: number;
cropY: number;
imageSmoothing: boolean;
filters: BaseFilter<string, Record<string, any>>[];
resizeFilter?: Resize;
}
export const imageDefaultValues: Partial<TClassProperties<FabricImage>> = {
strokeWidth: 0,
srcFromAttribute: false,
minimumScaleTrigger: 0.5,
cropX: 0,
cropY: 0,
imageSmoothing: true,
};
export interface SerializedImageProps extends SerializedObjectProps {
src: string;
crossOrigin: TCrossOrigin;
filters: any[];
resizeFilter?: any;
cropX: number;
cropY: number;
}
export interface ImageProps extends FabricObjectProps, UniqueImageProps {}
const IMAGE_PROPS = ['cropX', 'cropY'] as const;
/**
* @tutorial {@link http://fabricjs.com/fabric-intro-part-1#images}
*/
export class FabricImage<
Props extends TOptions<ImageProps> = Partial<ImageProps>,
SProps extends SerializedImageProps = SerializedImageProps,
EventSpec extends ObjectEvents = ObjectEvents,
>
extends FabricObject<Props, SProps, EventSpec>
implements ImageProps
{
/**
* 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
*/
declare srcFromAttribute: boolean;
/**
* private
* contains last value of scaleX to detect
* if the Image got resized after the last Render
* @type Number
*/
protected _lastScaleX = 1;
/**
* private
* contains last value of scaleY to detect
* if the Image got resized after the last Render
* @type Number
*/
protected _lastScaleY = 1;
/**
* private
* contains last value of scaling applied by the apply filter chain
* @type Number
*/
protected _filterScalingX = 1;
/**
* private
* contains last value of scaling applied by the apply filter chain
* @type Number
*/
protected _filterScalingY = 1;
/**
* minimum scale factor under which any resizeFilter is triggered to resize the image
* 0 will disable the automatic resize. 1 will trigger automatically always.
* number bigger than 1 are not implemented yet.
* @type Number
*/
declare minimumScaleTrigger: number;
/**
* key used to retrieve the texture representing this image
* @since 2.0.0
* @type String
* @default
*/
declare cacheKey: string;
/**
* Image crop in pixels from original image size.
* @since 2.0.0
* @type Number
* @default
*/
declare cropX: number;
/**
* Image crop in pixels from original image size.
* @since 2.0.0
* @type Number
* @default
*/
declare cropY: number;
/**
* Indicates whether this canvas will use image smoothing when painting this image.
* Also influence if the cacheCanvas for this image uses imageSmoothing
* @since 4.0.0-beta.11
* @type Boolean
* @default
*/
declare imageSmoothing: boolean;
declare preserveAspectRatio: string;
protected declare src: string;
declare filters: BaseFilter<string, Record<string, any>>[];
declare resizeFilter: Resize;
declare _element: ImageSource;
declare _filteredEl?: HTMLCanvasElement;
declare _originalElement: ImageSource;
static type = 'Image';
static cacheProperties = [...cacheProperties, ...IMAGE_PROPS];
static ownDefaults = imageDefaultValues;
static getDefaults(): Record<string, any> {
return {
...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(elementId: string, options?: Props);
constructor(element: ImageSource, options?: Props);
constructor(arg0: ImageSource | string, options?: Props) {
super();
this.filters = [];
Object.assign(this, FabricImage.ownDefaults);
this.setOptions(options);
this.cacheKey = `texture${uid()}`;
this.setElement(
typeof arg0 === 'string'
? ((
(this.canvas && getDocumentFromElement(this.canvas.getElement())) ||
getFabricDocument()
).getElementById(arg0) as ImageSource)
: 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: ImageSource, size: Partial<TSize> = {}) {
this.removeTexture(this.cacheKey);
this.removeTexture(`${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: string) {
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(`${this.cacheKey}_filtered`);
this._cacheContext = null;
(
['_originalElement', '_element', '_filteredEl', '_cacheCanvas'] as const
).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(): string | null {
return (
this._originalElement &&
((this._originalElement as any).crossOrigin || null)
);
}
/**
* Returns original size of an image
*/
getOriginalSize() {
const element = this.getElement() as any;
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: CanvasRenderingContext2D) {
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<
T extends Omit<Props & TClassProperties<this>, keyof SProps>,
K extends keyof T = never,
>(propertiesToInclude: K[] = []): Pick<T, K> & SProps {
const filters: Record<string, any>[] = [];
this.filters.forEach((filterObj) => {
filterObj && filters.push(filterObj.toObject());
});
return {
...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: string[] = [],
element = this._element,
x = -this.width / 2,
y = -this.height / 2;
let svgString: string[] = [],
strokeSvg: string[] = [],
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="${this.getSvgSrc(true)}" x="${x - this.cropX}" y="${
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="${
element.width || (element as HTMLImageElement).naturalWidth
}" height="${
element.height || (element as HTMLImageElement).naturalHeight
}"${imageRendering}${clipPath}></image>\n`,
);
if (this.stroke || this.strokeDashArray) {
const origFill = this.fill;
this.fill = null;
strokeSvg = [
`\t<rect x="${x}" y="${y}" width="${this.width}" height="${
this.height
}" style="${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?: boolean): string {
const element = filtered ? this._element : this._originalElement;
if (element) {
if ((element as HTMLCanvasElement).toDataURL) {
return (element as HTMLCanvasElement).toDataURL();
}
if (this.srcFromAttribute) {
return element.getAttribute('src') || '';
} else {
return (element as HTMLImageElement).src;
}
} else {
return this.src || '';
}
}
/**
* Alias for getSrc
* @param filtered
* @deprecated
*/
getSvgSrc(filtered?: boolean) {
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: string, { crossOrigin, signal }: LoadImageOptions = {}) {
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: "${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 = createCanvasElementFor(elementToFilter),
{ width, height } = elementToFilter;
this._element = canvasEl;
this._lastScaleX = filter.scaleX = scaleX;
this._lastScaleY = filter.scaleY = scaleY;
getFilterBackend().applyFilters(
[filter],
elementToFilter,
width,
height,
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(
filters: BaseFilter<string, Record<string, any>>[] = 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(`${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 as HTMLImageElement).naturalWidth || imgElement.width,
sourceHeight =
(imgElement as HTMLImageElement).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 = createCanvasElementFor({
width: sourceWidth,
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 as HTMLCanvasElement,
this.cacheKey,
);
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: CanvasRenderingContext2D) {
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(
this: TCachedFabricObject<FabricImage>,
ctx: CanvasRenderingContext2D,
) {
ctx.imageSmoothingEnabled = this.imageSmoothing;
super.drawCacheOnCanvas(ctx);
}
/**
* Decide if the FabricImage should cache or not. Create its own cache level
* needsItsOwnCache should be used when the object drawing method requires
* a cache step.
* 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: CanvasRenderingContext2D) {
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 as HTMLImageElement).naturalWidth || elementToDraw.width,
elHeight =
(elementToDraw as HTMLImageElement).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({ width, height }: Partial<TSize> = {}) {
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
* Will be removed from fabric 7
* @static
* @deprecated
* @type String
* @default
*/
static 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}
*/
static ATTRIBUTE_NAMES = [
...SHARED_ATTRIBUTES,
'x',
'y',
'width',
'height',
'preserveAspectRatio',
'xlink:href',
'href',
'crossOrigin',
'image-rendering',
];
/**
* 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<T extends TOptions<SerializedImageProps>>(
{ filters: f, resizeFilter: rf, src, crossOrigin, type, ...object }: T,
options?: Abortable,
) {
return Promise.all([
loadImage(src!, { ...options, crossOrigin }),
f && enlivenObjects<BaseFilter<string>>(f, options),
// TODO: redundant - handled by enlivenObjectEnlivables
rf && enlivenObjects<BaseFilter<'Resize'>>([rf], options),
enlivenObjectEnlivables(object, options),
]).then(([el, filters = [], [resizeFilter] = [], hydratedProps = {}]) => {
return new this(el, {
...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<T extends TOptions<ImageProps>>(
url: string,
{ crossOrigin = null, signal }: LoadImageOptions = {},
imageOptions?: T,
): Promise<FabricImage> {
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: HTMLElement,
options: Abortable = {},
cssRules?: CSSRules,
) {
const parsedAttributes = parseAttributes(
element,
this.ATTRIBUTE_NAMES,
cssRules,
);
return this.fromURL(
parsedAttributes['xlink:href'] || parsedAttributes['href'],
options,
parsedAttributes,
).catch((err) => {
log('log', 'Unable to parse Image', err);
return null;
});
}
}
classRegistry.setClass(FabricImage);
classRegistry.setSVGClass(FabricImage);