/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-lines */
/* eslint-disable max-params */
import {
Control,
controlsUtils,
type FabricObject,
type Transform,
type TPointerEvent,
type ControlCursorCallback,
} from 'fabric';
import { Snap } from '../typings';
import { snap } from './snap/snap';
const getAngle = (a: number) => {
if (a >= 360) {
return getAngle(a - 360);
}
if (a < 0) {
return getAngle(a + 360);
}
return a;
};
const svg0 =
'';
const svg90 =
'';
const svg180 =
'';
const svg270 =
'';
const svg2Base64 = (svg: string) => `data:image/svg+xml;base64,${btoa(svg)}`;
const cursorRotate0 = svg2Base64(svg0);
const cursorRotate90 = svg2Base64(svg90);
const cursorRotate180 = svg2Base64(svg180);
const cursorRotate270 = svg2Base64(svg270);
const getCursor = (angle: number) => {
const a = getAngle(angle);
if (a >= 225 && a < 315) {
return cursorRotate270;
} else if (a >= 135 && a < 225) {
return cursorRotate180;
} else if (a >= 45 && a < 135) {
return cursorRotate90;
} else {
return cursorRotate0;
}
};
const {
scalingEqually,
scaleCursorStyleHandler,
rotationWithSnapping,
scalingX,
scalingY,
} = controlsUtils;
type GetControls = (props?: {
x?: number;
y?: number;
callback?: (data: { element: FabricObject }) => void;
needResetScaleAndSnap?: boolean;
}) => Control;
/**
* 直线起点控制点
*/
export const getLineStartControl: GetControls = (props = {}) => {
const { x, y, callback } = props;
return new Control({
x,
y,
actionHandler: (e, transformData, _x, _y) => {
transformData.target.set({
x1: _x,
y1: _y,
x2:
transformData.lastX +
transformData.width * (transformData.corner === 'tl' ? 1 : -1),
y2: transformData.lastY + transformData.height,
});
callback?.({ element: transformData.target });
return true;
},
actionName: 'startControl', // 控制点的名称
});
};
/**
* 直线终点控制点
*/
export const getLineEndControl: GetControls = (props = {}) => {
const { x, y, callback } = props;
return new Control({
x,
y,
actionHandler: (e, transformData, _x, _y) => {
transformData.target.set({
x1:
transformData.lastX -
transformData.width * (transformData.corner === 'br' ? 1 : -1),
y1: transformData.lastY - transformData.height,
x2: _x,
y2: _y,
});
callback?.({ element: transformData.target });
return true;
},
actionName: 'endControl', // 控制点的名称
});
};
const originData = {
width: 0,
height: 0,
top: 0,
left: 0,
};
type LeftTopCalcFn = (originData: {
angle: number;
originTop: number;
originLeft: number;
originWidth: number;
originHeight: number;
newWidth: number;
newHeight: number;
}) => {
left: number;
top: number;
};
const scaleToSize = (
transformData: Transform,
options?: {
scaleEqual?: boolean;
leftTopCalcFn?: LeftTopCalcFn;
},
) => {
const { width, height, scaleX, scaleY, strokeWidth, angle } =
transformData.target;
let targetWidth = Math.max((width + strokeWidth) * scaleX - strokeWidth, 1);
let targetHeight = Math.max((height + strokeWidth) * scaleY - strokeWidth, 1);
if (options?.scaleEqual) {
if (targetWidth - originData.width > targetHeight - originData.height) {
targetHeight = (originData.height / originData.width) * targetWidth;
} else {
targetWidth = (originData.width / originData.height) * targetHeight;
}
}
let targetLeft = originData.left;
let targetTop = originData.top;
if (options?.leftTopCalcFn) {
const rs = options.leftTopCalcFn({
angle,
originTop: originData.top,
originLeft: originData.left,
originWidth: originData.width,
originHeight: originData.height,
newWidth: targetWidth,
newHeight: targetHeight,
});
targetLeft = rs.left;
targetTop = rs.top;
}
transformData.target.set({
width: targetWidth,
height: targetHeight,
// textBox 特化属性
customFixedHeight: targetHeight,
scaleX: 1,
scaleY: 1,
top: targetTop,
left: targetLeft,
});
};
/**
* 直接问 GPT:
一个矩形,宽度 w ,高度 h
将矩形顺时针旋转角度 a,左上角坐标为 x1 y1
拉伸矩形左上角,使矩形右下角保持不变,宽度增加到 w1,高度增加到 h1
求左上角坐标
*/
const calcLeftTopByTopLeft: LeftTopCalcFn = ({
angle,
originTop,
originLeft,
originWidth,
originHeight,
newWidth,
newHeight,
}) => {
const anglePI = angle * (Math.PI / 180);
return {
left:
originLeft +
originWidth * Math.cos(anglePI) -
originHeight * Math.sin(anglePI) -
newWidth * Math.cos(anglePI) +
newHeight * Math.sin(anglePI),
top:
originTop +
originWidth * Math.sin(anglePI) +
originData.height * Math.cos(anglePI) -
newWidth * Math.sin(anglePI) -
newHeight * Math.cos(anglePI),
};
};
/**
* 直接问 GPT:
一个矩形,宽度 w ,高度 h
将矩形顺时针旋转角度 a , 左上角坐标为 x1 y1
拉伸矩形右上角,使矩形左下角保持不变,宽度增加到 w1,高度增加到 h1
求左上角坐标
(GPT 给的答案不准确,需要稍微理解下,修改加减)
*/
const calcLeftTopByTopRight: LeftTopCalcFn = ({
angle,
originTop,
originLeft,
originHeight,
newHeight,
}) => {
const anglePI = angle * (Math.PI / 180);
return {
left: originLeft - (originHeight - newHeight) * Math.sin(anglePI),
top: originTop + (originHeight - newHeight) * Math.cos(anglePI),
};
};
/**
* 直接问 GPT:
一个矩形,宽度 w ,高度 h
将矩形顺时针旋转角度 a , 左上角坐标为 x1 y1
拉伸矩形左下角,使矩形右上角保持不变,宽度增加到 w1,高度增加到 h1
求左上角坐标
GPT 给的答案不准确,这个比较麻烦,所以写出了每一步的推导过程
*/
const calcLeftTopByBottomLeft: LeftTopCalcFn = ({
angle,
originTop,
originLeft,
originWidth,
newWidth,
newHeight,
}) => {
// 将角度转换为弧度
const aRad = (angle * Math.PI) / 180;
// 计算旋转后的右上角坐标
const x2 = originLeft + originWidth * Math.cos(aRad);
const y2 = originTop + originWidth * Math.sin(aRad);
// 计算拉伸后的左下角坐标
const x3 = x2 - newHeight * Math.sin(aRad) - newWidth * Math.cos(aRad);
const y3 = y2 + newHeight * Math.cos(aRad) - newWidth * Math.sin(aRad);
// 计算拉伸后的左上角坐标
const x1New = x3 + newHeight * Math.sin(aRad);
const y1New = y3 - newHeight * Math.cos(aRad);
return {
left: x1New,
top: y1New,
};
};
const _mouseDownHandler = (
e: TPointerEvent,
transformData: Transform,
): boolean => {
originData.width = transformData.target.width;
originData.height = transformData.target.height;
originData.top = transformData.target.top;
originData.left = transformData.target.left;
return false;
};
const cursorMap: Record = {
'e-resize': 'ew-resize',
'w-resize': 'ew-resize',
'n-resize': 'ns-resize',
's-resize': 'ns-resize',
'nw-resize': 'nwse-resize',
'ne-resize': 'nesw-resize',
'sw-resize': 'nesw-resize',
'se-resize': 'nwse-resize',
};
const customCursorStyleHandler: ControlCursorCallback = (a, b, c) => {
const cursor = scaleCursorStyleHandler(a, b, c);
return cursorMap[cursor] ?? cursor;
};
const _actionHandler = ({
e,
transformData,
x,
y,
needResetScaleAndSnap,
fn,
callback,
snapPosition,
leftTopCalcFn,
}: {
e: TPointerEvent;
transformData: Transform;
x: number;
y: number;
needResetScaleAndSnap?: boolean;
callback: ((data: { element: FabricObject }) => void) | undefined;
fn: (
eventData: TPointerEvent,
transform: Transform,
x: number,
y: number,
) => boolean;
snapPosition: Snap.ControlType;
leftTopCalcFn?: LeftTopCalcFn;
}) => {
const rs = fn(
// 如果使用吸附则禁用默认缩放;否则取反
{ ...e, shiftKey: needResetScaleAndSnap ? true : !e.shiftKey },
transformData,
x,
y,
);
if (needResetScaleAndSnap) {
scaleToSize(transformData, {
scaleEqual: e.shiftKey,
leftTopCalcFn,
});
snap.resize(transformData.target, snapPosition);
}
callback?.({ element: transformData.target });
return rs;
};
/**
* 上左
*/
export const getResizeTLControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap = true } = props;
return new Control({
x: -0.5,
y: -0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingEqually,
leftTopCalcFn: calcLeftTopByTopLeft,
snapPosition: Snap.ControlType.TopLeft,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeTLControl', // 控制点的名称
});
};
/**
* 上中
*/
export const getResizeMTControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap = true } = props;
return new Control({
x: 0,
y: -0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingY,
leftTopCalcFn: calcLeftTopByTopLeft,
snapPosition: Snap.ControlType.Top,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeMTControl', // 控制点的名称
});
};
/**
* 上右
*/
export const getResizeTRControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: 0.5,
y: -0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingEqually,
leftTopCalcFn: calcLeftTopByTopRight,
snapPosition: Snap.ControlType.TopRight,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeTRControl', // 控制点的名称
});
};
/**
* 中左
*/
export const getResizeMLControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: -0.5,
y: 0,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingX,
leftTopCalcFn: calcLeftTopByBottomLeft,
snapPosition: Snap.ControlType.Left,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeMLControl', // 控制点的名称
});
};
/**
* 中右
*/
export const getResizeMRControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: 0.5,
y: 0,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingX,
snapPosition: Snap.ControlType.Right,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeMRControl', // 控制点的名称
});
};
/**
* 下左
*/
export const getResizeBLControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: -0.5,
y: 0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingEqually,
leftTopCalcFn: calcLeftTopByBottomLeft,
snapPosition: Snap.ControlType.BottomLeft,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeBLControl', // 控制点的名称
});
};
/**
* 下中
*/
export const getResizeMBControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: 0,
y: 0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingY,
snapPosition: Snap.ControlType.Bottom,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeMBControl', // 控制点的名称
});
};
/**
* 下右
*/
export const getResizeBRControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: 0.5,
y: 0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingEqually,
snapPosition: Snap.ControlType.BottomRight,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeBRControl', // 控制点的名称
});
};
const _getRotateControl =
({
x,
y,
offsetY,
offsetX,
actionName,
rotateStaff,
}: {
x: number;
y: number;
offsetY: -1 | 1;
offsetX: -1 | 1;
actionName: string;
rotateStaff: number;
}): GetControls =>
(props = {}) => {
const { callback } = props;
// 这个大小,取决于 resize 控制点的大小
const offset = 12;
return new Control({
x,
y,
sizeX: 20,
sizeY: 20,
offsetY: offsetY * offset,
offsetX: offsetX * offset,
// 覆盖旋转控制点渲染,预期不显示,所以啥都没写
// eslint-disable-next-line @typescript-eslint/no-empty-function
render: () => {},
// 只能做到 hover 上时的 cursor,旋转过程中 cursor 无法修改
cursorStyleHandler: (eventData, control, object) =>
`url(${getCursor(object.angle + rotateStaff)}) 16 16, crosshair`,
actionHandler: (e, transformData, _x, _y) => {
// 旋转吸附,单位:角度 一圈 = 360度
if (e.shiftKey) {
transformData.target.set({
snapAngle: 15,
});
} else {
transformData.target.set({
snapAngle: undefined,
});
}
const rs = rotationWithSnapping(
e,
{ ...transformData, originX: 'center', originY: 'center' },
_x,
_y,
);
// scaleToSize(transformData);
// transformData.target.canvas?.requestRenderAll();
callback?.({ element: transformData.target });
return rs;
},
actionName, // 控制点的名称
});
};
// 上左旋转点
export const getRotateTLControl: GetControls = (props = {}) =>
_getRotateControl({
x: -0.5,
y: -0.5,
offsetY: -1,
offsetX: -1,
rotateStaff: 0,
actionName: 'rotateTLControl',
})(props);
// 上右旋转点
export const getRotateTRControl: GetControls = (props = {}) =>
_getRotateControl({
x: 0.5,
y: -0.5,
offsetY: -1,
offsetX: 1,
rotateStaff: 90,
actionName: 'rotateTRControl',
})(props);
// 下右旋转点
export const getRotateBRControl: GetControls = (props = {}) =>
_getRotateControl({
x: 0.5,
y: 0.5,
offsetY: 1,
offsetX: 1,
rotateStaff: 180,
actionName: 'rotateBRControl',
})(props);
// 下左旋转点
export const getRotateBLControl: GetControls = (props = {}) =>
_getRotateControl({
x: -0.5,
y: 0.5,
offsetY: 1,
offsetX: -1,
rotateStaff: 270,
actionName: 'rotateBLControl',
})(props);