feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -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;
|
||||
};
|
||||
696
frontend/packages/workflow/fabric-canvas/src/utils/controls.tsx
Normal file
696
frontend/packages/workflow/fabric-canvas/src/utils/controls.tsx
Normal 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);
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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]: {},
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
35
frontend/packages/workflow/fabric-canvas/src/utils/index.tsx
Normal file
35
frontend/packages/workflow/fabric-canvas/src/utils/index.tsx
Normal 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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
189
frontend/packages/workflow/fabric-canvas/src/utils/snap/snap.tsx
Normal file
189
frontend/packages/workflow/fabric-canvas/src/utils/snap/snap.tsx
Normal 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 };
|
||||
162
frontend/packages/workflow/fabric-canvas/src/utils/snap/util.tsx
Normal file
162
frontend/packages/workflow/fabric-canvas/src/utils/snap/util.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user