feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
/*
* 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.
*/
export const getNumberBetween = ({
value,
max,
min,
}: {
value: number;
max: number;
min: number;
}) => {
if (value > max) {
return max;
}
if (value < min) {
return min;
}
return value;
};

View File

@@ -0,0 +1,696 @@
/*
* 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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M12.87 22 10 19.13h1.99v-.659a6.483 6.483 0 0 1 6.482-6.482h.723V10l2.869 2.87-2.87 2.869v-1.99h-.722a4.722 4.722 0 0 0-4.722 4.722v.66h1.989L12.869 22" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M12.87 22 10 19.13h1.99v-.659a6.483 6.483 0 0 1 6.482-6.482h.723V10l2.869 2.87-2.87 2.869v-1.99h-.722a4.722 4.722 0 0 0-4.722 4.722v.66h1.989L12.869 22" clip-rule="evenodd"/><path fill="#fff" d="M10 19.13v-.8H8.068l1.366 1.367.566-.566M12.87 22l-.567.566.566.566.566-.566L12.87 22m-.88-2.87v.801h.8v-.8h-.8m6.482-7.141v-.8.8m.723 0v.8h.8v-.8h-.8m0-1.989.565-.566-1.366-1.366V10h.8m2.869 2.87.566.565.566-.566-.566-.566-.566.566m-2.87 2.869h-.8v1.932l1.366-1.366-.566-.566m0-1.99h.8v-.8h-.8v.8m-.722 0v-.8.8m-4.722 5.382h-.8v.8h.8v-.8m1.989 0 .566.566 1.366-1.367h-1.932v.8m-6.305.566 2.87 2.869 1.131-1.132-2.87-2.87-1.13 1.133m2.555-1.367H10v1.601h1.99v-1.6m-.8.142v.659h1.6v-.66h-1.6m7.283-7.284a7.283 7.283 0 0 0-7.283 7.283h1.6a5.682 5.682 0 0 1 5.683-5.682v-1.6m.723 0h-.723v1.601h.723v-1.6m.8.8V10h-1.6v1.989h1.6m-1.366-1.422 2.869 2.87 1.132-1.133-2.87-2.869-1.131 1.132m2.869 1.737-2.87 2.87 1.132 1.132 2.87-2.87-1.132-1.132m-1.503 3.436v-1.99h-1.6v1.99h1.6m-1.523-1.19h.723v-1.6h-.723v1.6m-3.922 3.922a3.922 3.922 0 0 1 3.922-3.921v-1.601a5.522 5.522 0 0 0-5.523 5.522h1.601m0 .66v-.66h-1.6v.66h1.6m1.189-.8h-1.99v1.6h1.99v-1.6m-2.304 4.235 2.87-2.87-1.132-1.131-2.87 2.87 1.132 1.13" mask="url(#b)"/></g><defs><filter id="a" width="18.728" height="18.665" x="6.268" y="7.268" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36430"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36430" result="shape"/></filter></defs></svg>';
const svg90 =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M10 12.87 12.87 10v1.99h.658a6.482 6.482 0 0 1 6.482 6.482v.722H22l-2.87 2.87-2.868-2.87h1.989v-.722a4.722 4.722 0 0 0-4.722-4.722h-.659v1.988L10 12.87" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M10 12.87 12.87 10v1.99h.658a6.482 6.482 0 0 1 6.482 6.482v.722H22l-2.87 2.87-2.868-2.87h1.989v-.722a4.722 4.722 0 0 0-4.722-4.722h-.659v1.988L10 12.87" clip-rule="evenodd"/><path fill="#fff" d="M12.87 10h.8V8.068l-1.367 1.366.566.566M10 12.87l-.566-.567-.566.566.566.566.566-.566m2.87-.88h-.801v.8h.8v-.8m7.14 7.204h-.8v.8h.8v-.8m1.989 0 .566.566 1.366-1.366H22v.8m-2.87 2.87-.565.565.566.566.566-.566-.566-.566m-2.868-2.87v-.8h-1.932l1.366 1.366.566-.566m1.989 0v.8h.8v-.8h-.8m-5.38-5.443v-.8h-.801v.8h.8m0 1.987-.567.566 1.366 1.366v-1.932h-.8m-.567-6.304-2.869 2.87 1.132 1.131 2.869-2.87-1.132-1.13m1.366 2.556V10h-1.6v1.99h1.6m-.141-.8h-.659v1.6h.659v-1.6m7.283 7.282a7.283 7.283 0 0 0-7.283-7.282v1.6a5.682 5.682 0 0 1 5.682 5.682h1.6m0 .722v-.722h-1.6v.722h1.6m-.8.8h1.988v-1.6H20.01v1.6m1.422-1.366-2.869 2.87 1.132 1.131 2.869-2.869-1.132-1.132m-1.737 2.87-2.87-2.87-1.131 1.132 2.869 2.87 1.132-1.132m-3.435-1.503h1.989v-1.6h-1.99v1.6m1.188-1.523v.722h1.601v-.722h-1.6m-3.921-3.921a3.921 3.921 0 0 1 3.921 3.921h1.601a5.522 5.522 0 0 0-5.522-5.522v1.6m-.659 0h.659v-1.6h-.659v1.6m.8 1.187v-1.987h-1.6v1.987h1.6m-4.235-2.303 2.87 2.87 1.131-1.133-2.87-2.869-1.13 1.132" mask="url(#b)"/></g><defs><filter id="a" width="18.663" height="18.727" x="7.068" y="7.268" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36425"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36425" result="shape"/></filter></defs></svg>';
const svg180 =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M19.13 10 22 12.87h-1.99v.659a6.483 6.483 0 0 1-6.482 6.482h-.723V22l-2.869-2.87 2.87-2.869v1.99h.722a4.722 4.722 0 0 0 4.722-4.723v-.659h-1.989L19.131 10" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M19.13 10 22 12.87h-1.99v.659a6.483 6.483 0 0 1-6.482 6.482h-.723V22l-2.869-2.87 2.87-2.869v1.99h.722a4.722 4.722 0 0 0 4.722-4.723v-.659h-1.989L19.131 10" clip-rule="evenodd"/><path fill="#fff" d="M22 12.87v.8h1.932l-1.366-1.367-.566.566M19.13 10l.567-.566-.566-.566-.566.566.566.566m.88 2.87v-.801h-.8v.8h.8m0 .659h-.8.8m-7.205 6.482v-.8h-.8v.8h.8m0 1.989-.565.566 1.366 1.366V22h-.8m-2.869-2.87-.566-.565-.566.566.566.566.566-.566m2.87-2.869h.8V14.33l-1.366 1.366.566.566m0 1.99h-.8v.8h.8v-.8m5.444-4.723h-.8.8m0-.659h.8v-.8h-.8v.8m-1.989 0-.566-.566-1.366 1.367h1.932v-.8m6.305-.566-2.87-2.869-1.131 1.132 2.87 2.87 1.13-1.133M20.01 13.67H22v-1.601h-1.99v1.6m.8-.142v-.659h-1.6v.66h1.6m-7.283 7.284a7.283 7.283 0 0 0 7.283-7.284h-1.6a5.682 5.682 0 0 1-5.683 5.683v1.6m-.723 0h.723V19.21h-.723v1.6m-.8-.8V22h1.6V20.01h-1.6m1.366 1.422-2.869-2.87-1.132 1.133 2.87 2.869 1.131-1.132m-2.869-1.737 2.87-2.87-1.132-1.132-2.87 2.87 1.132 1.132m1.503-3.436v1.99h1.6v-1.99h-1.6m1.523 1.19h-.723v1.6h.723v-1.6m3.922-3.923a3.922 3.922 0 0 1-3.922 3.922v1.601a5.522 5.522 0 0 0 5.522-5.522h-1.6m0-.659v.66h1.6v-.66h-1.6m-1.189.8h1.99v-1.6h-1.99v1.6m2.304-4.235-2.87 2.87 1.132 1.131 2.87-2.87-1.132-1.13" mask="url(#b)"/></g><defs><filter id="a" width="18.728" height="18.665" x="7.004" y="8.067" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36440"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36440" result="shape"/></filter></defs></svg>';
const svg270 =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M22 19.13 19.13 22v-1.99h-.658a6.483 6.483 0 0 1-6.483-6.483v-.722H10l2.87-2.87 2.869 2.87h-1.99v.722a4.722 4.722 0 0 0 4.723 4.722h.659v-1.988L22 19.131" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M22 19.13 19.13 22v-1.99h-.658a6.483 6.483 0 0 1-6.483-6.483v-.722H10l2.87-2.87 2.869 2.87h-1.99v.722a4.722 4.722 0 0 0 4.723 4.722h.659v-1.988L22 19.131" clip-rule="evenodd"/><path fill="#fff" d="M19.13 22h-.8v1.932l1.367-1.366L19.13 22M22 19.13l.566.567.566-.566-.566-.566-.566.566m-2.87.88h.801v-.8h-.8v.8m-7.141-6.483h.8-.8m0-.722h.8v-.8h-.8v.8m-1.989 0-.566-.566-1.366 1.366H10v-.8m2.87-2.87.565-.565-.566-.566-.566.566.566.566m2.869 2.87v.8h1.932l-1.366-1.366-.566.566m-1.99 0v-.8h-.8v.8h.8m0 .722h.801-.8m5.382 4.722v.8h.8v-.8h-.8m0-1.988.566-.566-1.367-1.366v1.932h.8m.566 6.305 2.869-2.87-1.132-1.131-2.87 2.87 1.133 1.13M18.33 20.01V22h1.601v-1.99h-1.6m.142.8h.659v-1.6h-.66v1.6m-7.284-7.283a7.283 7.283 0 0 0 7.284 7.283v-1.6a5.682 5.682 0 0 1-5.683-5.683h-1.6m0-.722v.722h1.601v-.722h-1.6m.8-.8H10v1.6h1.989v-1.6m-1.422 1.366 2.87-2.87-1.133-1.131-2.869 2.869 1.132 1.132m1.737-2.87 2.87 2.87 1.132-1.132-2.87-2.87-1.132 1.133m3.436 1.504h-1.99v1.6h1.99v-1.6m-1.189 1.522v-.722h-1.6v.722h1.6m3.922 3.922a3.922 3.922 0 0 1-3.922-3.922h-1.6a5.522 5.522 0 0 0 5.522 5.523v-1.601m.659 0h-.66v1.6h.66v-1.6m-.8-1.188v1.988h1.6v-1.988h-1.6m4.235 2.304-2.87-2.87-1.131 1.132 2.87 2.87 1.13-1.132" mask="url(#b)"/></g><defs><filter id="a" width="18.664" height="18.727" x="6.268" y="8.005" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36435"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36435" result="shape"/></filter></defs></svg>';
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<string, string> = {
'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);

View File

@@ -0,0 +1,257 @@
/*
* 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.
*/
import { type Ellipse, type FabricObject, type Line } from 'fabric';
import { Mode } from '../typings';
import { setImageFixed } from '../share';
import { resetElementClip } from './fabric-utils';
import {
getLineEndControl,
getLineStartControl,
getResizeBLControl,
getResizeBRControl,
getResizeMBControl,
getResizeMLControl,
getResizeMRControl,
getResizeMTControl,
getResizeTLControl,
getResizeTRControl,
getRotateTLControl,
getRotateTRControl,
getRotateBLControl,
getRotateBRControl,
} from './controls';
export const setLineControlVisible = ({
element,
}: {
element: FabricObject;
}) => {
const { x1, x2, y1, y2 } = element as Line;
if ((x1 < x2 && y1 < y2) || (x1 > x2 && y1 > y2)) {
element.setControlsVisibility({
ml: false, // 中点左
mr: false, // 中点右
mt: false, // 中点上
mb: false, // 中点下
bl: false, // 底部左
br: true, // 底部右
tl: true, // 顶部左
tr: false, // 顶部右
mtr: false, // 旋转控制点
});
} else {
element.setControlsVisibility({
ml: false, // 中点左
mr: false, // 中点右
mt: false, // 中点上
mb: false, // 中点下
bl: true, // 底部左
br: false, // 底部右
tl: false, // 顶部左
tr: true, // 顶部右
mtr: false, // 旋转控制点
});
}
};
const setCircleRxRy = ({ element }: { element: FabricObject }) => {
const { width, height } = element as Ellipse;
element.set({ rx: width / 2, ry: height / 2 });
};
const getCommonControl = ({
element,
needResetScaleAndSnap = true,
}: {
element: FabricObject;
needResetScaleAndSnap?: boolean;
}) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
// resize
// 上左
element.controls.tl = getResizeTLControl({
needResetScaleAndSnap,
});
// 上中
element.controls.mt = getResizeMTControl({
needResetScaleAndSnap,
});
// 上右
element.controls.tr = getResizeTRControl({
needResetScaleAndSnap,
});
// 中左
element.controls.ml = getResizeMLControl({
needResetScaleAndSnap,
});
// 中右
element.controls.mr = getResizeMRControl({
needResetScaleAndSnap,
});
// 下左
element.controls.bl = getResizeBLControl({
needResetScaleAndSnap,
});
// 下中
element.controls.mb = getResizeMBControl({
needResetScaleAndSnap,
});
// 下右
element.controls.br = getResizeBRControl({
needResetScaleAndSnap,
});
// rotate
// 上左
element.controls.tlr = getRotateTLControl();
// 上右
element.controls.trr = getRotateTRControl();
// 下左
element.controls.blr = getRotateBLControl();
// 下右
element.controls.brr = getRotateBRControl();
};
export const createControls: Partial<
Record<Mode, (data: { element: FabricObject }) => void>
> = {
[Mode.STRAIGHT_LINE]: ({ element }) => {
setLineControlVisible({ element });
// 左上
element.controls.tl = getLineStartControl({
x: -0.5,
y: -0.5,
callback: setLineControlVisible,
});
// 右上
element.controls.tr = getLineStartControl({
x: 0.5,
y: -0.5,
callback: setLineControlVisible,
});
// 左下
element.controls.bl = getLineEndControl({
x: -0.5,
y: 0.5,
callback: setLineControlVisible,
});
// 右下
element.controls.br = getLineEndControl({
x: 0.5,
y: 0.5,
callback: setLineControlVisible,
});
},
[Mode.RECT]: getCommonControl,
[Mode.TRIANGLE]: getCommonControl,
[Mode.PENCIL]: props => {
getCommonControl({
...props,
needResetScaleAndSnap: false,
});
},
[Mode.CIRCLE]: ({ element }) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
const controlProps = {
callback: setCircleRxRy,
needResetScaleAndSnap: true,
};
element.controls.tl = getResizeTLControl(controlProps);
element.controls.mt = getResizeMTControl(controlProps);
element.controls.tr = getResizeTRControl(controlProps);
element.controls.ml = getResizeMLControl(controlProps);
element.controls.mr = getResizeMRControl(controlProps);
element.controls.bl = getResizeBLControl(controlProps);
element.controls.mb = getResizeMBControl(controlProps);
element.controls.br = getResizeBRControl(controlProps);
element.controls.tlr = getRotateTLControl(controlProps);
element.controls.trr = getRotateTRControl(controlProps);
element.controls.blr = getRotateBLControl(controlProps);
element.controls.brr = getRotateBRControl(controlProps);
},
[Mode.BLOCK_TEXT]: ({ element }) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
const controlProps = {
callback: () => {
resetElementClip({ element });
element.fire('moving');
},
needResetScaleAndSnap: true,
};
element.controls.tl = getResizeTLControl(controlProps);
element.controls.mt = getResizeMTControl(controlProps);
element.controls.tr = getResizeTRControl(controlProps);
element.controls.ml = getResizeMLControl(controlProps);
element.controls.mr = getResizeMRControl(controlProps);
element.controls.bl = getResizeBLControl(controlProps);
element.controls.mb = getResizeMBControl(controlProps);
element.controls.br = getResizeBRControl(controlProps);
element.controls.tlr = getRotateTLControl(controlProps);
element.controls.trr = getRotateTRControl(controlProps);
element.controls.blr = getRotateBLControl(controlProps);
element.controls.brr = getRotateBRControl(controlProps);
},
[Mode.INLINE_TEXT]: ({ element }) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
element.controls.tlr = getRotateTLControl();
element.controls.trr = getRotateTRControl();
element.controls.blr = getRotateBLControl();
element.controls.brr = getRotateBRControl();
},
[Mode.IMAGE]: ({ element }) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
const controlProps = {
callback: () => {
setImageFixed({ element });
resetElementClip({ element });
element.fire('moving');
},
needResetScaleAndSnap: true,
};
element.controls.tl = getResizeTLControl(controlProps);
element.controls.mt = getResizeMTControl(controlProps);
element.controls.tr = getResizeTRControl(controlProps);
element.controls.ml = getResizeMLControl(controlProps);
element.controls.mr = getResizeMRControl(controlProps);
element.controls.bl = getResizeBLControl(controlProps);
element.controls.mb = getResizeMBControl(controlProps);
element.controls.br = getResizeBRControl(controlProps);
element.controls.tlr = getRotateTLControl(controlProps);
element.controls.trr = getRotateTRControl(controlProps);
element.controls.blr = getRotateBLControl(controlProps);
element.controls.brr = getRotateBRControl(controlProps);
},
};

