- Fix router import path in main.js - Handle Django REST Framework pagination format in API calls - Add getTemplates function to project API - Restart frontend development server
492 lines
14 KiB
TypeScript
492 lines
14 KiB
TypeScript
import type {
|
|
TModificationEvents,
|
|
TransformActionHandler,
|
|
FabricImage,
|
|
ObjectEvents,
|
|
Control,
|
|
TMat2D,
|
|
} from 'fabric';
|
|
import { controlsUtils, Point, util } from 'fabric';
|
|
|
|
const { wrapWithFixedAnchor, wrapWithFireEvent } = controlsUtils;
|
|
|
|
/**
|
|
* Wraps a handler to swap behavior based on flip state.
|
|
*/
|
|
export const withFlip = (
|
|
handler: TransformActionHandler,
|
|
flippedHandler: TransformActionHandler,
|
|
axis: 'flipX' | 'flipY',
|
|
): TransformActionHandler => {
|
|
return (eventData, transform, x, y) => {
|
|
if (transform.target[axis]) {
|
|
return flippedHandler(eventData, transform, x, y);
|
|
}
|
|
return handler(eventData, transform, x, y);
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Wraps corner handlers to swap both X and Y behavior based on flip state.
|
|
*/
|
|
export const withCornerFlip = (
|
|
xHandler: TransformActionHandler,
|
|
xFlippedHandler: TransformActionHandler,
|
|
yHandler: TransformActionHandler,
|
|
yFlippedHandler: TransformActionHandler,
|
|
): TransformActionHandler => {
|
|
return (eventData, transform, x, y) => {
|
|
const target = transform.target as FabricImage;
|
|
const xResult = (target.flipX ? xFlippedHandler : xHandler)(
|
|
eventData,
|
|
transform,
|
|
x,
|
|
y,
|
|
);
|
|
const yResult = (target.flipY ? yFlippedHandler : yHandler)(
|
|
eventData,
|
|
transform,
|
|
x,
|
|
y,
|
|
);
|
|
return xResult || yResult;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Wrap controlsUtils.changeObjectWidth with image constrains
|
|
*/
|
|
export const changeImageWidth: TransformActionHandler = (
|
|
eventData,
|
|
transform,
|
|
x,
|
|
y,
|
|
) => {
|
|
const { target } = transform;
|
|
const { width } = target;
|
|
const image = target as FabricImage;
|
|
const modified = controlsUtils.changeObjectWidth(eventData, transform, x, y);
|
|
const availableWidth = image._element.width - image.cropX;
|
|
if (modified) {
|
|
if (image.width > availableWidth) {
|
|
image.width = availableWidth;
|
|
}
|
|
if (image.width < 1) {
|
|
image.width = 1;
|
|
}
|
|
}
|
|
return width !== image.width;
|
|
};
|
|
|
|
export const changeCropWidth = wrapWithFireEvent(
|
|
'CROPPING' as TModificationEvents,
|
|
wrapWithFixedAnchor(changeImageWidth),
|
|
);
|
|
|
|
/**
|
|
* Wrap controlsUtils.changeObjectHeight with image constrains
|
|
*/
|
|
export const changeImageHeight: TransformActionHandler = (
|
|
eventData,
|
|
transform,
|
|
x,
|
|
y,
|
|
) => {
|
|
const { target } = transform;
|
|
const { height } = target;
|
|
const image = target as FabricImage;
|
|
const modified = controlsUtils.changeObjectHeight(eventData, transform, x, y);
|
|
const availableHeight = image._element.height - image.cropY;
|
|
if (modified) {
|
|
if (image.height > availableHeight) {
|
|
image.height = availableHeight;
|
|
}
|
|
if (image.height < 1) {
|
|
image.height = 1;
|
|
}
|
|
}
|
|
return height !== image.height;
|
|
};
|
|
|
|
export const changeCropHeight = wrapWithFireEvent(
|
|
'CROPPING' as TModificationEvents,
|
|
wrapWithFixedAnchor(changeImageHeight),
|
|
);
|
|
|
|
export const changeImageCropX: TransformActionHandler = (
|
|
eventData,
|
|
transform,
|
|
x,
|
|
y,
|
|
) => {
|
|
const { target } = transform;
|
|
const image = target as FabricImage;
|
|
const { width, cropX } = image;
|
|
const modified = controlsUtils.changeObjectWidth(eventData, transform, x, y);
|
|
let newCropX = cropX + width - image.width;
|
|
image.width = width;
|
|
if (modified) {
|
|
if (newCropX < 0) {
|
|
newCropX = 0;
|
|
}
|
|
image.cropX = newCropX;
|
|
// calculate new width on the base of how much crop we have now
|
|
image.width += cropX - newCropX;
|
|
}
|
|
return newCropX !== cropX;
|
|
};
|
|
|
|
export const changeImageCropY: TransformActionHandler = (
|
|
eventData,
|
|
transform,
|
|
x,
|
|
y,
|
|
) => {
|
|
const { target } = transform;
|
|
const image = target as FabricImage;
|
|
const { height, cropY } = image;
|
|
const modified = controlsUtils.changeObjectHeight(eventData, transform, x, y);
|
|
let newCropY = cropY + height - image.height;
|
|
image.height = height;
|
|
if (modified) {
|
|
if (newCropY < 0) {
|
|
newCropY = 0;
|
|
}
|
|
image.cropY = newCropY;
|
|
image.height += cropY - newCropY;
|
|
}
|
|
return newCropY !== cropY;
|
|
};
|
|
|
|
export const changeCropX = wrapWithFireEvent(
|
|
'CROPPING' as TModificationEvents,
|
|
wrapWithFixedAnchor(changeImageCropX),
|
|
);
|
|
|
|
export const changeCropY = wrapWithFireEvent(
|
|
'CROPPING' as TModificationEvents,
|
|
wrapWithFixedAnchor(changeImageCropY),
|
|
);
|
|
|
|
/**
|
|
* A function to counter the move action and change cropX/cropY of an image
|
|
* Keep the image steady, but moves it inside its own cropping rectangle
|
|
*/
|
|
export const cropPanMoveHandler = ({ transform }: ObjectEvents['moving']) => {
|
|
// this makes the image pan too fast.
|
|
const { target, original } = transform;
|
|
const fabricImage = target as FabricImage;
|
|
const p = new Point(
|
|
target.left - original.left,
|
|
target.top - original.top,
|
|
).transform(
|
|
util.invertTransform(
|
|
util.createRotateMatrix({ angle: fabricImage.getTotalAngle() }),
|
|
),
|
|
);
|
|
let cropX =
|
|
original.cropX! - (p.x / fabricImage.scaleX) * (fabricImage.flipX ? -1 : 1);
|
|
let cropY =
|
|
original.cropY! - (p.y / fabricImage.scaleY) * (fabricImage.flipY ? -1 : 1);
|
|
const { width, height, _element } = fabricImage;
|
|
if (cropX < 0) {
|
|
cropX = 0;
|
|
}
|
|
if (cropY < 0) {
|
|
cropY = 0;
|
|
}
|
|
if (cropX + width > _element.width) {
|
|
cropX = _element.width - width;
|
|
}
|
|
if (cropY + height > _element.height) {
|
|
cropY = _element.height - height;
|
|
}
|
|
fabricImage.cropX = cropX;
|
|
fabricImage.cropY = cropY;
|
|
fabricImage.left = original.left;
|
|
fabricImage.top = original.top;
|
|
};
|
|
|
|
/**
|
|
* This position handler works only for this specific use case.
|
|
* It does not support padding nor offset, and it reduces all possible positions
|
|
* to the main 4 corners only.
|
|
* Any position that is < 0 is the extreme left/top, the rest are right/bottom
|
|
*/
|
|
export function ghostScalePositionHandler(
|
|
this: Control,
|
|
dim: Point, // currentDimension
|
|
finalMatrix: TMat2D,
|
|
fabricObject: FabricImage,
|
|
// currentControl: Control,
|
|
) {
|
|
const matrix = fabricObject.calcTransformMatrix();
|
|
const vpt = fabricObject.getViewportTransform();
|
|
const _finalMatrix = util.multiplyTransformMatrices(vpt, matrix);
|
|
|
|
const x =
|
|
this.x < 0
|
|
? -fabricObject.width / 2 - fabricObject.cropX
|
|
: fabricObject.getElement().width -
|
|
fabricObject.width / 2 -
|
|
fabricObject.cropX;
|
|
|
|
const y =
|
|
this.y < 0
|
|
? -fabricObject.height / 2 - fabricObject.cropY
|
|
: fabricObject.getElement().height -
|
|
fabricObject.height / 2 -
|
|
fabricObject.cropY;
|
|
return new Point(x, y).transform(_finalMatrix);
|
|
}
|
|
|
|
const calcScale = (currentPoint: Point, height: number, width: number) =>
|
|
Math.min(Math.abs(currentPoint.x / width), Math.abs(currentPoint.y / height));
|
|
|
|
const flipNumericOrigin = (origin: number, flipped: boolean) =>
|
|
flipped ? 1 - origin : origin;
|
|
|
|
/**
|
|
* Reflects pointer position across object center when image is flipped.
|
|
* This compensates for the inverted local coordinate system.
|
|
*/
|
|
// const reflectPointerForFlip = (
|
|
// target: FabricImage,
|
|
// x: number,
|
|
// y: number,
|
|
// ): Point => {
|
|
// if (!target.flipX && !target.flipY) {
|
|
// return new Point(x, y);
|
|
// }
|
|
// const center = target.getCenterPoint();
|
|
// return new Point(
|
|
// target.flipX ? center.x - x : x,
|
|
// target.flipY ? center.y - y : y,
|
|
// );
|
|
// };
|
|
|
|
/**
|
|
* Action handler generator that handles scaling of an image in crop mode.
|
|
* The goal is to keep the current bounding box steady.
|
|
* So this action handler has its own calculations for a dynamic anchor point
|
|
*/
|
|
export const scaleEquallyCropGenerator =
|
|
(cx: number, cy: number): TransformActionHandler =>
|
|
(eventData, transform, x, y) => {
|
|
const { target } = transform as unknown as { target: FabricImage };
|
|
const { width: fullWidth, height: fullHeight } = target.getElement();
|
|
const remainderX = fullWidth - target.width - target.cropX;
|
|
const remainderY = fullHeight - target.height - target.cropY;
|
|
const anchorOriginX = flipNumericOrigin(
|
|
cx < 0 ? 1 + remainderX / target.width : -target.cropX / target.width,
|
|
target.flipX,
|
|
);
|
|
const anchorOriginY = flipNumericOrigin(
|
|
cy < 0 ? 1 + remainderY / target.height : -target.cropY / target.height,
|
|
target.flipY,
|
|
);
|
|
const constraint = target.translateToOriginPoint(
|
|
target.getCenterPoint(),
|
|
anchorOriginX,
|
|
anchorOriginY,
|
|
);
|
|
|
|
const newPoint = controlsUtils.getLocalPoint(
|
|
transform,
|
|
anchorOriginX,
|
|
anchorOriginY,
|
|
x,
|
|
y,
|
|
);
|
|
|
|
const scale = calcScale(newPoint, fullHeight, fullWidth);
|
|
|
|
const scaleChangeX = scale / target.scaleX;
|
|
const scaleChangeY = scale / target.scaleY;
|
|
const scaledRemainderX = remainderX / scaleChangeX;
|
|
const scaledRemainderY = remainderY / scaleChangeY;
|
|
const newWidth = target.width / scaleChangeX;
|
|
const newHeight = target.height / scaleChangeY;
|
|
const newCropX =
|
|
cx < 0
|
|
? fullWidth - newWidth - scaledRemainderX
|
|
: target.cropX / scaleChangeX;
|
|
const newCropY =
|
|
cy < 0
|
|
? fullHeight - newHeight - scaledRemainderY
|
|
: target.cropY / scaleChangeY;
|
|
|
|
const boundsFailX =
|
|
(cx < 0 ? scaledRemainderX : newCropX) + newWidth > fullWidth;
|
|
const boundsFailY =
|
|
(cy < 0 ? scaledRemainderY : newCropY) + newHeight > fullHeight;
|
|
|
|
if (boundsFailX || boundsFailY) {
|
|
return false;
|
|
}
|
|
|
|
target.scaleX = scale;
|
|
target.scaleY = scale;
|
|
target.width = newWidth;
|
|
target.height = newHeight;
|
|
target.cropX = newCropX;
|
|
target.cropY = newCropY;
|
|
const newAnchorOriginX = flipNumericOrigin(
|
|
cx < 0 ? 1 + scaledRemainderX / newWidth : -newCropX / newWidth,
|
|
target.flipX,
|
|
);
|
|
const newAnchorOriginY = flipNumericOrigin(
|
|
cy < 0 ? 1 + scaledRemainderY / newHeight : -newCropY / newHeight,
|
|
target.flipY,
|
|
);
|
|
|
|
target.setPositionByOrigin(constraint, newAnchorOriginX, newAnchorOriginY);
|
|
return true;
|
|
};
|
|
|
|
export function renderGhostImage(
|
|
this: FabricImage,
|
|
{ ctx }: { ctx: CanvasRenderingContext2D },
|
|
) {
|
|
const element = this._element;
|
|
const ghostX = -this.width / 2 - this.cropX;
|
|
const ghostY = -this.height / 2 - this.cropY;
|
|
|
|
const alpha = ctx.globalAlpha;
|
|
ctx.globalAlpha *= 0.5;
|
|
ctx.drawImage(element, ghostX, ghostY);
|
|
|
|
ctx.strokeStyle = this.borderColor;
|
|
// we assume this.scaleX and this.scaleY are same in an image.
|
|
// it is not common use case to stretch images, and if it is, and is brought up,
|
|
// this border for the image needs to be drawn differently.
|
|
ctx.lineWidth = this.borderScaleFactor / this.scaleX;
|
|
ctx.strokeRect(ghostX, ghostY, element.width, element.height);
|
|
|
|
ctx.globalAlpha = alpha;
|
|
}
|
|
|
|
const { capValue } = util;
|
|
|
|
/**
|
|
* Those are controls used to resize an image, similar to cropX,cropY,width,height
|
|
* But they change the scale of an image to accomodate out of bounds resizing.
|
|
* When resize comes back they scale the image back to what was before.
|
|
* The memory effect for bounce back works for the same transform.
|
|
* Once you mouseup, the bounce back is lost.
|
|
*/
|
|
const changeImageSizeWithAutoCoverGenerator =
|
|
(axis: 'x' | 'y'): TransformActionHandler =>
|
|
(_eventData, transform, x, y) => {
|
|
const image = transform.target as FabricImage;
|
|
const original = transform.original;
|
|
|
|
const isX = axis === 'x';
|
|
const isFlipped = isX ? image.flipX : image.flipY;
|
|
const elementSize = isX ? image._element.width : image._element.height;
|
|
const crossElementSize = isX ? image._element.height : image._element.width;
|
|
const isNegativeEdge = isX
|
|
? transform.originX === 'right'
|
|
: transform.originY === 'bottom';
|
|
|
|
const initialSize = isX ? transform.width : transform.height;
|
|
const initialCrossSize = isX ? transform.height : transform.width;
|
|
const initialCrop = isX ? (original.cropX ?? 0) : (original.cropY ?? 0);
|
|
const initialCrossCrop = isX
|
|
? (original.cropY ?? 0)
|
|
: (original.cropX ?? 0);
|
|
const initialScale = isX ? original.scaleX : original.scaleY;
|
|
const initialCrossScale = isX ? original.scaleY : original.scaleX;
|
|
|
|
const localPoint = controlsUtils.getLocalPoint(
|
|
transform,
|
|
transform.originX,
|
|
transform.originY,
|
|
x,
|
|
y,
|
|
);
|
|
|
|
const coordinate = isX ? localPoint.x : localPoint.y;
|
|
const rawSize = isNegativeEdge ? -coordinate : coordinate;
|
|
const requestedSize = Math.max(10, rawSize / initialScale);
|
|
|
|
const availableSize =
|
|
isNegativeEdge !== isFlipped
|
|
? initialCrop + initialSize
|
|
: elementSize - initialCrop;
|
|
|
|
const setImageProps = (
|
|
size: number,
|
|
crossSize: number,
|
|
scale: number,
|
|
crop: number,
|
|
crossCrop: number,
|
|
) => {
|
|
if (isX) {
|
|
image.width = size;
|
|
image.height = crossSize;
|
|
image.cropX = crop;
|
|
image.cropY = crossCrop;
|
|
} else {
|
|
image.height = size;
|
|
image.width = crossSize;
|
|
image.cropY = crop;
|
|
image.cropX = crossCrop;
|
|
}
|
|
image.scaleX = scale;
|
|
image.scaleY = scale;
|
|
};
|
|
|
|
if (requestedSize <= availableSize) {
|
|
const newCrop =
|
|
isNegativeEdge !== isFlipped
|
|
? Math.max(0, initialCrop + initialSize - requestedSize)
|
|
: initialCrop;
|
|
setImageProps(
|
|
Math.max(1, requestedSize),
|
|
initialCrossSize,
|
|
initialScale,
|
|
newCrop,
|
|
initialCrossCrop,
|
|
);
|
|
} else {
|
|
const targetScaledSize = requestedSize * initialScale;
|
|
const newScale = targetScaledSize / availableSize;
|
|
|
|
const scaledCrossSize = initialCrossSize * initialCrossScale;
|
|
const crossNaturalInView = scaledCrossSize / newScale;
|
|
const newCrossSize = Math.min(crossNaturalInView, crossElementSize);
|
|
const crossCenter = initialCrossCrop + initialCrossSize / 2;
|
|
const newCrossCrop = capValue(
|
|
crossCenter - newCrossSize / 2,
|
|
0,
|
|
crossElementSize - newCrossSize,
|
|
);
|
|
|
|
setImageProps(
|
|
availableSize,
|
|
newCrossSize,
|
|
newScale,
|
|
isNegativeEdge !== isFlipped ? 0 : initialCrop,
|
|
newCrossCrop,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
export const changeImageWidthWithAutoCover =
|
|
changeImageSizeWithAutoCoverGenerator('x');
|
|
export const changeImageHeightWithAutoCover =
|
|
changeImageSizeWithAutoCoverGenerator('y');
|
|
|
|
export const changeWidthAndScaleToCover = wrapWithFireEvent(
|
|
'RESIZING' as TModificationEvents,
|
|
wrapWithFixedAnchor(changeImageWidthWithAutoCover),
|
|
);
|
|
|
|
export const changeHeightAndScaleToCover = wrapWithFireEvent(
|
|
'RESIZING' as TModificationEvents,
|
|
wrapWithFixedAnchor(changeImageHeightWithAutoCover),
|
|
);
|