feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
18
frontend/packages/workflow/fabric-canvas/src/hooks/index.ts
Normal file
18
frontend/packages/workflow/fabric-canvas/src/hooks/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 { useFabricPreview } from './use-fabric-preview';
|
||||
export { useFabricEditor } from './use-fabric-editor';
|
||||
@@ -0,0 +1,479 @@
|
||||
/*
|
||||
* 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 complexity */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import {
|
||||
type Canvas,
|
||||
type FabricImage,
|
||||
type FabricObject,
|
||||
type Group,
|
||||
type IText,
|
||||
type Rect,
|
||||
} from 'fabric';
|
||||
|
||||
import { resetElementClip } from '../utils/fabric-utils';
|
||||
import {
|
||||
createElement,
|
||||
getPopPosition,
|
||||
loadFont,
|
||||
selectedBorderProps,
|
||||
} from '../utils';
|
||||
import {
|
||||
Mode,
|
||||
type FabricObjectSchema,
|
||||
type FabricObjectWithCustomProps,
|
||||
} from '../typings';
|
||||
import { setImageFixed } from '../share';
|
||||
import { useCanvasChange } from './use-canvas-change';
|
||||
|
||||
// 设置元素属性
|
||||
const setElementProps = async ({
|
||||
element,
|
||||
props,
|
||||
canvas,
|
||||
}: {
|
||||
element: FabricObject;
|
||||
props: Partial<FabricObjectSchema>;
|
||||
canvas?: Canvas;
|
||||
}): Promise<void> => {
|
||||
// 特化一:img 的属性设置需要设置到 img 元素上,而不是外层包裹的 group
|
||||
if (
|
||||
element?.isType('group') &&
|
||||
(element as Group)?.getObjects()?.[0]?.isType('image')
|
||||
) {
|
||||
const { stroke, strokeWidth, src, ...rest } = props;
|
||||
const group = element as Group;
|
||||
const img = group.getObjects()[0] as FabricImage;
|
||||
const borderRect = group.getObjects()[1] as Rect;
|
||||
|
||||
// 边框颜色设置到 borderRect 上
|
||||
if (stroke) {
|
||||
borderRect.set({
|
||||
stroke,
|
||||
});
|
||||
}
|
||||
|
||||
// 边框粗细设置到 borderRect 上
|
||||
if (typeof strokeWidth === 'number') {
|
||||
borderRect.set({
|
||||
strokeWidth,
|
||||
});
|
||||
}
|
||||
|
||||
// 替换图片
|
||||
if (src) {
|
||||
const newImg = document.createElement('img');
|
||||
await new Promise((done, reject) => {
|
||||
newImg.onload = () => {
|
||||
img.setElement(newImg);
|
||||
done(0);
|
||||
};
|
||||
newImg.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(rest).length > 0) {
|
||||
img.set(rest);
|
||||
}
|
||||
|
||||
setImageFixed({ element: group });
|
||||
} else {
|
||||
// 特化二:文本与段落切换,需要特化处理
|
||||
const { customType, ...rest } = props;
|
||||
if (
|
||||
customType &&
|
||||
[Mode.BLOCK_TEXT, Mode.INLINE_TEXT].includes(customType)
|
||||
) {
|
||||
const oldElement = element;
|
||||
let newLeft = oldElement.left;
|
||||
if (newLeft < 0) {
|
||||
newLeft = 0;
|
||||
} else if (newLeft > (canvas?.width as number)) {
|
||||
newLeft = canvas?.width as number;
|
||||
}
|
||||
let newTop = oldElement.top;
|
||||
if (newTop < 0) {
|
||||
newTop = 0;
|
||||
} else if (newTop > (canvas?.height as number)) {
|
||||
newTop = canvas?.height as number;
|
||||
}
|
||||
|
||||
const newFontSize = Math.round(
|
||||
(oldElement as IText).fontSize * (oldElement as IText).scaleY,
|
||||
);
|
||||
const needExtendPropKeys = [
|
||||
'customId',
|
||||
'text',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'fill',
|
||||
'stroke',
|
||||
'strokeWidth',
|
||||
'textAlign',
|
||||
'lineHeight',
|
||||
'editable',
|
||||
];
|
||||
|
||||
const extendsProps: Record<string, unknown> = {};
|
||||
needExtendPropKeys.forEach(key => {
|
||||
if ((oldElement as FabricObjectWithCustomProps)[key]) {
|
||||
extendsProps[key] = (oldElement as FabricObjectWithCustomProps)[key];
|
||||
}
|
||||
});
|
||||
const newElement = await createElement({
|
||||
mode: customType,
|
||||
canvas,
|
||||
position: [newLeft, newTop],
|
||||
elementProps: {
|
||||
...extendsProps,
|
||||
...(props.customType === Mode.INLINE_TEXT
|
||||
? // 块状 -> 单行
|
||||
{}
|
||||
: // 单行 -> 块状
|
||||
{
|
||||
// 单行切块状,尽量保持字体大小不变化
|
||||
fontSize: newFontSize,
|
||||
padding: newFontSize / 4,
|
||||
width: 200,
|
||||
height: 200,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// 如果还有别的属性,设置到新 element 上
|
||||
if (Object.keys(rest).length > 0) {
|
||||
newElement?.set(rest);
|
||||
}
|
||||
|
||||
// 添加新的,顺序不能错,否则删除时引用关系会被判定为无用关系而被删除掉
|
||||
canvas?.add(newElement as FabricObject);
|
||||
// 删掉老的
|
||||
canvas?.remove(oldElement);
|
||||
|
||||
canvas?.discardActiveObject();
|
||||
canvas?.setActiveObject(newElement as FabricObject);
|
||||
canvas?.requestRenderAll();
|
||||
|
||||
// 普通的属性设置
|
||||
} else {
|
||||
const { fontFamily } = props;
|
||||
// 特化三: 字体需要异步加载
|
||||
if (fontFamily) {
|
||||
await loadFont(fontFamily);
|
||||
}
|
||||
/**
|
||||
* textBox 比较恶心,不知道什么时机会给每个字都生成样式文件(对应 styles)
|
||||
* 这里主动清除下,否则字体相关的设置(fontSize、fontFamily...)不生效
|
||||
*/
|
||||
if (element?.isType('textbox')) {
|
||||
element?.set({
|
||||
styles: {},
|
||||
});
|
||||
}
|
||||
|
||||
// 特化四:padding = fontSize/2 , 避免文本上下被截断
|
||||
if (element?.isType('textbox') && typeof props.fontSize === 'number') {
|
||||
element?.set({
|
||||
padding: props.fontSize / 4,
|
||||
});
|
||||
resetElementClip({
|
||||
element: element as FabricObject,
|
||||
});
|
||||
}
|
||||
|
||||
element?.set(props);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const useActiveObjectChange = ({
|
||||
canvas,
|
||||
scale,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
scale: number;
|
||||
}) => {
|
||||
const [activeObjects, setActiveObjects] = useState<
|
||||
FabricObject[] | undefined
|
||||
>();
|
||||
|
||||
const [activeObjectsPopPosition, setActiveObjectsPopPosition] = useState<{
|
||||
tl: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
br: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}>({
|
||||
tl: {
|
||||
x: -9999,
|
||||
y: -9999,
|
||||
},
|
||||
br: {
|
||||
x: -9999,
|
||||
y: -9999,
|
||||
},
|
||||
});
|
||||
|
||||
const [isActiveObjectsInFront, setIsActiveObjectsInFront] =
|
||||
useState<boolean>(false);
|
||||
const [isActiveObjectsInBack, setIsActiveObjectsInBack] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const _setActiveObjectsState = useCallback(() => {
|
||||
const objects = canvas?.getObjects();
|
||||
setIsActiveObjectsInFront(
|
||||
activeObjects?.length === 1 &&
|
||||
objects?.[objects.length - 1] === activeObjects?.[0],
|
||||
);
|
||||
setIsActiveObjectsInBack(
|
||||
activeObjects?.length === 1 && objects?.[0] === activeObjects?.[0],
|
||||
);
|
||||
}, [canvas, activeObjects]);
|
||||
|
||||
useCanvasChange({
|
||||
canvas,
|
||||
onChange: _setActiveObjectsState,
|
||||
listenerEvents: ['object:modified-zIndex'],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
_setActiveObjectsState();
|
||||
}, [activeObjects, _setActiveObjectsState]);
|
||||
|
||||
const _setActiveObjectsPopPosition = () => {
|
||||
if (canvas) {
|
||||
setActiveObjectsPopPosition(
|
||||
getPopPosition({
|
||||
canvas,
|
||||
scale,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const disposers = (
|
||||
['selection:created', 'selection:updated'] as (
|
||||
| 'selection:created'
|
||||
| 'selection:updated'
|
||||
)[]
|
||||
).map(eventName =>
|
||||
canvas?.on(eventName, e => {
|
||||
setActiveObjects(canvas?.getActiveObjects());
|
||||
_setActiveObjectsPopPosition();
|
||||
|
||||
const selected = canvas?.getActiveObject();
|
||||
if (selected) {
|
||||
selected.set(selectedBorderProps);
|
||||
/**
|
||||
* 为什么禁用选中多元素的控制点?
|
||||
* 因为直线不期望有旋转,旋转会影响控制点的计算逻辑。
|
||||
* 想要放开这个限制,需要在直线的控制点内考虑旋转 & 缩放因素
|
||||
*/
|
||||
if (selected.isType('activeselection')) {
|
||||
selected.setControlsVisibility({
|
||||
tl: false,
|
||||
tr: false,
|
||||
bl: false,
|
||||
br: false,
|
||||
ml: false,
|
||||
mt: false,
|
||||
mr: false,
|
||||
mb: false,
|
||||
mtr: false,
|
||||
});
|
||||
}
|
||||
canvas?.requestRenderAll();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const disposerCleared = canvas?.on('selection:cleared', e => {
|
||||
setActiveObjects(undefined);
|
||||
_setActiveObjectsPopPosition();
|
||||
});
|
||||
disposers.push(disposerCleared);
|
||||
|
||||
return () => {
|
||||
disposers.forEach(disposer => disposer?.());
|
||||
};
|
||||
}, [canvas]);
|
||||
|
||||
// 窗口大小变化时,修正下位置
|
||||
useEffect(() => {
|
||||
_setActiveObjectsPopPosition();
|
||||
}, [scale]);
|
||||
|
||||
useCanvasChange({
|
||||
canvas,
|
||||
onChange: _setActiveObjectsPopPosition,
|
||||
listenerEvents: [
|
||||
'object:modified',
|
||||
'object:added',
|
||||
'object:removed',
|
||||
'object:moving',
|
||||
],
|
||||
});
|
||||
|
||||
const setActiveObjectsProps = async (
|
||||
props: Partial<FabricObjectSchema>,
|
||||
customId?: string,
|
||||
) => {
|
||||
let elements = activeObjects;
|
||||
if (customId) {
|
||||
const element = canvas
|
||||
?.getObjects()
|
||||
.find(d => (d as FabricObjectWithCustomProps).customId === customId);
|
||||
if (element) {
|
||||
elements = [element];
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
(elements ?? []).map(element =>
|
||||
setElementProps({
|
||||
element,
|
||||
props,
|
||||
canvas,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
canvas?.requestRenderAll();
|
||||
canvas?.fire('object:modified');
|
||||
};
|
||||
|
||||
// 实现 shift 水平/垂直移动
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
let originalPos = { left: 0, top: 0 };
|
||||
|
||||
const disposers = [
|
||||
// 监听对象移动开始事件
|
||||
canvas.on('object:moving', function (e) {
|
||||
const obj = e.target;
|
||||
// 手动 canvas.fire('object:moving') 获取不到 obj
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是第一次移动,记录对象的原始位置
|
||||
if (originalPos.left === 0 && originalPos.top === 0) {
|
||||
originalPos = { left: obj.left, top: obj.top };
|
||||
}
|
||||
|
||||
// 检查是否按下了Shift键
|
||||
if (e?.e?.shiftKey) {
|
||||
// 计算从开始移动以来的水平和垂直距离
|
||||
const distanceX = obj.left - originalPos.left;
|
||||
const distanceY = obj.top - originalPos.top;
|
||||
|
||||
// 根据移动距离的绝对值判断是水平移动还是垂直移动
|
||||
if (Math.abs(distanceX) > Math.abs(distanceY)) {
|
||||
// 水平移动:保持垂直位置不变
|
||||
obj.set('top', originalPos.top);
|
||||
} else {
|
||||
// 垂直移动:保持水平位置不变
|
||||
obj.set('left', originalPos.left);
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// 监听对象移动结束事件
|
||||
canvas.on('object:modified', function (e) {
|
||||
// 移动结束后重置原始位置
|
||||
originalPos = { left: 0, top: 0 };
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
disposers.forEach(disposer => disposer?.());
|
||||
};
|
||||
}, [canvas]);
|
||||
|
||||
const controlsVisibility = useRef<
|
||||
| {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
||||
// 元素移动过程中,隐藏控制点
|
||||
useEffect(() => {
|
||||
const disposers: (() => void)[] = [];
|
||||
if (activeObjects?.length === 1) {
|
||||
const element = activeObjects[0];
|
||||
disposers.push(
|
||||
element.on('moving', () => {
|
||||
if (!controlsVisibility.current) {
|
||||
controlsVisibility.current = Object.assign(
|
||||
// fabric 规则: undefined 认为是 true
|
||||
{
|
||||
ml: true, // 中点左
|
||||
mr: true, // 中点右
|
||||
mt: true, // 中点上
|
||||
mb: true, // 中点下
|
||||
bl: true, // 底部左
|
||||
br: true, // 底部右
|
||||
tl: true, // 顶部左
|
||||
tr: true, // 顶部右
|
||||
},
|
||||
element._controlsVisibility,
|
||||
);
|
||||
}
|
||||
element.setControlsVisibility({
|
||||
ml: false, // 中点左
|
||||
mr: false, // 中点右
|
||||
mt: false, // 中点上
|
||||
mb: false, // 中点下
|
||||
bl: false, // 底部左
|
||||
br: false, // 底部右
|
||||
tl: false, // 顶部左
|
||||
tr: false, // 顶部右
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
disposers.push(
|
||||
element.on('mouseup', () => {
|
||||
if (controlsVisibility.current) {
|
||||
element.setControlsVisibility(controlsVisibility.current);
|
||||
controlsVisibility.current = undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposers.forEach(dispose => dispose());
|
||||
};
|
||||
}, [activeObjects]);
|
||||
|
||||
return {
|
||||
activeObjects,
|
||||
activeObjectsPopPosition,
|
||||
setActiveObjectsProps,
|
||||
isActiveObjectsInBack,
|
||||
isActiveObjectsInFront,
|
||||
};
|
||||
};
|
||||
213
frontend/packages/workflow/fabric-canvas/src/hooks/use-align.tsx
Normal file
213
frontend/packages/workflow/fabric-canvas/src/hooks/use-align.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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 { useCallback } from 'react';
|
||||
|
||||
import { type FabricObject, type Canvas } from 'fabric';
|
||||
|
||||
export const useAlign = ({
|
||||
canvas,
|
||||
selectObjects = [],
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
selectObjects?: FabricObject[];
|
||||
}) => {
|
||||
// 水平居左
|
||||
const alignLeft = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
left: -activeObject.width / 2,
|
||||
});
|
||||
obj.setCoords();
|
||||
});
|
||||
activeObject.setCoords();
|
||||
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 水平居右
|
||||
const alignRight = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
left: activeObject.width / 2 - obj.getBoundingRect().width,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 水平居中
|
||||
const alignCenter = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
left: -obj.getBoundingRect().width / 2,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 垂直居上
|
||||
const alignTop = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
top: -activeObject.height / 2,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 垂直居中
|
||||
const alignMiddle = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
top: -obj.getBoundingRect().height / 2,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 垂直居下
|
||||
const alignBottom = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
top: activeObject.height / 2 - obj.getBoundingRect().height,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 水平均分
|
||||
const verticalAverage = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalWidth = selectObjects.reduce(
|
||||
(sum, obj) => sum + obj.getBoundingRect().width,
|
||||
0,
|
||||
);
|
||||
const spacing =
|
||||
(activeObject.width - totalWidth) / (selectObjects.length - 1);
|
||||
|
||||
let currentLeft = -activeObject.width / 2; // 初始位置
|
||||
|
||||
selectObjects
|
||||
.sort((a, b) => a.getBoundingRect().left - b.getBoundingRect().left)
|
||||
.forEach(obj => {
|
||||
obj.set({
|
||||
left: currentLeft,
|
||||
});
|
||||
currentLeft += obj.getBoundingRect().width + spacing;
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 垂直均分
|
||||
const horizontalAverage = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalHeight = selectObjects.reduce(
|
||||
(sum, obj) => sum + obj.getBoundingRect().height,
|
||||
0,
|
||||
);
|
||||
const spacing =
|
||||
(activeObject.height - totalHeight) / (selectObjects.length - 1);
|
||||
|
||||
let currentTop = -activeObject.height / 2; // 初始位置
|
||||
|
||||
selectObjects
|
||||
.sort((a, b) => a.getBoundingRect().top - b.getBoundingRect().top)
|
||||
.forEach(obj => {
|
||||
obj.set({
|
||||
top: currentTop,
|
||||
});
|
||||
currentTop += obj.getBoundingRect().height + spacing;
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
return {
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenter,
|
||||
alignTop,
|
||||
alignMiddle,
|
||||
alignBottom,
|
||||
horizontalAverage,
|
||||
verticalAverage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 { useEffect, useState } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
import { useDebounceEffect } from 'ahooks';
|
||||
|
||||
import { type FabricSchema } from '../typings';
|
||||
|
||||
export const useBackground = ({
|
||||
canvas,
|
||||
schema,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
schema: FabricSchema;
|
||||
}) => {
|
||||
const [backgroundColor, setBackgroundColor] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBackgroundColor(
|
||||
(canvas as unknown as { backgroundColor: string }).backgroundColor,
|
||||
);
|
||||
}, [canvas]);
|
||||
|
||||
// 防抖的作用在于,form.schema.backgroundColor 的变化是异步的,setBackgroundColor 是同步的,两者可能会打架
|
||||
useDebounceEffect(
|
||||
() => {
|
||||
setBackgroundColor(schema.backgroundColor as string);
|
||||
},
|
||||
[schema.backgroundColor],
|
||||
{
|
||||
wait: 300,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
backgroundColor &&
|
||||
canvas &&
|
||||
(canvas as unknown as { backgroundColor: string }).backgroundColor !==
|
||||
backgroundColor
|
||||
) {
|
||||
canvas.set({
|
||||
backgroundColor,
|
||||
});
|
||||
canvas.fire('object:modified');
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
}, [backgroundColor, canvas]);
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
setBackgroundColor,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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 { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { type Canvas, type CanvasEvents, type FabricObject } from 'fabric';
|
||||
import { useLatest } from 'ahooks';
|
||||
import {
|
||||
ViewVariableType,
|
||||
type InputVariable,
|
||||
} from '@coze-workflow/base/types';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { getUploadCDNAsset } from '@coze-workflow/base-adapter';
|
||||
|
||||
import { createElement, defaultProps } from '../utils';
|
||||
import {
|
||||
Mode,
|
||||
UNKNOWN_VARIABLE_NAME,
|
||||
type FabricObjectWithCustomProps,
|
||||
type FabricSchema,
|
||||
type VariableRef,
|
||||
} from '../typings';
|
||||
|
||||
const ImagePlaceholder = `${getUploadCDNAsset('')}/workflow/fabric-canvas/img-placeholder.png`;
|
||||
|
||||
// 需要额外保存的属性
|
||||
export const saveProps = [
|
||||
'width',
|
||||
'height',
|
||||
'editable',
|
||||
'text',
|
||||
'backgroundColor',
|
||||
'padding',
|
||||
// 自定义参数
|
||||
// textBox 的真实高度
|
||||
'customFixedHeight',
|
||||
// 元素 id
|
||||
'customId',
|
||||
// 元素类型
|
||||
'customType',
|
||||
// image 的适应模式
|
||||
'customFixedType',
|
||||
// // 由变量生成元素的 title
|
||||
// 引用关系
|
||||
'customVariableRefs',
|
||||
];
|
||||
|
||||
export const useCanvasChange = ({
|
||||
variables,
|
||||
canvas,
|
||||
onChange,
|
||||
schema,
|
||||
listenerEvents = [
|
||||
'object:modified',
|
||||
'object:added',
|
||||
'object:removed',
|
||||
'object:moving',
|
||||
'object:modified-zIndex',
|
||||
],
|
||||
}: {
|
||||
variables?: InputVariable[];
|
||||
canvas?: Canvas;
|
||||
onChange?: (schema: FabricSchema) => void;
|
||||
schema?: FabricSchema;
|
||||
listenerEvents?: (
|
||||
| 'object:modified'
|
||||
| 'object:added'
|
||||
| 'object:removed'
|
||||
| 'object:moving'
|
||||
| 'object:modified-zIndex'
|
||||
)[];
|
||||
}) => {
|
||||
const eventDisposers = useRef<(() => void)[]>([]);
|
||||
|
||||
const [isListen, setIsListener] = useState(true);
|
||||
const onChangeLatest = useLatest(onChange);
|
||||
const schemaLatest = useLatest(schema);
|
||||
const cacheCustomVariableRefs = useRef<VariableRef[]>(
|
||||
schema?.customVariableRefs ?? [],
|
||||
);
|
||||
|
||||
// 删除画布中不存在的引用关系
|
||||
const resetCustomVariableRefs = useCallback(
|
||||
({ schema: _schema }: { schema: FabricSchema }) => {
|
||||
let newCustomVariableRefs = cacheCustomVariableRefs.current;
|
||||
|
||||
const allObjectIds = _schema.objects.map(d => d.customId);
|
||||
newCustomVariableRefs = newCustomVariableRefs?.filter(d =>
|
||||
allObjectIds.includes(d.objectId),
|
||||
);
|
||||
cacheCustomVariableRefs.current = newCustomVariableRefs;
|
||||
|
||||
return newCustomVariableRefs;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 监听画布变化
|
||||
useEffect(() => {
|
||||
if (canvas && onChangeLatest.current && isListen) {
|
||||
const _onChange = ({ isRemove }: { isRemove: boolean }) => {
|
||||
const json = canvas.toObject(saveProps) as FabricSchema;
|
||||
// 删除时,顺便删掉无效 ref
|
||||
if (isRemove) {
|
||||
json.customVariableRefs = resetCustomVariableRefs({
|
||||
schema: json,
|
||||
});
|
||||
} else {
|
||||
json.customVariableRefs = cloneDeep(cacheCustomVariableRefs.current);
|
||||
}
|
||||
|
||||
onChangeLatest.current?.(json);
|
||||
};
|
||||
|
||||
eventDisposers.current.forEach(disposer => disposer());
|
||||
eventDisposers.current = [];
|
||||
|
||||
listenerEvents.forEach(event => {
|
||||
const disposer = canvas.on(event as keyof CanvasEvents, function (e) {
|
||||
_onChange({
|
||||
isRemove: event === 'object:removed',
|
||||
});
|
||||
});
|
||||
eventDisposers.current.push(disposer);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
eventDisposers.current.forEach(disposer => disposer?.());
|
||||
eventDisposers.current = [];
|
||||
};
|
||||
}, [canvas, isListen]);
|
||||
|
||||
/**
|
||||
* 生成带引用的新元素
|
||||
*/
|
||||
const addRefObjectByVariable = useCallback(
|
||||
async (variable: InputVariable, element?: FabricObject) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
customVariableRefs = [],
|
||||
width = 0,
|
||||
height = 0,
|
||||
} = schemaLatest.current ?? {};
|
||||
|
||||
const { id, name, type } = variable;
|
||||
const centerXY = [
|
||||
width / 2 + customVariableRefs.length * 16,
|
||||
height / 2 + customVariableRefs.length * 16,
|
||||
];
|
||||
|
||||
let _element: FabricObject | undefined = element;
|
||||
|
||||
// 如果没有传入现有元素,则创建新元素
|
||||
if (!_element) {
|
||||
if (type === ViewVariableType.Image) {
|
||||
_element = await createElement({
|
||||
mode: Mode.IMAGE,
|
||||
position: [
|
||||
centerXY[0] - (defaultProps[Mode.IMAGE].width as number) / 2,
|
||||
centerXY[1] - (defaultProps[Mode.IMAGE].height as number) / 2,
|
||||
],
|
||||
elementProps: {
|
||||
width: defaultProps[Mode.IMAGE].width,
|
||||
height: defaultProps[Mode.IMAGE].width,
|
||||
editable: false,
|
||||
src: ImagePlaceholder,
|
||||
},
|
||||
});
|
||||
} else if (type === ViewVariableType.String) {
|
||||
_element = await createElement({
|
||||
mode: Mode.BLOCK_TEXT,
|
||||
position: [
|
||||
centerXY[0] - (defaultProps[Mode.BLOCK_TEXT].width as number) / 2,
|
||||
centerXY[1] -
|
||||
(defaultProps[Mode.BLOCK_TEXT].height as number) / 2,
|
||||
],
|
||||
elementProps: {
|
||||
text: I18n.t(
|
||||
'imageflow_canvas_change_text',
|
||||
{},
|
||||
'点击编辑文本预览',
|
||||
),
|
||||
width: defaultProps[Mode.BLOCK_TEXT].width,
|
||||
height: defaultProps[Mode.BLOCK_TEXT].height,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (_element) {
|
||||
// 更新引用关系
|
||||
cacheCustomVariableRefs.current.push({
|
||||
variableId: id as string,
|
||||
objectId: (_element as FabricObjectWithCustomProps)
|
||||
.customId as string,
|
||||
variableName: name,
|
||||
});
|
||||
|
||||
// 添加到画布并激活
|
||||
canvas.add(_element);
|
||||
canvas.setActiveObject(_element);
|
||||
}
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新指定 objectId 的元素的引用关系
|
||||
* 如果 variable 为空,则删除引用
|
||||
* 如果 variable 不为空 && customVariableRefs 已存在对应关系,则更新引用
|
||||
* 如果 variable 不为空 && customVariableRefs 不存在对应关系,则新增引用
|
||||
*
|
||||
*/
|
||||
const updateRefByObjectId = useCallback(
|
||||
({
|
||||
objectId,
|
||||
variable,
|
||||
}: {
|
||||
objectId: string;
|
||||
variable?: InputVariable;
|
||||
}) => {
|
||||
const customVariableRefs = cacheCustomVariableRefs.current;
|
||||
const targetRef = customVariableRefs.find(d => d.objectId === objectId);
|
||||
let newCustomVariableRefs = [];
|
||||
// 如果 variable 为空,则删除引用
|
||||
if (!variable) {
|
||||
newCustomVariableRefs = customVariableRefs.filter(
|
||||
d => d.objectId !== objectId,
|
||||
);
|
||||
// 如果 variable 不为空 && customVariableRefs 不存在对应关系,则新增引用
|
||||
} else if (!targetRef) {
|
||||
newCustomVariableRefs = [
|
||||
...customVariableRefs,
|
||||
{
|
||||
variableId: variable.id as string,
|
||||
objectId,
|
||||
variableName: variable.name,
|
||||
},
|
||||
];
|
||||
// 如果 variable 不为空 && customVariableRefs 已存在对应关系,则更新引用
|
||||
} else {
|
||||
newCustomVariableRefs = customVariableRefs.map(d => {
|
||||
if (d.objectId === objectId) {
|
||||
return {
|
||||
...d,
|
||||
variableId: variable.id as string,
|
||||
variableName: variable.name,
|
||||
};
|
||||
}
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
cacheCustomVariableRefs.current = newCustomVariableRefs;
|
||||
onChangeLatest.current?.({
|
||||
...(schemaLatest.current as FabricSchema),
|
||||
customVariableRefs: newCustomVariableRefs,
|
||||
});
|
||||
},
|
||||
[onChangeLatest, schemaLatest],
|
||||
);
|
||||
|
||||
/**
|
||||
* variables 变化时,更新引用关系中的变量名
|
||||
*/
|
||||
useEffect(() => {
|
||||
const { customVariableRefs = [] } = schemaLatest.current ?? {};
|
||||
const needsUpdate = customVariableRefs.some(ref => {
|
||||
const variable = variables?.find(v => v.id === ref.variableId);
|
||||
return ref.variableName !== (variable?.name ?? UNKNOWN_VARIABLE_NAME);
|
||||
});
|
||||
|
||||
if (needsUpdate) {
|
||||
const newCustomVariableRefs = customVariableRefs.map(ref => {
|
||||
const variable = variables?.find(v => v.id === ref.variableId);
|
||||
return {
|
||||
...ref,
|
||||
variableName: variable?.name ?? UNKNOWN_VARIABLE_NAME,
|
||||
};
|
||||
});
|
||||
|
||||
cacheCustomVariableRefs.current = newCustomVariableRefs;
|
||||
onChangeLatest.current?.({
|
||||
...(schemaLatest.current as FabricSchema),
|
||||
customVariableRefs: newCustomVariableRefs,
|
||||
});
|
||||
}
|
||||
}, [variables]);
|
||||
|
||||
const stopListen = useCallback(() => {
|
||||
setIsListener(false);
|
||||
}, []);
|
||||
|
||||
const startListen = useCallback(() => {
|
||||
setIsListener(true);
|
||||
// redo undo 完成后,更新引用关系
|
||||
cacheCustomVariableRefs.current =
|
||||
schemaLatest.current?.customVariableRefs ?? [];
|
||||
}, []);
|
||||
|
||||
return {
|
||||
customVariableRefs: cacheCustomVariableRefs.current,
|
||||
addRefObjectByVariable,
|
||||
updateRefByObjectId,
|
||||
stopListen,
|
||||
startListen,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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 { useCallback } from 'react';
|
||||
|
||||
import { Rect, type Canvas } from 'fabric';
|
||||
|
||||
import { type FabricSchema } from '../typings';
|
||||
|
||||
export const useCanvasClip = ({
|
||||
canvas,
|
||||
schema,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
schema: FabricSchema;
|
||||
}) => {
|
||||
const addClip = useCallback(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
canvas.clipPath = new Rect({
|
||||
absolutePositioned: true,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: schema.width,
|
||||
height: schema.height,
|
||||
});
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, schema]);
|
||||
|
||||
const removeClip = useCallback(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
canvas.clipPath = undefined;
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas]);
|
||||
|
||||
return {
|
||||
addClip,
|
||||
removeClip,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { useCallback } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
|
||||
export const useCanvasResize = ({
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const scale = Math.min(maxWidth / width, maxHeight / height);
|
||||
|
||||
const resize = useCallback(
|
||||
(canvas: Canvas | undefined) => {
|
||||
if (!maxWidth || !maxHeight || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas?.setDimensions({
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
canvas?.setDimensions(
|
||||
{
|
||||
width: `${width * scale}px`,
|
||||
height: `${height * scale}px`,
|
||||
},
|
||||
{ cssOnly: true },
|
||||
);
|
||||
},
|
||||
[maxWidth, maxHeight, width, height, scale],
|
||||
);
|
||||
|
||||
return { resize, scale };
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/* eslint-disable complexity */
|
||||
import {
|
||||
type BasicTransformEvent,
|
||||
type Canvas,
|
||||
type CanvasEvents,
|
||||
type FabricObject,
|
||||
type FabricObjectProps,
|
||||
type ObjectEvents,
|
||||
type SerializedObjectProps,
|
||||
type TPointerEvent,
|
||||
} from 'fabric';
|
||||
|
||||
type Event = BasicTransformEvent<TPointerEvent> & {
|
||||
target: FabricObject<
|
||||
Partial<FabricObjectProps>,
|
||||
SerializedObjectProps,
|
||||
ObjectEvents
|
||||
>;
|
||||
};
|
||||
export const useCommonOperation = ({ canvas }: { canvas?: Canvas }) => {
|
||||
const moveActiveObject = (
|
||||
direct: 'left' | 'right' | 'up' | 'down',
|
||||
offsetValue = 1,
|
||||
) => {
|
||||
// 这里不用额外考虑框选 case ,框选时会形成一个临时的组,对组做位移,会影响到组内的每一个元素
|
||||
const activeSelection = canvas?.getActiveObject();
|
||||
|
||||
switch (direct) {
|
||||
case 'left':
|
||||
activeSelection?.set({ left: activeSelection.left - offsetValue });
|
||||
break;
|
||||
case 'right':
|
||||
activeSelection?.set({ left: activeSelection.left + offsetValue });
|
||||
break;
|
||||
case 'up':
|
||||
activeSelection?.set({ top: activeSelection.top - offsetValue });
|
||||
break;
|
||||
case 'down':
|
||||
activeSelection?.set({ top: activeSelection.top + offsetValue });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* 键盘上下左右触发的图形位移,需要主动触发
|
||||
* 1. moving
|
||||
* if (activeSelection) canvas.fire('object:moving')
|
||||
* else activeSelection.fire('moving')
|
||||
*
|
||||
* 2. object:modified ,用来触发保存
|
||||
*/
|
||||
const isActiveSelection = activeSelection?.isType('activeselection');
|
||||
const fabricObject = (
|
||||
isActiveSelection ? canvas : activeSelection
|
||||
) as FabricObject;
|
||||
const eventName = (
|
||||
isActiveSelection ? 'object:moving' : 'moving'
|
||||
) as keyof ObjectEvents;
|
||||
|
||||
fabricObject?.fire(eventName, {
|
||||
target: activeSelection,
|
||||
} as unknown as Event);
|
||||
canvas?.fire('object:modified');
|
||||
canvas?.requestRenderAll();
|
||||
};
|
||||
|
||||
const discardActiveObject = () => {
|
||||
canvas?.discardActiveObject();
|
||||
canvas?.requestRenderAll();
|
||||
};
|
||||
|
||||
const removeActiveObjects = () => {
|
||||
const activeObjects = canvas?.getActiveObjects() ?? [];
|
||||
if (canvas && activeObjects.length > 0) {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.remove(obj);
|
||||
});
|
||||
discardActiveObject();
|
||||
}
|
||||
};
|
||||
|
||||
const moveTo = (type: 'front' | 'backend' | 'front-one' | 'backend-one') => {
|
||||
const activeObjects = canvas?.getActiveObjects() ?? [];
|
||||
if (canvas && activeObjects.length > 0) {
|
||||
if (type === 'front') {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.bringObjectToFront(obj);
|
||||
});
|
||||
} else if (type === 'backend') {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.sendObjectToBack(obj);
|
||||
});
|
||||
} else if (type === 'front-one') {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.bringObjectForward(obj);
|
||||
});
|
||||
} else if (type === 'backend-one') {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.sendObjectBackwards(obj);
|
||||
});
|
||||
}
|
||||
// 主动触发一次自定义事件:zIndex 变化
|
||||
canvas.fire('object:modified-zIndex' as keyof CanvasEvents);
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
};
|
||||
|
||||
const resetWidthHeight = ({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) => {
|
||||
width && canvas?.setWidth(width);
|
||||
height && canvas?.setHeight(height);
|
||||
canvas?.fire('object:modified');
|
||||
canvas?.requestRenderAll();
|
||||
};
|
||||
|
||||
return {
|
||||
moveActiveObject,
|
||||
removeActiveObjects,
|
||||
discardActiveObject,
|
||||
moveTo,
|
||||
resetWidthHeight,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
* 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 complexity */
|
||||
/* eslint-disable max-lines-per-function */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ActiveSelection, type Canvas, type FabricObject } from 'fabric';
|
||||
import { useLatest } from 'ahooks';
|
||||
import { type InputVariable } from '@coze-workflow/base/types';
|
||||
import { Toast } from '@coze-arch/coze-design';
|
||||
|
||||
import { snap } from '../utils/snap/snap';
|
||||
import { createElement, getNumberBetween } from '../utils';
|
||||
import {
|
||||
CopyMode,
|
||||
type VariableRef,
|
||||
type FabricObjectWithCustomProps,
|
||||
} from '../typings';
|
||||
import { saveProps } from './use-canvas-change';
|
||||
|
||||
/**
|
||||
* 粘贴后的默认偏移
|
||||
*/
|
||||
const staff = 16;
|
||||
export const useCopyPaste = ({
|
||||
canvas,
|
||||
mousePosition,
|
||||
couldAddNewObject,
|
||||
customVariableRefs,
|
||||
variables,
|
||||
addRefObjectByVariable,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
mousePosition: {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
couldAddNewObject: boolean;
|
||||
variables?: InputVariable[];
|
||||
customVariableRefs: VariableRef[];
|
||||
addRefObjectByVariable: (
|
||||
variable: InputVariable,
|
||||
element?: FabricObject,
|
||||
) => void;
|
||||
}) => {
|
||||
// ctrlCV 复制的元素
|
||||
const copiedObject1 = useRef<FabricObject>();
|
||||
// ctrlD 复制的元素
|
||||
const copiedObject2 = useRef<FabricObject>();
|
||||
// dragCopy 拖拽复制的元素
|
||||
const copiedObject3 = useRef<FabricObject>();
|
||||
|
||||
const latestCustomVariableRefs = useLatest(customVariableRefs);
|
||||
const latestVariables = useLatest(variables);
|
||||
|
||||
const [position, setPosition] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
}>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
const latestPosition = useLatest(position);
|
||||
const latestCouldAddNewObject = useLatest(couldAddNewObject);
|
||||
|
||||
const [ignoreMousePosition, setIgnoreMousePosition] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
}>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
const latestIgnoreMousePosition = useLatest(ignoreMousePosition);
|
||||
|
||||
// 如果鼠标动了,就以鼠标位置为准。仅影响 CopyMode.CtrlD 的粘贴
|
||||
useEffect(() => {
|
||||
// 默认 left top 对应元素的左上角。需要实现元素中点对齐鼠标位置,因此做偏移
|
||||
setPosition({
|
||||
left: mousePosition.left - (copiedObject1.current?.width ?? 0) / 2,
|
||||
top: mousePosition.top - (copiedObject1.current?.height ?? 0) / 2,
|
||||
});
|
||||
}, [mousePosition]);
|
||||
|
||||
const handleElement = async (element: FabricObject): Promise<void> => {
|
||||
const oldObjectId = (element as FabricObjectWithCustomProps).customId;
|
||||
const newObjectId = nanoid();
|
||||
// 设置新的 id
|
||||
element.set({
|
||||
customId: newObjectId,
|
||||
});
|
||||
|
||||
// 走统一的创建元素逻辑
|
||||
const rs = await createElement({
|
||||
element: element as FabricObjectWithCustomProps,
|
||||
canvas,
|
||||
});
|
||||
|
||||
const ref = latestCustomVariableRefs.current?.find(
|
||||
d => d.objectId === oldObjectId,
|
||||
);
|
||||
const variable = latestVariables.current?.find(
|
||||
v => v.id === ref?.variableId,
|
||||
);
|
||||
if (variable) {
|
||||
addRefObjectByVariable(variable, rs);
|
||||
} else {
|
||||
canvas?.add(rs as FabricObject);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* mode 分为三种:'ctrlCV' | 'ctrlD' | 'dragCopy'
|
||||
* 行为一致,区别就是三种行为的复制源隔离,互不影响
|
||||
*/
|
||||
const copy = useCallback(
|
||||
async (mode: CopyMode = CopyMode.CtrlCV) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIgnoreMousePosition({
|
||||
left: activeObject.left + staff,
|
||||
top: activeObject.top + staff,
|
||||
});
|
||||
|
||||
setPosition({
|
||||
left: activeObject.left + staff,
|
||||
top: activeObject.top + staff,
|
||||
});
|
||||
|
||||
switch (mode) {
|
||||
case CopyMode.CtrlCV:
|
||||
copiedObject1.current = await activeObject.clone(saveProps);
|
||||
break;
|
||||
case CopyMode.CtrlD:
|
||||
copiedObject2.current = await activeObject.clone(saveProps);
|
||||
break;
|
||||
case CopyMode.DragCV:
|
||||
copiedObject3.current = await activeObject.clone(saveProps);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
const paste = useCallback(
|
||||
async (options?: { mode?: CopyMode }) => {
|
||||
if (!latestCouldAddNewObject.current) {
|
||||
Toast.warning({
|
||||
content: '元素数量已达上限,无法添加新元素',
|
||||
duration: 3,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = options?.mode ?? CopyMode.CtrlCV;
|
||||
let copiedObject;
|
||||
switch (mode) {
|
||||
case CopyMode.CtrlCV:
|
||||
copiedObject = copiedObject1.current;
|
||||
break;
|
||||
case CopyMode.CtrlD:
|
||||
copiedObject = copiedObject2.current;
|
||||
break;
|
||||
case CopyMode.DragCV:
|
||||
copiedObject = copiedObject3.current;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
if (!canvas || !copiedObject) {
|
||||
return;
|
||||
}
|
||||
const cloneObj = await copiedObject.clone(saveProps);
|
||||
|
||||
// ctrlCV 需要考虑鼠标位置,其他的不用
|
||||
const isIgnoreMousePosition = mode !== CopyMode.CtrlCV;
|
||||
|
||||
const { left, top } = isIgnoreMousePosition
|
||||
? latestIgnoreMousePosition.current
|
||||
: latestPosition.current;
|
||||
|
||||
// 计算下次粘贴位置,向 left top 各偏移 staff
|
||||
if (isIgnoreMousePosition) {
|
||||
setIgnoreMousePosition({
|
||||
left: left + staff,
|
||||
top: top + staff,
|
||||
});
|
||||
} else {
|
||||
setPosition({
|
||||
left: left + staff,
|
||||
top: top + staff,
|
||||
});
|
||||
}
|
||||
|
||||
cloneObj.set({
|
||||
left: getNumberBetween({
|
||||
value: left,
|
||||
min: 0,
|
||||
max: canvas.width - cloneObj.getBoundingRect().width,
|
||||
}),
|
||||
top: getNumberBetween({
|
||||
value: top,
|
||||
min: 0,
|
||||
max: canvas.height - cloneObj.getBoundingRect().height,
|
||||
}),
|
||||
});
|
||||
|
||||
// 把需要复制的元素都拿出来,多选
|
||||
const allPasteObjects: FabricObject[] = [];
|
||||
const originXY = {
|
||||
left: cloneObj.left + cloneObj.width / 2,
|
||||
top: cloneObj.top + cloneObj.height / 2,
|
||||
};
|
||||
if (cloneObj.isType('activeselection')) {
|
||||
(cloneObj as ActiveSelection).getObjects().forEach(o => {
|
||||
o.set({
|
||||
left: o.left + originXY.left,
|
||||
top: o.top + originXY.top,
|
||||
});
|
||||
allPasteObjects.push(o);
|
||||
});
|
||||
// 把需要复制的元素都拿出来,单选
|
||||
} else {
|
||||
allPasteObjects.push(cloneObj);
|
||||
}
|
||||
|
||||
// 挨着调用 handleElement 处理元素
|
||||
await Promise.all(allPasteObjects.map(async o => handleElement(o)));
|
||||
|
||||
// 如果是多选,需要创新新的多选框,并激活
|
||||
let allPasteObjectsActiveSelection: ActiveSelection | undefined;
|
||||
if (cloneObj.isType('activeselection')) {
|
||||
allPasteObjectsActiveSelection = new ActiveSelection(
|
||||
// 很恶心,这里激活选框,并不会自动转换坐标,需要手动转一下
|
||||
allPasteObjects.map(o => {
|
||||
o.set({
|
||||
left: o.left - originXY.left,
|
||||
top: o.top - originXY.top,
|
||||
});
|
||||
return o;
|
||||
}),
|
||||
);
|
||||
}
|
||||
canvas.discardActiveObject();
|
||||
canvas.setActiveObject(allPasteObjectsActiveSelection ?? cloneObj);
|
||||
|
||||
canvas.requestRenderAll();
|
||||
|
||||
return allPasteObjectsActiveSelection ?? cloneObj;
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isAltPressing = false;
|
||||
const keyCodes = ['Alt'];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (keyCodes.includes(e.key)) {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
isAltPressing = true; // 标记 alt 已按下
|
||||
}
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (keyCodes.includes(e.key)) {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
isAltPressing = false; // 标记 alt 已松开
|
||||
}
|
||||
};
|
||||
|
||||
const onWindowBlur = () => {
|
||||
isAltPressing = false; // 标记 alt 已松开
|
||||
};
|
||||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
document.addEventListener('contextmenu', onContextMenu);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
|
||||
let isDragCopying = false;
|
||||
let pasteObj: FabricObject | undefined;
|
||||
let originalPos = { left: 0, top: 0 };
|
||||
|
||||
const disposers = [
|
||||
// 复制时机:按下 alt 键 & 鼠标按下激活元素
|
||||
canvas?.on('mouse:down', async e => {
|
||||
if (isAltPressing) {
|
||||
if (!latestCouldAddNewObject.current) {
|
||||
Toast.warning({
|
||||
content: '元素数量已达上限,无法添加新元素',
|
||||
duration: 3,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isDragCopying = true;
|
||||
const activeObject = canvas.getActiveObject();
|
||||
// 创建元素副本期间,锁定 xy 方向的移动
|
||||
activeObject?.set({
|
||||
lockMovementX: true,
|
||||
lockMovementY: true,
|
||||
});
|
||||
try {
|
||||
await copy(CopyMode.DragCV);
|
||||
pasteObj = await paste({
|
||||
mode: CopyMode.DragCV,
|
||||
});
|
||||
|
||||
// 记录对象的原始位置,实现 shift 垂直、水平移动
|
||||
originalPos = {
|
||||
left: pasteObj?.left ?? 0,
|
||||
top: pasteObj?.top ?? 0,
|
||||
};
|
||||
} finally {
|
||||
activeObject?.set({
|
||||
lockMovementX: false,
|
||||
lockMovementY: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// 因为 copy 是异步的,所以这里会有一些延迟(大图片比较明显),没啥好办法
|
||||
canvas?.on('mouse:move', event => {
|
||||
if (isAltPressing && isDragCopying && pasteObj) {
|
||||
const pointer = canvas.getScenePoint(event.e);
|
||||
|
||||
// 检查是否按下了Shift键
|
||||
if (event.e.shiftKey) {
|
||||
// 计算从开始移动以来的水平和垂直距离
|
||||
const distanceX = pointer.x - originalPos.left;
|
||||
const distanceY = pointer.y - originalPos.top;
|
||||
|
||||
// 根据移动距离的绝对值判断是水平移动还是垂直移动
|
||||
if (Math.abs(distanceX) > Math.abs(distanceY)) {
|
||||
// 水平移动:保持垂直位置不变
|
||||
pasteObj?.set({
|
||||
left: pointer.x - (pasteObj?.width ?? 0) / 2,
|
||||
top: originalPos.top,
|
||||
});
|
||||
} else {
|
||||
// 垂直移动:保持水平位置不变
|
||||
pasteObj?.set({
|
||||
left: originalPos.left,
|
||||
top: pointer.y - (pasteObj?.height ?? 0) / 2,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
pasteObj?.set({
|
||||
left: pointer.x - (pasteObj?.width ?? 0) / 2,
|
||||
top: pointer.y - (pasteObj?.height ?? 0) / 2,
|
||||
});
|
||||
}
|
||||
|
||||
snap.move(pasteObj);
|
||||
canvas.requestRenderAll();
|
||||
canvas.fire('object:moving');
|
||||
}
|
||||
}),
|
||||
|
||||
canvas?.on('mouse:up', () => {
|
||||
isDragCopying = false;
|
||||
pasteObj = undefined;
|
||||
// 释放拖拽复制对象,避免对下次拖拽(按着 alt 不松手)造成干扰
|
||||
copiedObject3.current = undefined;
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
document.removeEventListener('contextmenu', onContextMenu);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
disposers.forEach(disposer => disposer?.());
|
||||
};
|
||||
}, [canvas, copy, paste]);
|
||||
|
||||
// 拖拽复制
|
||||
return {
|
||||
copy,
|
||||
paste,
|
||||
disabledPaste: !copiedObject1.current,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* 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 { useRef } from 'react';
|
||||
|
||||
import {
|
||||
type ObjectEvents,
|
||||
type Canvas,
|
||||
type FabricObject,
|
||||
type Line,
|
||||
} from 'fabric';
|
||||
|
||||
import { snap } from '../utils/snap/snap';
|
||||
import { resetElementClip } from '../utils/fabric-utils';
|
||||
import { createElement, defaultProps } from '../utils';
|
||||
import { type FabricObjectWithCustomProps, Mode, Snap } from '../typings';
|
||||
|
||||
const modeElementMap: Partial<
|
||||
Record<
|
||||
Mode,
|
||||
{
|
||||
down: (props: {
|
||||
left: number;
|
||||
top: number;
|
||||
canvas?: Canvas;
|
||||
}) => Promise<FabricObject | undefined>;
|
||||
move: (e: { element: FabricObject; dx: number; dy: number }) => void;
|
||||
up?: (e: { element: FabricObject }) => void;
|
||||
}
|
||||
>
|
||||
> = {
|
||||
[Mode.RECT]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.RECT,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
width: dx,
|
||||
height: dy,
|
||||
});
|
||||
snap.resize(element, Snap.ControlType.BottomRight);
|
||||
},
|
||||
up: ({ element }) => {
|
||||
element.set({
|
||||
width: defaultProps[Mode.RECT].width,
|
||||
height: defaultProps[Mode.RECT].height,
|
||||
});
|
||||
},
|
||||
},
|
||||
[Mode.CIRCLE]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.CIRCLE,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
rx: Math.max(dx / 2, 0),
|
||||
ry: Math.max(dy / 2, 0),
|
||||
});
|
||||
snap.resize(element, Snap.ControlType.BottomRight);
|
||||
},
|
||||
up: ({ element }) => {
|
||||
element.set({
|
||||
rx: defaultProps[Mode.CIRCLE].rx,
|
||||
ry: defaultProps[Mode.CIRCLE].ry,
|
||||
});
|
||||
},
|
||||
},
|
||||
[Mode.TRIANGLE]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.TRIANGLE,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
width: dx,
|
||||
height: dy,
|
||||
});
|
||||
snap.resize(element, Snap.ControlType.BottomRight);
|
||||
},
|
||||
up: ({ element }) => {
|
||||
element.set({
|
||||
width: defaultProps[Mode.TRIANGLE].width,
|
||||
height: defaultProps[Mode.TRIANGLE].height,
|
||||
});
|
||||
},
|
||||
},
|
||||
[Mode.STRAIGHT_LINE]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.STRAIGHT_LINE,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
x2: dx + (element as Line).x1,
|
||||
y2: dy + (element as Line).y1,
|
||||
});
|
||||
|
||||
// 创建直线时的终点位置修改,需要主动 fire 影响控制点的显示
|
||||
element.fire('start-end:modified' as keyof ObjectEvents);
|
||||
},
|
||||
},
|
||||
|
||||
[Mode.BLOCK_TEXT]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.BLOCK_TEXT,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
customFixedHeight: dy,
|
||||
width: dx,
|
||||
height: dy,
|
||||
});
|
||||
snap.resize(element, Snap.ControlType.BottomRight);
|
||||
resetElementClip({ element });
|
||||
},
|
||||
|
||||
up: ({ element }) => {
|
||||
element.set({
|
||||
width: defaultProps[Mode.BLOCK_TEXT].width,
|
||||
height: defaultProps[Mode.BLOCK_TEXT].height,
|
||||
customFixedHeight: defaultProps[Mode.BLOCK_TEXT].height,
|
||||
});
|
||||
resetElementClip({ element });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const useDragAdd = ({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
onShapeAdded?: (data: { element: FabricObjectWithCustomProps }) => void;
|
||||
}): {
|
||||
enterDragAddElement: (mode: Mode) => void;
|
||||
exitDragAddElement: () => void;
|
||||
} => {
|
||||
const newElement = useRef<
|
||||
{ element: FabricObject; x: number; y: number; moved: boolean } | undefined
|
||||
>();
|
||||
|
||||
const disposers = useRef<(() => void)[]>([]);
|
||||
const enterDragAddElement = (mode: Mode) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mouseDownDisposer = canvas.on('mouse:down', async function ({ e }) {
|
||||
canvas.selection = false;
|
||||
const pointer = canvas.getScenePoint(e);
|
||||
e.preventDefault();
|
||||
|
||||
const element = await modeElementMap[mode]?.down({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
canvas,
|
||||
});
|
||||
|
||||
if (element) {
|
||||
canvas.add(element);
|
||||
canvas.setActiveObject(element);
|
||||
newElement.current = {
|
||||
element,
|
||||
x: pointer.x,
|
||||
y: pointer.y,
|
||||
moved: false,
|
||||
};
|
||||
|
||||
// 隐藏控制点,否则 onmouseup 可能被控制点截胡
|
||||
element.set('hasControls', false);
|
||||
}
|
||||
});
|
||||
|
||||
const mouseMoveDisposer = canvas.on('mouse:move', function ({ e }) {
|
||||
e.preventDefault();
|
||||
if (newElement.current) {
|
||||
const { element, x, y } = newElement.current;
|
||||
const pointer = canvas.getScenePoint(e);
|
||||
const dx = pointer.x - x;
|
||||
const dy = pointer.y - y;
|
||||
|
||||
modeElementMap[mode]?.move({
|
||||
element,
|
||||
dx,
|
||||
dy,
|
||||
});
|
||||
|
||||
// 修正元素坐标信息
|
||||
element.setCoords();
|
||||
|
||||
newElement.current.moved = true;
|
||||
canvas.fire('object:modified');
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
const mouseUpDisposer = canvas.on('mouse:up', function ({ e }) {
|
||||
e.preventDefault();
|
||||
if (newElement.current) {
|
||||
const { element } = newElement.current;
|
||||
if (!newElement.current.moved) {
|
||||
modeElementMap[mode]?.up?.({
|
||||
element,
|
||||
});
|
||||
}
|
||||
|
||||
onShapeAdded?.({ element: element as FabricObjectWithCustomProps });
|
||||
|
||||
// 恢复控制点
|
||||
element.set('hasControls', true);
|
||||
newElement.current = undefined;
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
disposers.current.push(
|
||||
mouseDownDisposer,
|
||||
mouseMoveDisposer,
|
||||
mouseUpDisposer,
|
||||
);
|
||||
};
|
||||
|
||||
const exitDragAddElement = () => {
|
||||
if (canvas) {
|
||||
canvas.selection = true;
|
||||
}
|
||||
|
||||
if (disposers.current.length > 0) {
|
||||
disposers.current.forEach(disposer => disposer());
|
||||
disposers.current = [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enterDragAddElement,
|
||||
exitDragAddElement,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* 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 { useEffect, useMemo } from 'react';
|
||||
|
||||
import { type InputVariable } from '@coze-workflow/base/types';
|
||||
|
||||
import {
|
||||
REF_VARIABLE_ID_PREFIX,
|
||||
type FabricClickEvent,
|
||||
type FabricObjectWithCustomProps,
|
||||
type FabricSchema,
|
||||
type VariableRef,
|
||||
} from '../typings';
|
||||
import { useViewport } from './use-viewport';
|
||||
import { useSnapMove } from './use-snap-move';
|
||||
import { useRedoUndo } from './use-redo-undo';
|
||||
import { usePosition } from './use-position';
|
||||
import { useMousePosition } from './use-mouse-position';
|
||||
import { useInlineTextAdd } from './use-inline-text-add';
|
||||
import { useInitCanvas } from './use-init-canvas';
|
||||
import { useImagAdd } from './use-img-add';
|
||||
import { useGroup } from './use-group';
|
||||
import { useFreePencil } from './use-free-pencil';
|
||||
import { useDragAdd } from './use-drag-add';
|
||||
import { useCopyPaste } from './use-copy-paste';
|
||||
import { useCommonOperation } from './use-common-operation';
|
||||
import { useCanvasResize } from './use-canvas-resize';
|
||||
import { useCanvasChange } from './use-canvas-change';
|
||||
import { useBackground } from './use-background';
|
||||
import { useAlign } from './use-align';
|
||||
import { useActiveObjectChange } from './use-active-object-change';
|
||||
|
||||
export const useFabricEditor = ({
|
||||
ref,
|
||||
schema: _schema,
|
||||
onChange,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
startInit,
|
||||
maxZoom = 3,
|
||||
minZoom = 0.3,
|
||||
readonly = false,
|
||||
onShapeAdded,
|
||||
variables,
|
||||
id,
|
||||
helpLineLayerId,
|
||||
onClick,
|
||||
}: {
|
||||
id?: string;
|
||||
helpLineLayerId: string;
|
||||
ref: React.RefObject<HTMLCanvasElement>;
|
||||
schema: FabricSchema;
|
||||
onChange?: (schema: FabricSchema) => void;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
startInit: boolean;
|
||||
maxZoom?: number;
|
||||
minZoom?: number;
|
||||
readonly?: boolean;
|
||||
onShapeAdded?: (data: { element: FabricObjectWithCustomProps }) => void;
|
||||
variables?: InputVariable[];
|
||||
onClick?: (e: FabricClickEvent) => void;
|
||||
}) => {
|
||||
const schema: FabricSchema = useMemo(() => {
|
||||
/**
|
||||
* 兼容历史数据
|
||||
* 删除时机,见 apps/fabric-canvas-node-render/utils/replace-ref-value.ts 注释
|
||||
*/
|
||||
if (
|
||||
!_schema?.customVariableRefs &&
|
||||
(_schema?.objects?.filter(d => d.customVariableName)?.length ?? 0) > 0
|
||||
) {
|
||||
const refObjects = _schema?.objects?.filter(d => d.customVariableName);
|
||||
const newRefs: VariableRef[] =
|
||||
refObjects?.map(d => ({
|
||||
variableId: d.customId
|
||||
.replace(`${REF_VARIABLE_ID_PREFIX}-img-`, '')
|
||||
.replace(`${REF_VARIABLE_ID_PREFIX}-text-`, ''),
|
||||
objectId: d.customId,
|
||||
variableName: d.customVariableName as string,
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
..._schema,
|
||||
customVariableRefs: newRefs,
|
||||
};
|
||||
}
|
||||
return _schema;
|
||||
}, [_schema]);
|
||||
|
||||
const objectLength = useMemo(() => schema.objects.length, [schema]);
|
||||
|
||||
/**
|
||||
* 最大可添加元素数量限制
|
||||
*/
|
||||
const MAX_OBJECT_LENGTH = 50;
|
||||
const couldAddNewObject = useMemo(
|
||||
() => objectLength < MAX_OBJECT_LENGTH,
|
||||
[objectLength],
|
||||
);
|
||||
|
||||
const { resize, scale } = useCanvasResize({
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
width: schema.width,
|
||||
height: schema.height,
|
||||
});
|
||||
|
||||
// 初始化 fabric canvas
|
||||
const { canvas, loadFromJSON } = useInitCanvas({
|
||||
startInit,
|
||||
ref: ref.current,
|
||||
schema,
|
||||
resize,
|
||||
scale,
|
||||
readonly,
|
||||
onClick,
|
||||
});
|
||||
|
||||
const { viewport, setViewport, zoomToPoint } = useViewport({
|
||||
canvas,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
schema,
|
||||
});
|
||||
|
||||
const { mousePosition } = useMousePosition({ canvas });
|
||||
|
||||
const { group, unGroup } = useGroup({
|
||||
canvas,
|
||||
});
|
||||
|
||||
const {
|
||||
startListen,
|
||||
stopListen,
|
||||
addRefObjectByVariable,
|
||||
updateRefByObjectId,
|
||||
customVariableRefs,
|
||||
} = useCanvasChange({
|
||||
variables,
|
||||
canvas,
|
||||
schema,
|
||||
onChange: json => {
|
||||
onChange?.(json);
|
||||
pushOperation(json);
|
||||
},
|
||||
});
|
||||
|
||||
useSnapMove({ canvas, helpLineLayerId, scale });
|
||||
|
||||
const { copy, paste, disabledPaste } = useCopyPaste({
|
||||
canvas,
|
||||
mousePosition,
|
||||
couldAddNewObject,
|
||||
customVariableRefs,
|
||||
addRefObjectByVariable,
|
||||
variables,
|
||||
});
|
||||
|
||||
const { pushOperation, undo, redo, disabledRedo, disabledUndo, redoUndoing } =
|
||||
useRedoUndo({
|
||||
id,
|
||||
schema,
|
||||
loadFromJSON,
|
||||
startListen,
|
||||
stopListen,
|
||||
onChange,
|
||||
});
|
||||
|
||||
const {
|
||||
activeObjects,
|
||||
activeObjectsPopPosition,
|
||||
setActiveObjectsProps,
|
||||
isActiveObjectsInBack,
|
||||
isActiveObjectsInFront,
|
||||
} = useActiveObjectChange({
|
||||
canvas,
|
||||
scale,
|
||||
});
|
||||
|
||||
const {
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenter,
|
||||
alignTop,
|
||||
alignBottom,
|
||||
alignMiddle,
|
||||
verticalAverage,
|
||||
horizontalAverage,
|
||||
} = useAlign({
|
||||
canvas,
|
||||
selectObjects: activeObjects,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (canvas) {
|
||||
resize(canvas);
|
||||
}
|
||||
}, [resize, canvas]);
|
||||
|
||||
const { backgroundColor, setBackgroundColor } = useBackground({
|
||||
canvas,
|
||||
schema,
|
||||
});
|
||||
|
||||
const {
|
||||
moveActiveObject,
|
||||
removeActiveObjects,
|
||||
moveTo,
|
||||
discardActiveObject,
|
||||
resetWidthHeight,
|
||||
} = useCommonOperation({
|
||||
canvas,
|
||||
});
|
||||
|
||||
const { addImage } = useImagAdd({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
});
|
||||
|
||||
const { enterDragAddElement, exitDragAddElement } = useDragAdd({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
});
|
||||
|
||||
const { enterFreePencil, exitFreePencil } = useFreePencil({
|
||||
canvas,
|
||||
});
|
||||
|
||||
const { enterAddInlineText, exitAddInlineText } = useInlineTextAdd({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
});
|
||||
|
||||
const { allObjectsPositionInScreen } = usePosition({
|
||||
canvas,
|
||||
scale,
|
||||
viewport,
|
||||
});
|
||||
|
||||
return {
|
||||
canvas,
|
||||
canvasSettings: {
|
||||
width: schema.width,
|
||||
height: schema.height,
|
||||
backgroundColor,
|
||||
},
|
||||
state: {
|
||||
viewport,
|
||||
cssScale: scale,
|
||||
activeObjects,
|
||||
activeObjectsPopPosition,
|
||||
objectLength,
|
||||
couldAddNewObject,
|
||||
disabledUndo,
|
||||
disabledRedo,
|
||||
redoUndoing,
|
||||
disabledPaste,
|
||||
isActiveObjectsInBack,
|
||||
isActiveObjectsInFront,
|
||||
canvasWidth: canvas?.getElement().getBoundingClientRect().width,
|
||||
canvasHeight: canvas?.getElement().getBoundingClientRect().height,
|
||||
customVariableRefs,
|
||||
allObjectsPositionInScreen,
|
||||
},
|
||||
sdk: {
|
||||
discardActiveObject,
|
||||
setActiveObjectsProps,
|
||||
setBackgroundColor,
|
||||
moveToFront: () => {
|
||||
moveTo('front');
|
||||
},
|
||||
moveToBackend: () => {
|
||||
moveTo('backend');
|
||||
},
|
||||
moveToFrontOne: () => {
|
||||
moveTo('front-one');
|
||||
},
|
||||
moveToBackendOne: () => {
|
||||
moveTo('backend-one');
|
||||
},
|
||||
zoomToPoint,
|
||||
setViewport,
|
||||
moveActiveObject,
|
||||
removeActiveObjects,
|
||||
enterDragAddElement,
|
||||
exitDragAddElement,
|
||||
enterFreePencil,
|
||||
exitFreePencil,
|
||||
enterAddInlineText,
|
||||
exitAddInlineText,
|
||||
addImage,
|
||||
undo,
|
||||
redo,
|
||||
copy,
|
||||
paste,
|
||||
group,
|
||||
unGroup,
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenter,
|
||||
alignTop,
|
||||
alignBottom,
|
||||
alignMiddle,
|
||||
verticalAverage,
|
||||
horizontalAverage,
|
||||
resetWidthHeight,
|
||||
addRefObjectByVariable,
|
||||
updateRefByObjectId,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 { useEffect } from 'react';
|
||||
|
||||
import { type FabricSchema } from '../typings';
|
||||
import { useSchemaChange } from './use-schema-change';
|
||||
import { useInitCanvas } from './use-init-canvas';
|
||||
import { useCanvasResize } from './use-canvas-resize';
|
||||
|
||||
export const useFabricPreview = ({
|
||||
ref,
|
||||
schema,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
startInit,
|
||||
}: {
|
||||
ref: React.RefObject<HTMLCanvasElement>;
|
||||
schema: FabricSchema;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
startInit: boolean;
|
||||
}) => {
|
||||
const { resize, scale } = useCanvasResize({
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
width: schema.width,
|
||||
height: schema.height,
|
||||
});
|
||||
|
||||
const { canvas } = useInitCanvas({
|
||||
ref: ref.current,
|
||||
schema,
|
||||
startInit,
|
||||
readonly: true,
|
||||
resize,
|
||||
scale,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (canvas) {
|
||||
resize(canvas);
|
||||
}
|
||||
}, [resize, canvas]);
|
||||
|
||||
useSchemaChange({
|
||||
canvas,
|
||||
schema,
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
return {
|
||||
state: {
|
||||
cssScale: scale,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 { useEffect } from 'react';
|
||||
|
||||
import { PencilBrush, type Canvas } from 'fabric';
|
||||
|
||||
import { createControls } from '../utils/create-controls';
|
||||
import { defaultProps, createCommonObjectOptions } from '../utils';
|
||||
import { Mode } from '../typings';
|
||||
|
||||
export const useFreePencil = ({ canvas }: { canvas?: Canvas }) => {
|
||||
const enterFreePencil = () => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
// 启用自由绘图模式
|
||||
canvas.isDrawingMode = true;
|
||||
|
||||
// 设置 PencilBrush 为当前的画笔
|
||||
canvas.freeDrawingBrush = new PencilBrush(canvas);
|
||||
|
||||
// 设置画笔的一些属性
|
||||
canvas.freeDrawingBrush.color = defaultProps[Mode.PENCIL].stroke as string; // 画笔颜色
|
||||
canvas.freeDrawingBrush.width = defaultProps[Mode.PENCIL]
|
||||
.strokeWidth as number; // 画笔宽度
|
||||
|
||||
// 你也可以设置其他属性,比如 opacity (不透明度)
|
||||
// canvas.freeDrawingBrush.opacity = 0.6;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const disposer = canvas.on('path:created', function (event) {
|
||||
const { path } = event;
|
||||
const commonOptions = createCommonObjectOptions(Mode.PENCIL);
|
||||
path.set({ ...commonOptions, ...defaultProps[Mode.PENCIL] });
|
||||
|
||||
createControls[Mode.PENCIL]?.({
|
||||
element: path,
|
||||
});
|
||||
|
||||
// 得触发一次 object:added ,以触发 onSave,否则 schema 里并不会包含 commonOptions
|
||||
canvas.fire('object:modified');
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposer();
|
||||
};
|
||||
}, [canvas]);
|
||||
|
||||
const exitFreePencil = () => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
// 禁用自由绘图模式
|
||||
canvas.isDrawingMode = false;
|
||||
};
|
||||
|
||||
return {
|
||||
enterFreePencil,
|
||||
exitFreePencil,
|
||||
};
|
||||
};
|
||||
102
frontend/packages/workflow/fabric-canvas/src/hooks/use-group.tsx
Normal file
102
frontend/packages/workflow/fabric-canvas/src/hooks/use-group.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 这是个半成品,暂时不做了,后面再考虑
|
||||
* 1. 成组后要支持下钻继续选择
|
||||
* 实现思路:
|
||||
* a.双击解组,并记录组关系;
|
||||
* b.下钻选择子组,继续解组,并记录组关系;
|
||||
* c.点击画布(没有任何选中元素时),恢复组(要注意 z-index)。
|
||||
*
|
||||
* 2. 复制粘贴组时,需要排除掉引用元素
|
||||
* 3. 删除组是,也需要排除引用元素
|
||||
* 4. 因为组的引入,打破了所有元素都是拍平的原则,要注意这个改动的破坏性。
|
||||
* eg:
|
||||
* a. 获取所有元素
|
||||
* b. 元素的位置计算是由每层父元素叠加来的
|
||||
* c. 服务端渲染:遍历找所有的图片元素。完成图片下载后恢复组
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
ActiveSelection,
|
||||
type Canvas,
|
||||
type FabricObject,
|
||||
type Group,
|
||||
} from 'fabric';
|
||||
|
||||
import { isGroupElement } from '../utils/fabric-utils';
|
||||
import { createElement } from '../utils';
|
||||
import { Mode, type FabricObjectWithCustomProps } from '../typings';
|
||||
|
||||
export const useGroup = ({ canvas }: { canvas?: Canvas }) => {
|
||||
const group = useCallback(async () => {
|
||||
const activeObject = canvas?.getActiveObject();
|
||||
const objects = (activeObject as ActiveSelection)?.getObjects();
|
||||
// 选中了多个元素时,才可以 group
|
||||
if ((objects?.length ?? 0) > 1) {
|
||||
const _group = await createElement({
|
||||
mode: Mode.GROUP,
|
||||
elementProps: {
|
||||
left: activeObject?.left,
|
||||
top: activeObject?.top,
|
||||
width: activeObject?.width,
|
||||
height: activeObject?.height,
|
||||
},
|
||||
});
|
||||
|
||||
(_group as Group).add(...objects);
|
||||
canvas?.add(_group as Group);
|
||||
canvas?.setActiveObject(_group as Group);
|
||||
canvas?.remove(...objects);
|
||||
}
|
||||
}, [canvas]);
|
||||
|
||||
const unGroup = useCallback(async () => {
|
||||
const activeObject = canvas?.getActiveObject();
|
||||
|
||||
// 仅选中了一个 group 元素时,才可以 ungroup
|
||||
if (isGroupElement(activeObject)) {
|
||||
const _group = activeObject as Group;
|
||||
const objects = _group.getObjects();
|
||||
|
||||
await Promise.all(
|
||||
objects.map(async d => {
|
||||
const element = await createElement({
|
||||
mode: (d as FabricObjectWithCustomProps).customType,
|
||||
element: d as FabricObjectWithCustomProps,
|
||||
});
|
||||
|
||||
_group.remove(d);
|
||||
canvas?.add(element as FabricObject);
|
||||
}),
|
||||
);
|
||||
|
||||
canvas?.discardActiveObject();
|
||||
canvas?.remove(_group);
|
||||
|
||||
const activeSelection = new ActiveSelection(objects);
|
||||
canvas?.setActiveObject(activeSelection);
|
||||
canvas?.requestRenderAll();
|
||||
}
|
||||
}, [canvas]);
|
||||
|
||||
return {
|
||||
group,
|
||||
unGroup,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 } from 'fabric';
|
||||
|
||||
import { createElement, defaultProps } from '../utils';
|
||||
import { type FabricObjectWithCustomProps, Mode } from '../typings';
|
||||
|
||||
export const useImagAdd = ({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
onShapeAdded?: (data: { element: FabricObjectWithCustomProps }) => void;
|
||||
}) => {
|
||||
const addImage = async (url: string) => {
|
||||
const img = await createElement({
|
||||
mode: Mode.IMAGE,
|
||||
position: [
|
||||
(canvas?.width as number) / 2 -
|
||||
(defaultProps[Mode.IMAGE].width as number) / 2,
|
||||
(canvas?.height as number) / 2 -
|
||||
(defaultProps[Mode.IMAGE].height as number) / 2,
|
||||
],
|
||||
canvas,
|
||||
elementProps: {
|
||||
src: url,
|
||||
},
|
||||
});
|
||||
if (img) {
|
||||
canvas?.add(img);
|
||||
canvas?.setActiveObject(img);
|
||||
onShapeAdded?.({ element: img as FabricObjectWithCustomProps });
|
||||
}
|
||||
};
|
||||
return {
|
||||
addImage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Canvas, type FabricObject } from 'fabric';
|
||||
import { useAsyncEffect, useUnmount } from 'ahooks';
|
||||
|
||||
import { loadFontWithSchema, setElementAfterLoad } from '../utils';
|
||||
import { type FabricClickEvent, type FabricSchema } from '../typings';
|
||||
|
||||
export const useInitCanvas = ({
|
||||
startInit,
|
||||
ref,
|
||||
schema,
|
||||
readonly,
|
||||
resize,
|
||||
scale = 1,
|
||||
onClick,
|
||||
}: {
|
||||
startInit: boolean;
|
||||
ref: HTMLCanvasElement | null;
|
||||
schema: FabricSchema;
|
||||
readonly: boolean;
|
||||
resize?: (canvas: Canvas) => void;
|
||||
scale?: number;
|
||||
onClick?: (e: FabricClickEvent) => void;
|
||||
}) => {
|
||||
const [canvas, setCanvas] = useState<Canvas | undefined>(undefined);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!startInit || !ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按比例给个初始化高度,随后会通过 resize 修正为真正的宽高
|
||||
const _canvas = new Canvas(ref, {
|
||||
width: schema.width * scale,
|
||||
height: schema.height * scale,
|
||||
backgroundColor: schema.backgroundColor as string,
|
||||
selection: !readonly,
|
||||
preserveObjectStacking: true,
|
||||
});
|
||||
resize?.(_canvas);
|
||||
|
||||
await loadFromJSON(schema, _canvas);
|
||||
|
||||
setCanvas(_canvas);
|
||||
|
||||
loadFontWithSchema({
|
||||
schema,
|
||||
canvas: _canvas,
|
||||
});
|
||||
|
||||
if (!readonly) {
|
||||
(
|
||||
window as unknown as {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_fabric_canvas: Canvas;
|
||||
}
|
||||
)._fabric_canvas = _canvas;
|
||||
}
|
||||
}, [startInit]);
|
||||
|
||||
useUnmount(() => {
|
||||
canvas?.dispose();
|
||||
setCanvas(undefined);
|
||||
});
|
||||
|
||||
const loadFromJSON = useCallback(
|
||||
async (_schema: FabricSchema, _canvas?: Canvas) => {
|
||||
const fabricCanvas = _canvas ?? canvas;
|
||||
await fabricCanvas?.loadFromJSON(
|
||||
JSON.stringify(_schema),
|
||||
async (elementSchema, element) => {
|
||||
// 每个元素被加载后的回调
|
||||
await setElementAfterLoad({
|
||||
element: element as FabricObject,
|
||||
options: { readonly },
|
||||
canvas: fabricCanvas,
|
||||
});
|
||||
},
|
||||
);
|
||||
fabricCanvas?.requestRenderAll();
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const disposers: (() => void)[] = [];
|
||||
if (canvas) {
|
||||
disposers.push(
|
||||
canvas.on('mouse:down', e => {
|
||||
onClick?.(e);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposers.forEach(disposer => disposer());
|
||||
};
|
||||
}, [canvas, onClick]);
|
||||
|
||||
return { canvas, loadFromJSON };
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 { useRef } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
|
||||
import { createElement } from '../utils';
|
||||
import { type FabricObjectWithCustomProps, Mode } from '../typings';
|
||||
|
||||
export const useInlineTextAdd = ({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
onShapeAdded?: (data: { element: FabricObjectWithCustomProps }) => void;
|
||||
}) => {
|
||||
const disposers = useRef<(() => void)[]>([]);
|
||||
|
||||
const enterAddInlineText = () => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mouseDownDisposer = canvas.on('mouse:down', async ({ e }) => {
|
||||
const pointer = canvas.getScenePoint(e);
|
||||
e.preventDefault();
|
||||
|
||||
canvas.selection = false;
|
||||
const text = await createElement({
|
||||
mode: Mode.INLINE_TEXT,
|
||||
position: [pointer.x, pointer.y],
|
||||
canvas,
|
||||
});
|
||||
|
||||
if (text) {
|
||||
canvas.add(text);
|
||||
canvas.setActiveObject(text);
|
||||
|
||||
onShapeAdded?.({ element: text as FabricObjectWithCustomProps });
|
||||
}
|
||||
});
|
||||
disposers.current.push(mouseDownDisposer);
|
||||
};
|
||||
|
||||
const exitAddInlineText = () => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.selection = true;
|
||||
|
||||
if (disposers.current.length > 0) {
|
||||
disposers.current.forEach(disposer => disposer());
|
||||
disposers.current = [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enterAddInlineText,
|
||||
exitAddInlineText,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 { useEffect, useState } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
|
||||
export const useMousePosition = ({ canvas }: { canvas?: Canvas }) => {
|
||||
const [position, setPosition] = useState<{ left: number; top: number }>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dispose = canvas.on('mouse:move', event => {
|
||||
const pointer = canvas.getScenePoint(event.e);
|
||||
setPosition({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
});
|
||||
});
|
||||
return dispose;
|
||||
}, [canvas]);
|
||||
|
||||
return {
|
||||
mousePosition: position,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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 { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
type Canvas,
|
||||
type FabricObject,
|
||||
type Group,
|
||||
type TMat2D,
|
||||
} from 'fabric';
|
||||
|
||||
import {
|
||||
type FabricObjectWithCustomProps,
|
||||
type IRefPosition,
|
||||
Mode,
|
||||
} from '../typings';
|
||||
import { useCanvasChange } from './use-canvas-change';
|
||||
|
||||
const getElementTitlePosition = ({
|
||||
element,
|
||||
scale,
|
||||
}: {
|
||||
element: FabricObjectWithCustomProps;
|
||||
scale: number;
|
||||
}): IRefPosition => {
|
||||
const isImg = (element as FabricObject).isType('group');
|
||||
const isInlineText = element.customType === Mode.INLINE_TEXT;
|
||||
|
||||
const targetElement = isImg
|
||||
? (element as unknown as Group).getObjects()[0]
|
||||
: element;
|
||||
const targetElementTopLeft = targetElement.calcOCoords().tl;
|
||||
const { width, scaleX = 1, padding = 0 } = targetElement;
|
||||
|
||||
let left = targetElementTopLeft.x * scale;
|
||||
let top = targetElementTopLeft.y * scale;
|
||||
|
||||
// 图片特化,需要考虑比例拉伸,位置限定在 group 范围内
|
||||
if (isImg) {
|
||||
const strokeWidth =
|
||||
(element as unknown as Group).getObjects()?.[1]?.strokeWidth ?? 0;
|
||||
top = top - strokeWidth / 2;
|
||||
left = left - strokeWidth / 2;
|
||||
|
||||
const groupTopLeft = element.calcOCoords().tl;
|
||||
left = Math.max(groupTopLeft.x * scale, left);
|
||||
top = Math.max(groupTopLeft.y * scale, top);
|
||||
}
|
||||
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
angle: element.angle,
|
||||
id: element.customId,
|
||||
maxWidth: isInlineText ? 999 : (width * scaleX + padding * 2) * scale,
|
||||
isImg,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePosition = ({
|
||||
canvas,
|
||||
scale,
|
||||
viewport,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
scale: number;
|
||||
viewport?: TMat2D;
|
||||
}) => {
|
||||
// const [objects, setObjects] = useState<FabricObjectWithCustomProps[]>([]);
|
||||
const [screenPositions, setScreenPositions] = useState<IRefPosition[]>([]);
|
||||
|
||||
const _setPositions = useCallback(() => {
|
||||
// 为什么要 setTimeout?批量时,需要延迟才能拿到正确的坐标
|
||||
setTimeout(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const _objects = canvas.getObjects() as FabricObjectWithCustomProps[];
|
||||
// setObjects(_objects);
|
||||
const _positions = _objects?.map(ref =>
|
||||
getElementTitlePosition({
|
||||
element: ref,
|
||||
scale,
|
||||
}),
|
||||
);
|
||||
setScreenPositions(_positions);
|
||||
}, 0);
|
||||
}, [canvas, scale, viewport]);
|
||||
|
||||
useEffect(() => {
|
||||
_setPositions();
|
||||
}, [_setPositions]);
|
||||
|
||||
useCanvasChange({
|
||||
canvas,
|
||||
onChange: _setPositions,
|
||||
});
|
||||
|
||||
return {
|
||||
allObjectsPositionInScreen: screenPositions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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 { useCallback, useState } from 'react';
|
||||
|
||||
import { cloneDeep, isUndefined } from 'lodash-es';
|
||||
import { useDebounceFn, useLatest } from 'ahooks';
|
||||
|
||||
import { type FabricSchema } from '../typings';
|
||||
import { useStorageState } from './use-storage';
|
||||
|
||||
export const useRedoUndo = ({
|
||||
schema,
|
||||
loadFromJSON,
|
||||
stopListen,
|
||||
startListen,
|
||||
onChange,
|
||||
id,
|
||||
}: {
|
||||
schema: FabricSchema;
|
||||
loadFromJSON?: (schema: FabricSchema) => void;
|
||||
stopListen: () => void;
|
||||
startListen: () => void;
|
||||
onChange?: (schema: FabricSchema) => void;
|
||||
id?: string;
|
||||
}) => {
|
||||
const [history, setHistory] = useStorageState<FabricSchema[]>(
|
||||
`${id}-history`,
|
||||
{
|
||||
defaultValue: [cloneDeep(schema)],
|
||||
},
|
||||
);
|
||||
const historyLatest = useLatest(history);
|
||||
const [step, setStep] = useStorageState<number>(`${id}-step`, {
|
||||
defaultValue: 0,
|
||||
});
|
||||
const stepLatest = useLatest(step);
|
||||
|
||||
const [redoUndoing, setRedoUndoing] = useState(false);
|
||||
|
||||
const push = useCallback((_schema: FabricSchema) => {
|
||||
if (
|
||||
isUndefined(historyLatest.current) ||
|
||||
isUndefined(stepLatest.current) ||
|
||||
JSON.stringify(_schema) ===
|
||||
JSON.stringify(historyLatest.current[stepLatest.current])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 保存多少步
|
||||
const max = 20;
|
||||
const end = stepLatest.current + 1;
|
||||
const start = Math.max(0, end - max);
|
||||
const newHistory = [...historyLatest.current.splice(start, end), _schema];
|
||||
setHistory(newHistory);
|
||||
setStep(newHistory.length - 1);
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
if (
|
||||
isUndefined(historyLatest.current) ||
|
||||
isUndefined(stepLatest.current) ||
|
||||
stepLatest.current === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStep = stepLatest.current - 1;
|
||||
const _schema = historyLatest.current[newStep];
|
||||
|
||||
// 开始执行 undo
|
||||
setRedoUndoing(true);
|
||||
|
||||
// 停止监听画布变化
|
||||
stopListen();
|
||||
|
||||
// 保存 schema
|
||||
onChange?.(_schema);
|
||||
|
||||
// 画布重新加载
|
||||
await loadFromJSON?.(_schema);
|
||||
|
||||
// 同步 step
|
||||
setStep(newStep);
|
||||
|
||||
// 恢复画布监听
|
||||
startListen();
|
||||
|
||||
// undo 执行完成
|
||||
setRedoUndoing(false);
|
||||
}, [loadFromJSON]);
|
||||
|
||||
const redo = useCallback(async () => {
|
||||
if (
|
||||
isUndefined(historyLatest.current) ||
|
||||
isUndefined(stepLatest.current) ||
|
||||
stepLatest.current === historyLatest.current.length - 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStep = stepLatest.current + 1;
|
||||
const _schema = historyLatest.current[newStep];
|
||||
|
||||
// 开始执行 redo
|
||||
setRedoUndoing(true);
|
||||
|
||||
// 停止监听画布变化
|
||||
stopListen();
|
||||
|
||||
// 保存 schema
|
||||
onChange?.(_schema);
|
||||
|
||||
// 画布重新加载
|
||||
await loadFromJSON?.(_schema);
|
||||
|
||||
// 同步 step
|
||||
setStep(newStep);
|
||||
|
||||
// 恢复画布监听
|
||||
startListen();
|
||||
|
||||
// redo 执行完成
|
||||
setRedoUndoing(false);
|
||||
}, [loadFromJSON]);
|
||||
|
||||
const { run: pushOperation } = useDebounceFn(
|
||||
(_schema: FabricSchema) => {
|
||||
push(cloneDeep(_schema));
|
||||
},
|
||||
{
|
||||
wait: 300,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
pushOperation,
|
||||
undo: async () => {
|
||||
if (!redoUndoing) {
|
||||
await undo();
|
||||
}
|
||||
},
|
||||
redo: async () => {
|
||||
if (!redoUndoing) {
|
||||
await redo();
|
||||
}
|
||||
},
|
||||
disabledUndo: step === 0,
|
||||
disabledRedo: history && step === history.length - 1,
|
||||
redoUndoing,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { useEffect, useState } from 'react';
|
||||
|
||||
import { type Canvas, type FabricObject } from 'fabric';
|
||||
|
||||
import { setElementAfterLoad } from '../utils';
|
||||
import { type FabricSchema } from '../typings';
|
||||
|
||||
/**
|
||||
* 监听 schema 变化,reload canvas
|
||||
* 仅只读态需要
|
||||
*/
|
||||
export const useSchemaChange = ({
|
||||
canvas,
|
||||
schema,
|
||||
readonly,
|
||||
}: {
|
||||
canvas: Canvas | undefined;
|
||||
schema: FabricSchema;
|
||||
readonly: boolean;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
canvas
|
||||
?.loadFromJSON(JSON.stringify(schema), (elementSchema, element) => {
|
||||
// 这里是 schema 中每个元素被加载后的回调
|
||||
setElementAfterLoad({
|
||||
element: element as FabricObject,
|
||||
options: { readonly },
|
||||
canvas,
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
canvas?.requestRenderAll();
|
||||
});
|
||||
}, [schema, canvas]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 { useEffect } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
|
||||
import { createSnap, snap } from '../utils/snap/snap';
|
||||
|
||||
export const useSnapMove = ({
|
||||
canvas,
|
||||
helpLineLayerId,
|
||||
scale,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
helpLineLayerId: string;
|
||||
scale: number;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const _snap = createSnap(canvas, helpLineLayerId, scale);
|
||||
canvas.on('mouse:down', e => {
|
||||
snap.resetAllObjectsPosition(e.target);
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', e => {
|
||||
_snap.reset();
|
||||
});
|
||||
|
||||
canvas?.on('object:moving', function (e) {
|
||||
if (e.target) {
|
||||
_snap.move(e.target);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
_snap.destroy();
|
||||
};
|
||||
}, [canvas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (snap) {
|
||||
snap.helpline.resetScale(scale);
|
||||
}
|
||||
}, [scale]);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 { useState } from 'react';
|
||||
|
||||
import { useMemoizedFn, useUpdateEffect } from 'ahooks';
|
||||
|
||||
export interface Options<T> {
|
||||
defaultValue?: T | (() => T);
|
||||
}
|
||||
|
||||
const storage: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* 持久化保存到内存
|
||||
*/
|
||||
export function useStorageState<T>(key: string, options: Options<T> = {}) {
|
||||
function getStoredValue() {
|
||||
const raw = storage?.[key] ?? options?.defaultValue;
|
||||
return raw as T;
|
||||
}
|
||||
|
||||
const [state, setState] = useState<T>(getStoredValue);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
setState(getStoredValue());
|
||||
}, [key]);
|
||||
|
||||
const updateState = (value: T) => {
|
||||
setState(value);
|
||||
storage[key] = value;
|
||||
};
|
||||
|
||||
return [state, useMemoizedFn(updateState)] as const;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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 { useCallback, useState } from 'react';
|
||||
|
||||
import { type Point, type TMat2D, type Canvas } from 'fabric';
|
||||
|
||||
import { setViewport, zoomToPoint } from '../utils';
|
||||
import { type FabricSchema } from '../typings';
|
||||
|
||||
export const useViewport = ({
|
||||
canvas,
|
||||
schema,
|
||||
minZoom,
|
||||
maxZoom: maxZoom,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
schema: FabricSchema;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
}) => {
|
||||
const [viewport, _setViewport] = useState<TMat2D>([1, 0, 0, 1, 0, 0]);
|
||||
|
||||
const setCanvasViewport = useCallback(
|
||||
(vpt: TMat2D) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const _vpt: TMat2D = [...vpt];
|
||||
// 限制 viewport 移动区域:不能移出画布
|
||||
if (_vpt[4] > 0) {
|
||||
_vpt[4] = 0;
|
||||
}
|
||||
|
||||
if (_vpt[4] < -schema.width * (_vpt[0] - 1)) {
|
||||
_vpt[4] = -schema.width * (_vpt[0] - 1);
|
||||
}
|
||||
|
||||
if (_vpt[5] > 0) {
|
||||
_vpt[5] = 0;
|
||||
}
|
||||
|
||||
if (_vpt[5] < -schema.height * (_vpt[0] - 1)) {
|
||||
_vpt[5] = -schema.height * (_vpt[0] - 1);
|
||||
}
|
||||
|
||||
setViewport({ canvas, vpt: _vpt });
|
||||
_setViewport(_vpt);
|
||||
canvas.fire('object:moving');
|
||||
},
|
||||
[canvas, schema, minZoom, maxZoom],
|
||||
);
|
||||
|
||||
const _zoomToPoint = useCallback(
|
||||
(point: Point, zoomLevel: number) => {
|
||||
const vpt = zoomToPoint({
|
||||
canvas,
|
||||
point,
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
});
|
||||
setCanvasViewport(vpt);
|
||||
},
|
||||
[setCanvasViewport],
|
||||
);
|
||||
|
||||
return {
|
||||
setViewport: setCanvasViewport,
|
||||
viewport,
|
||||
zoomToPoint: _zoomToPoint,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user