View File

@@ -0,0 +1,91 @@
/*
* 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.
*/
import {
ImageFixedType,
Mode,
TextAlign,
type FabricObjectSchema,
} from '../typings';
/**
* 选中态边框及控制点样式
*/
export const selectedBorderProps = {
borderColor: '#4D53E8',
borderWidth: 2,
cornerStyle: 'circle',
cornerColor: '#ffffff',
cornerStrokeColor: '#4D53E8',
transparentCorners: false,
borderOpacityWhenMoving: 0.8,
};
const defaultFontSize = 24;
const textProps = {
fontSize: defaultFontSize,
fontFamily: '常规体-思源黑体',
fill: '#000000ff',
stroke: '#000000ff',
strokeWidth: 0,
textAlign: TextAlign.LEFT,
lineHeight: 1.2,
};
const shapeProps = {
fill: '#ccccccff',
stroke: '#000000ff',
strokeWidth: 0,
width: 200,
height: 200,
};
export const defaultProps: Record<Mode, Partial<FabricObjectSchema>> = {
[Mode.INLINE_TEXT]: textProps,
[Mode.BLOCK_TEXT]: {
...textProps,
width: 200,
height: 200,
padding: defaultFontSize / 2,
// 必须拆分true否则中文不会换行。splitByGrapheme:true 约等于 wordBreak: break-all
splitByGrapheme: true,
},
[Mode.RECT]: shapeProps,
[Mode.CIRCLE]: {
...shapeProps,
rx: shapeProps.width / 2,
ry: shapeProps.height / 2,
},
[Mode.TRIANGLE]: shapeProps,
[Mode.STRAIGHT_LINE]: {
strokeWidth: 1,
stroke: '#ccccccff',
strokeLineCap: 'round',
},
[Mode.PENCIL]: {
strokeWidth: 1,
stroke: '#000000ff',
},
[Mode.IMAGE]: {
customFixedType: ImageFixedType.FILL,
stroke: '#000000ff',
strokeWidth: 0,
width: 400,
height: 400,
opacity: 1,
},
[Mode.GROUP]: {},
};

View File

@@ -0,0 +1,366 @@
/*
* 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 @coze-arch/max-line-per-function */
/* eslint-disable complexity */
/**
* 托管所有的图形创建,赋予业务改造
* 调用时机
* 1. 初次创建
* 2. loadFromSchema
*/
import { nanoid } from 'nanoid';
import {
Ellipse,
FabricImage,
Group,
IText,
Line,
Rect,
Textbox,
Triangle,
type Canvas,
FabricObject,
type ObjectEvents,
} from 'fabric';
import { I18n } from '@coze-arch/i18n';
import {
Mode,
type FabricObjectWithCustomProps,
type FabricObjectSchema,
} from '../typings';
import { setImageFixed } from '../share';
import { resetElementClip } from './fabric-utils';
import { defaultProps } from './default-props';
import { createControls, setLineControlVisible } from './create-controls';
/**
* 覆盖默认的 Textbox height 计算逻辑
* 默认:根据内容,撑起 Textbox
* 预期:严格按照给定高度渲染,溢出隐藏
*/
const _calcTextHeight = Textbox.prototype.calcTextHeight;
Textbox.prototype.calcTextHeight = function () {
return ((this as Textbox & { customFixedHeight?: number })
.customFixedHeight ?? _calcTextHeight.call(this)) as number;
};
/**
* 修复 fabric bug使用某些自定义字体后Text 宽度计算异常
* 修复方案 from https://github.com/fabricjs/fabric.js/issues/9852
*/
IText.getDefaults = () => ({});
// for each class in the chain that has a ownDefaults object:
Object.assign(IText.prototype, Textbox.ownDefaults);
Object.assign(Text.prototype, IText.ownDefaults);
Object.assign(FabricObject.prototype, FabricObject.ownDefaults);
const textDefaultText = I18n.t('imageflow_canvas_text_default');
const textBoxDefaultText = I18n.t('imageflow_canvas_text_default');
export const createCommonObjectOptions = (
mode: Mode,
): Partial<FabricObjectWithCustomProps> => ({
customId: nanoid(),
customType: mode as Mode,
});
/**
* 元素创建入口,所有的元素创建逻辑都走这里
*/
export const createElement = async ({
mode,
position,
element,
elementProps = {},
canvas,
}: {
mode?: Mode;
position?: [x?: number, y?: number];
canvas?: Canvas;
element?: FabricObjectWithCustomProps;
elementProps?: Partial<FabricObjectSchema>;
}): Promise<FabricObject | undefined> => {
const left = element?.left ?? position?.[0] ?? 0;
const top = element?.top ?? position?.[1] ?? 0;
const _mode = mode ?? element?.customType;
const commonNewObjectOptions = createCommonObjectOptions(_mode as Mode);
switch (_mode) {
case Mode.RECT: {
let _element: FabricObject | undefined = element;
if (!_element) {
_element = new Rect({
...commonNewObjectOptions,
...defaultProps[_mode],
...elementProps,
left,
top,
width: 1,
height: 1,
});
}
createControls[_mode]?.({
element: _element,
});
return _element;
}
case Mode.CIRCLE: {
let _element: FabricObject | undefined = element;
if (!_element) {
_element = new Ellipse({
...commonNewObjectOptions,
...defaultProps[_mode],
...elementProps,
left,
top,
rx: 1,
ry: 1,
});
}
createControls[_mode]?.({
element: _element,
});
return _element;
}
case Mode.TRIANGLE: {
let _element: FabricObject | undefined = element;
if (!_element) {
_element = new Triangle({
...commonNewObjectOptions,
...defaultProps[_mode],
...elementProps,
left,
top,
width: 1,
height: 1,
});
}
createControls[_mode]?.({
element: _element as Triangle,
});
return _element;
}
case Mode.STRAIGHT_LINE: {
let _element: FabricObject | undefined = element;
if (!_element) {
_element = new Line([left, top, left, top], {
...commonNewObjectOptions,
...defaultProps[_mode],
...elementProps,
});
// 创建直线时的起始点不是通过控制点触发的,需要额外监听
_element.on('start-end:modified' as keyof ObjectEvents, () => {
setLineControlVisible({
element: _element as Line,
});
});
}
createControls[_mode]?.({
element: _element as Line,
});
return _element;
}
case Mode.INLINE_TEXT: {
let _element: FabricObject | undefined = element;
if (!_element) {
_element = new IText(elementProps?.text ?? textDefaultText, {
...commonNewObjectOptions,
...defaultProps[_mode],
...elementProps,
left,
top,
});
}
createControls[_mode]?.({
element: _element,
});
return _element;
}
case Mode.BLOCK_TEXT: {
let _element: FabricObject | undefined = element;
const width = elementProps?.width ?? _element?.width ?? 1;
const height = elementProps?.height ?? _element?.height ?? 1;
if (!_element) {
_element = new Textbox(
(elementProps?.text as string) ?? textBoxDefaultText,
{
...commonNewObjectOptions,
...defaultProps[_mode],
customFixedHeight: height,
left,
top,
width,
height,
...elementProps,
},
);
const rect = new Rect();
_element.set({
clipPath: rect,
});
}
resetElementClip({ element: _element as FabricObject });
createControls[_mode]?.({
element: _element,
});
return _element;
}
case Mode.IMAGE: {
let _element: FabricObject | undefined = element;
/**
* FabricImage 不支持拉伸/自适应
* 需要用 Group 包一下,根据 Group 大小,计算 image 的位置
* 1. customId customType 要给到 Group。根其他元素保持一致从 element 上能直接取到)
* 2. 边框的相关设置要给到图片
*
* 因此而产生的 Hack
* 1. 属性表单根据 schema 解析成 formValue ,需要取 groupSchema.objects[0]
* 2. 属性表单设置元素属性(边框),需要调用 group.getObjects()[0].set
*/
if (!_element) {
if (elementProps?.src) {
const img = await FabricImage.fromURL(elementProps?.src);
const defaultWidth = defaultProps[_mode].width as number;
const defaultHeight = defaultProps[_mode].height as number;
const defaultLeft =
left ??
((elementProps?.left ?? defaultProps[_mode].left) as number);
const defaultTop =
top ?? ((elementProps?.top ?? defaultProps[_mode].top) as number);
/**
* stroke, strokeWidth 设置给 borderRect objects[1]
*/
const { stroke, strokeWidth, ...rest } = {
...defaultProps[_mode],
...elementProps,
};
img.set(rest);
_element = new Group([img]);
const groupProps = {
...commonNewObjectOptions,
left: defaultLeft,
top: defaultTop,
width: defaultWidth,
height: defaultHeight,
customId: elementProps?.customId ?? commonNewObjectOptions.customId,
};
const borderRect = new Rect({
width: groupProps.width,
height: groupProps.height,
stroke,
strokeWidth,
fill: '#00000000',
});
(_element as Group).add(borderRect);
_element.set(groupProps);
// 比例填充时,图片会溢出,所以加了裁剪
const clipRect = new Rect();
_element.set({
clipPath: clipRect,
});
}
}
resetElementClip({ element: _element as FabricObject });
// 计算 image 的渲染位置
setImageFixed({
element: _element as Group,
});
createControls[_mode]?.({
element: _element as FabricImage,
});
return _element;
}
case Mode.GROUP: {
let _element: FabricObject | undefined = element;
if (!_element) {
const { objects = [] } = elementProps;
_element = new Group(objects as unknown as FabricObject[], {
...commonNewObjectOptions,
...defaultProps[_mode],
...elementProps,
});
}
return _element;
}
case Mode.PENCIL: {
if (element) {
createControls[_mode]?.({
element,
});
}
return element;
}
default:
return element;
}
};
// hook element 加载到画布
export const setElementAfterLoad = async ({
element,
options: { readonly },
canvas,
}: {
element: FabricObject;
options: { readonly: boolean };
canvas?: Canvas;
}) => {
element.selectable = !readonly;
await createElement({
element: element as FabricObjectWithCustomProps,
canvas,
});
if (readonly) {
element.set({
hoverCursor: 'default',
});
}
};

View File

@@ -0,0 +1,173 @@
/*
* 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.
*/
import {
type Canvas,
type FabricObject,
type Point,
type TMat2D,
type Textbox,
} from 'fabric';
import { Mode, type FabricObjectWithCustomProps } from '../typings';
/**
* 缩放到指定点
*/
export const zoomToPoint = ({
canvas,
point,
zoomLevel,
minZoom,
maxZoom,
}: {
point: Point;
zoomLevel: number;
canvas?: Canvas;
minZoom: number;
maxZoom: number;
}): TMat2D => {
// 设置缩放级别的限制
zoomLevel = Math.max(zoomLevel, minZoom); // 最小缩放级别
zoomLevel = Math.min(zoomLevel, maxZoom); // 最大缩放级别
// 以鼠标位置为中心进行缩放
canvas?.zoomToPoint(point, zoomLevel);
return [...(canvas?.viewportTransform as TMat2D)];
};
/**
* 设置 canvas 视图
*/
export const setViewport = ({
canvas,
vpt,
}: {
vpt: TMat2D;
canvas?: Canvas;
}): TMat2D => {
canvas?.setViewportTransform(vpt);
canvas?.requestRenderAll();
return [...(canvas?.viewportTransform as TMat2D)];
};
/**
* 画布坐标点距离画布左上角距离单位px
*/
export const canvasXYToScreen = ({
canvas,
scale,
point,
}: {
canvas: Canvas;
scale: number;
point: { x: number; y: number };
}) => {
// 获取画布的变换矩阵
const transform = canvas.viewportTransform;
// 应用缩放和平移
const zoomX = transform[0];
const zoomY = transform[3];
const translateX = transform[4];
const translateY = transform[5];
const screenX = (point.x * zoomX + translateX) * scale;
const screenY = (point.y * zoomY + translateY) * scale;
// 获取画布在屏幕上的位置
const x = screenX;
const y = screenY;
// 不做限制
return {
x,
y,
};
};
/**
* 得到选中元素的屏幕坐标(左上 tl、右下 br
*/
export const getPopPosition = ({
canvas,
scale,
}: {
canvas: Canvas;
scale: number;
}) => {
const selection = canvas?.getActiveObject();
if (canvas && selection) {
const boundingRect = selection.getBoundingRect();
// 左上角坐标
const tl = {
x: boundingRect.left,
y: boundingRect.top,
};
// 右下角坐标
const br = {
x: boundingRect.left + boundingRect.width,
y: boundingRect.top + boundingRect.height,
};
return {
tl: canvasXYToScreen({ canvas, scale, point: tl }),
br: canvasXYToScreen({ canvas, scale, point: br }),
};
}
return {
tl: {
x: -9999,
y: -9999,
},
br: {
x: -9999,
y: -9999,
},
};
};
export const resetElementClip = ({ element }: { element: FabricObject }) => {
if (!element.clipPath) {
return;
}
const clipRect = element.clipPath;
const padding = (element as Textbox).padding ?? 0;
const { height, width } = element as FabricObject;
const _height = height + padding * 2;
const _width = width + padding * 2;
const newPosition = {
originX: 'left',
originY: 'top',
left: -_width / 2,
top: -_height / 2,
height: _height,
width: _width,
absolutePositioned: false,
};
clipRect?.set(newPosition);
};
export const isGroupElement = (obj?: FabricObject) =>
(obj as FabricObjectWithCustomProps)?.customType === Mode.GROUP;

View File

@@ -0,0 +1,75 @@
/*
* 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.
*/
import { type Canvas, type IText } from 'fabric';
import { QueryClient } from '@tanstack/react-query';
import { Mode, type FabricSchema } from '../typings';
import { getFontUrl, supportFonts } from '../assert/font';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
});
export const loadFont = async (font: string): Promise<void> => {
await queryClient.fetchQuery({
queryKey: [font],
queryFn: async () => {
if (supportFonts.includes(font)) {
const url = getFontUrl(font);
const fontFace = new FontFace(font, `url(${url})`);
document.fonts.add(fontFace);
await fontFace.load();
}
return font;
},
});
};
export const loadFontWithSchema = ({
schema,
canvas,
fontFamily,
}: {
schema?: FabricSchema;
canvas?: Canvas;
fontFamily?: string;
}) => {
let fonts: string[] = fontFamily ? [fontFamily] : [];
if (schema) {
fonts = schema.objects
.filter(o => [Mode.INLINE_TEXT, Mode.BLOCK_TEXT].includes(o.customType))
.map(o => o.fontFamily) as string[];
fonts = Array.from(new Set(fonts));
}
fonts.forEach(async font => {
await loadFont(font);
canvas
?.getObjects()
.filter(o => (o as IText)?.fontFamily === font)
.forEach(o => {
o.set({
fontFamily: font,
});
});
canvas?.requestRenderAll();
});
};

View File

@@ -0,0 +1,35 @@
/*
* 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.
*/
export {
createElement,
setElementAfterLoad,
createCommonObjectOptions,
} from './element-factory';
export {
canvasXYToScreen,
getPopPosition,
setViewport,
zoomToPoint,
} from './fabric-utils';
export { schemaToFormValue } from './schema-to-form-value';
export { loadFontWithSchema, loadFont } from './font-loader';
export { defaultProps, selectedBorderProps } from './default-props';
export { getNumberBetween } from './common';

View File

@@ -0,0 +1,64 @@
/*
* 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.
*/
import {
Mode,
type FabricObjectSchema,
type FabricSchema,
type FormMeta,
} from '../typings';
/**
* fabric schema 转 formValue
*/
export const schemaToFormValue = ({
schema,
activeObjectId,
formMeta,
}: {
schema: FabricSchema;
activeObjectId: string;
formMeta: FormMeta;
}): Partial<FabricObjectSchema> => {
let s = schema.objects.find(o => o.customId === activeObjectId);
// 图片是 Group 复合元素,要把需要的元素取出来
if (s?.customType === Mode.IMAGE) {
s = {
...s,
...s.objects?.[0],
// 描边颜色和粗细从 borderRect 上取
stroke: s.objects?.[1].stroke,
strokeWidth: s.objects?.[1].strokeWidth,
} as unknown as FabricObjectSchema;
}
const defaultFormValue: Partial<FabricObjectSchema> = {};
formMeta?.content.forEach(item => {
if (item.name) {
defaultFormValue[item.name] =
s?.[item.name] ?? item.setterProps?.defaultValue;
}
if ((item.tooltip?.content.length ?? 0) > 0) {
item.tooltip?.content.forEach(d => {
if (d.name) {
defaultFormValue[d.name] = s?.[d.name] ?? d.setterProps?.defaultValue;
}
});
}
});
return defaultFormValue;
};

View File

@@ -0,0 +1,145 @@
/*
* 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.
*/
import { type Canvas, type FabricObject } from 'fabric';
import { canvasXYToScreen } from '../fabric-utils';
import { type Snap } from '../../typings';
import { numberEqual } from './util';
const pointSize = 12;
const getPointHtml = (point: Snap.Point) => `
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="${pointSize}"
height="${pointSize}"
class="absolute opacity-80 rotate-45"
style="
top: ${point.y - pointSize / 2}px;
left: ${point.x - pointSize / 2}px;
fill: #00B2B2;
"
>
<path d="M930.688 487.338667s3.584 0.469333 6.314667 1.322666a32 32 0 0 1 5.973333 58.496c-4.906667 2.730667-15.530667 4.053333-15.530667 4.053334H96.554667s-3.584-0.085333-6.442667-0.682667a31.786667 31.786667 0 0 1-16.725333-9.301333 32.256 32.256 0 0 1 0-44.074667 32.213333 32.213333 0 0 1 7.637333-5.930667c4.906667-2.730667 15.530667-4.010667 15.530667-4.010666l834.133333 0.128z" p-id="4210"></path>
<path d="M516.864 72.149333a32.042667 32.042667 0 0 1 25.685333 22.016 59.733333 59.733333 0 0 1 1.450667 9.6v830.848s-1.322667 10.666667-4.010667 15.530667a31.573333 31.573333 0 0 1-13.909333 13.226667 32.341333 32.341333 0 0 1-36.138667-5.546667 32.341333 32.341333 0 0 1-9.301333-16.768c-0.554667-2.816-0.64-6.442667-0.64-6.442667V103.765333s0.469333-6.4 1.450667-9.6a32.298667 32.298667 0 0 1 19.456-20.394666 34.816 34.816 0 0 1 12.714666-1.962667l3.242667 0.341333z"></path>
</svg>`;
const lineWidth = 1;
const getLineHtml = (startXY: Snap.Point, endXY: Snap.Point) => {
let innerHTML = '';
if (numberEqual(startXY.x, endXY.x)) {
innerHTML += `<div
class="absolute bg-[#00B2B2]"
style="
top: ${Math.min(startXY.y, endXY.y)}px;
left: ${startXY.x - lineWidth / 2}px;
width: ${lineWidth}px;
height: ${Math.abs(endXY.y - startXY.y)}px;
"
></div>`;
} else {
// 横线
innerHTML += `<div
class="absolute bg-[#00B2B2]"
style="
top: ${startXY.y - lineWidth / 2}px;
left: ${Math.min(startXY.x, endXY.x)}px;
width: ${Math.abs(endXY.x - startXY.x)}px;
height: ${lineWidth}px;
"
></div>`;
}
return innerHTML;
};
class Helpline {
canvas: Canvas;
helpLineLayerId: string;
scale: number;
constructor(canvas: Canvas, helpLineLayerId: string, scale?: number) {
this.canvas = canvas;
this.helpLineLayerId = helpLineLayerId;
this.scale = scale ?? 1;
}
objects: FabricObject[] = [];
resetScale = (scale: number) => {
this.scale = scale;
};
test = (points: Snap.Point[]) => {
const layer = document.getElementById(this.helpLineLayerId);
if (!layer) {
return;
}
let innerHTML = '';
points.forEach(point => {
const xy = canvasXYToScreen({
canvas: this.canvas,
scale: this.scale,
point,
});
innerHTML += getPointHtml(xy);
});
layer.innerHTML = innerHTML;
};
show = (lines: Snap.Line[]) => {
const layer = document.getElementById(this.helpLineLayerId);
if (layer) {
let innerHTML = '';
lines.forEach(line => {
line.forEach(point => {
const xy = canvasXYToScreen({
canvas: this.canvas,
scale: this.scale,
point,
});
innerHTML += getPointHtml(xy);
});
const startXY = canvasXYToScreen({
canvas: this.canvas,
scale: this.scale,
point: line[0],
});
const endXY = canvasXYToScreen({
canvas: this.canvas,
scale: this.scale,
point: line[line.length - 1],
});
innerHTML += getLineHtml(startXY, endXY);
});
layer.innerHTML = innerHTML;
}
};
hide = () => {
const layer = document.getElementById(this.helpLineLayerId);
if (layer) {
layer.innerHTML = '';
}
};
}
export default Helpline;

View File

@@ -0,0 +1,370 @@
/*
* 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 @coze-arch/max-line-per-function */
import { findLatestObject, numberEqual } from '../util';
import { Snap } from '../../../typings';
type Attribute =
| 'top'
| 'left'
| 'height'
| 'width'
| 'top|height'
| 'left|width';
interface Config {
/**
* 影响到的属性
*/
key: Attribute;
/**
* 吸附方向
*/
direction: 'x' | 'y';
/**
* 吸附到的值
*/
snapValue: number[];
}
// 计算目标元素的未来位置
const getNextHelplinePoint = ({
targetPoint,
latestDistance,
controlType,
direction,
}: {
targetPoint: Snap.ObjectPointsWithMiddle;
latestDistance: number;
controlType: Snap.ControlType;
direction: 'x' | 'y';
}) => {
const map: Record<Snap.ControlType, Snap.Point[]> = {
[Snap.ControlType.TopLeft]: [targetPoint.tl],
[Snap.ControlType.TopRight]: [targetPoint.tr],
[Snap.ControlType.BottomLeft]: [targetPoint.bl],
[Snap.ControlType.BottomRight]: [targetPoint.br],
[Snap.ControlType.Top]: [
{
x: targetPoint.tl.x + (targetPoint.tr.x - targetPoint.tl.x) / 2,
y: targetPoint.tl.y,
},
],
[Snap.ControlType.Left]: [
{
x: targetPoint.tl.x,
y: targetPoint.tl.y + (targetPoint.bl.y - targetPoint.tl.y) / 2,
},
],
[Snap.ControlType.Bottom]: [
{
x: targetPoint.tl.x + (targetPoint.br.x - targetPoint.tl.x) / 2,
y: targetPoint.bl.y,
},
],
[Snap.ControlType.Right]: [
{
x: targetPoint.tr.x,
y: targetPoint.tl.y + (targetPoint.br.y - targetPoint.tl.y) / 2,
},
],
[Snap.ControlType.Center]: Object.values(targetPoint).map(d => ({
...d,
isTarget: true,
})),
};
return map[controlType].map(p => ({
...p,
[direction]: p[direction] + latestDistance,
}));
};
// 计算吸附结果
const getNextRs = ({
latestDistance,
latestDistanceAbs,
helplines,
attribute,
}: {
latestDistance: number;
latestDistanceAbs: number;
helplines: Snap.Line[];
attribute: Attribute;
}): Partial<Snap.RuleResult> => {
if (attribute === 'top|height') {
return {
top: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
height: {
helplines: [],
snapDistance: 999,
next: -latestDistance,
isSnap: true,
},
};
} else if (attribute === 'left|width') {
return {
left: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
width: {
helplines: [],
snapDistance: 999,
next: -latestDistance,
isSnap: true,
},
};
} else if (attribute === 'top') {
return {
top: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
};
} else if (attribute === 'left') {
return {
left: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
};
} else if (attribute === 'width') {
return {
width: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
};
} else if (attribute === 'height') {
return {
height: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
};
}
return {};
};
// 对齐规则
export const alignRule: Snap.Rule = ({
otherPoints,
targetPoint,
threshold,
controlType,
}) => {
let rs: Snap.RuleResult = {
top: {
helplines: [],
snapDistance: 999,
next: targetPoint.tl.y,
},
left: {
helplines: [],
snapDistance: 999,
next: targetPoint.tl.x,
},
width: {
helplines: [],
snapDistance: 999,
next: targetPoint.tl.x,
},
height: {
helplines: [],
snapDistance: 999,
next: targetPoint.tl.y,
},
};
if (!otherPoints || otherPoints.length === 0) {
return rs;
}
const configMap: Record<Snap.ControlType, Config[]> = {
[Snap.ControlType.TopLeft]: [
{ key: 'top|height', direction: 'y', snapValue: [targetPoint.tl.y] },
{ key: 'left|width', direction: 'x', snapValue: [targetPoint.tl.x] },
],
[Snap.ControlType.TopRight]: [
{ key: 'top|height', direction: 'y', snapValue: [targetPoint.tr.y] },
{ key: 'width', direction: 'x', snapValue: [targetPoint.tr.x] },
],
[Snap.ControlType.BottomLeft]: [
{ key: 'height', direction: 'y', snapValue: [targetPoint.bl.y] },
{ key: 'left|width', direction: 'x', snapValue: [targetPoint.bl.x] },
],
[Snap.ControlType.BottomRight]: [
{ key: 'height', direction: 'y', snapValue: [targetPoint.br.y] },
{ key: 'width', direction: 'x', snapValue: [targetPoint.br.x] },
],
[Snap.ControlType.Top]: [
{
key: 'top|height',
direction: 'y',
snapValue: [targetPoint.tl.y],
},
],
[Snap.ControlType.Left]: [
{
key: 'left|width',
direction: 'x',
snapValue: [targetPoint.tl.x],
},
],
[Snap.ControlType.Bottom]: [
{
key: 'height',
direction: 'y',
snapValue: [targetPoint.bl.y],
},
],
[Snap.ControlType.Right]: [
{
key: 'width',
direction: 'x',
snapValue: [targetPoint.tr.x],
},
],
[Snap.ControlType.Center]: [
{
key: 'top',
direction: 'y',
snapValue: Array.from(
new Set(Object.values(targetPoint).map(p => p.y)),
),
},
{
key: 'left',
direction: 'x',
snapValue: Array.from(
new Set(Object.values(targetPoint).map(p => p.x)),
),
},
],
};
const config = configMap[controlType];
config.forEach(item => {
// 需要判断吸附的点位集合
const points = item.snapValue;
// 找到距离最近的吸附点集合
const {
snapPoints,
distance: latestDistance,
distanceAbs: latestDistanceAbs,
} = findLatestObject(otherPoints, points, item.direction);
// 如果距离小于阈值,则进行吸附
if (latestDistanceAbs <= threshold) {
const helplines: Snap.Line[] = [];
const allPoints: (Snap.Point & { isTarget?: boolean })[] = [];
// 将所有其他对象的点添加到 allPoints 中
otherPoints.forEach(a => {
allPoints.push(...Object.values(a));
});
// 将目标对象的点添加到 allPoints 中
allPoints.push(
...getNextHelplinePoint({
targetPoint,
latestDistance,
controlType,
direction: item.direction,
}),
);
// 根据吸附方向对 allPoints 进行排序
const sortKey = item.direction === 'x' ? 'y' : 'x';
// 根据吸附结果,从所有点中挑选出需要绘制辅助线的点
snapPoints.forEach(sp => {
const _helpline = allPoints
.filter(p => numberEqual(p[item.direction], sp[item.direction]))
.sort((a, b) => a[sortKey] - b[sortKey]);
helplines.push(_helpline);
});
rs = {
...rs,
...getNextRs({
latestDistance,
latestDistanceAbs,
helplines,
attribute: item.key,
}),
};
}
});
// x,y 一起吸附,需要对 x 吸附线的 y 坐标进行修正
if (
controlType === Snap.ControlType.Center &&
rs.top?.isSnap &&
rs.left?.helplines?.length
) {
rs.left.helplines = rs.left.helplines.map(line =>
line.map(p => {
if ((p as Snap.Point & { isTarget?: boolean }).isTarget) {
return {
...p,
y: p.y + (rs.top?.next ?? 0),
};
}
return p;
}),
);
}
// x,y 一起吸附,需要对 y 吸附线的 x 坐标进行修正
if (
controlType === Snap.ControlType.Center &&
rs.left?.isSnap &&
rs.top?.helplines?.length
) {
rs.top.helplines = rs.top.helplines.map(line =>
line.map(p => {
if ((p as Snap.Point & { isTarget?: boolean }).isTarget) {
return {
...p,
x: p.x + (rs.left?.next ?? 0),
};
}
return p;
}),
);
}
return rs;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,317 @@
/*
* 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.
*/
import { findLatestObject } from '../util';
import { Snap } from '../../../typings';
type Attribute =
| 'top'
| 'left'
| 'height'
| 'width'
| 'top|height'
| 'left|width';
interface Config {
/**
* 影响到的属性
*/
key: Attribute;
/**
* 吸附方向
*/
direction: 'x' | 'y';
/**
* 吸附到的值
*/
snapValue: number[];
}
// 计算目标元素的未来位置
const getNextHelplinePoint = ({
targetPoint,
latestDistance,
controlType,
direction,
}: {
targetPoint: Snap.ObjectPointsWithMiddle;
latestDistance: number;
controlType: Snap.ControlType;
direction: 'x' | 'y';
}) => {
const map: Record<Snap.ControlType, Snap.Point[]> = {
[Snap.ControlType.TopLeft]: [targetPoint.tl],
[Snap.ControlType.TopRight]: [targetPoint.tr],
[Snap.ControlType.BottomLeft]: [targetPoint.bl],
[Snap.ControlType.BottomRight]: [targetPoint.br],
[Snap.ControlType.Top]: [
{
x: targetPoint.tl.x + (targetPoint.tr.x - targetPoint.tl.x) / 2,
y: targetPoint.tl.y,
},
],
[Snap.ControlType.Left]: [
{
x: targetPoint.tl.x,
y: targetPoint.tl.y + (targetPoint.bl.y - targetPoint.tl.y) / 2,
},
],
[Snap.ControlType.Bottom]: [
{
x: targetPoint.tl.x + (targetPoint.br.x - targetPoint.tl.x) / 2,
y: targetPoint.bl.y,
},
],
[Snap.ControlType.Right]: [
{
x: targetPoint.tr.x,
y: targetPoint.tl.y + (targetPoint.br.y - targetPoint.tl.y) / 2,
},
],
[Snap.ControlType.Center]: Object.values(targetPoint),
};
return map[controlType].map(p => ({
...p,
[direction]: p[direction] + latestDistance,
}));
};
const getNextRs = ({
latestDistance,
latestDistanceAbs,
helplines,
attribute,
}: {
latestDistance: number;
latestDistanceAbs: number;
helplines: Snap.Line[];
attribute: Attribute;
}): Partial<Snap.RuleResult> => {
if (attribute === 'top|height') {
return {
top: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
height: {
helplines: [],
snapDistance: 999,
next: -latestDistance,
isSnap: true,
},
};
} else if (attribute === 'left|width') {
return {
left: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
width: {
helplines: [],
snapDistance: 999,
next: -latestDistance,
isSnap: true,
},
};
} else if (attribute === 'top') {
return {
top: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
};
} else if (attribute === 'left') {
return {
left: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
};
} else if (attribute === 'width') {
return {
width: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
};
} else if (attribute === 'height') {
return {
height: {
helplines,
snapDistance: latestDistanceAbs,
next: latestDistance,
isSnap: true,
},
};
}
return {};
};
export const resizeRule: Snap.Rule = ({
otherPoints,
targetPoint,
threshold,
controlType,
}) => {
let rs: Snap.RuleResult = {
top: {
helplines: [],
snapDistance: 999,
next: targetPoint.tl.y,
},
left: {
helplines: [],
snapDistance: 999,
next: targetPoint.tl.x,
},
width: {
helplines: [],
snapDistance: 999,
next: targetPoint.tl.x,
},
height: {
helplines: [],
snapDistance: 999,
next: targetPoint.tl.y,
},
};
if (!otherPoints || otherPoints.length === 0) {
return rs;
}
const configMap: Record<Snap.ControlType, Config[]> = {
[Snap.ControlType.TopLeft]: [
{ key: 'top|height', direction: 'y', snapValue: [targetPoint.tl.y] },
{ key: 'left|width', direction: 'x', snapValue: [targetPoint.tl.x] },
],
[Snap.ControlType.TopRight]: [
{ key: 'top|height', direction: 'y', snapValue: [targetPoint.tr.y] },
{ key: 'width', direction: 'x', snapValue: [targetPoint.tr.x] },
],
[Snap.ControlType.BottomLeft]: [
{ key: 'height', direction: 'y', snapValue: [targetPoint.bl.y] },
{ key: 'left|width', direction: 'x', snapValue: [targetPoint.bl.x] },
],
[Snap.ControlType.BottomRight]: [
{ key: 'height', direction: 'y', snapValue: [targetPoint.br.y] },
{ key: 'width', direction: 'x', snapValue: [targetPoint.br.x] },
],
[Snap.ControlType.Top]: [
{
key: 'top|height',
direction: 'y',
snapValue: [targetPoint.tl.y],
},
],
[Snap.ControlType.Left]: [
{
key: 'left|width',
direction: 'x',
snapValue: [targetPoint.tl.x],
},
],
[Snap.ControlType.Bottom]: [
{
key: 'height',
direction: 'y',
snapValue: [targetPoint.bl.y],
},
],
[Snap.ControlType.Right]: [
{
key: 'width',
direction: 'x',
snapValue: [targetPoint.tr.x],
},
],
[Snap.ControlType.Center]: [
{
key: 'top',
direction: 'y',
snapValue: Array.from(
new Set(Object.values(targetPoint).map(p => p.y)),
),
},
{
key: 'left',
direction: 'x',
snapValue: Array.from(
new Set(Object.values(targetPoint).map(p => p.x)),
),
},
],
};
const config = configMap[controlType];
config.forEach(item => {
const points = item.snapValue;
const {
snapPoints,
distance: latestDistance,
distanceAbs: latestDistanceAbs,
} = findLatestObject(otherPoints, points, item.direction);
if (latestDistanceAbs <= threshold) {
const helplines: Snap.Line[] = [];
const allPoints: Snap.Point[] = [];
otherPoints.forEach(a => {
allPoints.push(...Object.values(a));
});
allPoints.push(
...getNextHelplinePoint({
targetPoint,
latestDistance,
controlType,
direction: item.direction,
}),
);
const sortKey = item.direction === 'x' ? 'y' : 'x';
snapPoints.forEach(sp => {
const _helpline = allPoints
.filter(p => p[item.direction] === sp[item.direction])
.sort((a, b) => a[sortKey] - b[sortKey]);
helplines.push(_helpline);
});
rs = {
...rs,
...getNextRs({
latestDistance,
latestDistanceAbs,
helplines,
attribute: item.key,
}),
};
}
});
return rs;
};

View File

@@ -0,0 +1,189 @@
/*
* 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 @typescript-eslint/naming-convention */
/* eslint-disable complexity */
import { type Canvas, type FabricObject, type Group } from 'fabric';
import { Snap } from '../../typings';
import {
bboxHeightToHeight,
bboxWidthToWidth,
fixedMiddlePoint,
getBBoxHeight,
getBBoxWidth,
getLatestSnapRs,
getObjectPoints,
} from './util';
import { paddingRule } from './rule/padding';
import { alignRule } from './rule/align';
import Helpline from './helpline';
class SnapService {
helpline: Helpline;
canvas: Canvas;
threshold = 10;
rules: Snap.Rule[] = [paddingRule, alignRule];
constructor(canvas: Canvas, helpLineLayerId: string, scale?: number) {
this.canvas = canvas;
this.helpline = new Helpline(canvas, helpLineLayerId, scale);
document.addEventListener('keydown', this.onKeyDown);
document.addEventListener('keyup', this.onKeyUp);
}
testPoints: Snap.Point[] = [];
snapOpen = true;
// 开发模式,开启后,按下 shift 会显示激活元素的 5 个点位
devMode = false;
onKeyDown = (event: KeyboardEvent) => {
// 按下 cmd 键,关闭吸附(与截图冲突,暂时隐藏)
if (event.key.toLowerCase() === 'meta') {
// this.snapOpen = false;
} else if (event.key.toLowerCase() === 'shift' && this.devMode) {
const target = this.canvas.getActiveObject();
if (target) {
const targetPoints = getObjectPoints(target);
this.testPoints = Object.values(targetPoints);
this.helpline.test(this.testPoints);
}
}
};
onKeyUp = (event: KeyboardEvent) => {
// 松手 cmd 键,打开吸附(与截图冲突,暂时隐藏)
if (event.key.toLowerCase() === 'meta') {
// this.snapOpen = true;
// this.helpline.hide();
} else if (event.key.toLowerCase() === 'shift' && this.devMode) {
this.helpline.hide();
}
};
points: Snap.ObjectPointsWithMiddle[] = [];
resetAllObjectsPosition = (target?: FabricObject) => {
const objects = this.canvas.getObjects();
const _points: Snap.ObjectPoints[] = [];
const _target = [target, ...((target as Group)?.getObjects?.() ?? [])];
objects
.filter(object => !_target.includes(object))
.forEach(object => {
_points.push(object.aCoords);
});
this.points = _points.map(fixedMiddlePoint);
};
reset = () => {
this.helpline.hide();
};
private _move = ({
target,
controlType,
}: {
target: FabricObject;
controlType: Snap.ControlType;
}): Record<string, number> | undefined => {
if (!this.snapOpen) {
return;
}
const targetPoints = getObjectPoints(target);
const snapRs = this.rules.map(rule =>
rule({
otherPoints: this.points,
targetPoint: targetPoints,
threshold: this.threshold,
controlType,
}),
);
const rs: Snap.RuleResult = {
top: getLatestSnapRs(snapRs.map(d => d.top).filter(Boolean)),
left: getLatestSnapRs(snapRs.map(d => d.left).filter(Boolean)),
height: getLatestSnapRs(snapRs.map(d => d.height).filter(Boolean)),
width: getLatestSnapRs(snapRs.map(d => d.width).filter(Boolean)),
};
const helplines = [
...(rs.top?.helplines || []),
...(rs.left?.helplines || []),
...(rs.height?.helplines || []),
...(rs.width?.helplines || []),
];
this.helpline.show(helplines);
const newAttrs = {
top: rs.top?.isSnap ? target.top + rs.top.next : target.top,
left: rs.left?.isSnap ? target.left + rs.left.next : target.left,
width: rs.width?.isSnap
? bboxWidthToWidth({
nextWidth: getBBoxWidth(target) + rs.width.next,
target,
})
: target.width,
height: rs.height?.isSnap
? bboxHeightToHeight({
nextHeight: getBBoxHeight(target) + rs.height.next,
target,
})
: target.height,
};
Object.keys(newAttrs).forEach(key => {
if (rs[key as keyof typeof rs]?.isSnap) {
target.set(key, newAttrs[key as keyof typeof newAttrs]);
}
});
return newAttrs;
};
// move 和 resize 影响到的属性不同所以分开。move 仅影响 left top
move = (target: FabricObject) =>
this._move({
target,
controlType: Snap.ControlType.Center,
});
// resize 根据控制点的不同,可能影响到多个属性
resize = (target: FabricObject, controlType: Snap.ControlType) => {
if (target.angle !== 0) {
return;
}
return this._move({
target,
controlType,
});
};
destroy = () => {
this.reset();
document.removeEventListener('keydown', this.onKeyDown);
document.removeEventListener('keyup', this.onKeyUp);
};
}
let snap: SnapService;
const createSnap = (canvas: Canvas, id: string, scale?: number) => {
if (snap) {
snap.destroy();
}
snap = new SnapService(canvas, id, scale);
return snap;
};
export { createSnap, snap };

View File

@@ -0,0 +1,162 @@
/*
* 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.
*/
import { type FabricObject } from 'fabric';
import { type Snap } from '../../typings';
export const getObjectPoints = (
object: FabricObject,
): Snap.ObjectPointsWithMiddle => {
const {
left,
top,
height: _height,
width: _width,
scaleX,
scaleY,
angle,
strokeWidth,
} = object;
const height = _height * scaleY + strokeWidth;
const width = _width * scaleX + strokeWidth;
const tl = { x: left, y: top };
const anglePI = angle * (Math.PI / 180);
const tr = {
x: left + width * Math.cos(anglePI),
y: top + width * Math.sin(anglePI),
};
const bl = {
x: left - height * Math.sin(anglePI),
y: top + height * Math.cos(anglePI),
};
const br = {
x: left - height * Math.sin(anglePI) + width * Math.cos(anglePI),
y: top + height * Math.cos(anglePI) + width * Math.sin(anglePI),
};
return fixedMiddlePoint({ tl, tr, bl, br });
};
export const getBBoxWidth = (target: FabricObject) => {
const { width: _width, scaleX, strokeWidth } = target;
const width = _width * scaleX + strokeWidth;
return width;
};
export const getBBoxHeight = (target: FabricObject) => {
const { height: _height, scaleY, strokeWidth } = target;
const height = _height * scaleY + strokeWidth;
return height;
};
export const bboxWidthToWidth = ({
nextWidth,
target,
}: {
nextWidth: number;
target: FabricObject;
}) => {
const width = (nextWidth - target.strokeWidth) / target.scaleX;
return width;
};
export const bboxHeightToHeight = ({
nextHeight,
target,
}: {
nextHeight: number;
target: FabricObject;
}) => {
const height = (nextHeight - target.strokeWidth) / target.scaleY;
return height;
};
export const numberEqual = (a: number, b: number) => Math.abs(a - b) < 0.01;
export const fixedMiddlePoint = (
objectPoints: Snap.ObjectPoints,
): Snap.ObjectPointsWithMiddle => {
const { tl, tr, bl, br } = objectPoints;
return {
tl,
tr,
m: { x: tl.x + (br.x - tl.x) / 2, y: tl.y + (br.y - tl.y) / 2 },
bl,
br,
};
};
// 寻找距离指定点,最近元素及点位
export const findLatestObject = (
otherPoints: Snap.ObjectPointsWithMiddle[],
targets: number[],
direction: 'x' | 'y' = 'y',
) => {
let latestObject: Snap.ObjectPointsWithMiddle[] = [];
let latestSnapPoint: Snap.Point[] = [];
let latestDistance = Infinity;
let latestDistanceAbs = Infinity;
targets.forEach(target => {
otherPoints.forEach(point => {
Object.values(point).forEach(p => {
const abs = Math.abs(p[direction] - target);
if (numberEqual(abs, latestDistanceAbs)) {
latestObject.push(point);
latestSnapPoint.push(p);
} else if (abs < latestDistanceAbs) {
latestObject = [point];
latestSnapPoint = [p];
latestDistance = p[direction] - target;
latestDistanceAbs = abs;
}
});
});
});
return {
object: latestObject,
snapPoints: latestSnapPoint,
distance: latestDistance,
distanceAbs: latestDistanceAbs,
};
};
export const getLatestSnapRs = (
snapRs: (Snap.SnapLine | undefined)[] = [],
): Snap.SnapLine => {
const snapRsFilterEmpty = snapRs.filter(Boolean) as Snap.SnapLine[];
const sortedSnapRs = snapRsFilterEmpty.sort(
(a, b) => a.snapDistance - b.snapDistance,
);
// 找到最近的距离
const latestSnapRs = sortedSnapRs[0];
// 找到最近的距离的 helplines最近的距离可能有多个要把 helplines 合并
const helplinesRs = snapRsFilterEmpty
.filter(rs => numberEqual(rs.snapDistance, latestSnapRs.snapDistance))
.map(rs => rs.helplines)
.flat();
return {
...latestSnapRs,
helplines: helplinesRs,
};
};