feat: manually mirror opencoze's code from bytedance

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

View File

@@ -0,0 +1,31 @@
import { mergeConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal: config =>
mergeConfig(config, {
plugins: [
svgr({
svgrOptions: {
native: false,
},
}),
],
}),
};
export default config;

View File

@@ -0,0 +1,14 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# fabric-canvas
> Project template for react component with storybook.
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,301 @@
/*
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas, type FabricObject } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { useAlign } from '../../src/hooks/use-align';
describe('useAlign', () => {
const createMockCanvas = () => {
const mockCanvas = {
getActiveObject: vi.fn(),
fire: vi.fn(),
requestRenderAll: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockObject = (props = {}) => {
const mockObject = {
set: vi.fn(),
setCoords: vi.fn(),
getBoundingRect: vi.fn(() => ({
width: 100,
height: 100,
left: 0,
top: 0,
...props,
})),
width: 100,
height: 100,
...props,
};
return mockObject as unknown as FabricObject;
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('alignLeft', () => {
it('应该在 canvas 为 undefined 时不执行任何操作', () => {
const { result } = renderHook(() => useAlign({ canvas: undefined }));
act(() => {
result.current.alignLeft();
});
// 由于没有 canvas不应该有任何操作发生
});
it('应该在选中对象少于 2 个时不执行任何操作', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: [createMockObject()] }),
);
act(() => {
result.current.alignLeft();
});
expect(mockCanvas.fire).not.toHaveBeenCalled();
});
it('应该正确执行左对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignLeft();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
left: -mockActiveObject.width / 2,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignRight', () => {
it('应该正确执行右对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignRight();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
left: mockActiveObject.width / 2 - obj.getBoundingRect().width,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignCenter', () => {
it('应该正确执行水平居中对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignCenter();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
left: -obj.getBoundingRect().width / 2,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignTop', () => {
it('应该正确执行顶部对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignTop();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
top: -mockActiveObject.height / 2,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignMiddle', () => {
it('应该正确执行垂直居中对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignMiddle();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
top: -obj.getBoundingRect().height / 2,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignBottom', () => {
it('应该正确执行底部对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignBottom();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
top: mockActiveObject.height / 2 - obj.getBoundingRect().height,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('verticalAverage', () => {
it('应该正确执行水平均分', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject({ width: 300 });
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.verticalAverage();
});
expect(mockObjects[0].set).toHaveBeenCalledWith({ left: -150 });
expect(mockObjects[1].set).toHaveBeenCalledWith({ left: -50 });
expect(mockObjects[2].set).toHaveBeenCalledWith({ left: 50 });
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('horizontalAverage', () => {
it('应该正确执行垂直均分', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject({ height: 300 });
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.horizontalAverage();
});
expect(mockObjects[0].set).toHaveBeenCalledWith({ top: -150 });
expect(mockObjects[1].set).toHaveBeenCalledWith({ top: -50 });
expect(mockObjects[2].set).toHaveBeenCalledWith({ top: 50 });
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,139 @@
/*
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import type { FabricSchema } from '../../src/typings';
import { Mode } from '../../src/typings';
import { useBackground } from '../../src/hooks/use-background';
// Mock ahooks
vi.mock('ahooks', () => ({
useDebounceEffect: (fn: () => void, deps: any[], options: any) => {
useEffect(() => {
fn();
}, deps);
},
}));
describe('useBackground', () => {
const createMockCanvas = () => {
const mockCanvas = {
backgroundColor: '#ffffff',
set: vi.fn(),
fire: vi.fn(),
requestRenderAll: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockSchema = (backgroundColor = '#ffffff'): FabricSchema => ({
width: 800,
height: 600,
background: backgroundColor,
backgroundColor,
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
});
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
it('应该在 canvas 为 undefined 时不设置背景色', () => {
const schema = createMockSchema();
const { result } = renderHook(() =>
useBackground({ canvas: undefined, schema }),
);
expect(result.current.backgroundColor).toBe('#ffffff');
});
it('应该在初始化时从 canvas 获取背景色', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema();
const { result } = renderHook(() =>
useBackground({ canvas: mockCanvas, schema }),
);
expect(result.current.backgroundColor).toBe('#ffffff');
});
it('应该在 schema 的背景色变化时更新背景色', async () => {
const mockCanvas = createMockCanvas();
const initialSchema = createMockSchema('#ffffff');
const { result, rerender } = renderHook(
({ currentSchema }) =>
useBackground({ canvas: mockCanvas, schema: currentSchema }),
{
initialProps: { currentSchema: initialSchema },
},
);
// 更新 schema
const newSchema = createMockSchema('#000000');
rerender({ currentSchema: newSchema });
// 等待 debounce
await vi.runAllTimers();
expect(result.current.backgroundColor).toBe('#000000');
expect(mockCanvas.set).toHaveBeenCalledWith({
backgroundColor: '#000000',
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该在背景色变化时更新 canvas', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema();
const { result } = renderHook(() =>
useBackground({ canvas: mockCanvas, schema }),
);
act(() => {
result.current.setBackgroundColor('#000000');
});
expect(mockCanvas.set).toHaveBeenCalledWith({
backgroundColor: '#000000',
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该在背景色相同时不重复更新 canvas', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema('#ffffff');
const { result } = renderHook(() =>
useBackground({ canvas: mockCanvas, schema }),
);
act(() => {
result.current.setBackgroundColor('#ffffff');
});
expect(mockCanvas.set).not.toHaveBeenCalled();
expect(mockCanvas.fire).not.toHaveBeenCalled();
expect(mockCanvas.requestRenderAll).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,337 @@
/*
* 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 { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { type Canvas, type FabricObject } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { ViewVariableType } from '@coze-workflow/base/types';
import { createElement } from '../../src/utils';
import type { FabricSchema } from '../../src/typings';
import { useCanvasChange, saveProps } from '../../src/hooks/use-canvas-change';
enum Mode {
INLINE_TEXT = 'inline_text',
BLOCK_TEXT = 'block_text',
RECT = 'rect',
TRIANGLE = 'triangle',
CIRCLE = 'ellipse',
STRAIGHT_LINE = 'straight_line',
PENCIL = 'pencil',
IMAGE = 'img',
GROUP = 'group',
}
// Mock createElement
vi.mock('../../src/utils', () => ({
createElement: vi.fn(),
defaultProps: {
[Mode.IMAGE]: {
width: 100,
height: 100,
},
[Mode.BLOCK_TEXT]: {
width: 200,
height: 50,
},
},
}));
// Mock getUploadCDNAsset
vi.mock('@coze-workflow/base-adapter', () => ({
getUploadCDNAsset: vi.fn(path => `https://cdn.example.com${path}`),
}));
describe('useCanvasChange', () => {
const createMockCanvas = () => {
const mockCanvas = {
on: vi.fn((event: string, callback: (event: any) => void) =>
// 返回一个清理函数
() => {
mockCanvas.off(event, callback);
},
),
off: vi.fn(),
add: vi.fn(),
setActiveObject: vi.fn(),
toObject: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockObject = (props = {}) => {
const mockObject = {
customId: 'test-id',
customType: 'test-type',
...props,
};
return mockObject as unknown as FabricObject;
};
const createMockSchema = (overrides = {}): FabricSchema => ({
width: 800,
height: 600,
background: '#ffffff',
customType: Mode.IMAGE,
customId: 'canvas-1',
objects: [],
customVariableRefs: [],
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
});
it('应该在没有 canvas 时不设置事件监听', () => {
const mockOnChange = vi.fn();
renderHook(() =>
useCanvasChange({
canvas: undefined,
onChange: mockOnChange,
}),
);
expect(mockOnChange).not.toHaveBeenCalled();
});
it('应该正确监听画布变化事件', () => {
const mockCanvas = createMockCanvas();
const mockOnChange = vi.fn();
const mockSchema = createMockSchema();
(mockCanvas.toObject as Mock).mockReturnValue(mockSchema);
renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
onChange: mockOnChange,
}),
);
// 验证是否监听了所有默认事件
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:modified',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:added',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:removed',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:moving',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:modified-zIndex',
expect.any(Function),
);
});
it('应该在事件触发时调用 onChange', () => {
const mockCanvas = createMockCanvas();
const mockOnChange = vi.fn();
const mockSchema = createMockSchema();
(mockCanvas.toObject as Mock).mockReturnValue(mockSchema);
renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
onChange: mockOnChange,
}),
);
// 获取 object:modified 事件的回调函数
const modifiedCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'object:modified',
)?.[1];
// 模拟事件触发
modifiedCallback?.();
expect(mockCanvas.toObject).toHaveBeenCalledWith(saveProps);
expect(mockOnChange).toHaveBeenCalledWith(mockSchema);
});
it('应该在删除对象时重置引用关系', () => {
const mockCanvas = createMockCanvas();
const mockOnChange = vi.fn();
const mockVariables = [
{ id: 'var1', name: 'var1', type: ViewVariableType.String, index: 0 },
{ id: 'var2', name: 'var2', type: ViewVariableType.String, index: 1 },
];
const mockSchema = createMockSchema({
objects: [{ customId: 'obj1' }],
customVariableRefs: [
{ objectId: 'obj1', variableId: 'var1', variableName: 'var1' },
{ objectId: 'obj2', variableId: 'var2', variableName: 'var2' },
],
});
(mockCanvas.toObject as Mock).mockReturnValue(mockSchema);
renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
onChange: mockOnChange,
schema: mockSchema,
variables: mockVariables,
}),
);
// 获取 object:removed 事件的回调函数
const removedCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'object:removed',
)?.[1];
// 模拟删除事件
removedCallback?.();
// 验证只保留了存在对象的引用
expect(mockOnChange).toHaveBeenCalledWith({
...mockSchema,
customVariableRefs: [
{ objectId: 'obj1', variableId: 'var1', variableName: 'var1' },
],
});
});
it('应该正确添加图片类型的变量引用', async () => {
const mockCanvas = createMockCanvas();
const mockElement = createMockObject({ customId: 'img1' });
vi.mocked(createElement).mockResolvedValue(mockElement);
const { result } = renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
schema: createMockSchema(),
}),
);
await act(async () => {
await result.current.addRefObjectByVariable({
id: 'var1',
name: 'image1',
type: ViewVariableType.Image,
index: 0,
});
});
expect(createElement).toHaveBeenCalledWith(
expect.objectContaining({
mode: Mode.IMAGE,
elementProps: expect.objectContaining({
editable: false,
src: expect.stringContaining('img-placeholder.png'),
}),
}),
);
expect(mockCanvas.add).toHaveBeenCalledWith(mockElement);
expect(mockCanvas.setActiveObject).toHaveBeenCalledWith(mockElement);
});
it('应该正确添加文本类型的变量引用', async () => {
const mockCanvas = createMockCanvas();
const mockElement = createMockObject({ customId: 'text1' });
vi.mocked(createElement).mockResolvedValue(mockElement);
const { result } = renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
schema: createMockSchema(),
}),
);
await act(async () => {
await result.current.addRefObjectByVariable({
id: 'var1',
name: 'text1',
type: ViewVariableType.String,
index: 0,
});
});
expect(mockCanvas.add).toHaveBeenCalledWith(mockElement);
expect(mockCanvas.setActiveObject).toHaveBeenCalledWith(mockElement);
});
it('应该正确更新对象的引用关系', () => {
const mockCanvas = createMockCanvas();
const mockVariables = [
{ id: 'var1', name: 'var1', type: ViewVariableType.String, index: 0 },
{ id: 'var2', name: 'var2', type: ViewVariableType.String, index: 1 },
{ id: 'var3', name: 'var3', type: ViewVariableType.String, index: 2 },
];
const initialRefs = [
{ objectId: 'obj1', variableId: 'var1', variableName: 'var1' },
];
const mockSchema = createMockSchema({ customVariableRefs: initialRefs });
(mockCanvas.toObject as Mock).mockReturnValue({
...mockSchema,
customVariableRefs: [
{ objectId: 'obj2', variableId: 'var3', variableName: 'var3' },
],
});
const { result } = renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
schema: createMockSchema({ customVariableRefs: initialRefs }),
variables: mockVariables,
}),
);
// 更新已存在的引用
act(() => {
result.current.updateRefByObjectId({
objectId: 'obj1',
variable: {
id: 'var2',
name: 'var2',
type: ViewVariableType.String,
index: 0,
},
});
});
// 添加新的引用
act(() => {
result.current.updateRefByObjectId({
objectId: 'obj2',
variable: {
id: 'var3',
name: 'var3',
type: ViewVariableType.String,
index: 0,
},
});
});
// 删除引用
act(() => {
result.current.updateRefByObjectId({
objectId: 'obj1',
});
});
// 验证最终的引用关系
expect(mockCanvas.toObject().customVariableRefs).toEqual([
{ objectId: 'obj2', variableId: 'var3', variableName: 'var3' },
]);
});
});

View File

@@ -0,0 +1,127 @@
/*
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas, Rect } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import type { FabricSchema } from '../../src/typings';
import { Mode } from '../../src/typings';
import { useCanvasClip } from '../../src/hooks/use-canvas-clip';
// Mock fabric
vi.mock('fabric', () => ({
Rect: vi.fn(),
}));
describe('useCanvasClip', () => {
const createMockCanvas = () => {
const mockCanvas = {
clipPath: undefined,
requestRenderAll: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockSchema = (): FabricSchema => ({
width: 800,
height: 600,
background: '#ffffff',
backgroundColor: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('addClip', () => {
it('应该在 canvas 为 undefined 时不执行任何操作', () => {
const schema = createMockSchema();
const { result } = renderHook(() =>
useCanvasClip({ canvas: undefined, schema }),
);
act(() => {
result.current.addClip();
});
expect(Rect).not.toHaveBeenCalled();
});
it('应该正确添加裁剪区域', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema();
const mockRect = {};
(Rect as any).mockReturnValue(mockRect);
const { result } = renderHook(() =>
useCanvasClip({ canvas: mockCanvas, schema }),
);
act(() => {
result.current.addClip();
});
expect(Rect).toHaveBeenCalledWith({
absolutePositioned: true,
top: 0,
left: 0,
width: schema.width,
height: schema.height,
});
expect(mockCanvas.clipPath).toBe(mockRect);
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('removeClip', () => {
it('应该在 canvas 为 undefined 时不执行任何操作', () => {
const schema = createMockSchema();
const { result } = renderHook(() =>
useCanvasClip({ canvas: undefined, schema }),
);
act(() => {
result.current.removeClip();
});
});
it('应该正确移除裁剪区域', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema();
const { result } = renderHook(() =>
useCanvasClip({ canvas: mockCanvas, schema }),
);
// 先添加裁剪区域
act(() => {
result.current.addClip();
});
// 移除裁剪区域
act(() => {
result.current.removeClip();
});
expect(mockCanvas.clipPath).toBeUndefined();
expect(mockCanvas.requestRenderAll).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,168 @@
/*
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { type Canvas } from 'fabric';
import { renderHook } from '@testing-library/react';
import { useCanvasResize } from '../../src/hooks/use-canvas-resize';
describe('useCanvasResize', () => {
const mockCanvas = {
setDimensions: vi.fn(),
} as unknown as Canvas;
beforeEach(() => {
vi.clearAllMocks();
});
it('should calculate correct scale', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 500,
height: 500,
}),
);
expect(result.current.scale).toBe(1.6);
});
it('should calculate scale based on width constraint', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 800,
maxHeight: 1000,
width: 1000,
height: 500,
}),
);
expect(result.current.scale).toBe(0.8);
});
it('should calculate scale based on height constraint', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 500,
height: 1000,
}),
);
expect(result.current.scale).toBe(0.8);
});
it('should not resize canvas when maxWidth is 0', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 0,
maxHeight: 800,
width: 500,
height: 500,
}),
);
result.current.resize(mockCanvas);
expect(mockCanvas.setDimensions).not.toHaveBeenCalled();
});
it('should not resize canvas when maxHeight is 0', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 0,
width: 500,
height: 500,
}),
);
result.current.resize(mockCanvas);
expect(mockCanvas.setDimensions).not.toHaveBeenCalled();
});
it('should not resize canvas when canvas is undefined', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 500,
height: 500,
}),
);
result.current.resize(undefined);
expect(mockCanvas.setDimensions).not.toHaveBeenCalled();
});
it('should set canvas dimensions correctly', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 500,
height: 500,
}),
);
result.current.resize(mockCanvas);
// Check base dimensions
expect(mockCanvas.setDimensions).toHaveBeenNthCalledWith(1, {
width: 500,
height: 500,
});
// Check CSS dimensions
expect(mockCanvas.setDimensions).toHaveBeenNthCalledWith(
2,
{
width: '800px',
height: '800px',
},
{
cssOnly: true,
},
);
});
it('should maintain aspect ratio when resizing', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 1000,
height: 500,
}),
);
result.current.resize(mockCanvas);
// Check CSS dimensions maintain aspect ratio
expect(mockCanvas.setDimensions).toHaveBeenNthCalledWith(
2,
{
width: '1000px',
height: '500px',
},
{
cssOnly: true,
},
);
});
});

View File

@@ -0,0 +1,283 @@
/*
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas, type FabricObject } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { useCommonOperation } from '../../src/hooks/use-common-operation';
describe('useCommonOperation', () => {
const createMockCanvas = () => {
const mockCanvas = {
getActiveObject: vi.fn(),
getActiveObjects: vi.fn(),
discardActiveObject: vi.fn(),
requestRenderAll: vi.fn(),
remove: vi.fn(),
bringObjectToFront: vi.fn(),
sendObjectToBack: vi.fn(),
bringObjectForward: vi.fn(),
sendObjectBackwards: vi.fn(),
setWidth: vi.fn(),
setHeight: vi.fn(),
fire: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockObject = (props = {}) => {
const mockObject = {
isType: vi.fn(),
fire: vi.fn(),
set: vi.fn(),
left: 100,
top: 100,
...props,
};
return mockObject as unknown as FabricObject;
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('moveActiveObject', () => {
it('应该在 canvas 为 undefined 时不执行任何操作', () => {
const { result } = renderHook(() =>
useCommonOperation({ canvas: undefined }),
);
act(() => {
result.current.moveActiveObject('left');
});
});
it('应该正确移动单个对象', () => {
const mockCanvas = createMockCanvas();
const mockObject = createMockObject();
(mockObject.isType as any).mockReturnValue(false);
(mockCanvas.getActiveObject as any).mockReturnValue(mockObject);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveActiveObject('left', 10);
});
expect(mockObject.set).toHaveBeenCalledWith({ left: 90 });
expect(mockObject.fire).toHaveBeenCalledWith('moving', {
target: mockObject,
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确移动选中组', () => {
const mockCanvas = createMockCanvas();
const mockObject = createMockObject();
(mockObject.isType as any).mockReturnValue(true);
(mockCanvas.getActiveObject as any).mockReturnValue(mockObject);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveActiveObject('right', 10);
});
expect(mockObject.set).toHaveBeenCalledWith({ left: 110 });
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving', {
target: mockObject,
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('discardActiveObject', () => {
it('应该正确取消选中对象', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.discardActiveObject();
});
expect(mockCanvas.discardActiveObject).toHaveBeenCalled();
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('removeActiveObjects', () => {
it('应该正确移除选中的对象', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.removeActiveObjects();
});
mockObjects.forEach(obj => {
expect(mockCanvas.remove).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.discardActiveObject).toHaveBeenCalled();
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('moveTo', () => {
it('应该正确将对象移动到最前', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveTo('front');
});
mockObjects.forEach(obj => {
expect(mockCanvas.bringObjectToFront).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified-zIndex');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确将对象移动到最后', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveTo('backend');
});
mockObjects.forEach(obj => {
expect(mockCanvas.sendObjectToBack).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified-zIndex');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确将对象向前移动一层', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveTo('front-one');
});
mockObjects.forEach(obj => {
expect(mockCanvas.bringObjectForward).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified-zIndex');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确将对象向后移动一层', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveTo('backend-one');
});
mockObjects.forEach(obj => {
expect(mockCanvas.sendObjectBackwards).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified-zIndex');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('resetWidthHeight', () => {
it('应该正确重置画布宽度', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.resetWidthHeight({ width: 800 });
});
expect(mockCanvas.setWidth).toHaveBeenCalledWith(800);
expect(mockCanvas.setHeight).not.toHaveBeenCalled();
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确重置画布高度', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.resetWidthHeight({ height: 600 });
});
expect(mockCanvas.setWidth).not.toHaveBeenCalled();
expect(mockCanvas.setHeight).toHaveBeenCalledWith(600);
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确同时重置画布宽度和高度', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.resetWidthHeight({ width: 800, height: 600 });
});
expect(mockCanvas.setWidth).toHaveBeenCalledWith(800);
expect(mockCanvas.setHeight).toHaveBeenCalledWith(600);
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,418 @@
/*
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { Mode, type FabricSchema } from '../../src/typings';
import { useViewport } from '../../src/hooks/use-viewport';
import { useSnapMove } from '../../src/hooks/use-snap-move';
import { useRedoUndo } from '../../src/hooks/use-redo-undo';
import { usePosition } from '../../src/hooks/use-position';
import { useMousePosition } from '../../src/hooks/use-mouse-position';
import { useInlineTextAdd } from '../../src/hooks/use-inline-text-add';
import { useInitCanvas } from '../../src/hooks/use-init-canvas';
import { useImagAdd } from '../../src/hooks/use-img-add';
import { useGroup } from '../../src/hooks/use-group';
import { useFreePencil } from '../../src/hooks/use-free-pencil';
import { useFabricEditor } from '../../src/hooks/use-fabric-editor';
import { useDragAdd } from '../../src/hooks/use-drag-add';
import { useCopyPaste } from '../../src/hooks/use-copy-paste';
import { useCommonOperation } from '../../src/hooks/use-common-operation';
import { useCanvasResize } from '../../src/hooks/use-canvas-resize';
import { useCanvasChange } from '../../src/hooks/use-canvas-change';
import { useBackground } from '../../src/hooks/use-background';
import { useAlign } from '../../src/hooks/use-align';
import { useActiveObjectChange } from '../../src/hooks/use-active-object-change';
// Mock all dependencies
vi.mock('../../src/hooks/use-canvas-resize', () => ({
useCanvasResize: vi.fn(),
}));
vi.mock('../../src/hooks/use-init-canvas', () => ({
useInitCanvas: vi.fn(),
}));
vi.mock('../../src/hooks/use-viewport', () => ({
useViewport: vi.fn(),
}));
vi.mock('../../src/hooks/use-mouse-position', () => ({
useMousePosition: vi.fn(),
}));
vi.mock('../../src/hooks/use-group', () => ({
useGroup: vi.fn(),
}));
vi.mock('../../src/hooks/use-canvas-change', () => ({
useCanvasChange: vi.fn(),
}));
vi.mock('../../src/hooks/use-snap-move', () => ({
useSnapMove: vi.fn(),
}));
vi.mock('../../src/hooks/use-copy-paste', () => ({
useCopyPaste: vi.fn(),
}));
vi.mock('../../src/hooks/use-redo-undo', () => ({
useRedoUndo: vi.fn(),
}));
vi.mock('../../src/hooks/use-active-object-change', () => ({
useActiveObjectChange: vi.fn(),
}));
vi.mock('../../src/hooks/use-align', () => ({
useAlign: vi.fn(),
}));
vi.mock('../../src/hooks/use-background', () => ({
useBackground: vi.fn(),
}));
vi.mock('../../src/hooks/use-common-operation', () => ({
useCommonOperation: vi.fn(),
}));
vi.mock('../../src/hooks/use-img-add', () => ({
useImagAdd: vi.fn(),
}));
vi.mock('../../src/hooks/use-drag-add', () => ({
useDragAdd: vi.fn(),
}));
vi.mock('../../src/hooks/use-free-pencil', () => ({
useFreePencil: vi.fn(),
}));
vi.mock('../../src/hooks/use-inline-text-add', () => ({
useInlineTextAdd: vi.fn(),
}));
vi.mock('../../src/hooks/use-position', () => ({
usePosition: vi.fn(),
}));
describe('useFabricEditor', () => {
const mockRef = { current: document.createElement('canvas') };
const mockSchema = {
width: 800,
height: 600,
background: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
};
const mockMaxWidth = 1000;
const mockMaxHeight = 800;
const mockHelpLineLayerId = 'help-line-layer';
beforeEach(() => {
vi.clearAllMocks();
// Setup default mock returns
(useCanvasResize as any).mockReturnValue({
resize: vi.fn(),
scale: 1,
});
(useInitCanvas as any).mockReturnValue({
canvas: null,
loadFromJSON: vi.fn(),
});
(useViewport as any).mockReturnValue({
viewport: { zoom: 1 },
setViewport: vi.fn(),
zoomToPoint: vi.fn(),
});
(useMousePosition as any).mockReturnValue({
mousePosition: { x: 0, y: 0 },
});
(useGroup as any).mockReturnValue({
group: vi.fn(),
unGroup: vi.fn(),
});
(useCanvasChange as any).mockReturnValue({
startListen: vi.fn(),
stopListen: vi.fn(),
addRefObjectByVariable: vi.fn(),
updateRefByObjectId: vi.fn(),
customVariableRefs: [],
});
(useSnapMove as any).mockImplementation(() => undefined);
(useCopyPaste as any).mockReturnValue({
copy: vi.fn(),
paste: vi.fn(),
disabledPaste: false,
});
(useRedoUndo as any).mockReturnValue({
pushOperation: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
disabledRedo: false,
disabledUndo: false,
redoUndoing: false,
});
(useActiveObjectChange as any).mockReturnValue({
activeObjects: [],
activeObjectsPopPosition: null,
setActiveObjectsProps: vi.fn(),
isActiveObjectsInBack: false,
isActiveObjectsInFront: false,
});
(useAlign as any).mockReturnValue({
alignLeft: vi.fn(),
alignRight: vi.fn(),
alignCenter: vi.fn(),
alignTop: vi.fn(),
alignBottom: vi.fn(),
alignMiddle: vi.fn(),
verticalAverage: vi.fn(),
horizontalAverage: vi.fn(),
});
(useBackground as any).mockReturnValue({
backgroundColor: '#ffffff',
setBackgroundColor: vi.fn(),
});
(useCommonOperation as any).mockReturnValue({
moveActiveObject: vi.fn(),
removeActiveObjects: vi.fn(),
moveTo: vi.fn(),
discardActiveObject: vi.fn(),
resetWidthHeight: vi.fn(),
});
(useImagAdd as any).mockReturnValue({
addImage: vi.fn(),
});
(useDragAdd as any).mockReturnValue({
enterDragAddElement: vi.fn(),
exitDragAddElement: vi.fn(),
});
(useFreePencil as any).mockReturnValue({
enterFreePencil: vi.fn(),
exitFreePencil: vi.fn(),
});
(useInlineTextAdd as any).mockReturnValue({
enterAddInlineText: vi.fn(),
exitAddInlineText: vi.fn(),
});
(usePosition as any).mockReturnValue({
allObjectsPositionInScreen: [],
});
});
it('should initialize with correct parameters', () => {
renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
// Verify useCanvasResize was called with correct params
expect(useCanvasResize).toHaveBeenCalledWith({
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
width: mockSchema.width,
height: mockSchema.height,
});
// Verify useInitCanvas was called with correct params
expect(useInitCanvas).toHaveBeenCalledWith({
ref: mockRef.current,
schema: mockSchema,
startInit: true,
readonly: false,
resize: expect.any(Function),
scale: 1,
onClick: undefined,
});
});
it('should handle schema with legacy customVariableName', () => {
const legacySchema = {
...mockSchema,
objects: [
{
customId: 'variable-img-123',
customType: Mode.IMAGE,
customVariableName: 'testVar',
},
{
customId: 'variable-text-456',
customType: Mode.INLINE_TEXT,
customVariableName: 'testVar2',
},
],
};
renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: legacySchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
// Verify useInitCanvas was called with correct params
expect(useInitCanvas).toHaveBeenCalledWith(
expect.objectContaining({
ref: mockRef.current,
startInit: true,
readonly: false,
scale: 1,
onClick: undefined,
schema: expect.objectContaining({
objects: legacySchema.objects,
}),
}),
);
});
it('should enforce object limit', () => {
const schemaWithObjects = {
...mockSchema,
objects: Array(50).fill({
customId: 'test',
customType: Mode.RECT,
}),
};
const { result } = renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: schemaWithObjects,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
expect(result.current.state.couldAddNewObject).toBe(false);
});
it('should return all required functions and states', () => {
const { result } = renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
// Verify the returned object structure
expect(result.current).toEqual({
canvas: null,
canvasSettings: {
width: mockSchema.width,
height: mockSchema.height,
backgroundColor: '#ffffff',
},
sdk: expect.any(Object),
state: expect.objectContaining({
viewport: { zoom: 1 },
cssScale: 1,
activeObjects: [],
activeObjectsPopPosition: null,
couldAddNewObject: true,
disabledPaste: false,
disabledRedo: false,
disabledUndo: false,
redoUndoing: false,
isActiveObjectsInBack: false,
isActiveObjectsInFront: false,
allObjectsPositionInScreen: [],
canvasWidth: undefined,
canvasHeight: undefined,
customVariableRefs: [],
objectLength: 0,
}),
});
});
it('should call onChange when canvas changes', () => {
const onChange = vi.fn();
const mockJson = { ...mockSchema };
let changeCallback: ((json: FabricSchema) => void) | undefined;
(useCanvasChange as any).mockImplementation(
({
onChange: onChangeCallback,
}: {
onChange: (json: FabricSchema) => void;
}) => {
changeCallback = onChangeCallback;
return {
startListen: vi.fn(),
stopListen: vi.fn(),
addRefObjectByVariable: vi.fn(),
updateRefByObjectId: vi.fn(),
customVariableRefs: [],
};
},
);
renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: mockSchema,
onChange,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
if (changeCallback) {
changeCallback(mockJson);
expect(onChange).toHaveBeenCalledWith(mockJson);
}
});
});

View File

@@ -0,0 +1,169 @@
/*
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { Mode } from '../../src/typings';
import { useSchemaChange } from '../../src/hooks/use-schema-change';
import { useInitCanvas } from '../../src/hooks/use-init-canvas';
import { useFabricPreview } from '../../src/hooks/use-fabric-preview';
import { useCanvasResize } from '../../src/hooks/use-canvas-resize';
// Mock dependencies
vi.mock('../../src/hooks/use-canvas-resize', () => ({
useCanvasResize: vi.fn(),
}));
vi.mock('../../src/hooks/use-init-canvas', () => ({
useInitCanvas: vi.fn(),
}));
vi.mock('../../src/hooks/use-schema-change', () => ({
useSchemaChange: vi.fn(),
}));
describe('useFabricPreview', () => {
const mockRef = { current: document.createElement('canvas') };
const mockSchema = {
width: 800,
height: 600,
background: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
};
const mockMaxWidth = 1000;
const mockMaxHeight = 800;
beforeEach(() => {
vi.clearAllMocks();
// Setup default mock returns
(useCanvasResize as any).mockReturnValue({
resize: vi.fn(),
scale: 1,
});
(useInitCanvas as any).mockReturnValue({
canvas: null,
});
(useSchemaChange as any).mockImplementation(() => undefined);
});
it('should initialize with correct parameters', () => {
renderHook(() =>
useFabricPreview({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
}),
);
// Verify useCanvasResize was called with correct params
expect(useCanvasResize).toHaveBeenCalledWith({
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
width: mockSchema.width,
height: mockSchema.height,
});
// Verify useInitCanvas was called with correct params
expect(useInitCanvas).toHaveBeenCalledWith({
ref: mockRef.current,
schema: mockSchema,
startInit: true,
readonly: true,
resize: expect.any(Function),
scale: 1,
});
});
it('should call resize when canvas is available', () => {
const mockResize = vi.fn();
const mockCanvas = {};
(useCanvasResize as any).mockReturnValue({
resize: mockResize,
scale: 1,
});
(useInitCanvas as any).mockReturnValue({
canvas: mockCanvas,
});
renderHook(() =>
useFabricPreview({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
}),
);
expect(mockResize).toHaveBeenCalledWith(mockCanvas);
});
it('should return correct state with cssScale', () => {
const mockScale = 0.5;
(useCanvasResize as any).mockReturnValue({
resize: vi.fn(),
scale: mockScale,
});
const { result } = renderHook(() =>
useFabricPreview({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
}),
);
expect(result.current.state).toEqual({
cssScale: mockScale,
});
});
it('should call useSchemaChange with correct params', () => {
const mockCanvas = {};
(useInitCanvas as any).mockReturnValue({
canvas: mockCanvas,
});
renderHook(() =>
useFabricPreview({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
}),
);
expect(useSchemaChange).toHaveBeenCalledWith({
canvas: mockCanvas,
schema: mockSchema,
readonly: true,
});
});
});

View File

@@ -0,0 +1,180 @@
/*
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import {
type Canvas,
type ActiveSelection,
type Group,
type FabricObject,
} from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { createElement } from '../../src/utils';
import { Mode } from '../../src/typings';
import { useGroup } from '../../src/hooks/use-group';
// Mock createElement and isGroupElement
const mockIsGroupElement = vi.fn();
vi.mock('../../src/utils', () => ({
createElement: vi.fn(),
isGroupElement: (obj: unknown) => mockIsGroupElement(obj),
}));
describe('useGroup', () => {
const createMockCanvas = () => {
const mockCanvas = {
getActiveObject: vi.fn(),
add: vi.fn(),
remove: vi.fn(),
setActiveObject: vi.fn(),
discardActiveObject: vi.fn(),
requestRenderAll: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockGroup = () => {
const group = {
type: 'group',
left: 100,
top: 100,
width: 200,
height: 200,
add: vi.fn(),
remove: vi.fn(),
getObjects: vi.fn(),
// 添加必要的 fabric.Object 属性
noScaleCache: false,
lockMovementX: false,
lockMovementY: false,
lockRotation: false,
scaleX: 1,
scaleY: 1,
angle: 0,
originX: 'left' as const,
originY: 'top' as const,
fill: '',
stroke: '',
strokeWidth: 1,
opacity: 1,
visible: true,
hasControls: true,
hasBorders: true,
selectable: true,
evented: true,
canvas: null,
};
return group as unknown as Group;
};
const createMockActiveSelection = () => {
const selection = {
getObjects: vi.fn(),
left: 100,
top: 100,
width: 200,
height: 200,
};
return selection as unknown as ActiveSelection;
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createElement).mockReset();
mockIsGroupElement.mockReset();
});
describe('group', () => {
it('应该在没有 canvas 时不执行任何操作', async () => {
const { result } = renderHook(() => useGroup({ canvas: undefined }));
await act(() => result.current.group());
expect(createElement).not.toHaveBeenCalled();
});
it('应该在选中少于两个元素时不执行分组', async () => {
const mockCanvas = createMockCanvas();
const mockActiveSelection = createMockActiveSelection();
const mockGetObjects = vi.fn().mockReturnValue([{ id: '1' }]);
Object.assign(mockActiveSelection, { getObjects: mockGetObjects });
(
mockCanvas.getActiveObject as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(mockActiveSelection);
const { result } = renderHook(() => useGroup({ canvas: mockCanvas }));
await act(() => result.current.group());
expect(createElement).not.toHaveBeenCalled();
expect(mockCanvas.add).not.toHaveBeenCalled();
});
it('应该正确执行分组操作', async () => {
const mockCanvas = createMockCanvas();
const mockActiveSelection = createMockActiveSelection();
const mockObjects = [
{ id: '1', type: 'rect' },
{ id: '2', type: 'circle' },
] as unknown as FabricObject[];
const mockGroupElement = createMockGroup();
const mockGetObjects = vi.fn().mockReturnValue(mockObjects);
Object.assign(mockActiveSelection, { getObjects: mockGetObjects });
(
mockCanvas.getActiveObject as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(mockActiveSelection);
vi.mocked(createElement).mockResolvedValue(mockGroupElement);
const { result } = renderHook(() => useGroup({ canvas: mockCanvas }));
await act(() => result.current.group());
expect(createElement).toHaveBeenCalledWith({
mode: Mode.GROUP,
elementProps: {
left: mockActiveSelection.left,
top: mockActiveSelection.top,
width: mockActiveSelection.width,
height: mockActiveSelection.height,
},
});
expect(mockGroupElement.add).toHaveBeenCalledWith(...mockObjects);
expect(mockCanvas.add).toHaveBeenCalledWith(mockGroupElement);
expect(mockCanvas.setActiveObject).toHaveBeenCalledWith(mockGroupElement);
expect(mockCanvas.remove).toHaveBeenCalledWith(...mockObjects);
});
});
describe('unGroup', () => {
it('应该在没有 canvas 时不执行任何操作', async () => {
const { result } = renderHook(() => useGroup({ canvas: undefined }));
await act(() => result.current.unGroup());
expect(createElement).not.toHaveBeenCalled();
});
it('应该在选中的不是组元素时不执行解组', async () => {
const mockCanvas = createMockCanvas();
(
mockCanvas.getActiveObject as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({ type: 'rect' });
const { result } = renderHook(() => useGroup({ canvas: mockCanvas }));
await act(() => result.current.unGroup());
expect(createElement).not.toHaveBeenCalled();
expect(mockCanvas.add).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,286 @@
/*
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { Canvas } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { loadFontWithSchema, setElementAfterLoad } from '../../src/utils';
import { Mode } from '../../src/typings';
import { useInitCanvas } from '../../src/hooks/use-init-canvas';
// Mock fabric
vi.mock('fabric', () => ({
Canvas: vi.fn(),
}));
// Mock utils
vi.mock('../../src/utils', () => ({
loadFontWithSchema: vi.fn(),
setElementAfterLoad: vi.fn(),
}));
describe('useInitCanvas', () => {
const mockRef = document.createElement('canvas');
const mockSchema = {
width: 800,
height: 600,
background: '#ffffff',
backgroundColor: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
};
const mockResize = vi.fn();
const mockLoadFromJSON = vi.fn();
const mockRequestRenderAll = vi.fn();
const mockDispose = vi.fn();
const mockOn = vi.fn(() => () => {
// 清理函数
});
let mockCanvas: any;
beforeEach(() => {
vi.clearAllMocks();
(window as any)._fabric_canvas = undefined;
mockCanvas = {
loadFromJSON: mockLoadFromJSON,
requestRenderAll: mockRequestRenderAll,
dispose: mockDispose,
on: mockOn,
};
// Setup Canvas mock
(Canvas as any).mockImplementation(() => mockCanvas);
// Mock loadFromJSON to resolve immediately
mockLoadFromJSON.mockImplementation((json, callback) => {
callback(null, {});
return Promise.resolve();
});
});
it('should not initialize canvas when startInit is false', () => {
act(() => {
renderHook(() =>
useInitCanvas({
startInit: false,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
});
expect(Canvas).not.toHaveBeenCalled();
});
it('should not initialize canvas when ref is null', () => {
act(() => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: null,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
});
expect(Canvas).not.toHaveBeenCalled();
});
it('should initialize canvas with correct parameters', async () => {
const scale = 1.5;
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
scale,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(Canvas).toHaveBeenCalledWith(mockRef, {
width: mockSchema.width * scale,
height: mockSchema.height * scale,
backgroundColor: mockSchema.backgroundColor,
selection: true,
preserveObjectStacking: true,
});
});
it('should call resize after canvas initialization', async () => {
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockResize).toHaveBeenCalled();
});
it('should load schema and fonts after initialization', async () => {
mockLoadFromJSON.mockImplementation((json, callback) => {
callback(null, {});
loadFontWithSchema({ schema: mockSchema, canvas: mockCanvas });
return Promise.resolve();
});
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockLoadFromJSON).toHaveBeenCalledWith(
JSON.stringify(mockSchema),
expect.any(Function),
);
expect(loadFontWithSchema).toHaveBeenCalledWith({
schema: mockSchema,
canvas: mockCanvas,
});
});
it('should set canvas in debug mode when not readonly', async () => {
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect((window as any)._fabric_canvas).toBe(mockCanvas);
});
it('should not set canvas in debug mode when readonly', async () => {
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: true,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect((window as any)._fabric_canvas).toBeUndefined();
});
it('should bind click handler when provided', async () => {
const mockOnClick = vi.fn();
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
onClick: mockOnClick,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockOn).toHaveBeenCalledWith('mouse:down', expect.any(Function));
});
it('should dispose canvas on unmount', async () => {
let unmount: () => void;
await act(async () => {
({ unmount } = renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
));
await new Promise(resolve => setTimeout(resolve, 0));
});
await act(async () => {
unmount();
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockDispose).toHaveBeenCalled();
});
it('should handle element loading callback', async () => {
const mockElement = { type: 'rect' };
mockLoadFromJSON.mockImplementation((json, callback) => {
callback(null, mockElement);
mockRequestRenderAll();
return Promise.resolve();
});
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(setElementAfterLoad).toHaveBeenCalledWith({
element: mockElement,
options: { readonly: false },
canvas: expect.any(Object),
});
expect(mockRequestRenderAll).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,149 @@
/*
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { useMousePosition } from '../../src/hooks/use-mouse-position';
describe('useMousePosition', () => {
const createMockCanvas = () => {
const mockCanvas = {
on: vi.fn((event: string, callback: (event: any) => void) =>
// 返回一个清理函数
() => {
mockCanvas.off(event, callback);
},
),
off: vi.fn(),
getScenePoint: vi.fn(e => ({
x: e.clientX,
y: e.clientY,
})),
};
return mockCanvas as unknown as Canvas;
};
beforeEach(() => {
vi.clearAllMocks();
});
it('应该返回初始位置 {left: 0, top: 0}', () => {
const { result } = renderHook(() =>
useMousePosition({ canvas: undefined }),
);
expect(result.current.mousePosition).toEqual({ left: 0, top: 0 });
});
it('应该在没有 canvas 时不设置事件监听', () => {
const mockCanvas = createMockCanvas();
renderHook(() => useMousePosition({ canvas: undefined }));
expect(mockCanvas.on).not.toHaveBeenCalled();
});
it('应该在有 canvas 时设置鼠标移动事件监听', () => {
const mockCanvas = createMockCanvas();
renderHook(() => useMousePosition({ canvas: mockCanvas }));
expect(mockCanvas.on).toHaveBeenCalledWith(
'mouse:move',
expect.any(Function),
);
});
it('应该在鼠标移动时更新位置', () => {
const mockCanvas = createMockCanvas();
let moveCallback: (event: {
e: { clientX: number; clientY: number };
}) => void;
const mockOn = vi.fn();
Object.assign(mockCanvas, { on: mockOn });
mockOn.mockImplementation((event: string, callback: any) => {
if (event === 'mouse:move') {
moveCallback = callback;
}
return () => {
mockCanvas.off(event, callback);
};
});
const { result } = renderHook(() =>
useMousePosition({ canvas: mockCanvas }),
);
// 初始位置
expect(result.current.mousePosition).toEqual({ left: 0, top: 0 });
// 模拟鼠标移动
act(() => {
moveCallback({
e: { clientX: 100, clientY: 200 },
});
});
expect(mockCanvas.getScenePoint).toHaveBeenCalledWith({
clientX: 100,
clientY: 200,
});
expect(result.current.mousePosition).toEqual({ left: 100, top: 200 });
});
it('应该在组件卸载时清理事件监听', () => {
const mockCanvas = createMockCanvas();
const cleanupSpy = vi.fn();
const mockOn = vi.fn().mockReturnValue(cleanupSpy);
Object.assign(mockCanvas, { on: mockOn });
const { unmount } = renderHook(() =>
useMousePosition({ canvas: mockCanvas }),
);
unmount();
expect(cleanupSpy).toHaveBeenCalled();
});
it('应该在 canvas 变化时重新设置事件监听', () => {
const mockCanvas1 = createMockCanvas();
const mockCanvas2 = createMockCanvas();
const cleanupSpy = vi.fn();
const mockOn = vi.fn().mockReturnValue(cleanupSpy);
Object.assign(mockCanvas1, { on: mockOn });
const { rerender } = renderHook(
({ canvas }) => useMousePosition({ canvas }),
{
initialProps: { canvas: mockCanvas1 },
},
);
expect(mockCanvas1.on).toHaveBeenCalledWith(
'mouse:move',
expect.any(Function),
);
// 更新 canvas
rerender({ canvas: mockCanvas2 });
// 应该清理旧的事件监听
expect(cleanupSpy).toHaveBeenCalled();
// 应该设置新的事件监听
expect(mockCanvas2.on).toHaveBeenCalledWith(
'mouse:move',
expect.any(Function),
);
});
});

View File

@@ -0,0 +1,267 @@
/*
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas } from 'fabric';
import { renderHook } from '@testing-library/react';
import { createSnap, snap } from '../../src/utils/snap/snap';
import type { SnapService } from '../../src/utils/snap/snap';
import { useSnapMove } from '../../src/hooks/use-snap-move';
// Mock snap utils
vi.mock('../../src/utils/snap/snap', () => ({
createSnap: vi.fn(() => ({
move: vi.fn(),
reset: vi.fn(),
destroy: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
canvas: null,
threshold: 5,
rules: [],
snapX: vi.fn(),
snapY: vi.fn(),
snapPoints: [],
snapLines: [],
snapObjects: [],
snapToObjects: vi.fn(),
snapToPoints: vi.fn(),
})),
snap: {
resetAllObjectsPosition: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
},
}));
describe('useSnapMove', () => {
const createMockCanvas = () => {
const mockCanvas = {
on: vi.fn((event: string, callback: (event: any) => void) =>
// 返回一个清理函数
() => {
mockCanvas.off(event, callback);
},
),
off: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
beforeEach(() => {
vi.clearAllMocks();
});
it('应该在没有 canvas 时不设置事件监听', () => {
renderHook(() =>
useSnapMove({
canvas: undefined,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
expect(createSnap).not.toHaveBeenCalled();
});
it('应该正确初始化 snap 功能', () => {
const mockCanvas = createMockCanvas();
renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
expect(createSnap).toHaveBeenCalledWith(mockCanvas, 'test-layer', 1);
expect(mockCanvas.on).toHaveBeenCalledWith(
'mouse:down',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'mouse:up',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:moving',
expect.any(Function),
);
});
it('应该在鼠标按下时重置所有对象位置', () => {
const mockCanvas = createMockCanvas();
renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
// 获取 mouse:down 事件的回调函数
const mouseDownCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'mouse:down',
)?.[1];
// 模拟事件触发
mouseDownCallback?.({ target: { id: 'test-object' } });
expect(snap.resetAllObjectsPosition).toHaveBeenCalledWith({
id: 'test-object',
});
});
it('应该在鼠标松开时重置 snap', () => {
const mockCanvas = createMockCanvas();
const mockSnap = {
move: vi.fn(),
reset: vi.fn(),
destroy: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
canvas: null,
threshold: 5,
rules: [],
snapX: vi.fn(),
snapY: vi.fn(),
snapPoints: [],
snapLines: [],
snapObjects: [],
snapToObjects: vi.fn(),
snapToPoints: vi.fn(),
} as unknown as SnapService;
vi.mocked(createSnap).mockReturnValue(mockSnap);
renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
// 获取 mouse:up 事件的回调函数
const mouseUpCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'mouse:up',
)?.[1];
// 模拟事件触发
mouseUpCallback?.({ target: { id: 'test-object' } });
expect(mockSnap.reset).toHaveBeenCalled();
});
it('应该在对象移动时调用 snap.move', () => {
const mockCanvas = createMockCanvas();
const mockSnap = {
move: vi.fn(),
reset: vi.fn(),
destroy: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
canvas: null,
threshold: 5,
rules: [],
snapX: vi.fn(),
snapY: vi.fn(),
snapPoints: [],
snapLines: [],
snapObjects: [],
snapToObjects: vi.fn(),
snapToPoints: vi.fn(),
} as unknown as SnapService;
vi.mocked(createSnap).mockReturnValue(mockSnap);
renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
// 获取 object:moving 事件的回调函数
const movingCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'object:moving',
)?.[1];
// 模拟事件触发
movingCallback?.({ target: { id: 'test-object' } });
expect(mockSnap.move).toHaveBeenCalledWith({ id: 'test-object' });
});
it('应该在组件卸载时销毁 snap', () => {
const mockCanvas = createMockCanvas();
const mockSnap = {
move: vi.fn(),
reset: vi.fn(),
destroy: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
canvas: null,
threshold: 5,
rules: [],
snapX: vi.fn(),
snapY: vi.fn(),
snapPoints: [],
snapLines: [],
snapObjects: [],
snapToObjects: vi.fn(),
snapToPoints: vi.fn(),
} as unknown as SnapService;
vi.mocked(createSnap).mockReturnValue(mockSnap);
const { unmount } = renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
unmount();
expect(mockSnap.destroy).toHaveBeenCalled();
});
it('应该在 scale 变化时重置辅助线比例', () => {
const mockCanvas = createMockCanvas();
const { rerender } = renderHook(
({ scale }) =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale,
}),
{
initialProps: { scale: 1 },
},
);
// 更新 scale
rerender({ scale: 2 });
expect(snap.helpline.resetScale).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,193 @@
/*
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { type Canvas, type Point } from 'fabric';
import { renderHook } from '@testing-library/react';
import { setViewport, zoomToPoint } from '../../src/utils';
import { Mode } from '../../src/typings';
import { useViewport } from '../../src/hooks/use-viewport';
// Mock dependencies
vi.mock('../../src/utils', () => ({
setViewport: vi.fn(),
zoomToPoint: vi.fn(),
}));
describe('useViewport', () => {
const mockSchema = {
width: 800,
height: 600,
background: '#ffffff',
backgroundColor: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
};
const mockFire = vi.fn();
const mockCanvas = {
fire: mockFire,
} as unknown as Canvas;
const minZoom = 0.3;
const maxZoom = 3;
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize with default viewport', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
expect(result.current.viewport).toEqual([1, 0, 0, 1, 0, 0]);
});
it('should not set viewport when canvas is undefined', () => {
const { result } = renderHook(() =>
useViewport({
canvas: undefined,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 100, 100]);
expect(setViewport).not.toHaveBeenCalled();
});
it('should limit viewport x position to not move beyond left edge', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 100, 0]);
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: [2, 0, 0, 2, 0, 0],
});
});
it('should limit viewport x position to not move beyond right edge', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, -2000, 0]);
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: [2, 0, 0, 2, -800, 0],
});
});
it('should limit viewport y position to not move beyond top edge', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 0, 100]);
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: [2, 0, 0, 2, 0, 0],
});
});
it('should limit viewport y position to not move beyond bottom edge', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 0, -2000]);
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: [2, 0, 0, 2, 0, -600],
});
});
it('should fire object:moving event after setting viewport', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 0, 0]);
expect(mockFire).toHaveBeenCalledWith('object:moving');
});
it('should call zoomToPoint with correct parameters', () => {
const mockPoint: Point = { x: 100, y: 100 };
const mockZoomLevel = 2;
const mockVpt = [2, 0, 0, 2, 0, 0];
(zoomToPoint as jest.Mock).mockReturnValue(mockVpt);
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.zoomToPoint(mockPoint, mockZoomLevel);
expect(zoomToPoint).toHaveBeenCalledWith({
canvas: mockCanvas,
point: mockPoint,
zoomLevel: mockZoomLevel,
minZoom,
maxZoom,
});
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: mockVpt,
});
});
});

View File

@@ -0,0 +1,95 @@
/*
* 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 { describe, it, expect } from 'vitest';
import { CopyMode, AlignMode } from '../src/typings';
describe('typings', () => {
describe('CopyMode', () => {
it('应该定义正确的复制模式枚举值', () => {
expect(CopyMode.CtrlCV).toBe('CtrlCV');
expect(CopyMode.CtrlD).toBe('CtrlD');
expect(CopyMode.DragCV).toBe('DragCV');
});
it('应该包含所有必要的复制模式', () => {
const modes = Object.values(CopyMode);
expect(modes).toHaveLength(3);
expect(modes).toContain('CtrlCV');
expect(modes).toContain('CtrlD');
expect(modes).toContain('DragCV');
});
});
describe('AlignMode', () => {
it('应该定义正确的对齐模式枚举值', () => {
// 注意:这里的具体值需要根据实际的 AlignMode 枚举定义来填写
expect(AlignMode).toBeDefined();
expect(typeof AlignMode).toBe('object');
});
});
// 由于其他导出主要是类型定义,在运行时无法直接测试
// 但我们可以通过 TypeScript 的类型检查来验证它们的正确性
it('应该正确定义 FormMetaItem 接口', () => {
const formMetaItem = {
name: 'test',
title: 'Test Item',
cacheSave: true,
visible: () => true,
setter: 'input',
setterProps: { placeholder: 'test' },
splitLine: true,
tooltip: {
content: [],
},
};
// 这个测试主要是确保类型定义正确,实际运行时不会失败
expect(formMetaItem).toBeDefined();
});
it('应该正确定义 FormMeta 接口', () => {
const formMeta = {
display: 'row' as const,
content: [],
style: { marginTop: 10 },
};
expect(formMeta).toBeDefined();
expect(formMeta.display).toBe('row');
});
it('应该正确定义 IRefPosition 接口', () => {
const refPosition = {
id: 'test',
top: 0,
left: 0,
isImg: false,
angle: 0,
maxWidth: 100,
};
expect(refPosition).toBeDefined();
expect(typeof refPosition.id).toBe('string');
expect(typeof refPosition.top).toBe('number');
expect(typeof refPosition.left).toBe('number');
expect(typeof refPosition.isImg).toBe('boolean');
expect(typeof refPosition.angle).toBe('number');
expect(typeof refPosition.maxWidth).toBe('number');
});
});

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["./dist"]
}
]
}

View File

@@ -0,0 +1,7 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
});

View File

@@ -0,0 +1,69 @@
{
"name": "@coze-workflow/fabric-canvas",
"version": "0.0.1",
"description": "fabric-canvas",
"license": "Apache-2.0",
"author": "gaojianyuan@bytedance.com",
"maintainers": [],
"exports": {
".": "./src/index.tsx",
"./share": "./src/share/index.ts"
},
"main": "src/index.tsx",
"scripts": {
"build": "exit 0",
"dev": "storybook dev -p 6006",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/i18n": "workspace:*",
"@coze-workflow/base": "workspace:*",
"@coze-workflow/base-adapter": "workspace:*",
"@coze-workflow/components": "workspace:*",
"@tanstack/react-query": "~5.13.4",
"@use-gesture/react": "10.3.1",
"ahooks": "^3.7.8",
"classnames": "^2.3.2",
"fabric": "6.0.0-rc2",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.2"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/tailwind-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@storybook/addon-essentials": "^7.6.7",
"@storybook/addon-interactions": "^7.6.7",
"@storybook/addon-links": "^7.6.7",
"@storybook/addon-onboarding": "^1.0.10",
"@storybook/blocks": "^7.6.7",
"@storybook/react": "^7.6.7",
"@storybook/react-vite": "^7.6.7",
"@storybook/test": "^7.6.7",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/lodash-es": "^4.17.10",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"storybook": "^7.6.7",
"stylelint": "^15.11.0",
"tailwindcss": "~3.3.3",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getUploadCDNAsset } from '@coze-workflow/base-adapter';
import { fonts, fontSvg, fontFamilyFilter } from '../share';
const cdnPrefix = `${getUploadCDNAsset('')}/fonts`;
export const supportFonts = fonts.map(fontFamilyFilter);
export const getFontUrl = (name: string) => {
if (supportFonts.includes(name)) {
const fontFullName = fonts.find(d => fontFamilyFilter(d) === name);
return `${cdnPrefix}/image-canvas-fonts/${fontFullName}`;
}
};
const fontsFormat: {
value: string;
label: React.ReactNode;
order: number;
name: string;
groupName: string;
children?: {
value: string;
order: number;
label: React.ReactNode;
name: string;
groupName: string;
}[];
}[] = fontSvg.map(d => {
const dArr = d.replace('.svg', '').split('-');
const name = dArr[1];
const group = dArr[2];
return {
// 原本的名称
value: dArr[1],
label: (
<img
alt={name}
className="h-[12px]"
src={`${cdnPrefix}/image-canvas-fonts-preview-svg/${d}`}
/>
),
// 顺序
order: Number(dArr[0]),
// 一级分组名称
name,
// 属于哪个分组
groupName: group,
};
});
const groups = fontsFormat.filter(d => !d.groupName);
groups.forEach(group => {
const children = fontsFormat.filter(d => d.groupName === group.name);
group.children = children;
});
export const fontTreeData = groups.sort((a, b) => a.order - b.order);

View File

@@ -0,0 +1,163 @@
/*
* 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 FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Menu } from '@coze-arch/coze-design';
import { PopInScreen } from '../pop-in-screen';
import { CopyMode } from '../../typings';
interface IProps {
left?: number;
top?: number;
offsetY?: number;
offsetX?: number;
cancelMenu?: () => void;
hasActiveObject?: boolean;
copy?: (mode: CopyMode) => void;
paste?: (options: { mode?: CopyMode }) => void;
disabledPaste?: boolean;
moveToFront?: () => void;
moveToBackend?: () => void;
moveToFrontOne?: () => void;
moveToBackendOne?: () => void;
isActiveObjectsInBack?: boolean;
isActiveObjectsInFront?: boolean;
limitRect?: {
width?: number;
height?: number;
};
}
export const ContentMenu: FC<IProps> = props => {
const {
left = 0,
top = 0,
offsetX = 0,
offsetY = 0,
cancelMenu,
hasActiveObject,
copy,
paste,
moveToFront,
moveToBackend,
moveToFrontOne,
moveToBackendOne,
isActiveObjectsInBack,
isActiveObjectsInFront,
disabledPaste,
limitRect,
} = props;
const isMac = navigator.platform.toLowerCase().includes('mac');
const ctrlKey = isMac ? '⌘' : 'ctrl';
const menuItems = [
{
label: I18n.t('imageflow_canvas_copy'),
suffix: `${ctrlKey} + C`,
onClick: () => {
copy?.(CopyMode.CtrlCV);
},
},
{
label: I18n.t('imageflow_canvas_paste'),
suffix: `${ctrlKey} + V`,
onClick: () => {
paste?.({ mode: CopyMode.CtrlCV });
},
disabled: disabledPaste,
alwaysShow: true,
},
{
label: I18n.t('Copy'),
suffix: `${ctrlKey} + D`,
onClick: async () => {
await copy?.(CopyMode.CtrlD);
paste?.({ mode: CopyMode.CtrlD });
},
},
{
key: 'divider1',
divider: true,
},
{
label: I18n.t('imageflow_canvas_top_1'),
suffix: ']',
onClick: () => {
moveToFrontOne?.();
},
disabled: isActiveObjectsInFront,
},
{
label: I18n.t('imageflow_canvas_down_1'),
suffix: '[',
onClick: () => {
moveToBackendOne?.();
},
disabled: isActiveObjectsInBack,
},
{
label: I18n.t('imageflow_canvas_to_front'),
suffix: `${ctrlKey} + ]`,
onClick: () => {
moveToFront?.();
},
disabled: isActiveObjectsInFront,
},
{
label: I18n.t('imageflow_canvas_to_back'),
suffix: `${ctrlKey} + [`,
onClick: () => {
moveToBackend?.();
},
disabled: isActiveObjectsInBack,
},
].filter(item => item.alwaysShow ?? hasActiveObject);
return (
<PopInScreen
position="bottom-right"
left={left + offsetX}
top={top + offsetY}
zIndex={1001}
onClick={e => {
e.stopPropagation();
cancelMenu?.();
}}
limitRect={limitRect}
>
<Menu.SubMenu mode="menu">
{menuItems.map(d => {
if (d.divider) {
return <Menu.Divider key={d.key} />;
}
return (
<Menu.Item
itemKey={d.label}
onClick={d.onClick}
disabled={d.disabled}
>
<div className="w-[120px] flex justify-between">
<div>{d.label}</div>
<div className="coz-fg-secondary">{d.suffix}</div>
</div>
</Menu.Item>
);
})}
</Menu.SubMenu>
</PopInScreen>
);
};

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* 画布最大缩放
*/
export const MAX_ZOOM = 3;
/**
* 画布最小缩放
*/
export const MIN_ZOOM = 1;
/**
* 画布最大宽度
*/
export const MAX_WIDTH = 10000;
/**
* 画布最小宽度
*/
export const MIN_WIDTH = 1;
/**
* 画布最大高度
*/
export const MAX_HEIGHT = 10000;
/**
* 画布最小高度
*/
export const MIN_HEIGHT = 1;
/**
* 画布最大面积
*/
export const MAX_AREA = 3840 * 2160;

View File

@@ -0,0 +1,778 @@
/*
* 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 */
/* eslint-disable @coze-arch/max-line-per-function */
/* eslint-disable max-lines-per-function */
import {
useEffect,
useRef,
useState,
type FC,
useMemo,
useCallback,
} from 'react';
import { nanoid } from 'nanoid';
import { pick } from 'lodash-es';
import { type IText, type Point, type TMat2D } from 'fabric';
import classNames from 'classnames';
import { useDebounce, useLatest, useSize } from 'ahooks';
import { createUseGesture, pinchAction, wheelAction } from '@use-gesture/react';
import { type InputVariable } from '@coze-workflow/base/types';
import { IconCozCross } from '@coze-arch/coze-design/icons';
import { ConfigProvider } from '@coze-arch/coze-design';
import { TopBar } from '../topbar';
import { RefTitle } from '../ref-title';
import { MyIconButton } from '../icon-button';
import { Form } from '../form';
import { ContentMenu } from '../content-menu';
import {
AlignMode,
Mode,
type FabricObjectWithCustomProps,
type FabricSchema,
} from '../../typings';
import s from '../../index.module.less';
import { useFabricEditor } from '../../hooks';
import { GlobalContext } from '../../context';
import { useShortcut } from './use-shortcut';
import {
MAX_AREA,
MAX_WIDTH,
MAX_ZOOM,
MIN_HEIGHT,
MIN_WIDTH,
MIN_ZOOM,
MAX_HEIGHT,
} from './const';
interface IProps {
onClose: () => void;
icon: string;
title: string;
schema: FabricSchema;
readonly?: boolean;
variables?: InputVariable[];
onChange: (schema: FabricSchema) => void;
className?: string;
/**
* 不强制,用来当做 redo/undo 操作栈保存到内存的 key
* 不传的话,不会保存操作栈到内存,表现:关闭侧拉窗,丢失操作栈
*/
id?: string;
}
const useGesture = createUseGesture([pinchAction, wheelAction]);
export const FabricEditor: FC<IProps> = props => {
const {
onClose,
icon,
title,
schema: _schema,
onChange: _onChange,
readonly,
variables: _variables,
className,
} = props;
const variables = useMemo(() => {
const _v = _variables?.filter(d => d.type);
return _v;
}, [_variables]);
/**
* props.onChange 是异步,这个异步导致 schema 的状态很难管理。
* 因此此处用 state 来管理 schema后续消费 onChange 的地方可以当同步处理
*
* 副作用:外界引发的 schema 变化,不会同步到画布(暂时没这个场景)
*/
const [schema, setSchema] = useState<FabricSchema>(_schema);
const onChange = useCallback(
(data: FabricSchema) => {
setSchema(data);
_onChange(data);
},
[_onChange],
);
const [id] = useState<string>(props.id ?? nanoid());
const helpLineLayerId = `help-line-${id}`;
// 快捷点监听区域
const shortcutRef = useRef<HTMLDivElement>(null);
// Popover 渲染至 dom
const popRef = useRef(null);
// Popover 渲染至 dom作用于 select dropdown 右对齐
const popRefAlignRight = useRef<HTMLDivElement>(null);
// canvas 可渲染区域 dom
const sizeRef = useRef<HTMLDivElement>(null);
const size = useSize(sizeRef);
// popover 渲染至 dom
const popoverRef = useRef<HTMLDivElement>(null);
const popoverSize = useSize(popoverRef);
// fabric canvas 渲染 dom
const canvasRef = useRef<HTMLCanvasElement>(null);
// 模式
const [drawMode, setDrawMode] = useState<Mode | undefined>();
const latestDrawMode = useLatest(drawMode);
const [contentMenuPosition, setContentMenuPosition] = useState<
| {
left: number;
top: number;
}
| undefined
>();
// 监听鼠标是否处于按下状态,松手时才显示属性设置面板
const [isMousePressing, setIsMousePressing] = useState(false);
const cancelContentMenu = useCallback(() => {
setContentMenuPosition(undefined);
}, []);
const {
state: {
activeObjects,
activeObjectsPopPosition,
viewport,
couldAddNewObject,
disabledUndo,
disabledRedo,
redoUndoing,
disabledPaste,
isActiveObjectsInBack,
isActiveObjectsInFront,
canvasWidth,
canvasHeight,
customVariableRefs,
allObjectsPositionInScreen,
},
sdk: {
setActiveObjectsProps,
moveToFront,
moveToBackend,
moveToFrontOne,
moveToBackendOne,
addImage,
removeActiveObjects,
moveActiveObject,
enterFreePencil,
exitFreePencil,
enterDragAddElement,
exitDragAddElement,
enterAddInlineText,
exitAddInlineText,
zoomToPoint,
setViewport,
setBackgroundColor,
discardActiveObject,
redo,
undo,
copy,
paste,
group,
unGroup,
alignLeft,
alignRight,
alignCenter,
alignTop,
alignBottom,
alignMiddle,
verticalAverage,
horizontalAverage,
resetWidthHeight,
addRefObjectByVariable,
updateRefByObjectId,
},
canvasSettings: { backgroundColor },
canvas,
} = useFabricEditor({
id,
helpLineLayerId,
onChange,
ref: canvasRef,
variables,
schema,
maxWidth: size?.width || 0,
maxHeight: size?.height ? size.height - 2 : 0,
startInit: !!size?.width,
maxZoom: MAX_ZOOM,
minZoom: MIN_ZOOM,
readonly,
onShapeAdded: () => {
if (latestDrawMode.current) {
modeSetting[latestDrawMode.current as Mode]?.exitFn();
setDrawMode(undefined);
}
},
onClick: cancelContentMenu,
});
const modeSetting: Partial<
Record<
Mode,
{
enterFn: () => void;
exitFn: () => void;
}
>
> = {
[Mode.INLINE_TEXT]: {
enterFn: () => {
enterAddInlineText();
},
exitFn: () => {
exitAddInlineText();
},
},
[Mode.BLOCK_TEXT]: {
enterFn: () => {
enterDragAddElement(Mode.BLOCK_TEXT);
},
exitFn: () => {
exitDragAddElement();
},
},
[Mode.RECT]: {
enterFn: () => {
enterDragAddElement(Mode.RECT);
},
exitFn: () => {
exitDragAddElement();
},
},
[Mode.CIRCLE]: {
enterFn: () => {
enterDragAddElement(Mode.CIRCLE);
},
exitFn: () => {
exitDragAddElement();
},
},
[Mode.STRAIGHT_LINE]: {
enterFn: () => {
enterDragAddElement(Mode.STRAIGHT_LINE);
},
exitFn: () => {
exitDragAddElement();
},
},
[Mode.TRIANGLE]: {
enterFn: () => {
enterDragAddElement(Mode.TRIANGLE);
},
exitFn: () => {
exitDragAddElement();
},
},
[Mode.PENCIL]: {
enterFn: () => {
enterFreePencil();
},
exitFn: () => {
exitFreePencil();
},
},
};
// 针对画笔模式,达到上限后,主动退出绘画模式
useEffect(() => {
if (drawMode && !couldAddNewObject) {
modeSetting[drawMode]?.exitFn();
setDrawMode(undefined);
}
}, [couldAddNewObject, drawMode]);
const zoomStartPointer = useRef<Point>();
// 鼠标滚轮缩放
const onWheelZoom = (e: WheelEvent, isFirst: boolean) => {
if (!canvas) {
return;
}
const zoomStep = 0.05;
let zoomLevel = viewport[0];
const delta = e.deltaY;
if (isFirst) {
const pointer = canvas.getViewportPoint(e);
zoomStartPointer.current = pointer;
}
// 根据滚轮方向确定是放大还是缩小
if (delta < 0) {
zoomLevel += zoomStep;
} else {
zoomLevel -= zoomStep;
}
zoomToPoint(
zoomStartPointer.current as Point,
Number(zoomLevel.toFixed(2)),
);
};
// 鼠标位移
const onWheelTransform = (deltaX: number, deltaY: number) => {
const vpt: TMat2D = [...viewport];
vpt[4] -= deltaX;
vpt[5] -= deltaY;
setViewport(vpt);
};
// 触摸板手势缩放、位移
const gestureBind = useGesture(
{
onPinch: state => {
const e = state.event as WheelEvent;
e.preventDefault();
onWheelZoom(e, state.first);
if (state.first) {
setIsMousePressing(true);
} else if (state.last) {
setIsMousePressing(false);
}
},
onWheel: state => {
const e = state.event;
e.preventDefault();
if (!state.pinching) {
if (state.metaKey) {
onWheelZoom(e, state.first);
} else {
onWheelTransform(e.deltaX, e.deltaY);
}
}
if (state.first) {
setIsMousePressing(true);
} else if (state.last) {
setIsMousePressing(false);
}
},
},
{
eventOptions: {
passive: false,
},
},
);
// 当用户编辑文本时,按删除键不应该执行删除元素操作
const [isTextEditing, setIsTextEditing] = useState(false);
useEffect(() => {
let disposers: (() => void)[] = [];
if (
activeObjects?.length === 1 &&
[Mode.BLOCK_TEXT, Mode.INLINE_TEXT].includes(
(activeObjects[0] as FabricObjectWithCustomProps).customType,
)
) {
disposers.push(
(activeObjects[0] as IText).on('editing:entered', () => {
setIsTextEditing(true);
}),
);
disposers.push(
(activeObjects[0] as IText).on('editing:exited', () => {
setIsTextEditing(false);
}),
);
}
return () => {
disposers.forEach(dispose => dispose());
disposers = [];
};
}, [activeObjects]);
useEffect(() => {
const openMenu = (e: MouseEvent) => {
e.preventDefault();
const sizeRect = sizeRef.current?.getBoundingClientRect();
setContentMenuPosition({
left: e.clientX - (sizeRect?.left ?? 0),
top: e.clientY - (sizeRect?.top ?? 0),
});
};
if (sizeRef.current) {
sizeRef.current.addEventListener('contextmenu', openMenu);
}
return () => {
if (sizeRef.current) {
sizeRef.current.removeEventListener('contextmenu', openMenu);
}
};
}, [sizeRef]);
// 点击画布外侧,取消选中
useEffect(() => {
const clickOutside = (e: MouseEvent) => {
setContentMenuPosition(undefined);
discardActiveObject();
};
document.addEventListener('click', clickOutside);
return () => {
document.removeEventListener('click', clickOutside);
};
}, [discardActiveObject]);
// 注册快捷键
useShortcut({
ref: shortcutRef,
state: {
isTextEditing,
disabledPaste,
},
sdk: {
moveActiveObject,
removeActiveObjects,
undo,
redo,
copy,
paste,
group,
unGroup,
moveToFront,
moveToBackend,
moveToFrontOne,
moveToBackendOne,
alignLeft,
alignRight,
alignCenter,
alignTop,
alignBottom,
alignMiddle,
verticalAverage,
horizontalAverage,
},
});
const isContentMenuShow = !readonly && contentMenuPosition;
// 选中元素是否为同一类型(包含框选)
const isSameActiveObjects =
Array.from(
new Set(
activeObjects?.map(
obj => (obj as FabricObjectWithCustomProps).customType,
),
),
).length === 1;
/**
* 属性菜单没有展示 &&
* 鼠标右键没有按下(拖拽 ing&&
* isSameActiveObjects &&
*/
const isFormShow =
!isContentMenuShow && !isMousePressing && isSameActiveObjects;
// 最大宽高有两层限制 1. 面积 2. 固定最大值
const { canvasMaxWidth, canvasMaxHeight } = useMemo(
() => ({
canvasMaxWidth: Math.min(MAX_AREA / schema.height, MAX_WIDTH),
canvasMaxHeight: Math.min(MAX_AREA / schema.width, MAX_HEIGHT),
}),
[schema.width, schema.height],
);
const [focus, setFocus] = useState(false);
const debouncedFocus = useDebounce(focus, {
wait: 300,
});
useEffect(() => {
setTimeout(() => {
shortcutRef.current?.focus();
}, 300);
}, []);
return (
<div
tabIndex={0}
className={`flex flex-col w-full h-full relative ${className} min-w-[900px]`}
ref={shortcutRef}
onFocus={() => {
setFocus(true);
}}
onBlur={() => {
setFocus(false);
}}
>
<GlobalContext.Provider
value={{
variables,
customVariableRefs,
allObjectsPositionInScreen,
activeObjects,
addRefObjectByVariable,
updateRefByObjectId,
}}
>
<div ref={popRef}></div>
<div
className={s['top-bar-pop-align-right']}
ref={popRefAlignRight}
></div>
<ConfigProvider
getPopupContainer={() => popRef.current ?? document.body}
>
<>
<div
className={classNames([
'flex gap-[8px] items-center',
'w-full h-[55px]',
'px-[16px]',
])}
>
<img className="w-[20px] h-[20px] rounded-[2px]" src={icon}></img>
<div className="text-xxl font-semibold">{title}</div>
<div className="flex-1">
<TopBar
redo={redo}
undo={undo}
disabledRedo={disabledRedo}
disabledUndo={disabledUndo}
redoUndoing={redoUndoing}
popRefAlignRight={popRefAlignRight}
readonly={readonly || !canvas}
maxLimit={!couldAddNewObject}
mode={drawMode}
onModeChange={(currentMode, prevMode) => {
setDrawMode(currentMode);
if (prevMode) {
modeSetting[prevMode]?.exitFn();
}
if (currentMode) {
modeSetting[currentMode]?.enterFn();
}
}}
isActiveObjectsInBack={isActiveObjectsInBack}
isActiveObjectsInFront={isActiveObjectsInFront}
onMoveToTop={e => {
(e as MouseEvent).stopPropagation();
moveToFront();
}}
onMoveToBackend={e => {
(e as MouseEvent).stopPropagation();
moveToBackend();
}}
onAddImg={url => {
addImage(url);
}}
zoomSettings={{
reset: () => {
setViewport([1, 0, 0, 1, 0, 0]);
},
zoom: viewport[0],
onChange(value: number): void {
if (isNaN(value)) {
return;
}
const vpt: TMat2D = [...viewport];
let v = Number(value.toFixed(2));
if (v > MAX_ZOOM) {
v = MAX_ZOOM;
} else if (v < MIN_ZOOM) {
v = MIN_ZOOM;
}
vpt[0] = v;
vpt[3] = v;
setViewport(vpt);
},
max: MAX_ZOOM,
min: MIN_ZOOM,
}}
aligns={{
[AlignMode.Left]: alignLeft,
[AlignMode.Right]: alignRight,
[AlignMode.Center]: alignCenter,
[AlignMode.Top]: alignTop,
[AlignMode.Bottom]: alignBottom,
[AlignMode.Middle]: alignMiddle,
[AlignMode.VerticalAverage]: verticalAverage,
[AlignMode.HorizontalAverage]: horizontalAverage,
}}
canvasSettings={{
width: schema.width,
minWidth: MIN_WIDTH,
maxWidth: canvasMaxWidth,
height: schema.height,
minHeight: MIN_HEIGHT,
maxHeight: canvasMaxHeight,
background: backgroundColor as string,
onChange(value: {
width?: number | undefined;
height?: number | undefined;
background?: string | undefined;
}): void {
if (value.background) {
setBackgroundColor(value.background);
return;
}
const _value = pick(value, ['width', 'height']);
if (_value.width) {
if (_value.width > canvasMaxWidth) {
_value.width = canvasMaxWidth;
}
if (_value.width < MIN_WIDTH) {
_value.width = MIN_WIDTH;
}
}
if (_value.height) {
if (_value.height > canvasMaxHeight) {
_value.height = canvasMaxHeight;
}
if (_value.height < MIN_HEIGHT) {
_value.height = MIN_HEIGHT;
}
}
resetWidthHeight({
..._value,
});
},
}}
/>
</div>
<MyIconButton
onClick={onClose}
icon={<IconCozCross className="text-[16px]" />}
/>
</div>
<div
className={classNames([
'flex-1 flex items-center justify-center',
'p-[16px]',
'overflow-hidden',
'coz-bg-primary',
'border-0 border-t coz-stroke-primary border-solid',
'scale-100',
])}
ref={popoverRef}
>
<div
onMouseDown={e => {
if (e.button === 0) {
setIsMousePressing(true);
}
}}
onMouseUp={e => {
if (e.button === 0) {
setIsMousePressing(false);
}
}}
ref={sizeRef}
tabIndex={0}
className={classNames([
'flex items-center justify-center',
'w-full h-full overflow-hidden',
])}
>
<div
{...gestureBind()}
className={`border border-solid ${
debouncedFocus ? 'coz-stroke-hglt' : 'coz-stroke-primary'
} rounded-small overflow-hidden`}
onClick={e => {
e.stopPropagation();
}}
>
{/* 引用 tag */}
<RefTitle visible={!isMousePressing} />
<div className="w-fit h-fit overflow-hidden">
<div
id={helpLineLayerId}
className="relative top-0 left-0 bg-red-500 z-[2] pointer-events-none"
></div>
<canvas ref={canvasRef} className="h-[0px]" />
</div>
</div>
</div>
{/* 右键菜单 */}
{isContentMenuShow ? (
<ContentMenu
limitRect={popoverSize}
left={contentMenuPosition.left}
top={contentMenuPosition.top}
cancelMenu={() => {
setContentMenuPosition(undefined);
}}
hasActiveObject={!!activeObjects?.length}
copy={copy}
paste={paste}
disabledPaste={disabledPaste}
moveToFront={moveToFront}
moveToBackend={moveToBackend}
moveToFrontOne={moveToFrontOne}
moveToBackendOne={moveToBackendOne}
isActiveObjectsInBack={isActiveObjectsInBack}
isActiveObjectsInFront={isActiveObjectsInFront}
offsetX={8}
offsetY={8}
/>
) : (
<></>
)}
{/* 属性面板 */}
{isFormShow ? (
<Form
// 文本切换时,涉及字号变化,需要 rerender form 同步状态
key={
(activeObjects as FabricObjectWithCustomProps[])?.[0]
?.customType
}
schema={schema}
activeObjects={activeObjects as FabricObjectWithCustomProps[]}
position={activeObjectsPopPosition}
onChange={value => {
setActiveObjectsProps(value);
}}
offsetX={((popoverSize?.width ?? 0) - (canvasWidth ?? 0)) / 2}
offsetY={
((popoverSize?.height ?? 0) - (canvasHeight ?? 0)) / 2
}
canvasHeight={canvasHeight}
limitRect={popoverSize}
/>
) : (
<></>
)}
</div>
</>
</ConfigProvider>
</GlobalContext.Provider>
</div>
);
};

View 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.
*/
import { FabricEditor } from './fabric-editor';
export { FabricEditor };

View File

@@ -0,0 +1,387 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-lines-per-function */
/* eslint-disable @coze-arch/max-line-per-function */
import { useKeyPress } from 'ahooks';
import { CopyMode } from '../../typings';
export const useShortcut = ({
ref,
state: { isTextEditing, disabledPaste },
sdk: {
moveActiveObject,
removeActiveObjects,
undo,
redo,
copy,
paste,
group,
unGroup,
moveToFront,
moveToBackend,
moveToFrontOne,
moveToBackendOne,
alignLeft,
alignRight,
alignCenter,
alignTop,
alignBottom,
alignMiddle,
verticalAverage,
horizontalAverage,
},
}: {
ref: React.RefObject<HTMLDivElement>;
state: {
isTextEditing: boolean;
disabledPaste: boolean;
};
sdk: {
moveActiveObject: (direction: 'up' | 'down' | 'left' | 'right') => void;
removeActiveObjects: () => void;
undo: () => void;
redo: () => void;
copy: (mode: CopyMode) => void;
paste: (options?: { mode?: CopyMode }) => void;
group: () => void;
unGroup: () => void;
moveToFront: () => void;
moveToBackend: () => void;
moveToFrontOne: () => void;
moveToBackendOne: () => void;
alignLeft: () => void;
alignRight: () => void;
alignCenter: () => void;
alignTop: () => void;
alignBottom: () => void;
alignMiddle: () => void;
verticalAverage: () => void;
horizontalAverage: () => void;
};
}) => {
// 上下左右微调元素位置
useKeyPress(
['uparrow', 'downarrow', 'leftarrow', 'rightarrow'],
e => {
switch (e.key) {
case 'ArrowUp':
moveActiveObject('up');
break;
case 'ArrowDown':
moveActiveObject('down');
break;
case 'ArrowLeft':
moveActiveObject('left');
break;
case 'ArrowRight':
moveActiveObject('right');
break;
default:
break;
}
},
{
target: ref,
},
);
// 删除元素
useKeyPress(
['backspace', 'delete'],
e => {
if (!isTextEditing) {
removeActiveObjects();
}
},
{
target: ref,
},
);
// redo undo
useKeyPress(
['ctrl.z', 'meta.z'],
e => {
// 一定要加,否则会命中浏览器乱七八糟的默认行为
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
},
{
events: ['keydown'],
target: ref,
},
);
/**
* 功能开发暂停了,原因详见 packages/workflow/fabric-canvas/src/hooks/use-group.tsx
*/
// useKeyPress(
// ['ctrl.g', 'meta.g'],
// e => {
// e.preventDefault();
// if (e.shiftKey) {
// unGroup();
// } else {
// group();
// }
// },
// {
// events: ['keydown'],
// target: ref,
// },
// );
// copy
useKeyPress(
['ctrl.c', 'meta.c'],
e => {
e.preventDefault();
copy(CopyMode.CtrlCV);
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// paste
useKeyPress(
['ctrl.v', 'meta.v'],
e => {
e.preventDefault();
if (!disabledPaste) {
paste({ mode: CopyMode.CtrlCV });
}
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// 生成副本
useKeyPress(
['ctrl.d', 'meta.d'],
async e => {
// 必须阻止默认行为,否则会触发添加标签
e.preventDefault();
await copy(CopyMode.CtrlD);
paste({
mode: CopyMode.CtrlD,
});
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// [ 下移一层
useKeyPress(
['openbracket'],
e => {
if (!isTextEditing) {
moveToBackendOne();
}
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// ] 上移一层
useKeyPress(
['closebracket'],
e => {
if (!isTextEditing) {
moveToFrontOne();
}
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// ⌘ + [、⌘ + ] 禁止浏览器默认行为 前进、后退
useKeyPress(
['meta.openbracket', 'meta.closebracket'],
e => {
if (!isTextEditing) {
e.preventDefault();
}
},
{
events: ['keydown', 'keyup'],
exactMatch: true,
target: ref,
},
);
// ⌘ + [ 置底
useKeyPress(
['meta.openbracket'],
e => {
if (!isTextEditing) {
moveToBackend();
}
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// ⌘ + ] 置顶
useKeyPress(
['meta.closebracket'],
e => {
if (!isTextEditing) {
moveToFront();
}
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// 水平居左
useKeyPress(
['alt.a'],
e => {
e.preventDefault();
alignLeft();
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// 水平居右
useKeyPress(
['alt.d'],
e => {
e.preventDefault();
alignRight();
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// 水平居中
useKeyPress(
['alt.h'],
e => {
e.preventDefault();
alignCenter();
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// 垂直居上
useKeyPress(
['alt.w'],
e => {
e.preventDefault();
alignTop();
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// 垂直居下
useKeyPress(
['alt.s'],
e => {
e.preventDefault();
alignBottom();
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// 垂直居中
useKeyPress(
['alt.v'],
e => {
e.preventDefault();
alignMiddle();
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// 水平均分
useKeyPress(
['alt.ctrl.h'],
e => {
e.preventDefault();
horizontalAverage();
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
// 垂直均分
useKeyPress(
['alt.ctrl.v'],
e => {
e.preventDefault();
verticalAverage();
},
{
events: ['keydown'],
exactMatch: true,
target: ref,
},
);
};

View File

@@ -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 { useEffect, useRef, type FC } from 'react';
import { useSize } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { type FabricSchema } from '../../typings';
import { useFabricPreview } from '../../hooks';
export interface IFabricPreview {
schema: FabricSchema;
showPlaceholder?: boolean;
}
export const FabricPreview: FC<IFabricPreview> = props => {
const { schema, showPlaceholder } = props;
const ref = useRef<HTMLCanvasElement>(null);
const sizeRef = useRef(null);
const size = useSize(sizeRef);
const oldWidth = useRef(0);
useEffect(() => {
if (size?.width && !oldWidth.current) {
oldWidth.current = size?.width || 0;
}
// 防止抖动,当宽度变化 > 20 时才更新宽度
if (size?.width && size.width - oldWidth.current > 20) {
oldWidth.current = size?.width || 0;
}
}, [size?.width]);
const maxWidth = oldWidth.current;
const maxHeight = 456;
useFabricPreview({
schema,
ref,
maxWidth,
maxHeight,
startInit: !!size?.width,
});
const isEmpty = schema.objects.length === 0;
return (
<div className="w-full relative">
<div ref={sizeRef} className="w-full"></div>
<canvas ref={ref} className="h-[0px]" />
{isEmpty && showPlaceholder ? (
<div className="w-full h-full absolute top-0 left-0 flex items-center justify-center">
<div className="text-[14px] coz-fg-secondary">
{I18n.t('imageflow_canvas_double_click', {}, '双击开始编辑')}
</div>
</div>
) : (
<></>
)}
</div>
);
};

View 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.
*/
import { FabricPreview, IFabricPreview } from './fabric-preview';
export { FabricPreview, IFabricPreview };

View File

@@ -0,0 +1,397 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import {
IconCozArrowDown,
IconCozPalette,
} from '@coze-arch/coze-design/icons';
import { Tooltip } from '@coze-arch/coze-design';
import { MyIconButton } from '../icon-button';
import { defaultProps } from '../../utils';
import { ColorMode, ImageFixedType, Mode, type FormMeta } from '../../typings';
import { fontTreeData } from '../../assert/font';
const createTextMeta = (
textType: Mode.BLOCK_TEXT | Mode.INLINE_TEXT,
): FormMeta => ({
display: 'row',
style: {
padding: '8px',
},
content: [
{
name: 'customId',
setter: 'RefSelect',
setterProps: {
label: I18n.t('imageflow_canvas_reference', {}, '引用'),
labelInside: true,
className: 'w-[160px]',
},
},
{
name: 'fontFamily',
splitLine: false,
setter: 'TextFamily',
setterProps: {
treeData: fontTreeData,
defaultValue: defaultProps[textType].fontFamily,
},
},
{
name: 'fontSize',
setter: 'FontSize',
setterProps: {
min: 10,
max: 300,
optionList: [12, 16, 20, 24, 32, 40, 48, 56, 72, 92, 120, 160, 220].map(
d => ({
value: d,
label: `${d}`,
}),
),
defaultValue: defaultProps[textType].fontSize,
},
},
{
name: 'lineHeight',
splitLine: false,
setter: 'LineHeight',
setterProps: {
optionList: [10, 50, 100, 120, 150, 200, 250, 300, 350, 400].map(d => ({
value: d,
label: `${d}%`,
})),
min: 10,
max: 400,
defaultValue: defaultProps[textType].lineHeight,
},
},
{
setter: ({ tooltipVisible }) => (
<Tooltip
mouseEnterDelay={300}
mouseLeaveDelay={300}
content={I18n.t('imageflow_canvas_style_tooltip')}
>
<MyIconButton
inForm
className="!w-[48px]"
color={tooltipVisible ? 'highlight' : 'secondary'}
icon={
<div className="flex flex-row items-center gap-[2px]">
<IconCozPalette className="text-[16px]" />
<IconCozArrowDown className="text-[16px]" />
</div>
}
/>
</Tooltip>
),
tooltip: {
content: [
{
name: 'colorMode',
cacheSave: true,
setter: 'SingleSelect',
setterProps: {
options: [
{
value: ColorMode.FILL,
label: I18n.t('imageflow_canvas_fill'),
},
{
value: ColorMode.STROKE,
label: I18n.t('imageflow_canvas_stroke'),
},
],
layout: 'fill',
defaultValue: ColorMode.FILL,
},
},
{
name: 'strokeWidth',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode ===
ColorMode.STROKE,
setter: 'BorderWidth',
setterProps: {
min: 0,
max: 20,
defaultValue: defaultProps[textType].strokeWidth,
},
splitLine: true,
},
{
name: 'stroke',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode ===
ColorMode.STROKE,
setter: 'ColorPicker',
setterProps: {
showOpacity: false,
defaultValue: defaultProps[textType].stroke,
},
},
{
name: 'fill',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode !==
ColorMode.STROKE,
setter: 'ColorPicker',
setterProps: {
defaultValue: defaultProps[textType].fill,
},
},
],
},
},
{
name: 'textAlign',
setter: 'TextAlign',
setterProps: {
defaultValue: defaultProps[textType].textAlign,
},
},
{
name: 'customType',
setter: 'TextType',
setterProps: {
defaultValue: textType,
},
},
],
});
const createShapeMeta = (
shapeType: Mode.RECT | Mode.CIRCLE | Mode.TRIANGLE,
): FormMeta => ({
display: 'col',
style: {
padding: '16px',
},
content: [
{
name: 'colorMode',
cacheSave: true,
setter: 'SingleSelect',
setterProps: {
options: [
{
value: ColorMode.FILL,
label: I18n.t('imageflow_canvas_fill'),
},
{
value: ColorMode.STROKE,
label: I18n.t('imageflow_canvas_stroke'),
},
],
layout: 'fill',
defaultValue: ColorMode.FILL,
},
},
{
title: I18n.t('imageflow_canvas_fill'),
name: 'fill',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
setter: 'ColorPicker',
setterProps: {
defaultValue: defaultProps[shapeType].fill,
},
},
{
title: I18n.t('imageflow_canvas_stroke'),
name: 'strokeWidth',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.STROKE,
setter: 'BorderWidth',
setterProps: {
min: 0,
max: 50,
defaultValue: defaultProps[shapeType].strokeWidth,
},
splitLine: true,
},
{
name: 'stroke',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.STROKE,
setter: 'ColorPicker',
setterProps: {
showOpacity: false,
defaultValue: defaultProps[shapeType].stroke,
},
},
],
});
const createLineMeta = (
lineType: Mode.STRAIGHT_LINE | Mode.PENCIL,
): FormMeta => ({
display: 'col',
style: {
padding: '16px',
},
content: [
{
title: I18n.t('imageflow_canvas_line_style'),
name: 'strokeWidth',
setter: 'BorderWidth',
setterProps: {
min: 0,
max: 20,
defaultValue: defaultProps[lineType].strokeWidth,
},
splitLine: true,
},
{
name: 'stroke',
setter: 'ColorPicker',
setterProps: {
showOpacity: false,
defaultValue: defaultProps[lineType].stroke,
},
},
],
});
type IFormMeta = Partial<Record<Mode, FormMeta>>;
export const formMetas: IFormMeta = {
[Mode.BLOCK_TEXT]: createTextMeta(Mode.BLOCK_TEXT),
[Mode.INLINE_TEXT]: createTextMeta(Mode.INLINE_TEXT),
[Mode.RECT]: createShapeMeta(Mode.RECT),
[Mode.CIRCLE]: createShapeMeta(Mode.CIRCLE),
[Mode.TRIANGLE]: createShapeMeta(Mode.TRIANGLE),
[Mode.STRAIGHT_LINE]: createLineMeta(Mode.STRAIGHT_LINE),
[Mode.PENCIL]: createLineMeta(Mode.PENCIL),
[Mode.IMAGE]: {
display: 'col',
style: {
padding: '16px',
},
content: [
{
name: 'customId',
setter: 'RefSelect',
setterProps: {
label: I18n.t('imageflow_canvas_reference', {}, '引用'),
labelInside: false,
className: 'flex-1 overflow-hidden max-w-[320px]',
},
splitLine: true,
},
{
name: 'colorMode',
cacheSave: true,
setter: 'SingleSelect',
setterProps: {
options: [
{
value: ColorMode.FILL,
label: I18n.t('imageflow_canvas_fill'),
},
{
value: ColorMode.STROKE,
label: I18n.t('imageflow_canvas_stroke'),
},
],
layout: 'fill',
defaultValue: ColorMode.FILL,
},
},
{
name: 'src',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
setter: 'Uploader',
setterProps: {
getLabel: (isRefElement: boolean) => {
if (isRefElement) {
return I18n.t('imageflow_canvas_fill_preview', {}, '内容预览');
} else {
return I18n.t('imageflow_canvas_fill_image', {}, '内容');
}
},
},
},
{
name: 'customFixedType',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
setter: 'LabelSelect',
setterProps: {
className: 'flex-1',
label: I18n.t('imageflow_canvas_fill_mode'),
optionList: [
{
value: ImageFixedType.AUTO,
label: I18n.t('imageflow_canvas_fill1'),
},
{
value: ImageFixedType.FILL,
label: I18n.t('imageflow_canvas_fill2'),
},
{
value: ImageFixedType.FULL,
label: I18n.t('imageflow_canvas_fill3'),
},
],
defaultValue: ImageFixedType.FILL,
},
},
{
name: 'opacity',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
setter: 'ColorPicker',
setterProps: {
showColor: false,
defaultValue: defaultProps[Mode.IMAGE].opacity,
},
},
{
title: I18n.t('imageflow_canvas_stroke'),
name: 'strokeWidth',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode ===
ColorMode.STROKE,
setter: 'BorderWidth',
setterProps: {
min: 0,
max: 50,
defaultValue: defaultProps[Mode.IMAGE].strokeWidth,
},
splitLine: true,
},
{
name: 'stroke',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode ===
ColorMode.STROKE,
setter: 'ColorPicker',
setterProps: {
showOpacity: false,
defaultValue: defaultProps[Mode.IMAGE].stroke,
},
},
],
},
};

View File

@@ -0,0 +1,324 @@
/*
* 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 {
memo,
useCallback,
useMemo,
useState,
type FC,
type ReactElement,
} from 'react';
import classNames from 'classnames';
import { useLatest } from 'ahooks';
import { ConfigProvider, Tooltip } from '@coze-arch/coze-design';
import { setters } from '../setters';
import { PopInScreen } from '../pop-in-screen';
import { schemaToFormValue } from '../../utils';
import {
REF_VARIABLE_ID_PREFIX,
type FabricObjectSchema,
type FabricObjectWithCustomProps,
type FabricSchema,
type FormMeta,
type FormMetaItem,
} from '../../typings';
import { formMetas } from './form-meta';
const schemaItemToNode = (props: {
metaItem: FormMetaItem;
value: unknown;
tooltipVisible?: boolean;
isRefElement: boolean;
onChange: (v: unknown) => void;
}) => {
const { metaItem, value, onChange, tooltipVisible, isRefElement } = props;
const { setter, setterProps, title } = metaItem;
let dom: ReactElement | string = setter as string;
if (typeof setter === 'function') {
dom = setter({
value,
onChange,
tooltipVisible,
});
}
if (typeof setter === 'string' && setters[setter]) {
const Setter = setters[setter];
dom = (
<Setter
value={value}
onChange={onChange}
isRefElement={isRefElement}
{...setterProps}
/>
);
}
return [
title ? (
<div className="w-full text-[14px] font-medium">{title}</div>
) : undefined,
<div className="flex items-center gap-[2px] text-[16px]">{dom}</div>,
];
};
const FormItem = memo(
(props: {
metaItem: FormMetaItem;
isLast: boolean;
isRow: boolean;
// 给图片上传组件特化的,需要根据是否为引用元素,设置不同的 label
isRefElement: boolean;
formValue: Partial<FabricObjectSchema>;
onChange: (v: Partial<FabricObjectSchema>, cacheSave?: boolean) => void;
}) => {
const { metaItem, isLast, isRow, formValue, onChange, isRefElement } =
props;
const { name = '', tooltip, splitLine, visible } = metaItem;
const _splitLine = splitLine ?? (isRow && !isLast ? true : false);
const [tooltipVisible, setTooltipVisible] = useState(false);
const _visible = visible?.(formValue) ?? true;
if (!_visible) {
return <></>;
}
return (
<>
<div key={`form-item-${name}`} className="flex flex-col gap-[12px]">
{tooltip ? (
<Tooltip
onVisibleChange={setTooltipVisible}
showArrow={false}
position={'bottom'}
trigger="click"
style={{
maxWidth: 'unset',
}}
spacing={{
y: 12,
x: 0,
}}
content={
<div
key={`tooltip-${name}`}
className="flex flex-col gap-[12px]"
>
{tooltip.content
.filter(d => {
const _v = d.visible?.(formValue) ?? true;
return _v;
})
.map(d =>
schemaItemToNode({
metaItem: d,
value: formValue[d.name ?? ''],
isRefElement,
onChange: v => {
onChange(
{
[d.name ?? '']: v,
},
d.cacheSave,
);
},
}),
)}
</div>
}
>
{schemaItemToNode({
metaItem,
value: formValue[name],
isRefElement,
tooltipVisible,
onChange: v => {
onChange(
{
[name]: v,
},
metaItem.cacheSave,
);
},
})}
</Tooltip>
) : (
schemaItemToNode({
metaItem,
value: formValue[name],
isRefElement,
onChange: v => {
onChange(
{
[name]: v,
},
metaItem.cacheSave,
);
},
})
)}
</div>
{_splitLine ? (
isRow ? (
<div
key={`split-${name}`}
className="w-[1px] h-[24px] coz-mg-primary-pressed"
/>
) : (
<div
key={`split-${name}`}
className="w-full h-[1px] coz-mg-primary-pressed"
/>
)
) : undefined}
</>
);
},
);
interface IProps {
position: { tl: { x: number; y: number }; br: { x: number; y: number } };
onChange: (value: Partial<FabricObjectSchema>) => void;
offsetY?: number;
offsetX?: number;
schema: FabricSchema;
activeObjects: FabricObjectWithCustomProps[];
canvasHeight?: number;
limitRect?: {
width: number;
height: number;
};
}
export const Form: FC<IProps> = props => {
const {
position,
offsetY = 10,
offsetX = 0,
schema,
activeObjects,
onChange,
limitRect,
canvasHeight,
} = props;
const { tl, br } = position;
const x = tl.x + (br.x - tl.x) / 2;
let { y } = br;
let showPositionY: 'bottom-center' | 'top-center' = 'bottom-center';
if (canvasHeight && tl.y + (br.y - tl.y) / 2 > canvasHeight / 2) {
y = tl.y;
showPositionY = 'top-center';
}
const formMeta = useMemo<FormMeta>(
() =>
formMetas[
(activeObjects[0] as FabricObjectWithCustomProps).customType
] as FormMeta,
[activeObjects],
);
// 临时保存不需要保存到 schema 中的表单值
const [cacheFormValue, setCacheFormValue] = useState<
Partial<FabricObjectSchema>
>({});
const formValue = {
...schemaToFormValue({
schema,
activeObjectId: activeObjects[0].customId,
formMeta,
}),
...cacheFormValue,
};
const isRow = formMeta.display === 'row';
const isCol = formMeta.display === 'col';
const latestCacheFromValue = useLatest(cacheFormValue);
const _onChange = useCallback(
(v: Partial<FabricObjectSchema>, cacheSave?: boolean) => {
if (!cacheSave) {
onChange(v);
}
setCacheFormValue({
...latestCacheFromValue.current,
...v,
});
},
[onChange],
);
const fields = useMemo(
() =>
formMeta.content.map((metaItem, i) => {
const isLast = i === formMeta.content.length - 1;
return (
<FormItem
key={metaItem.name}
formValue={formValue}
metaItem={metaItem}
isLast={isLast}
isRow={isRow}
onChange={_onChange}
isRefElement={activeObjects[0].customId.startsWith(
REF_VARIABLE_ID_PREFIX,
)}
/>
);
}),
[formMeta, isRow, isCol, _onChange, formValue],
);
return (
<ConfigProvider getPopupContainer={() => document.body}>
<PopInScreen
left={x + offsetX}
top={y + offsetY + (showPositionY === 'top-center' ? -10 : 10)}
position={showPositionY}
limitRect={limitRect}
>
<div
tabIndex={0}
style={{
...(formMeta.style ?? {}),
}}
onClick={e => {
e.stopPropagation();
}}
>
<div
className={classNames([
'flex gap-[12px]',
{
'flex-col': isCol,
'flex-row items-center': isRow,
},
])}
>
{fields}
</div>
</div>
</PopInScreen>
</ConfigProvider>
);
};

View File

@@ -0,0 +1,11 @@
.icon-button.coz-fg-secondary {
@apply !coz-fg-primary;
&:disabled{
:global{
.semi-button-content{
@apply coz-fg-dim;
}
}
}
}

View File

@@ -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 { forwardRef } from 'react';
import classNames from 'classnames';
import {
IconButton,
type ButtonProps,
type SemiButton,
} from '@coze-arch/coze-design';
import styles from './index.module.less';
/**
* 在 size:small 的基础上,覆盖了 padding 5px -> 4px
*/
export const MyIconButton = forwardRef<
SemiButton,
ButtonProps & { inForm?: boolean }
>((props, ref) => {
const {
className = '',
inForm = false,
color = 'secondary',
...rest
} = props;
return (
<IconButton
ref={ref}
className={classNames(
[styles['icon-button']],
{
'!p-[4px]': !inForm,
'!p-[8px] !w-[32px] !h-[32px]': inForm,
[styles['coz-fg-secondary']]: color === 'secondary',
},
className,
)}
size="small"
color={color}
{...rest}
/>
);
});

View File

@@ -0,0 +1,5 @@
.pop-in-screen {
*:focus{
outline: none;
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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 { useEffect, useRef, useState, type FC } from 'react';
import classNames from 'classnames';
import { useSize } from 'ahooks';
import { getNumberBetween } from '../../utils';
import styles from './index.module.less';
interface IProps {
left?: number;
top?: number;
offsetY?: number;
offsetX?: number;
children?: React.ReactNode;
position?: 'bottom-center' | 'bottom-right' | 'top-center';
zIndex?: number;
onClick?: (e: React.MouseEvent) => void;
className?: string;
limitRect?: {
width?: number;
height?: number;
};
}
export const PopInScreen: FC<IProps> = props => {
const ref = useRef(null);
const {
left = 0,
top = 0,
children,
position = 'bottom-center',
zIndex = 1000,
onClick,
className,
limitRect,
} = props;
// const documentSize = useSize(document.body);
const childrenSize = useSize(ref.current);
let maxLeft = (limitRect?.width ?? Infinity) - (childrenSize?.width ?? 0) / 2;
let minLeft = (childrenSize?.width ?? 0) / 2;
let transform = 'translate(-50%, 0)';
if (position === 'bottom-right') {
maxLeft = (limitRect?.width ?? Infinity) - (childrenSize?.width ?? 0);
minLeft = 0;
transform = 'translate(0, 0)';
} else if (position === 'top-center') {
transform = 'translate(-50%, -100%)';
}
/**
* ahooks useSize 初次执行会返回 undefined导致组件位置计算错误
* 这里监听 childrenSize ,如果为 undefined 则延迟 100ms 再渲染,以修正组件位置
*/
const [id, setId] = useState('');
const timer = useRef<NodeJS.Timeout>();
useEffect(() => {
clearTimeout(timer.current);
if (!childrenSize) {
timer.current = setTimeout(() => {
setId(`${Math.random()}`);
}, 100);
}
}, [childrenSize]);
return (
<div
ref={ref}
onClick={onClick}
className={classNames([
styles['pop-in-screen'],
'!fixed',
'coz-tooltip semi-tooltip-wrapper',
'p-0',
className,
])}
style={{
left: getNumberBetween({
value: left,
max: maxLeft,
min: minLeft,
}),
top: getNumberBetween({
value: top,
max: (limitRect?.height ?? Infinity) - (childrenSize?.height ?? 0),
min: position === 'top-center' ? (childrenSize?.height ?? 0) : 0,
}),
zIndex,
opacity: 1,
maxWidth: 'unset',
transform,
}}
>
{/* 为了触发二次渲染 */}
<div className="hidden" id={id} />
{children}
</div>
);
};

View File

@@ -0,0 +1,127 @@
/*
* 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 FC } from 'react';
import {
ViewVariableType,
type InputVariable,
} from '@coze-workflow/base/types';
import { I18n } from '@coze-arch/i18n';
import { IconCozImage, IconCozString } from '@coze-arch/coze-design/icons';
import { Tag } from '@coze-arch/coze-design';
import {
type FabricObjectWithCustomProps,
type IRefPosition,
} from '../../typings';
import { useGlobalContext } from '../../context';
const PADDING = 4;
interface IProps {
visible?: boolean;
offsetX?: number;
offsetY?: number;
}
type Positions = IRefPosition & {
zIndex: number;
active: boolean;
unused: boolean;
variable?: InputVariable;
};
export const RefTitle: FC<IProps> = props => {
const { visible, offsetX, offsetY } = props;
const {
allObjectsPositionInScreen,
customVariableRefs,
variables,
activeObjects,
} = useGlobalContext();
const refsPosition: Positions[] =
allObjectsPositionInScreen
?.filter(obj => customVariableRefs?.map(v => v.objectId).includes(obj.id))
?.map((obj, i) => {
const ref = customVariableRefs?.find(v => v.objectId === obj.id);
const variable = variables?.find(d => d.id === ref?.variableId);
return {
...obj,
active: !!activeObjects?.find(
o => (o as FabricObjectWithCustomProps).customId === obj.id,
),
unused: !variable,
zIndex: i + 1,
variable,
};
}) ?? [];
return (
<div
className={`relative w-full ${visible ? '' : 'hidden'}`}
style={{
top: offsetY ?? 0,
left: offsetX ?? 0,
}}
>
{refsPosition?.map(d => (
<div
key={d.id}
style={{
zIndex: d.active ? 999 : d.zIndex,
position: 'absolute',
width: 'fit-content',
top: `${d.top - PADDING}px`,
left: `${d.left}px`,
transform: `${'translateY(-100%)'} rotate(${d.angle}deg) scale(1)`,
transformOrigin: `0 calc(100% + ${PADDING}px)`,
opacity: d.active ? 1 : 0.3,
maxWidth: '200px',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
className="flex items-center gap-[3px]"
>
{d.unused ? (
<Tag className="w-full" color="yellow">
<div className="truncate w-full overflow-hidden">
{I18n.t('imageflow_canvas_var_delete', {}, '变量被删除')}
</div>
</Tag>
) : (
<Tag
className="w-full"
color="primary"
prefixIcon={
d.variable?.type === ViewVariableType.Image ? (
<IconCozImage className="coz-fg-dim" />
) : (
<IconCozString className="coz-fg-dim" />
)
}
>
<div className="truncate w-full overflow-hidden">
{d.variable?.name}
</div>
</Tag>
)}
</div>
)) ?? undefined}
</div>
);
};

View File

@@ -0,0 +1,9 @@
.imageflow-canvas-border-width {
:global{
.semi-slider-handle{
width: 8px;
height: 8px;
margin-top: 12px;
}
}
}

View File

@@ -0,0 +1,61 @@
/*
* 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 FC } from 'react';
import classnames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Slider } from '@coze-arch/coze-design';
import styles from './border-width.module.less';
interface IProps {
value: number;
onChange: (value: number) => void;
options?: [number, number, number];
min?: number;
max?: number;
}
export const BorderWidth: FC<IProps> = props => {
const { value, onChange, min, max } = props;
return (
<div
className={classnames(
'flex gap-[12px] text-[14px]',
styles['imageflow-canvas-border-width'],
)}
>
<div className="w-full flex items-center gap-[8px]">
<div className="min-w-[42px]">
{I18n.t('imageflow_canvas_stroke_width')}
</div>
<div className="flex-1 min-w-[320px]">
<Slider
min={min}
max={max}
step={1}
showArrow={false}
value={value}
onChange={o => {
onChange(o as number);
}}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,13 @@
.color-picker-slider{
:global{
.semi-slider{
padding: 0;
}
.semi-slider-handle{
width: 8px;
height: 8px;
margin-top: 12px;
}
}
}

View File

@@ -0,0 +1,188 @@
/*
* 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, type FC, useMemo } from 'react';
import classnames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozCheckMarkFill } from '@coze-arch/coze-design/icons';
import { Input, Slider } from '@coze-arch/coze-design';
import styles from './color-picker.module.less';
interface IProps {
// #ffffffff
value: string | number;
onChange: (value: string | number) => void;
showOpacity?: boolean;
showColor?: boolean;
readonly?: boolean;
}
const colors = [
'#000000',
'#ffffff',
'#C6C6CD',
'#FF441E',
'#3EC254',
'#4D53E8',
'#00B2B2',
'#FF9600',
];
const ColorRect = (props: {
color: string;
size?: number;
onClick?: () => void;
selected?: boolean;
className?: string;
}) => {
const { color, size = 24, onClick, selected, className } = props;
return (
<div
onClick={onClick}
className={`${className} rounded-[4px]`}
style={{ backgroundColor: color, width: size, height: size }}
>
<div
className={classnames([
'relative top-0 left-0',
'flex items-center justify-center',
'rounded-[4px] border border-solid border-stroke',
])}
style={{
width: size,
height: size,
color: color !== '#ffffff' ? '#fff' : '#000',
}}
>
{selected ? <IconCozCheckMarkFill /> : undefined}
</div>
</div>
);
};
const isHexOpacityColor = (value: string): boolean =>
/^#[0-9A-Fa-f]{8}$/.test(value);
const isHexColor = (value: string): boolean => /^#[0-9A-Fa-f]{6}$/.test(value);
const opacity16To255ScaleTo100 = (v: string): number => parseInt(v, 16) / 2.55;
const opacity100ScaleTo255To16 = (v: number): string =>
Math.floor(v * 2.55)
.toString(16)
.padStart(2, '0');
export const ColorPicker: FC<IProps> = props => {
const {
value = '#ffffffff',
onChange,
showOpacity = true,
showColor = true,
readonly = false,
} = props;
const { color, opacity } = useMemo(() => {
if (!showColor) {
return {
opacity: (value as number) * 100,
};
}
return {
color: (value as string).substring(0, 7),
opacity: opacity16To255ScaleTo100((value as string).substring(7, 9)),
};
}, [value, showColor]);
const _onChange = useCallback(
(v: string) => {
if (isHexOpacityColor(v)) {
onChange(v);
}
},
[onChange],
);
return (
<div className="flex flex-col w-full gap-[12px] text-[14px]">
{showColor ? (
<div className="flex items-center w-full gap-[16px]">
<div className="flex items-center flex-1 gap-[12px]">
{colors.map(c => {
const selected =
c.toUpperCase() === (color as string).toUpperCase();
return (
<ColorRect
key={`rect-${c}`}
className={`${readonly ? '' : 'cursor-pointer'}`}
selected={selected}
onClick={() => {
if (readonly) {
return;
}
_onChange(`${c}${opacity100ScaleTo255To16(opacity)}`);
}}
color={c}
/>
);
})}
</div>
<Input
// 因为是不受控模式,当点击色块时,需要重置 input.value。所以这里以 color 为 key
key={`input-${color}`}
disabled={readonly}
prefix={<ColorRect color={color as string} size={16} />}
type="text"
className="w-[110px]"
// 为什么不使用受控模式?使用受控模式,用户输入过程中触发的格式校验处理起来比较麻烦
defaultValue={color}
onChange={v => {
if (isHexColor(v)) {
_onChange(`${v}${opacity100ScaleTo255To16(opacity)}`);
}
}}
/>
</div>
) : undefined}
{showOpacity ? (
<div className="w-full flex items-center gap-[8px]">
<div className="min-w-[80px]">
{I18n.t('imageflow_canvas_transparency')}
</div>
<div
className={classnames(
'flex-1 min-w-[320px]',
styles['color-picker-slider'],
)}
>
<Slider
min={0}
showArrow={false}
max={100}
step={1}
value={opacity}
disabled={readonly}
onChange={o => {
if (!showColor) {
onChange((o as number) / 100);
} else {
_onChange(`${color}${opacity100ScaleTo255To16(o as number)}`);
}
}}
/>
</div>
</div>
) : undefined}
</div>
);
};

View File

@@ -0,0 +1,83 @@
/*
* 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 FC, useCallback, useMemo } from 'react';
import { clamp } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import { IconCozFontSize } from '@coze-arch/coze-design/icons';
import { Select, type SelectProps, Tooltip } from '@coze-arch/coze-design';
interface IProps extends Omit<SelectProps, 'onChange'> {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
}
export const FontSize: FC<IProps> = props => {
const { onChange, min, max, optionList, value, ...rest } = props;
const _onChange = useCallback(
(v: number) => {
if (isFinite(v)) {
onChange?.(clamp(v, min, max));
}
},
[onChange, min, max],
);
const _optionsList = useMemo(() => {
const _options = [...(optionList ?? [])];
if (!_options.map(o => o.value).includes(value)) {
_options.unshift({
label: `${value}`,
value,
});
}
return _options;
}, [optionList, value]);
return (
<div className="flex gap-[8px] items-center">
{/* <IconCozFontSize className="text-[16px] m-[8px]" /> */}
<Tooltip
content={I18n.t('imageflow_canvas_text_tooltip1')}
mouseEnterDelay={300}
mouseLeaveDelay={300}
>
<Select
{...rest}
prefix={
<IconCozFontSize className="text-[16px] coz-fg-secondary m-[8px]" />
}
/**
* 因为开启了 allowCreate所以 optionList 不会再响应动态变化
* 这里给个 key ,重新渲染 select保证 optionList 符合预期
*/
key={_optionsList.map(d => d.label).join()}
value={value}
optionList={_optionsList}
filter
allowCreate
onChange={v => {
_onChange(v as number);
}}
style={{ width: '98px' }}
/>
</Tooltip>
</div>
);
};

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type FC } from 'react';
import { Select } from '@coze-arch/coze-design';
import { Uploader } from './uploader';
import { TextType } from './text-type';
import { TextFamily } from './text-family';
import { TextAlign } from './text-align';
import { SingleSelect } from './single-select';
import { RefSelect } from './ref-select';
import { LineHeight } from './line-height';
import { LabelSelect } from './label-select';
import { InputNumber } from './input-number';
import { FontSize } from './font-size';
import { ColorPicker } from './color-picker';
import { BorderWidth } from './border-width';
export const setters: Record<string, FC<any>> = {
ColorPicker,
TextAlign,
InputNumber,
TextType,
SingleSelect,
BorderWidth,
Select,
TextFamily,
FontSize,
LineHeight,
LabelSelect,
Uploader,
RefSelect,
};

View File

@@ -0,0 +1,53 @@
/*
* 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 { forwardRef } from 'react';
import {
InputNumber as CozeInputNumber,
type InputNumberProps,
} from '@coze-arch/coze-design';
export const InputNumber = forwardRef<InputNumberProps, InputNumberProps>(
props => {
const { onChange, min, max, value, ...rest } = props;
return (
<CozeInputNumber
{...rest}
min={min}
max={max}
value={value}
// InputNumber 长按 + - 时,会一直触发变化。这里有 bug有时定时器清不掉会鬼畜一直增加/减小)。
// 把 pressInterval 设置成 24h ,变相禁用长按增减
pressInterval={1000 * 60 * 60 * 24}
onNumberChange={v => {
if (Number.isFinite(v)) {
if (typeof min === 'number' && (v as number) < min) {
onChange?.(min);
} else if (typeof max === 'number' && (v as number) > max) {
onChange?.(max);
} else {
const _v = Number((v as number).toFixed(1));
if (_v !== value) {
onChange?.(Number((v as number).toFixed(1)));
}
}
}
}}
/>
);
},
);

View File

@@ -0,0 +1,30 @@
/*
* 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 FC } from 'react';
import { Select, type SelectProps } from '@coze-arch/coze-design';
type IProps = SelectProps & { label: string };
export const LabelSelect: FC<IProps> = props => {
const { label, ...rest } = props;
return (
<div className="w-full flex gap-[8px] justify-between items-center text-[14px]">
<div className="min-w-[80px]">{label}</div>
<Select {...rest} />
</div>
);
};

View File

@@ -0,0 +1,90 @@
/*
* 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 FC, useCallback, useMemo } from 'react';
import { clamp } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import { IconCozFontHeight } from '@coze-arch/coze-design/icons';
import { Select, Tooltip, type SelectProps } from '@coze-arch/coze-design';
interface IProps extends Omit<SelectProps, 'onChange'> {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
}
export const LineHeight: FC<IProps> = props => {
const { onChange, min, max, value, optionList, ...rest } = props;
const _onChange = useCallback(
(v: string) => {
const _v = Number(`${v}`.replace('%', ''));
if (isFinite(_v)) {
onChange?.(Number((clamp(_v, min, max) / 100).toFixed(2)));
}
},
[onChange, min, max],
);
const _optionsList = useMemo(() => {
const _options = [...(optionList ?? [])];
if (
!_options
.map(o => o.value)
.includes(
Number((Number(`${value}`.replace('%', '')) * 100).toFixed(0)),
)
) {
_options.unshift({
label: `${Number((value * 100).toFixed(0))}%`,
value: Number((value * 100).toFixed(0)),
});
}
return _options;
}, [optionList, value]);
return (
<div className="flex gap-[8px] items-center">
{/* <IconCozFontHeight className="text-[16px] m-[8px]" /> */}
<Tooltip
content={I18n.t('imageflow_canvas_text_tooltip2')}
mouseEnterDelay={300}
mouseLeaveDelay={300}
>
<Select
prefix={
<IconCozFontHeight className="text-[16px] coz-fg-secondary m-[8px]" />
}
{...rest}
/**
* 因为开启了 allowCreate所以 optionList 不会再响应动态变化
* 这里给个 key ,重新渲染 select保证 optionList 符合预期
*/
key={_optionsList.map(d => d.label).join()}
filter
value={Number((value * 100).toFixed(0))}
allowCreate
onChange={v => {
_onChange(v as string);
}}
optionList={_optionsList}
style={{ width: '104px' }}
/>
</Tooltip>
</div>
);
};

View File

@@ -0,0 +1,9 @@
.ref-select{
// :global(.semi-select-content-wrapper){
// width: 100%;
// }
:global(.semi-select-selection){
@apply !ml-[4px];
}
}

View File

@@ -0,0 +1,135 @@
/*
* 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 FC } from 'react';
import cls from 'classnames';
import { ViewVariableType } from '@coze-workflow/base/types';
import { I18n } from '@coze-arch/i18n';
import { IconCozImage, IconCozString } from '@coze-arch/coze-design/icons';
import {
type RenderSelectedItemFn,
Select,
type SelectProps,
Tag,
} from '@coze-arch/coze-design';
import { useGlobalContext } from '../../context';
import s from './ref-select.module.less';
type IProps = SelectProps & {
value: string;
label: string;
labelInside: boolean;
};
export const RefSelect: FC<IProps> = props => {
const { value: objectId, label, labelInside, className } = props;
const { customVariableRefs, variables, updateRefByObjectId } =
useGlobalContext();
const targetRef = customVariableRefs?.find(ref => ref.objectId === objectId);
const value = targetRef?.variableId;
const targetVariable = variables?.find(v => v.id === value);
return (
<div className="w-full text-[14px] flex flex-row gap-[8px] items-center">
{!labelInside && label ? (
<div className="text-[14px] min-w-[80px]">{label}</div>
) : (
<></>
)}
<Select
prefix={
labelInside && label ? (
<div className="text-[14px] pl-[8px] pr-[4px] py-[2px] min-w-[40px]">
{label}
</div>
) : undefined
}
showClear
showTick={false}
placeholder={I18n.t('imageflow_canvas_select_var', {}, '选择变量')}
value={targetRef?.variableId}
className={cls(className, s['ref-select'])}
onChange={d => {
updateRefByObjectId?.({
objectId,
variable: d ? variables?.find(v => v.id === d) : undefined,
});
}}
renderSelectedItem={
((options: { value: string; label: React.ReactNode }) => {
const { value: _value, label: _label } = options;
const variable = variables?.find(v => v.id === _value);
if (variable) {
return (
<Tag color="primary" className="w-full">
<div className="flex flex-row items-center gap-[4px] w-full">
{variable.type === ViewVariableType.String ? (
<IconCozString className="coz-fg-dim" />
) : (
<IconCozImage className="coz-fg-dim" />
)}
<div className="flex-1 overflow-hidden">
<div className="truncate w-full overflow-hidden">
{variable.name}
</div>
</div>
</div>
</Tag>
);
}
return _label;
}) as RenderSelectedItemFn
}
>
{value && !targetVariable ? (
<Select.Option value={value}>
<Tag className="max-w-full m-[8px]" color="yellow">
<div className="truncate overflow-hidden">
{I18n.t('imageflow_canvas_var_delete', {}, '变量被删除')}
</div>
</Tag>
</Select.Option>
) : (
<></>
)}
{variables?.map(v => (
<Select.Option value={v.id}>
<div className="flex flex-row items-center gap-[4px] w-full p-[8px] max-w-[400px]">
{v.type === ViewVariableType.String ? (
<IconCozString className="coz-fg-dim" />
) : (
<IconCozImage className="coz-fg-dim" />
)}
<div className="flex-1 overflow-hidden">
<div className="truncate w-full overflow-hidden">{v.name}</div>
</div>
{v.id === value ? (
<Tag color="primary">
{I18n.t('eval_status_referenced', {}, '已引用')}
</Tag>
) : null}
</div>
</Select.Option>
))}
</Select>
</div>
);
};

View File

@@ -0,0 +1,38 @@
/*
* 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 { forwardRef } from 'react';
import {
SingleSelect as CozeSingleSelect,
type SingleSelectProps,
} from '@coze-arch/coze-design';
export const SingleSelect = forwardRef<SingleSelectProps, SingleSelectProps>(
props => {
// (props, ref) => {
const { onChange, ...rest } = props;
return (
<CozeSingleSelect
{...rest}
// ref={ref}
onChange={v => {
onChange?.(v.target.value);
}}
/>
);
},
);

View File

@@ -0,0 +1,77 @@
/*
* 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 React, { type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import {
IconCozTextAlignCenter,
IconCozTextAlignLeft,
IconCozTextAlignRight,
} from '@coze-arch/coze-design/icons';
import { Select, type RenderSelectedItemFn } from '@coze-arch/coze-design';
import { TextAlign as TextAlignEnum } from '../../typings';
interface IProps {
value: TextAlignEnum;
onChange: (value: TextAlignEnum) => void;
}
export const TextAlign: FC<IProps> = props => {
const { value, onChange } = props;
return (
<Select
// borderless
className="border-0 hover:border-0 focus:border-0"
value={value}
onChange={v => {
onChange(v as TextAlignEnum);
}}
optionList={[
{
icon: <IconCozTextAlignLeft className="text-[16px]" />,
label: I18n.t('card_builder_hover_align_left'),
value: TextAlignEnum.LEFT,
},
{
icon: <IconCozTextAlignCenter className="text-[16px]" />,
label: I18n.t('card_builder_hover_align_horizontal'),
value: TextAlignEnum.CENTER,
},
{
icon: <IconCozTextAlignRight className="text-[16px]" />,
label: I18n.t('card_builder_hover_align_right'),
value: TextAlignEnum.RIGHT,
},
].map(d => ({
...d,
label: (
<div className="flex flex-row items-center gap-[4px]">
{d.icon}
{d.label}
</div>
),
}))}
renderSelectedItem={
((option: { icon: React.ReactNode }) => {
const { icon } = option;
return <div className="flex flex-row items-center">{icon}</div>;
}) as RenderSelectedItemFn
}
/>
);
};

View File

@@ -0,0 +1,7 @@
.imageflow-canvas-font-family-cascader {
:global{
.semi-cascader-option-lists{
height: 300px;
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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 FC } from 'react';
import { Cascader } from '@coze-arch/coze-design';
import s from './text-family.module.less';
interface IProps {
value: string;
onChange: (value: string) => void;
}
export const TextFamily: FC<IProps> = props => {
// (props, ref) => {
const { onChange, value, ...rest } = props;
return (
<Cascader
{...rest}
value={value?.split('-')?.reverse()}
onChange={v => {
onChange?.((v as string[])?.reverse()?.join('-'));
}}
dropdownClassName={s['imageflow-canvas-font-family-cascader']}
/>
);
};

View File

@@ -0,0 +1,68 @@
/*
* 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 FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import {
IconCozFixedSize,
IconCozAutoWidth,
} from '@coze-arch/coze-design/icons';
import { Tooltip } from '@coze-arch/coze-design';
import { MyIconButton } from '../icon-button';
import { Mode } from '../../typings';
interface IProps {
value: Mode;
onChange: (value: Mode) => void;
}
export const TextType: FC<IProps> = props => {
const { value, onChange } = props;
return (
<div className="flex gap-[12px]">
<Tooltip
mouseEnterDelay={300}
mouseLeaveDelay={300}
content={I18n.t('imageflow_canvas_text1')}
>
<MyIconButton
inForm
color={value === Mode.INLINE_TEXT ? 'highlight' : 'secondary'}
onClick={() => {
onChange(Mode.INLINE_TEXT);
}}
icon={<IconCozAutoWidth className="text-[16px]" />}
/>
</Tooltip>
<Tooltip
mouseEnterDelay={300}
mouseLeaveDelay={300}
content={I18n.t('imageflow_canvas_text2')}
>
<MyIconButton
inForm
color={value === Mode.BLOCK_TEXT ? 'highlight' : 'secondary'}
onClick={() => {
onChange(Mode.BLOCK_TEXT);
}}
icon={<IconCozFixedSize className="text-[16px]" />}
/>
</Tooltip>
</div>
);
};

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconCozLoading, IconCozUpload } from '@coze-arch/coze-design/icons';
import { Button } from '@coze-arch/coze-design';
import { ImageUpload } from '../topbar/image-upload';
interface IProps {
getLabel: (isRefElement: boolean) => string;
onChange: (url: string) => void;
isRefElement: boolean;
}
export const Uploader: FC<IProps> = props => {
const { getLabel, onChange, isRefElement } = props;
return (
<div className="w-full flex gap-[8px] justify-between items-center text-[14px]">
<div className="min-w-[80px]">{getLabel(isRefElement)}</div>
<ImageUpload
disabledTooltip
onChange={onChange}
tooltip={I18n.t('card_builder_image')}
className="flex-1"
>
{({ loading, cancel }) => (
<Button
className="w-full"
color="primary"
onClick={() => {
loading && cancel();
}}
icon={
loading ? (
<IconCozLoading className={'loading coz-fg-dim'} />
) : (
<IconCozUpload />
)
}
>
{loading
? I18n.t('imageflow_canvas_cancel_change', {}, '取消上传')
: I18n.t('imageflow_canvas_change_img', {}, '更换图片')}
</Button>
)}
</ImageUpload>
</div>
);
};

View File

@@ -0,0 +1,149 @@
/*
* 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 FC, type RefObject } from 'react';
import { I18n } from '@coze-arch/i18n';
import {
IconCozAlignBottom,
IconCozAlignCenterHorizontal,
IconCozAlignCenterVertical,
IconCozAlignLeft,
IconCozAlignRight,
IconCozAlignTop,
IconCozDistributeHorizontal,
IconCozDistributeVertical,
} from '@coze-arch/coze-design/icons';
import { Select } from '@coze-arch/coze-design';
import { AlignMode } from '../../typings';
import styles from '../../index.module.less';
interface IProps {
readonly?: boolean;
popRefAlignRight: RefObject<HTMLDivElement> | null;
onChange: (v: AlignMode) => void;
}
export const Align: FC<IProps> = props => {
const { readonly, onChange, popRefAlignRight } = props;
const renderItem = ({
name,
value,
icon,
suffix,
}: {
name: string;
value: AlignMode;
icon: JSX.Element;
suffix: string;
}) => (
<Select.Option value={value}>
<div className="w-[172px] px-[8px] flex gap-[4px] align-center coz-fg-primary">
<div className="text-[16px] flex items-center">{icon}</div>
<div className="flex-1 text-[14px]">{name}</div>
<div className="coz-fg-secondary text-[12px]">{suffix}</div>
</div>
</Select.Option>
);
return (
// 禁止冒泡防止点击对齐时canvas 的选中状态被清空
<div
onClick={e => {
e.stopPropagation();
}}
>
<Select
disabled={readonly}
className={'hide-selected-label hide-border'}
dropdownClassName={styles['select-hidden-group-label']}
showTick={false}
size="small"
getPopupContainer={() => popRefAlignRight?.current ?? document.body}
onSelect={v => {
onChange(v as AlignMode);
}}
maxHeight={300}
restTagsPopoverProps={{
trigger: 'hover',
}}
>
<Select.OptGroup label="a">
{[
{
name: I18n.t('imageflow_canvas_align1', {}, '左对齐'),
value: AlignMode.Left,
icon: <IconCozAlignLeft />,
suffix: '⌥ + A',
},
{
name: I18n.t('imageflow_canvas_align2', {}, '水平居中'),
value: AlignMode.Center,
icon: <IconCozAlignCenterVertical />,
suffix: '⌥ + H',
},
{
name: I18n.t('imageflow_canvas_align3', {}, '右对齐'),
value: AlignMode.Right,
icon: <IconCozAlignRight />,
suffix: '⌥ + D',
},
].map(renderItem)}
</Select.OptGroup>
<Select.OptGroup label="b">
{[
{
name: I18n.t('imageflow_canvas_align4', {}, '顶部对齐'),
value: AlignMode.Top,
icon: <IconCozAlignTop />,
suffix: '⌥ + W',
},
{
name: I18n.t('imageflow_canvas_align5', {}, '垂直居中'),
value: AlignMode.Middle,
icon: <IconCozAlignCenterHorizontal />,
suffix: '⌥ + V',
},
{
name: I18n.t('imageflow_canvas_align6', {}, '底部对齐'),
value: AlignMode.Bottom,
icon: <IconCozAlignBottom />,
suffix: '⌥ + S',
},
].map(renderItem)}
</Select.OptGroup>
<Select.OptGroup label="c">
{[
{
name: I18n.t('imageflow_canvas_align7', {}, '水平均分'),
value: AlignMode.VerticalAverage,
icon: <IconCozDistributeHorizontal />,
suffix: '^ + ⌥ + H',
},
{
name: I18n.t('imageflow_canvas_align8', {}, '垂直均分'),
value: AlignMode.HorizontalAverage,
icon: <IconCozDistributeVertical />,
suffix: '^ + ⌥ + V',
},
].map(renderItem)}
</Select.OptGroup>
</Select>
</div>
);
};

View File

@@ -0,0 +1,27 @@
.loading-container{
:global{
.loading {
animation: semi-animation-rotate 0.6s linear infinite;
animation-fill-mode: forwards;
}
}
.hover-hidden{
@apply block;
}
.hover-visible{
@apply hidden;
}
&:hover{
.hover-hidden{
@apply hidden;
}
.hover-visible{
@apply block;
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* 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 classNames from 'classnames';
import { useImageUploader, type ImageRule } from '@coze-workflow/components';
import { I18n } from '@coze-arch/i18n';
import {
IconCozCrossCircle,
IconCozLoading,
} from '@coze-arch/coze-design/icons';
import {
Toast,
Tooltip,
Upload,
type customRequestArgs,
} from '@coze-arch/coze-design';
import styles from './image-upload.module.less';
const imageRules: ImageRule = {
suffix: ['jpg', 'jpeg', 'png', 'webp'],
maxSize: 1024 * 1024 * 5,
};
export const ImageUpload = (props: {
onChange?: (url: string) => void;
children?:
| React.ReactNode
| ((data: { loading: boolean; cancel: () => void }) => React.ReactNode);
tooltip?: string;
disabledTooltip?: boolean;
key?: string;
className?: string;
}) => {
const { onChange, children, className, tooltip, key, disabledTooltip } =
props;
// const focusRef = useRef(false);
const { uploadImg, clearImg, loading } = useImageUploader({
rules: imageRules,
});
const handleUpload: (object: customRequestArgs) => void = async ({
fileInstance,
}) => {
clearImg();
const res = await uploadImg(fileInstance);
if (res?.isSuccess) {
onChange?.(res.url);
}
};
const handleAcceptInvalid = () => {
Toast.error(
I18n.t('imageflow_upload_error_type', {
type: imageRules.suffix?.join('/'),
}),
);
};
const content = (
<div className={classNames([styles['loading-container'], className])}>
<Upload
action=""
disabled={loading}
customRequest={handleUpload}
draggable={true}
accept={imageRules.suffix?.map(item => `.${item}`).join(',')}
showUploadList={false}
onAcceptInvalid={handleAcceptInvalid}
>
{typeof children === 'function' ? (
children({ loading, cancel: clearImg })
) : loading ? (
<div>
<IconCozLoading
className={`loading coz-fg-dim ${styles['hover-hidden']}`}
/>
<IconCozCrossCircle
onClick={e => {
e.stopPropagation();
clearImg();
}}
className={`coz-fg-dim hover-visible ${styles['hover-visible']}`}
/>
</div>
) : (
children
)}
</Upload>
</div>
);
return disabledTooltip ? (
content
) : (
<Tooltip
key={key ?? 'image'}
content={loading ? I18n.t('Cancel') : tooltip}
mouseEnterDelay={300}
mouseLeaveDelay={300}
getPopupContainer={() => document.body}
>
{content}
</Tooltip>
);
};

View File

@@ -0,0 +1,678 @@
/*
* 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 */
/* eslint-disable @coze-arch/max-line-per-function */
/* eslint-disable max-lines-per-function */
import { useCallback, useRef, useState, type FC, type RefObject } from 'react';
import classNames from 'classnames';
import { useKeyPress } from 'ahooks';
import { SizeSelect, Text } from '@coze-workflow/components';
import { ViewVariableType } from '@coze-workflow/base/types';
import { I18n } from '@coze-arch/i18n';
import {
IconCozAlignBottom,
IconCozAlignCenterHorizontal,
IconCozAlignCenterVertical,
IconCozAlignLeft,
IconCozAlignRight,
IconCozAlignTop,
IconCozArrowBack,
IconCozArrowForward,
IconCozAutoView,
IconCozDistributeHorizontal,
IconCozDistributeVertical,
IconCozEllipse,
IconCozEmpty,
IconCozFixedSize,
IconCozImage,
IconCozLine,
IconCozMinus,
IconCozMoveToBottomFill,
IconCozMoveToTopFill,
IconCozParagraph,
IconCozPencil,
IconCozPlus,
IconCozRectangle,
IconCozRectangleSetting,
IconCozString,
IconCozTriangle,
IconCozVariables,
type OriginIconProps,
} from '@coze-arch/coze-design/icons';
import {
ConfigProvider,
EmptyState,
InputNumber,
Menu,
Select,
Tag,
Tooltip,
} from '@coze-arch/coze-design';
import { ColorPicker } from '../setters/color-picker';
import { MyIconButton } from '../icon-button';
import { AlignMode, Mode } from '../../typings';
import styles from '../../index.module.less';
import { useGlobalContext } from '../../context';
import { ImageUpload } from './image-upload';
import { Align } from './align';
interface IProps {
popRefAlignRight: RefObject<HTMLDivElement> | null;
readonly?: boolean;
mode?: Mode;
maxLimit?: boolean;
onModeChange: (currentMode?: Mode, prevMode?: Mode) => void;
onMoveToTop: (e: unknown) => void;
onMoveToBackend: (e: unknown) => void;
isActiveObjectsInBack?: boolean;
isActiveObjectsInFront?: boolean;
onAddImg: (url: string) => void;
zoomSettings: {
zoom: number;
onChange: (value: number) => void;
reset: () => void;
max: number;
min: number;
};
redo: () => void;
undo: () => void;
disabledUndo: boolean;
disabledRedo: boolean;
redoUndoing: boolean;
canvasSettings: {
minWidth: number;
minHeight: number;
maxWidth: number;
maxHeight: number;
width: number;
height: number;
background: string;
onChange: (value: {
width?: number;
height?: number;
type?: string;
background?: string;
}) => void;
};
aligns: Record<AlignMode, () => void>;
}
type Icon = React.ForwardRefExoticComponent<
Omit<OriginIconProps, 'ref'> & React.RefAttributes<SVGSVGElement>
>;
const SplitLine = () => (
<div className="h-[24px] w-[1px] coz-mg-primary-pressed"></div>
);
const textIcons: Partial<Record<Mode, { icon: Icon; text: string }>> = {
[Mode.INLINE_TEXT]: {
icon: IconCozParagraph,
text: I18n.t('imageflow_canvas_text1'),
},
[Mode.BLOCK_TEXT]: {
icon: IconCozFixedSize,
text: I18n.t('imageflow_canvas_text2'),
},
};
const shapeIcons: Partial<Record<Mode, { icon: Icon; text: string }>> = {
[Mode.RECT]: {
icon: IconCozRectangle,
text: I18n.t('imageflow_canvas_rect'),
},
[Mode.CIRCLE]: {
icon: IconCozEllipse,
text: I18n.t('imageflow_canvas_circle'),
},
[Mode.TRIANGLE]: {
icon: IconCozTriangle,
text: I18n.t('imageflow_canvas_trian'),
},
[Mode.STRAIGHT_LINE]: {
icon: IconCozLine,
text: I18n.t('imageflow_canvas_line'),
},
};
const alignIcons: Record<AlignMode, Icon> = {
[AlignMode.Bottom]: IconCozAlignBottom,
[AlignMode.Center]: IconCozAlignCenterHorizontal,
[AlignMode.Middle]: IconCozAlignCenterVertical,
[AlignMode.Left]: IconCozAlignLeft,
[AlignMode.Right]: IconCozAlignRight,
[AlignMode.Top]: IconCozAlignTop,
[AlignMode.HorizontalAverage]: IconCozDistributeHorizontal,
[AlignMode.VerticalAverage]: IconCozDistributeVertical,
};
const addTextPrefix = I18n.t('add');
const commonTooltipProps = {
mouseEnterDelay: 300,
mouseLeaveDelay: 300,
getPopupContainer: () => document.body,
};
export const TopBar: FC<IProps> = props => {
const {
popRefAlignRight,
readonly,
maxLimit,
mode,
onModeChange: _onModeChange,
onMoveToTop,
onMoveToBackend,
onAddImg,
zoomSettings,
canvasSettings,
redo,
undo,
disabledUndo,
disabledRedo,
redoUndoing,
isActiveObjectsInBack,
isActiveObjectsInFront,
aligns,
} = props;
// 点击已选中的,则取消选中
const onModeChange = useCallback(
(m: Mode | undefined) => {
if (m === mode) {
_onModeChange(undefined, mode);
} else {
_onModeChange(m, mode);
}
},
[_onModeChange, mode],
);
const ref = useRef<HTMLDivElement>(null);
const [textType, setTextType] = useState<Mode>(Mode.INLINE_TEXT);
const TextIcon = textIcons[textType]?.icon as Icon;
const [shapeType, setShapeType] = useState<Mode>(Mode.RECT);
const ShapeIcon = shapeIcons[shapeType]?.icon as Icon;
const [alignType, setAlignType] = useState<AlignMode>(AlignMode.Left);
const AlignIcon = alignIcons[alignType];
useKeyPress(
'esc',
() => {
onModeChange(undefined);
},
{
events: ['keyup'],
},
);
const { variables, addRefObjectByVariable, customVariableRefs } =
useGlobalContext();
return (
<div
className={classNames([
styles['top-bar'],
'flex justify-center items-center gap-[12px]',
])}
>
{/* 引用变量 */}
<Tooltip
key="ref-variable"
content={I18n.t('workflow_detail_condition_reference')}
{...commonTooltipProps}
>
<div>
<Menu
trigger="click"
position="bottomLeft"
className="max-h-[300px] overflow-y-auto"
render={
<Menu.SubMenu mode="menu">
{(variables ?? []).length > 0 ? (
<>
<div className="p-[8px] pt-[4px] coz-fg-secondary text-[12px]">
{I18n.t('imageflow_canvas_var_add', {}, '添加变量')}
</div>
{variables?.map(v => {
const counts = customVariableRefs?.filter(
d => d.variableId === v.id,
).length;
return (
<Menu.Item
itemKey={v.name}
key={v.name}
disabled={!v.type}
onClick={(_, e) => {
e.stopPropagation();
addRefObjectByVariable?.(v);
}}
>
<div className="flex flex-row gap-[4px] items-center w-[220px] h-[32px]">
<div className="flex flex-row gap-[4px] items-center flex-1 overflow-hidden w-full">
{v.type ? (
<>
{v.type === ViewVariableType.String ? (
<IconCozString className="coz-fg-dim" />
) : (
<IconCozImage className="coz-fg-dim" />
)}
</>
) : (
<></>
)}
<div className="flex-1 overflow-hidden flex flex-row gap-[4px] items-center">
<Text text={v.name} />
</div>
</div>
{counts && counts > 0 ? (
<Tag size="mini" color="primary">
{I18n.t(
'imageflow_canvas_var_reference',
{
n: counts,
},
`引用 ${counts}`,
)}
</Tag>
) : null}
</div>
</Menu.Item>
);
})}
</>
) : (
<EmptyState
className="py-[16px] w-[200px]"
size="default"
icon={<IconCozEmpty />}
darkModeIcon={<IconCozEmpty />}
title={I18n.t('imageflow_canvas_var_no', {}, '暂无变量')}
/>
)}
</Menu.SubMenu>
}
>
<MyIconButton icon={<IconCozVariables className="text-[16px]" />} />
</Menu>
</div>
</Tooltip>
<SplitLine />
{/* 画布基础设置 */}
<Tooltip
key="canvas-setting"
position="bottom"
trigger="click"
getPopupContainer={() => document.body}
className="!max-w-[600px]"
content={
<>
<div ref={ref}></div>
<div className="flex flex-col gap-[12px] px-[4px] py-[8px] w-[410px] rounded-[12px] relative">
<ConfigProvider
getPopupContainer={() => ref.current ?? document.body}
>
<div className="text-[16px] font-semibold">
{I18n.t('imageflow_canvas_setting')}
</div>
<div>{I18n.t('imageflow_canvas_frame')}</div>
<SizeSelect
selectClassName="w-[120px]"
readonly={readonly}
value={{
width: canvasSettings.width,
height: canvasSettings.height,
}}
minHeight={canvasSettings.minHeight}
minWidth={canvasSettings.minWidth}
maxHeight={canvasSettings.maxHeight}
maxWidth={canvasSettings.maxWidth}
onChange={v => {
canvasSettings.onChange(v);
}}
options={[
{
label: '16:9',
value: {
width: 1920,
height: 1080,
},
},
{
label: '9:16',
value: {
width: 1080,
height: 1920,
},
},
{
label: '1:1',
value: {
width: 1024,
height: 1024,
},
},
{
label: I18n.t('imageflow_canvas_a41'),
value: {
width: 1485,
height: 1050,
},
},
{
label: I18n.t('imageflow_canvas_a42'),
value: {
width: 1050,
height: 1485,
},
},
]}
/>
<div>{I18n.t('imageflow_canvas_color')}</div>
<ColorPicker
readonly={readonly}
value={canvasSettings.background}
onChange={color => {
canvasSettings.onChange({
background: color as string,
});
}}
/>
</ConfigProvider>
</div>
</>
}
>
<MyIconButton
icon={<IconCozRectangleSetting className="text-[16px]" />}
/>
</Tooltip>
{/* 重置视图 */}
<Tooltip
key="reset-view"
content={I18n.t('imageflow_canvas_restart')}
{...commonTooltipProps}
>
{/* zoom */}
<MyIconButton
onClick={() => {
zoomSettings.reset();
}}
icon={<IconCozAutoView className="text-[16px]" />}
/>
</Tooltip>
{/* zoom + - */}
<MyIconButton
disabled={readonly}
onClick={() => {
zoomSettings.onChange(
Math.max(zoomSettings.zoom - 0.1, zoomSettings.min),
);
}}
icon={<IconCozMinus className="text-[16px]" />}
/>
<InputNumber
disabled={readonly}
className="w-[60px]"
suffix="%"
min={zoomSettings.min * 100}
max={zoomSettings.max * 100}
hideButtons
precision={0}
onNumberChange={v => {
zoomSettings.onChange((v as number) / 100);
}}
value={zoomSettings.zoom * 100}
/>
<MyIconButton
disabled={readonly}
onClick={() => {
zoomSettings.onChange(
Math.min(zoomSettings.zoom + 0.1, zoomSettings.max),
);
}}
icon={<IconCozPlus className="text-[16px]" />}
/>
<SplitLine />
{/* undo redo */}
<Tooltip
key="undo"
content={I18n.t('card_builder_redoUndo_undo')}
{...commonTooltipProps}
>
<MyIconButton
loading={redoUndoing}
disabled={readonly || disabledUndo}
onClick={() => {
undo();
}}
icon={<IconCozArrowBack className="text-[16px]" />}
/>
</Tooltip>
<Tooltip
key="redo"
content={I18n.t('card_builder_redoUndo_redo')}
{...commonTooltipProps}
>
<MyIconButton
loading={redoUndoing}
disabled={readonly || disabledRedo}
onClick={() => {
redo();
}}
icon={<IconCozArrowForward className="text-[16px]" />}
/>
</Tooltip>
<SplitLine />
{/* 置底 置顶 */}
<Tooltip
key="move-to-bottom"
content={I18n.t('card_builder_move_to_bottom')}
{...commonTooltipProps}
>
<MyIconButton
disabled={readonly || isActiveObjectsInBack}
onClick={onMoveToBackend}
icon={<IconCozMoveToBottomFill className="text-[16px]" />}
/>
</Tooltip>
<Tooltip
key="move-to-top"
content={I18n.t('card_builder_move_to_top')}
{...commonTooltipProps}
>
<MyIconButton
disabled={readonly || isActiveObjectsInFront}
onClick={onMoveToTop}
icon={<IconCozMoveToTopFill className="text-[16px]" />}
/>
</Tooltip>
{/* 对齐 */}
<div className="flex">
<MyIconButton
disabled={readonly}
onClick={e => {
// 禁止冒泡防止点击对齐时canvas 的选中状态被清空
e.stopPropagation();
aligns[alignType]();
}}
icon={<AlignIcon className="text-[16px]" />}
/>
<Align
readonly={readonly}
onChange={alignMode => {
setAlignType(alignMode);
aligns[alignMode]();
}}
popRefAlignRight={popRefAlignRight}
/>
</div>
{/* 文本 */}
<div className="flex">
<Tooltip
key="text"
content={
textType === Mode.INLINE_TEXT
? `${addTextPrefix}${I18n.t('imageflow_canvas_text1')}`
: `${addTextPrefix}${I18n.t('imageflow_canvas_text2')}`
}
{...commonTooltipProps}
>
<MyIconButton
disabled={readonly || maxLimit}
onClick={() => {
onModeChange(textType);
}}
className={classNames({
'!coz-mg-secondary-pressed':
mode && [Mode.INLINE_TEXT, Mode.BLOCK_TEXT].includes(mode),
})}
icon={<TextIcon className="text-[16px]" />}
/>
</Tooltip>
<Select
disabled={readonly || maxLimit}
className="hide-selected-label hide-border"
// showTick={false}
value={textType}
size="small"
getPopupContainer={() => popRefAlignRight?.current ?? document.body}
optionList={Object.entries(textIcons).map(([key, value]) => {
const Icon = value.icon;
const { text } = value;
return {
value: key,
label: (
<div className="px-[8px] flex gap-[8px] items-center">
<Icon className="text-[16px]" />
<span>{text}</span>
</div>
),
};
})}
onSelect={v => {
setTextType(v as Mode);
onModeChange(v as Mode);
}}
/>
</div>
{/* 图片 */}
<ImageUpload
onChange={onAddImg}
tooltip={`${addTextPrefix}${I18n.t('card_builder_image')}`}
>
<MyIconButton
disabled={readonly || maxLimit}
icon={<IconCozImage className="text-[16px]" />}
/>
</ImageUpload>
{/* 形状 */}
<div className="flex">
<Tooltip
key="shape"
content={(() => {
if (shapeType === Mode.CIRCLE) {
return `${addTextPrefix}${I18n.t('imageflow_canvas_circle')}`;
} else if (shapeType === Mode.TRIANGLE) {
return `${addTextPrefix}${I18n.t('imageflow_canvas_trian')}`;
} else if (shapeType === Mode.STRAIGHT_LINE) {
return `${addTextPrefix}${I18n.t('imageflow_canvas_line')}`;
}
return `${addTextPrefix}${I18n.t('imageflow_canvas_rect')}`;
})()}
{...commonTooltipProps}
>
<MyIconButton
disabled={readonly || maxLimit}
onClick={() => {
onModeChange(shapeType);
}}
className={classNames({
'!coz-mg-secondary-pressed':
mode &&
[
Mode.RECT,
Mode.CIRCLE,
Mode.TRIANGLE,
Mode.STRAIGHT_LINE,
].includes(mode),
})}
icon={<ShapeIcon className="text-[16px]" />}
/>
</Tooltip>
<Select
disabled={readonly || maxLimit}
className="hide-selected-label hide-border"
// showTick={false}
value={shapeType}
size="small"
getPopupContainer={() => popRefAlignRight?.current ?? document.body}
optionList={Object.entries(shapeIcons).map(([key, value]) => {
const Icon = value.icon;
const { text } = value;
return {
value: key,
label: (
<div className="px-[8px] flex gap-[8px] items-center">
<Icon className="text-[16px]" />
<span>{text}</span>
</div>
),
};
})}
onSelect={v => {
setShapeType(v as Mode);
onModeChange(v as Mode);
}}
/>
</div>
{/* 自由画笔 */}
<Tooltip
key="pencil"
content={I18n.t('imageflow_canvas_draw')}
{...commonTooltipProps}
>
<MyIconButton
disabled={readonly || maxLimit}
onClick={() => {
onModeChange(Mode.PENCIL);
}}
className={classNames({
'!coz-mg-secondary-pressed': mode && [Mode.PENCIL].includes(mode),
})}
icon={<IconCozPencil className="text-[16px]" />}
/>
</Tooltip>
</div>
);
};

View File

@@ -0,0 +1,36 @@
/*
* 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 { createContext, useContext } from 'react';
import { type FabricObject } from 'fabric';
import { type InputVariable } from '@coze-workflow/base';
import { type IRefPosition, type VariableRef } from '../typings';
export const GlobalContext = createContext<{
variables?: InputVariable[];
customVariableRefs?: VariableRef[];
allObjectsPositionInScreen?: IRefPosition[];
activeObjects?: FabricObject[];
addRefObjectByVariable?: (variable: InputVariable) => void;
updateRefByObjectId?: (data: {
objectId: string;
variable?: InputVariable;
}) => void;
}>({});
export const useGlobalContext = () => useContext(GlobalContext);

View File

@@ -0,0 +1,17 @@
/*
* 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 { GlobalContext, useGlobalContext } from './global';

View File

@@ -0,0 +1,26 @@
/*
* 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.
*/
/// <reference types='@coze-arch/bot-typings' />
declare module '*.otf' {
const content: string;
export default content;
}
declare module '*.ttf' {
const content: string;
export default content;
}

View 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';

View File

@@ -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,
};
};

View 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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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 };
};

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* 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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
},
};
};

View File

@@ -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,
},
};
};

View File

@@ -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,
};
};

View 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,
};
};

View File

@@ -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,
};
};

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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]);
};

View File

@@ -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;
}

View File

@@ -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,
};
};

View File

@@ -0,0 +1,48 @@
/* stylelint-disable declaration-no-important */
.top-bar {
:global {
// 实现 top-bar 文本选择 & 形状选择,选中后仅显示 icon ,隐藏文本
.hide-selected-label {
.semi-select-selection {
@apply hidden;
}
}
.hide-border {
@apply border-0;
}
// .semi-portal-inner{
// transform: translateX(calc(-100% + 24px)) translateY(0%) !important;
// }
}
}
// 实现 top-bar 文本选择 & 形状选择的 popover 显示在左下
.top-bar-pop-align-right {
:global {
.semi-portal-inner {
transform: translateX(calc(-100% + 24px)) translateY(0%) !important;
}
}
}
// select 分组,隐藏组名,仅显示分割线
.select-hidden-group-label {
:global {
.semi-select-group{
@apply m-0;
@apply !p-0;
@apply h-0;
@apply overflow-hidden;
}
}
}
// 解决 fabric itext 内容过长,编辑时讲光标移动到行尾,会导致 body 出现滚动条
textarea[data-fabric='textarea'] {
top: 0 !important;
left: 0 !important;
}

View File

@@ -0,0 +1,25 @@
/*
* 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 { FabricEditor } from './components/fabric-editor';
export {
FabricPreview,
type IFabricPreview,
} from './components/fabric-preview';
export { loadFont } from './utils/font-loader';
export { fontTreeData } from './assert/font';
// eslint-disable-next-line @coze-arch/no-batch-import-or-export
export * from './typings';

View File

@@ -0,0 +1,3 @@
这个目录放置了一些和 @flow-workflow/fabric-canvas-node-render 可以共用的函数、类型、常量...
因为 @flow-workflow/fabric-canvas-node-render 是一个 node 工程,要注意 share 中不要出现 tsx 文件,否则会导致 node 工程无法编译。

View File

@@ -0,0 +1,95 @@
/*
* 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.
*/
/**
* 这个文件仅实现 setImageFixed 一个函数就好
* nodejs 图片渲染同样需要计算位置。要在 packages/workflow/nodejs/fabric-render 实现一份功能完全一致的 js 版
*/
import {
type FabricImage,
type FabricObject,
type Group,
type Rect,
} from 'fabric';
import { ImageFixedType, type FabricObjectWithCustomProps } from './typings';
/**
* 调整 img 位置
*/
export const setImageFixed = ({ element }: { element: FabricObject }) => {
const { width, height } = element;
const img = (element as Group).getObjects()[0] as FabricImage;
const { customFixedType } = img as unknown as FabricObjectWithCustomProps;
const borderRect = (element as Group).getObjects()[1] as Rect;
const { strokeWidth = 0 } = borderRect;
// 填充/拉伸时,框适配 group 大小即可
const borderRectWidth = width - strokeWidth;
const borderRectHeight = height - strokeWidth;
borderRect.set({
width: borderRectWidth,
height: borderRectHeight,
left: -width / 2,
top: -height / 2,
});
const { width: originWidth, height: originHeight } = img.getOriginalSize();
/**
* 为什么 +1
* 经过计算后,存储位数有限,不管是 scaleX/Y width/height top/left都会丢失一点点精度
* 这点精度反馈到图片上,就是图片与边框有一点点间隙
* 这里 +1 让图片显示的稍微大一点,弥补精度带来的间隙。
* 弊端:边框会覆盖一点点图片(覆盖多少看缩放比),用户基本无感
*/
const realScaleX = (width - strokeWidth * 2 + 1) / originWidth;
const realScaleY = (height - strokeWidth * 2 + 1) / originHeight;
const minScale = Math.min(realScaleX, realScaleY);
const maxScale = Math.max(realScaleX, realScaleY);
let scaleX = minScale;
let scaleY = minScale;
if (customFixedType === ImageFixedType.FILL) {
scaleX = maxScale;
scaleY = maxScale;
} else if (customFixedType === ImageFixedType.FULL) {
scaleX = realScaleX;
scaleY = realScaleY;
}
const imgLeft = -(originWidth * scaleX) / 2;
const imgTop = -(originHeight * scaleY) / 2;
// 自适应时需要对图片描边
if (customFixedType === ImageFixedType.AUTO) {
borderRect.set({
width: Math.min(borderRectWidth, originWidth * scaleX + strokeWidth),
height: Math.min(borderRectHeight, originHeight * scaleY + strokeWidth),
left: Math.max(-width / 2, imgLeft - strokeWidth),
top: Math.max(-height / 2, imgTop - strokeWidth),
});
}
img.set({
left: imgLeft,
top: imgTop,
width: originWidth,
height: originHeight,
scaleX,
scaleY,
});
};

View File

@@ -0,0 +1,247 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const fonts = [
'1-中等-思源黑体.otf',
'1-常规体-思源黑体.otf',
'1-特细-思源黑体.otf',
'1-粗体-思源黑体.otf',
'1-细体-思源黑体.otf',
'1-黑体-思源黑体.otf',
'10-字语趣淘体.ttf',
'11-字语金农漆书体.ttf',
'12-字语文娱体.ttf',
'13-中等-思源宋体.otf',
'13-常规体-思源宋体.otf',
'13-次粗体-思源宋体.otf',
'13-特细-思源宋体.otf',
'13-粗体-思源宋体.otf',
'13-细体-思源宋体.otf',
'13-黑体-思源宋体.otf',
'14-字语文刻体.ttf',
'15-字语国文楷书.ttf',
'16-字语咏楷体.ttf',
'17-字语纤隶体.ttf',
'18-字语古兰体.ttf',
'19-字语古隶体.ttf',
'2-抖音美好体.ttf',
'20-常规体-字语文圆体.ttf',
'20-粗体-字语文圆体.ttf',
'20-细体-字语文圆体.ttf',
'21-字语趣味像素.ttf',
'22-字语文畅体.ttf',
'23-字语漫雅手书.ttf',
'24-字语香雪煮茶.ttf',
'25-字语逸风手书.ttf',
'26-字语家书体.ttf',
'27-字语青梅硬笔.ttf',
'28-字语明媚体.ttf',
'29-字语萌酱体.ttf',
'3-字语咏乐体.ttf',
'30-字语软糖体.ttf',
'31-中等-源云明体(繁体).ttc',
'31-常规体-源云明体(繁体).ttc',
'31-次粗体-源云明体(繁体).ttc',
'31-特细-源云明体(繁体).ttc',
'31-粗体-源云明体(繁体).ttc',
'31-细体-源云明体(繁体).ttc',
'31-黑体-源云明体(繁体).ttc',
'32-Bold-WixMadefor.otf',
'32-ExtraBold-WixMadefor.otf',
'32-Medium-WixMadefor.otf',
'32-Regular-WixMadefor.otf',
'32-SemiBold-WixMadefor.otf',
'33-Black-Outfit.otf',
'33-ExtraBold-Outfit.otf',
'33-Extralight-Outfit.otf',
'33-Light-Outfit.otf',
'33-Medium-Outfit.otf',
'33-Regular-Outfit.otf',
'33-SemiBold-Outfit.otf',
'33-Thin-Outfit.otf',
'34-110Medium-LibreClarendonNormal.otf',
'34-162Bold-LibreClarendonNormal.otf',
'34-212Black-LibreClarendonNormal.otf',
'34-42Light-LibreClarendonNormal.otf',
'34-68Regular-LibreClarendonNormal.otf',
'35-BoldExt-Coconat.otf',
'35-Regular-Coconat.otf',
'36-Joan.otf',
'37-Bold-Messapia.otf',
'37-Regular-Messapia.otf',
'38-Squatina.otf',
'39-ZYLAAAgoodbook.ttf',
'4-字语咏宏体.ttf',
'40-ZYLAABravery.ttf',
'41-ZYLAADontforget.ttf',
'42-ZYLAAElegance.ttf',
'43-ZYLAAAnemone.ttf',
'44-StoryScript.otf',
'45-ZYLAAIridescent.ttf',
'46-ZYENADelicacy.ttf',
'47-Bolderslant.ttf',
'48-PinyonScript.otf',
'49-ZYLAADeepblue.ttf',
'5-站酷庆科黄油体.ttf',
'50-ZYLAASylph.ttf',
'51-ZYENAFetching.ttf',
'52-ZYLAACosy.ttf',
'53-ZYENAConfectionary.ttf',
'54-ZYENAGambol.ttf',
'55-RubikBubbles.ttf',
'56-Bold-KabinettFraktur.ttf',
'56-Regular-KabinettFraktur.ttf',
'57-RibesBlack.otf',
'58-Bold-DynaPuff.otf',
'58-Medium-DynaPuff.otf',
'58-Regular-DynaPuff.otf',
'58-SemiBold-DynaPuff.otf',
'59-ZYLAAAugenstern.ttf',
'6-字语寂黑体.ttf',
'60-MatrixSans.otf',
'61-MatrixSansPrint.otf',
'62-MatrixSansRaster.otf',
'63-MatrixSansScreen.otf',
'64-MatrixSansVideo.otf',
'7-字语墨黑体.ttf',
'8-字语酷黑体.ttf',
'9-字语趣逗体.ttf',
];
export const fontSvg = [
'1-特细-思源黑体.svg',
'1-细体-思源黑体.svg',
'1-常规体-思源黑体.svg',
'1-中等-思源黑体.svg',
'1-粗体-思源黑体.svg',
'1-黑体-思源黑体.svg',
'1-思源黑体.svg',
'10-字语趣淘体.svg',
'11-字语金农漆书体.svg',
'12-字语文娱体.svg',
'13-特细-思源宋体.svg',
'13-细体-思源宋体.svg',
'13-常规体-思源宋体.svg',
'13-中等-思源宋体.svg',
'13-次粗体-思源宋体.svg',
'13-粗体-思源宋体.svg',
'13-黑体-思源宋体.svg',
'13-思源宋体.svg',
'14-字语文刻体.svg',
'15-字语国文楷书.svg',
'16-字语咏楷体.svg',
'17-字语纤隶体.svg',
'18-字语古兰体.svg',
'19-字语古隶体.svg',
'2-抖音美好体.svg',
'20-细体-字语文圆体.svg',
'20-常规体-字语文圆体.svg',
'20-粗体-字语文圆体.svg',
'20-字语文圆体.svg',
'21-字语趣味像素.svg',
'22-字语文畅体.svg',
'23-字语漫雅手书.svg',
'24-字语香雪煮茶.svg',
'25-字语逸风手书.svg',
'26-字语家书体.svg',
'27-字语青梅硬笔.svg',
'28-字语明媚体.svg',
'29-字语萌酱体.svg',
'3-字语咏乐体.svg',
'30-字语软糖体.svg',
'31-特细-源云明体(繁体).svg',
'31-细体-源云明体(繁体).svg',
'31-常规体-源云明体(繁体).svg',
'31-中等-源云明体(繁体).svg',
'31-次粗体-源云明体(繁体).svg',
'31-粗体-源云明体(繁体).svg',
'31-黑体-源云明体(繁体).svg',
'31-源云明体(繁体).svg',
'32-Regular-WixMadefor.svg',
'32-Medium-WixMadefor.svg',
'32-SemiBold-WixMadefor.svg',
'32-Bold-WixMadefor.svg',
'32-ExtraBold-WixMadefor.svg',
'32-WixMadefor.svg',
'33-Thin-Outfit.svg',
'33-Extralight-Outfit.svg',
'33-Light-Outfit.svg',
'33-Regular-Outfit.svg',
'33-Medium-Outfit.svg',
'33-SemiBold-Outfit.svg',
'33-Bold-Outfit.svg',
'33-Black-Outfit.svg',
'33-ExtraBold-Outfit.svg',
'33-Outfit.svg',
'34-42Light-LibreClarendonNormal.svg',
'34-68Regular-LibreClarendonNormal.svg',
'34-110Medium-LibreClarendonNormal.svg',
'34-162Bold-LibreClarendonNormal.svg',
'34-212Black-LibreClarendonNormal.svg',
'34-LibreClarendonNormal.svg',
'35-Regular-Coconat.svg',
'35-BoldExt-Coconat.svg',
'35-Coconat.svg',
'36-Joan.svg',
'37-Regular-Messapia.svg',
'37-Bold-Messapia.svg',
'37-Messapia.svg',
'38-Squatina.svg',
'39-ZYLAAAgoodbook.svg',
'4-字语咏宏体.svg',
'40-ZYLAABravery.svg',
'41-ZYLAADontforget.svg',
'42-ZYLAAElegance.svg',
'43-ZYLAAAnemone.svg',
'44-StoryScript.svg',
'45-ZYLAAIridescent.svg',
'46-ZYENADelicacy.svg',
'47-Bolderslant.svg',
'48-PinyonScript.svg',
'49-ZYLAADeepblue.svg',
// 站酷庆科黄油体 加载总是失败 ,找不到原因,暂时屏蔽
// '5-站酷庆科黄油体.svg',
'50-ZYLAASylph.svg',
'51-ZYENAFetching.svg',
'52-ZYLAACosy.svg',
'53-ZYENAConfectionary.svg',
'54-ZYENAGambol.svg',
'55-RubikBubbles.svg',
'56-Regular-KabinettFraktur.svg',
'56-Bold-KabinettFraktur.svg',
'56-KabinettFraktur.svg',
'57-RibesBlack.svg',
'58-Regular-DynaPuff.svg',
'58-Medium-DynaPuff.svg',
'58-SemiBold-DynaPuff.svg',
'58-Bold-DynaPuff.svg',
'58-DynaPuff.svg',
'59-ZYLAAAugenstern.svg',
'6-字语寂黑体.svg',
'60-MatrixSans.svg',
'61-MatrixSansPrint.svg',
'62-MatrixSansRaster.svg',
'63-MatrixSansScreen.svg',
'64-MatrixSansVideo.svg',
'7-字语墨黑体.svg',
'8-字语酷黑体.svg',
'9-字语趣逗体.svg',
];
export const fontFamilyFilter = (name: string) => {
const match = name.match(/^\d+-(.*?)\./);
return match ? match[1] : null;
};

View File

@@ -0,0 +1,20 @@
/*
* 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 { setImageFixed } from './fabric-image';
export { fonts, fontSvg, fontFamilyFilter } from './font';
// eslint-disable-next-line @coze-arch/no-batch-import-or-export
export * from './typings';

View File

@@ -0,0 +1,109 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FabricObject } from 'fabric';
export const REF_VARIABLE_ID_PREFIX = 'variable';
export interface FabricObjectSchema extends CustomFabricProps {
fontSize?: number;
fontFamily?: string;
fill?: string;
stroke?: string;
strokeWidth?: number;
textAlign?: TextAlign;
width?: number;
height?: number;
lineHeight?: number;
text?: string;
/**
* 图片链接
*/
src?: string;
objects?: FabricObjectSchema[];
}
export interface VariableRef {
variableId: string;
objectId: string;
variableName: string;
}
export interface FabricSchema extends FabricObjectSchema {
width: number;
height: number;
background: string;
objects: FabricObjectSchema[];
customVariableRefs: VariableRef[];
}
/**
* 为什么不用 FabricObject.type
* 因为 fabricSchema.type 跟 fabricObject.type 对不上
* eg Textbox 在 schema 里是 textbox实例化后是 Textbox
*/
export enum Mode {
INLINE_TEXT = 'inline_text',
BLOCK_TEXT = 'block_text',
RECT = 'rect',
TRIANGLE = 'triangle',
CIRCLE = 'ellipse',
STRAIGHT_LINE = 'straight_line',
PENCIL = 'pencil',
IMAGE = 'img',
GROUP = 'group',
}
/**
* 填充和描边
*/
export enum ColorMode {
FILL = 'fill',
STROKE = 'stroke',
}
/**
* 文本对齐方式
*/
export enum TextAlign {
LEFT = 'left',
CENTER = 'center',
RIGHT = 'right',
JUSTIFY = 'justify',
}
/**
* 图片填充方式
*/
export enum ImageFixedType {
AUTO = 'auto',
FILL = 'fill',
FULL = 'full',
}
export interface CustomFabricProps {
customType: Mode;
customId: string;
customFixedHeight?: number;
customFixedType?: ImageFixedType;
/** @deprecated 兼容历史,不可新增消费 */
customVariableName?: string;
[k: string]: unknown;
}
export interface FabricObjectWithCustomProps
extends FabricObject,
CustomFabricProps {}
export const UNKNOWN_VARIABLE_NAME = '__unknown__';

View File

@@ -0,0 +1,142 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-namespace */
// eslint-disable-next-line @coze-arch/no-batch-import-or-export
export * from './share/typings';
import { type TPointerEvent, type TPointerEventInfo } from 'fabric';
import { type FabricObjectSchema } from './share/typings';
export interface FormMetaItem {
name?: string;
title?: string;
// 临时存储,不保存到后端
cacheSave?: boolean;
visible?: (formValue: Partial<FabricObjectSchema>) => boolean;
setter:
| string
| ((props: {
value: unknown;
tooltipVisible?: boolean;
onChange: (v: unknown) => void;
}) => React.ReactElement);
setterProps?: Record<string, unknown>;
splitLine?: boolean;
tooltip?: {
content: FormMetaItem[];
};
}
export interface FormMeta {
display: 'row' | 'col';
content: FormMetaItem[];
style?: React.CSSProperties;
}
export interface IRefPosition {
id: string;
top: number;
left: number;
isImg: boolean;
angle: number;
maxWidth: number;
}
export enum CopyMode {
CtrlCV = 'CtrlCV',
CtrlD = 'CtrlD',
DragCV = 'DragCV',
}
export enum AlignMode {
Left = 'left',
Center = 'center',
Right = 'right',
Top = 'top',
Middle = 'middle',
Bottom = 'bottom',
HorizontalAverage = 'horizontalAverage',
VerticalAverage = 'verticalAverage',
}
export type FabricClickEvent = TPointerEventInfo<TPointerEvent>;
export namespace Snap {
export interface Point {
x: number;
y: number;
}
export type Line = Point[];
export interface ObjectPoints {
tl: Point;
tr: Point;
bl: Point;
br: Point;
}
export interface ObjectPointsWithMiddle {
tl: Point;
tr: Point;
m: Point;
bl: Point;
br: Point;
}
export type GetObjectPoints = (
object: ObjectPoints,
) => ObjectPointsWithMiddle;
export interface SnapLine {
helplines: Line[];
snapDistance: number;
next: number;
isSnap?: boolean;
}
export interface RuleResult {
top?: SnapLine;
left?: SnapLine;
height?: SnapLine;
width?: SnapLine;
}
export type Rule = ({
otherPoints,
targetPoint,
threshold,
controlType,
}: {
otherPoints: ObjectPointsWithMiddle[];
targetPoint: ObjectPointsWithMiddle;
threshold: number;
controlType: ControlType;
}) => RuleResult;
export enum ControlType {
TopLeft = 'topLeft',
TopRight = 'topRight',
BottomLeft = 'bottomLeft',
BottomRight = 'bottomRight',
Top = 'top',
Bottom = 'bottom',
Left = 'left',
Right = 'right',
Center = 'center',
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const getNumberBetween = ({
value,
max,
min,
}: {
value: number;
max: number;
min: number;
}) => {
if (value > max) {
return max;
}
if (value < min) {
return min;
}
return value;
};

View File

@@ -0,0 +1,696 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-lines */
/* eslint-disable max-params */
import {
Control,
controlsUtils,
type FabricObject,
type Transform,
type TPointerEvent,
type ControlCursorCallback,
} from 'fabric';
import { Snap } from '../typings';
import { snap } from './snap/snap';
const getAngle = (a: number) => {
if (a >= 360) {
return getAngle(a - 360);
}
if (a < 0) {
return getAngle(a + 360);
}
return a;
};
const svg0 =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M12.87 22 10 19.13h1.99v-.659a6.483 6.483 0 0 1 6.482-6.482h.723V10l2.869 2.87-2.87 2.869v-1.99h-.722a4.722 4.722 0 0 0-4.722 4.722v.66h1.989L12.869 22" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M12.87 22 10 19.13h1.99v-.659a6.483 6.483 0 0 1 6.482-6.482h.723V10l2.869 2.87-2.87 2.869v-1.99h-.722a4.722 4.722 0 0 0-4.722 4.722v.66h1.989L12.869 22" clip-rule="evenodd"/><path fill="#fff" d="M10 19.13v-.8H8.068l1.366 1.367.566-.566M12.87 22l-.567.566.566.566.566-.566L12.87 22m-.88-2.87v.801h.8v-.8h-.8m6.482-7.141v-.8.8m.723 0v.8h.8v-.8h-.8m0-1.989.565-.566-1.366-1.366V10h.8m2.869 2.87.566.565.566-.566-.566-.566-.566.566m-2.87 2.869h-.8v1.932l1.366-1.366-.566-.566m0-1.99h.8v-.8h-.8v.8m-.722 0v-.8.8m-4.722 5.382h-.8v.8h.8v-.8m1.989 0 .566.566 1.366-1.367h-1.932v.8m-6.305.566 2.87 2.869 1.131-1.132-2.87-2.87-1.13 1.133m2.555-1.367H10v1.601h1.99v-1.6m-.8.142v.659h1.6v-.66h-1.6m7.283-7.284a7.283 7.283 0 0 0-7.283 7.283h1.6a5.682 5.682 0 0 1 5.683-5.682v-1.6m.723 0h-.723v1.601h.723v-1.6m.8.8V10h-1.6v1.989h1.6m-1.366-1.422 2.869 2.87 1.132-1.133-2.87-2.869-1.131 1.132m2.869 1.737-2.87 2.87 1.132 1.132 2.87-2.87-1.132-1.132m-1.503 3.436v-1.99h-1.6v1.99h1.6m-1.523-1.19h.723v-1.6h-.723v1.6m-3.922 3.922a3.922 3.922 0 0 1 3.922-3.921v-1.601a5.522 5.522 0 0 0-5.523 5.522h1.601m0 .66v-.66h-1.6v.66h1.6m1.189-.8h-1.99v1.6h1.99v-1.6m-2.304 4.235 2.87-2.87-1.132-1.131-2.87 2.87 1.132 1.13" mask="url(#b)"/></g><defs><filter id="a" width="18.728" height="18.665" x="6.268" y="7.268" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36430"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36430" result="shape"/></filter></defs></svg>';
const svg90 =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M10 12.87 12.87 10v1.99h.658a6.482 6.482 0 0 1 6.482 6.482v.722H22l-2.87 2.87-2.868-2.87h1.989v-.722a4.722 4.722 0 0 0-4.722-4.722h-.659v1.988L10 12.87" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M10 12.87 12.87 10v1.99h.658a6.482 6.482 0 0 1 6.482 6.482v.722H22l-2.87 2.87-2.868-2.87h1.989v-.722a4.722 4.722 0 0 0-4.722-4.722h-.659v1.988L10 12.87" clip-rule="evenodd"/><path fill="#fff" d="M12.87 10h.8V8.068l-1.367 1.366.566.566M10 12.87l-.566-.567-.566.566.566.566.566-.566m2.87-.88h-.801v.8h.8v-.8m7.14 7.204h-.8v.8h.8v-.8m1.989 0 .566.566 1.366-1.366H22v.8m-2.87 2.87-.565.565.566.566.566-.566-.566-.566m-2.868-2.87v-.8h-1.932l1.366 1.366.566-.566m1.989 0v.8h.8v-.8h-.8m-5.38-5.443v-.8h-.801v.8h.8m0 1.987-.567.566 1.366 1.366v-1.932h-.8m-.567-6.304-2.869 2.87 1.132 1.131 2.869-2.87-1.132-1.13m1.366 2.556V10h-1.6v1.99h1.6m-.141-.8h-.659v1.6h.659v-1.6m7.283 7.282a7.283 7.283 0 0 0-7.283-7.282v1.6a5.682 5.682 0 0 1 5.682 5.682h1.6m0 .722v-.722h-1.6v.722h1.6m-.8.8h1.988v-1.6H20.01v1.6m1.422-1.366-2.869 2.87 1.132 1.131 2.869-2.869-1.132-1.132m-1.737 2.87-2.87-2.87-1.131 1.132 2.869 2.87 1.132-1.132m-3.435-1.503h1.989v-1.6h-1.99v1.6m1.188-1.523v.722h1.601v-.722h-1.6m-3.921-3.921a3.921 3.921 0 0 1 3.921 3.921h1.601a5.522 5.522 0 0 0-5.522-5.522v1.6m-.659 0h.659v-1.6h-.659v1.6m.8 1.187v-1.987h-1.6v1.987h1.6m-4.235-2.303 2.87 2.87 1.131-1.133-2.87-2.869-1.13 1.132" mask="url(#b)"/></g><defs><filter id="a" width="18.663" height="18.727" x="7.068" y="7.268" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36425"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36425" result="shape"/></filter></defs></svg>';
const svg180 =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M19.13 10 22 12.87h-1.99v.659a6.483 6.483 0 0 1-6.482 6.482h-.723V22l-2.869-2.87 2.87-2.869v1.99h.722a4.722 4.722 0 0 0 4.722-4.723v-.659h-1.989L19.131 10" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M19.13 10 22 12.87h-1.99v.659a6.483 6.483 0 0 1-6.482 6.482h-.723V22l-2.869-2.87 2.87-2.869v1.99h.722a4.722 4.722 0 0 0 4.722-4.723v-.659h-1.989L19.131 10" clip-rule="evenodd"/><path fill="#fff" d="M22 12.87v.8h1.932l-1.366-1.367-.566.566M19.13 10l.567-.566-.566-.566-.566.566.566.566m.88 2.87v-.801h-.8v.8h.8m0 .659h-.8.8m-7.205 6.482v-.8h-.8v.8h.8m0 1.989-.565.566 1.366 1.366V22h-.8m-2.869-2.87-.566-.565-.566.566.566.566.566-.566m2.87-2.869h.8V14.33l-1.366 1.366.566.566m0 1.99h-.8v.8h.8v-.8m5.444-4.723h-.8.8m0-.659h.8v-.8h-.8v.8m-1.989 0-.566-.566-1.366 1.367h1.932v-.8m6.305-.566-2.87-2.869-1.131 1.132 2.87 2.87 1.13-1.133M20.01 13.67H22v-1.601h-1.99v1.6m.8-.142v-.659h-1.6v.66h1.6m-7.283 7.284a7.283 7.283 0 0 0 7.283-7.284h-1.6a5.682 5.682 0 0 1-5.683 5.683v1.6m-.723 0h.723V19.21h-.723v1.6m-.8-.8V22h1.6V20.01h-1.6m1.366 1.422-2.869-2.87-1.132 1.133 2.87 2.869 1.131-1.132m-2.869-1.737 2.87-2.87-1.132-1.132-2.87 2.87 1.132 1.132m1.503-3.436v1.99h1.6v-1.99h-1.6m1.523 1.19h-.723v1.6h.723v-1.6m3.922-3.923a3.922 3.922 0 0 1-3.922 3.922v1.601a5.522 5.522 0 0 0 5.522-5.522h-1.6m0-.659v.66h1.6v-.66h-1.6m-1.189.8h1.99v-1.6h-1.99v1.6m2.304-4.235-2.87 2.87 1.132 1.131 2.87-2.87-1.132-1.13" mask="url(#b)"/></g><defs><filter id="a" width="18.728" height="18.665" x="7.004" y="8.067" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36440"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36440" result="shape"/></filter></defs></svg>';
const svg270 =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M22 19.13 19.13 22v-1.99h-.658a6.483 6.483 0 0 1-6.483-6.483v-.722H10l2.87-2.87 2.869 2.87h-1.99v.722a4.722 4.722 0 0 0 4.723 4.722h.659v-1.988L22 19.131" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M22 19.13 19.13 22v-1.99h-.658a6.483 6.483 0 0 1-6.483-6.483v-.722H10l2.87-2.87 2.869 2.87h-1.99v.722a4.722 4.722 0 0 0 4.723 4.722h.659v-1.988L22 19.131" clip-rule="evenodd"/><path fill="#fff" d="M19.13 22h-.8v1.932l1.367-1.366L19.13 22M22 19.13l.566.567.566-.566-.566-.566-.566.566m-2.87.88h.801v-.8h-.8v.8m-7.141-6.483h.8-.8m0-.722h.8v-.8h-.8v.8m-1.989 0-.566-.566-1.366 1.366H10v-.8m2.87-2.87.565-.565-.566-.566-.566.566.566.566m2.869 2.87v.8h1.932l-1.366-1.366-.566.566m-1.99 0v-.8h-.8v.8h.8m0 .722h.801-.8m5.382 4.722v.8h.8v-.8h-.8m0-1.988.566-.566-1.367-1.366v1.932h.8m.566 6.305 2.869-2.87-1.132-1.131-2.87 2.87 1.133 1.13M18.33 20.01V22h1.601v-1.99h-1.6m.142.8h.659v-1.6h-.66v1.6m-7.284-7.283a7.283 7.283 0 0 0 7.284 7.283v-1.6a5.682 5.682 0 0 1-5.683-5.683h-1.6m0-.722v.722h1.601v-.722h-1.6m.8-.8H10v1.6h1.989v-1.6m-1.422 1.366 2.87-2.87-1.133-1.131-2.869 2.869 1.132 1.132m1.737-2.87 2.87 2.87 1.132-1.132-2.87-2.87-1.132 1.133m3.436 1.504h-1.99v1.6h1.99v-1.6m-1.189 1.522v-.722h-1.6v.722h1.6m3.922 3.922a3.922 3.922 0 0 1-3.922-3.922h-1.6a5.522 5.522 0 0 0 5.522 5.523v-1.601m.659 0h-.66v1.6h.66v-1.6m-.8-1.188v1.988h1.6v-1.988h-1.6m4.235 2.304-2.87-2.87-1.131 1.132 2.87 2.87 1.13-1.132" mask="url(#b)"/></g><defs><filter id="a" width="18.664" height="18.727" x="6.268" y="8.005" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36435"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36435" result="shape"/></filter></defs></svg>';
const svg2Base64 = (svg: string) => `data:image/svg+xml;base64,${btoa(svg)}`;
const cursorRotate0 = svg2Base64(svg0);
const cursorRotate90 = svg2Base64(svg90);
const cursorRotate180 = svg2Base64(svg180);
const cursorRotate270 = svg2Base64(svg270);
const getCursor = (angle: number) => {
const a = getAngle(angle);
if (a >= 225 && a < 315) {
return cursorRotate270;
} else if (a >= 135 && a < 225) {
return cursorRotate180;
} else if (a >= 45 && a < 135) {
return cursorRotate90;
} else {
return cursorRotate0;
}
};
const {
scalingEqually,
scaleCursorStyleHandler,
rotationWithSnapping,
scalingX,
scalingY,
} = controlsUtils;
type GetControls = (props?: {
x?: number;
y?: number;
callback?: (data: { element: FabricObject }) => void;
needResetScaleAndSnap?: boolean;
}) => Control;
/**
* 直线起点控制点
*/
export const getLineStartControl: GetControls = (props = {}) => {
const { x, y, callback } = props;
return new Control({
x,
y,
actionHandler: (e, transformData, _x, _y) => {
transformData.target.set({
x1: _x,
y1: _y,
x2:
transformData.lastX +
transformData.width * (transformData.corner === 'tl' ? 1 : -1),
y2: transformData.lastY + transformData.height,
});
callback?.({ element: transformData.target });
return true;
},
actionName: 'startControl', // 控制点的名称
});
};
/**
* 直线终点控制点
*/
export const getLineEndControl: GetControls = (props = {}) => {
const { x, y, callback } = props;
return new Control({
x,
y,
actionHandler: (e, transformData, _x, _y) => {
transformData.target.set({
x1:
transformData.lastX -
transformData.width * (transformData.corner === 'br' ? 1 : -1),
y1: transformData.lastY - transformData.height,
x2: _x,
y2: _y,
});
callback?.({ element: transformData.target });
return true;
},
actionName: 'endControl', // 控制点的名称
});
};
const originData = {
width: 0,
height: 0,
top: 0,
left: 0,
};
type LeftTopCalcFn = (originData: {
angle: number;
originTop: number;
originLeft: number;
originWidth: number;
originHeight: number;
newWidth: number;
newHeight: number;
}) => {
left: number;
top: number;
};
const scaleToSize = (
transformData: Transform,
options?: {
scaleEqual?: boolean;
leftTopCalcFn?: LeftTopCalcFn;
},
) => {
const { width, height, scaleX, scaleY, strokeWidth, angle } =
transformData.target;
let targetWidth = Math.max((width + strokeWidth) * scaleX - strokeWidth, 1);
let targetHeight = Math.max((height + strokeWidth) * scaleY - strokeWidth, 1);
if (options?.scaleEqual) {
if (targetWidth - originData.width > targetHeight - originData.height) {
targetHeight = (originData.height / originData.width) * targetWidth;
} else {
targetWidth = (originData.width / originData.height) * targetHeight;
}
}
let targetLeft = originData.left;
let targetTop = originData.top;
if (options?.leftTopCalcFn) {
const rs = options.leftTopCalcFn({
angle,
originTop: originData.top,
originLeft: originData.left,
originWidth: originData.width,
originHeight: originData.height,
newWidth: targetWidth,
newHeight: targetHeight,
});
targetLeft = rs.left;
targetTop = rs.top;
}
transformData.target.set({
width: targetWidth,
height: targetHeight,
// textBox 特化属性
customFixedHeight: targetHeight,
scaleX: 1,
scaleY: 1,
top: targetTop,
left: targetLeft,
});
};
/**
* 直接问 GPT
一个矩形,宽度 w ,高度 h
将矩形顺时针旋转角度 a左上角坐标为 x1 y1
拉伸矩形左上角,使矩形右下角保持不变,宽度增加到 w1高度增加到 h1
求左上角坐标
*/
const calcLeftTopByTopLeft: LeftTopCalcFn = ({
angle,
originTop,
originLeft,
originWidth,
originHeight,
newWidth,
newHeight,
}) => {
const anglePI = angle * (Math.PI / 180);
return {
left:
originLeft +
originWidth * Math.cos(anglePI) -
originHeight * Math.sin(anglePI) -
newWidth * Math.cos(anglePI) +
newHeight * Math.sin(anglePI),
top:
originTop +
originWidth * Math.sin(anglePI) +
originData.height * Math.cos(anglePI) -
newWidth * Math.sin(anglePI) -
newHeight * Math.cos(anglePI),
};
};
/**
* 直接问 GPT
一个矩形,宽度 w ,高度 h
将矩形顺时针旋转角度 a , 左上角坐标为 x1 y1
拉伸矩形右上角,使矩形左下角保持不变,宽度增加到 w1高度增加到 h1
求左上角坐标
GPT 给的答案不准确,需要稍微理解下,修改加减)
*/
const calcLeftTopByTopRight: LeftTopCalcFn = ({
angle,
originTop,
originLeft,
originHeight,
newHeight,
}) => {
const anglePI = angle * (Math.PI / 180);
return {
left: originLeft - (originHeight - newHeight) * Math.sin(anglePI),
top: originTop + (originHeight - newHeight) * Math.cos(anglePI),
};
};
/**
* 直接问 GPT
一个矩形,宽度 w ,高度 h
将矩形顺时针旋转角度 a , 左上角坐标为 x1 y1
拉伸矩形左下角,使矩形右上角保持不变,宽度增加到 w1高度增加到 h1
求左上角坐标
GPT 给的答案不准确,这个比较麻烦,所以写出了每一步的推导过程
*/
const calcLeftTopByBottomLeft: LeftTopCalcFn = ({
angle,
originTop,
originLeft,
originWidth,
newWidth,
newHeight,
}) => {
// 将角度转换为弧度
const aRad = (angle * Math.PI) / 180;
// 计算旋转后的右上角坐标
const x2 = originLeft + originWidth * Math.cos(aRad);
const y2 = originTop + originWidth * Math.sin(aRad);
// 计算拉伸后的左下角坐标
const x3 = x2 - newHeight * Math.sin(aRad) - newWidth * Math.cos(aRad);
const y3 = y2 + newHeight * Math.cos(aRad) - newWidth * Math.sin(aRad);
// 计算拉伸后的左上角坐标
const x1New = x3 + newHeight * Math.sin(aRad);
const y1New = y3 - newHeight * Math.cos(aRad);
return {
left: x1New,
top: y1New,
};
};
const _mouseDownHandler = (
e: TPointerEvent,
transformData: Transform,
): boolean => {
originData.width = transformData.target.width;
originData.height = transformData.target.height;
originData.top = transformData.target.top;
originData.left = transformData.target.left;
return false;
};
const cursorMap: Record<string, string> = {
'e-resize': 'ew-resize',
'w-resize': 'ew-resize',
'n-resize': 'ns-resize',
's-resize': 'ns-resize',
'nw-resize': 'nwse-resize',
'ne-resize': 'nesw-resize',
'sw-resize': 'nesw-resize',
'se-resize': 'nwse-resize',
};
const customCursorStyleHandler: ControlCursorCallback = (a, b, c) => {
const cursor = scaleCursorStyleHandler(a, b, c);
return cursorMap[cursor] ?? cursor;
};
const _actionHandler = ({
e,
transformData,
x,
y,
needResetScaleAndSnap,
fn,
callback,
snapPosition,
leftTopCalcFn,
}: {
e: TPointerEvent;
transformData: Transform;
x: number;
y: number;
needResetScaleAndSnap?: boolean;
callback: ((data: { element: FabricObject }) => void) | undefined;
fn: (
eventData: TPointerEvent,
transform: Transform,
x: number,
y: number,
) => boolean;
snapPosition: Snap.ControlType;
leftTopCalcFn?: LeftTopCalcFn;
}) => {
const rs = fn(
// 如果使用吸附则禁用默认缩放;否则取反
{ ...e, shiftKey: needResetScaleAndSnap ? true : !e.shiftKey },
transformData,
x,
y,
);
if (needResetScaleAndSnap) {
scaleToSize(transformData, {
scaleEqual: e.shiftKey,
leftTopCalcFn,
});
snap.resize(transformData.target, snapPosition);
}
callback?.({ element: transformData.target });
return rs;
};
/**
* 上左
*/
export const getResizeTLControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap = true } = props;
return new Control({
x: -0.5,
y: -0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingEqually,
leftTopCalcFn: calcLeftTopByTopLeft,
snapPosition: Snap.ControlType.TopLeft,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeTLControl', // 控制点的名称
});
};
/**
* 上中
*/
export const getResizeMTControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap = true } = props;
return new Control({
x: 0,
y: -0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingY,
leftTopCalcFn: calcLeftTopByTopLeft,
snapPosition: Snap.ControlType.Top,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeMTControl', // 控制点的名称
});
};
/**
* 上右
*/
export const getResizeTRControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: 0.5,
y: -0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingEqually,
leftTopCalcFn: calcLeftTopByTopRight,
snapPosition: Snap.ControlType.TopRight,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeTRControl', // 控制点的名称
});
};
/**
* 中左
*/
export const getResizeMLControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: -0.5,
y: 0,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingX,
leftTopCalcFn: calcLeftTopByBottomLeft,
snapPosition: Snap.ControlType.Left,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeMLControl', // 控制点的名称
});
};
/**
* 中右
*/
export const getResizeMRControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: 0.5,
y: 0,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingX,
snapPosition: Snap.ControlType.Right,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeMRControl', // 控制点的名称
});
};
/**
* 下左
*/
export const getResizeBLControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: -0.5,
y: 0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingEqually,
leftTopCalcFn: calcLeftTopByBottomLeft,
snapPosition: Snap.ControlType.BottomLeft,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeBLControl', // 控制点的名称
});
};
/**
* 下中
*/
export const getResizeMBControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: 0,
y: 0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingY,
snapPosition: Snap.ControlType.Bottom,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeMBControl', // 控制点的名称
});
};
/**
* 下右
*/
export const getResizeBRControl: GetControls = (props = {}) => {
const { callback, needResetScaleAndSnap } = props;
return new Control({
x: 0.5,
y: 0.5,
cursorStyleHandler: customCursorStyleHandler,
actionHandler: (e, transformData, _x, _y) => {
const rs = _actionHandler({
e,
transformData,
x: _x,
y: _y,
needResetScaleAndSnap,
callback,
fn: scalingEqually,
snapPosition: Snap.ControlType.BottomRight,
});
return rs;
},
mouseDownHandler: _mouseDownHandler,
actionName: 'resizeBRControl', // 控制点的名称
});
};
const _getRotateControl =
({
x,
y,
offsetY,
offsetX,
actionName,
rotateStaff,
}: {
x: number;
y: number;
offsetY: -1 | 1;
offsetX: -1 | 1;
actionName: string;
rotateStaff: number;
}): GetControls =>
(props = {}) => {
const { callback } = props;
// 这个大小,取决于 resize 控制点的大小
const offset = 12;
return new Control({
x,
y,
sizeX: 20,
sizeY: 20,
offsetY: offsetY * offset,
offsetX: offsetX * offset,
// 覆盖旋转控制点渲染,预期不显示,所以啥都没写
// eslint-disable-next-line @typescript-eslint/no-empty-function
render: () => {},
// 只能做到 hover 上时的 cursor旋转过程中 cursor 无法修改
cursorStyleHandler: (eventData, control, object) =>
`url(${getCursor(object.angle + rotateStaff)}) 16 16, crosshair`,
actionHandler: (e, transformData, _x, _y) => {
// 旋转吸附,单位:角度 一圈 = 360度
if (e.shiftKey) {
transformData.target.set({
snapAngle: 15,
});
} else {
transformData.target.set({
snapAngle: undefined,
});
}
const rs = rotationWithSnapping(
e,
{ ...transformData, originX: 'center', originY: 'center' },
_x,
_y,
);
// scaleToSize(transformData);
// transformData.target.canvas?.requestRenderAll();
callback?.({ element: transformData.target });
return rs;
},
actionName, // 控制点的名称
});
};
// 上左旋转点
export const getRotateTLControl: GetControls = (props = {}) =>
_getRotateControl({
x: -0.5,
y: -0.5,
offsetY: -1,
offsetX: -1,
rotateStaff: 0,
actionName: 'rotateTLControl',
})(props);
// 上右旋转点
export const getRotateTRControl: GetControls = (props = {}) =>
_getRotateControl({
x: 0.5,
y: -0.5,
offsetY: -1,
offsetX: 1,
rotateStaff: 90,
actionName: 'rotateTRControl',
})(props);
// 下右旋转点
export const getRotateBRControl: GetControls = (props = {}) =>
_getRotateControl({
x: 0.5,
y: 0.5,
offsetY: 1,
offsetX: 1,
rotateStaff: 180,
actionName: 'rotateBRControl',
})(props);
// 下左旋转点
export const getRotateBLControl: GetControls = (props = {}) =>
_getRotateControl({
x: -0.5,
y: 0.5,
offsetY: 1,
offsetX: -1,
rotateStaff: 270,
actionName: 'rotateBLControl',
})(props);

View File

@@ -0,0 +1,257 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type Ellipse, type FabricObject, type Line } from 'fabric';
import { Mode } from '../typings';
import { setImageFixed } from '../share';
import { resetElementClip } from './fabric-utils';
import {
getLineEndControl,
getLineStartControl,
getResizeBLControl,
getResizeBRControl,
getResizeMBControl,
getResizeMLControl,
getResizeMRControl,
getResizeMTControl,
getResizeTLControl,
getResizeTRControl,
getRotateTLControl,
getRotateTRControl,
getRotateBLControl,
getRotateBRControl,
} from './controls';
export const setLineControlVisible = ({
element,
}: {
element: FabricObject;
}) => {
const { x1, x2, y1, y2 } = element as Line;
if ((x1 < x2 && y1 < y2) || (x1 > x2 && y1 > y2)) {
element.setControlsVisibility({
ml: false, // 中点左
mr: false, // 中点右
mt: false, // 中点上
mb: false, // 中点下
bl: false, // 底部左
br: true, // 底部右
tl: true, // 顶部左
tr: false, // 顶部右
mtr: false, // 旋转控制点
});
} else {
element.setControlsVisibility({
ml: false, // 中点左
mr: false, // 中点右
mt: false, // 中点上
mb: false, // 中点下
bl: true, // 底部左
br: false, // 底部右
tl: false, // 顶部左
tr: true, // 顶部右
mtr: false, // 旋转控制点
});
}
};
const setCircleRxRy = ({ element }: { element: FabricObject }) => {
const { width, height } = element as Ellipse;
element.set({ rx: width / 2, ry: height / 2 });
};
const getCommonControl = ({
element,
needResetScaleAndSnap = true,
}: {
element: FabricObject;
needResetScaleAndSnap?: boolean;
}) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
// resize
// 上左
element.controls.tl = getResizeTLControl({
needResetScaleAndSnap,
});
// 上中
element.controls.mt = getResizeMTControl({
needResetScaleAndSnap,
});
// 上右
element.controls.tr = getResizeTRControl({
needResetScaleAndSnap,
});
// 中左
element.controls.ml = getResizeMLControl({
needResetScaleAndSnap,
});
// 中右
element.controls.mr = getResizeMRControl({
needResetScaleAndSnap,
});
// 下左
element.controls.bl = getResizeBLControl({
needResetScaleAndSnap,
});
// 下中
element.controls.mb = getResizeMBControl({
needResetScaleAndSnap,
});
// 下右
element.controls.br = getResizeBRControl({
needResetScaleAndSnap,
});
// rotate
// 上左
element.controls.tlr = getRotateTLControl();
// 上右
element.controls.trr = getRotateTRControl();
// 下左
element.controls.blr = getRotateBLControl();
// 下右
element.controls.brr = getRotateBRControl();
};
export const createControls: Partial<
Record<Mode, (data: { element: FabricObject }) => void>
> = {
[Mode.STRAIGHT_LINE]: ({ element }) => {
setLineControlVisible({ element });
// 左上
element.controls.tl = getLineStartControl({
x: -0.5,
y: -0.5,
callback: setLineControlVisible,
});
// 右上
element.controls.tr = getLineStartControl({
x: 0.5,
y: -0.5,
callback: setLineControlVisible,
});
// 左下
element.controls.bl = getLineEndControl({
x: -0.5,
y: 0.5,
callback: setLineControlVisible,
});
// 右下
element.controls.br = getLineEndControl({
x: 0.5,
y: 0.5,
callback: setLineControlVisible,
});
},
[Mode.RECT]: getCommonControl,
[Mode.TRIANGLE]: getCommonControl,
[Mode.PENCIL]: props => {
getCommonControl({
...props,
needResetScaleAndSnap: false,
});
},
[Mode.CIRCLE]: ({ element }) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
const controlProps = {
callback: setCircleRxRy,
needResetScaleAndSnap: true,
};
element.controls.tl = getResizeTLControl(controlProps);
element.controls.mt = getResizeMTControl(controlProps);
element.controls.tr = getResizeTRControl(controlProps);
element.controls.ml = getResizeMLControl(controlProps);
element.controls.mr = getResizeMRControl(controlProps);
element.controls.bl = getResizeBLControl(controlProps);
element.controls.mb = getResizeMBControl(controlProps);
element.controls.br = getResizeBRControl(controlProps);
element.controls.tlr = getRotateTLControl(controlProps);
element.controls.trr = getRotateTRControl(controlProps);
element.controls.blr = getRotateBLControl(controlProps);
element.controls.brr = getRotateBRControl(controlProps);
},
[Mode.BLOCK_TEXT]: ({ element }) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
const controlProps = {
callback: () => {
resetElementClip({ element });
element.fire('moving');
},
needResetScaleAndSnap: true,
};
element.controls.tl = getResizeTLControl(controlProps);
element.controls.mt = getResizeMTControl(controlProps);
element.controls.tr = getResizeTRControl(controlProps);
element.controls.ml = getResizeMLControl(controlProps);
element.controls.mr = getResizeMRControl(controlProps);
element.controls.bl = getResizeBLControl(controlProps);
element.controls.mb = getResizeMBControl(controlProps);
element.controls.br = getResizeBRControl(controlProps);
element.controls.tlr = getRotateTLControl(controlProps);
element.controls.trr = getRotateTRControl(controlProps);
element.controls.blr = getRotateBLControl(controlProps);
element.controls.brr = getRotateBRControl(controlProps);
},
[Mode.INLINE_TEXT]: ({ element }) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
element.controls.tlr = getRotateTLControl();
element.controls.trr = getRotateTRControl();
element.controls.blr = getRotateBLControl();
element.controls.brr = getRotateBRControl();
},
[Mode.IMAGE]: ({ element }) => {
element.setControlsVisibility({
mtr: false, // 旋转控制点
});
const controlProps = {
callback: () => {
setImageFixed({ element });
resetElementClip({ element });
element.fire('moving');
},
needResetScaleAndSnap: true,
};
element.controls.tl = getResizeTLControl(controlProps);
element.controls.mt = getResizeMTControl(controlProps);
element.controls.tr = getResizeTRControl(controlProps);
element.controls.ml = getResizeMLControl(controlProps);
element.controls.mr = getResizeMRControl(controlProps);
element.controls.bl = getResizeBLControl(controlProps);
element.controls.mb = getResizeMBControl(controlProps);
element.controls.br = getResizeBRControl(controlProps);
element.controls.tlr = getRotateTLControl(controlProps);
element.controls.trr = getRotateTRControl(controlProps);
element.controls.blr = getRotateBLControl(controlProps);
element.controls.brr = getRotateBRControl(controlProps);
},
};

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ImageFixedType,
Mode,
TextAlign,
type FabricObjectSchema,
} from '../typings';
/**
* 选中态边框及控制点样式
*/
export const selectedBorderProps = {
borderColor: '#4D53E8',
borderWidth: 2,
cornerStyle: 'circle',
cornerColor: '#ffffff',
cornerStrokeColor: '#4D53E8',
transparentCorners: false,
borderOpacityWhenMoving: 0.8,
};
const defaultFontSize = 24;
const textProps = {
fontSize: defaultFontSize,
fontFamily: '常规体-思源黑体',
fill: '#000000ff',
stroke: '#000000ff',
strokeWidth: 0,
textAlign: TextAlign.LEFT,
lineHeight: 1.2,
};
const shapeProps = {
fill: '#ccccccff',
stroke: '#000000ff',
strokeWidth: 0,
width: 200,
height: 200,
};
export const defaultProps: Record<Mode, Partial<FabricObjectSchema>> = {
[Mode.INLINE_TEXT]: textProps,
[Mode.BLOCK_TEXT]: {
...textProps,
width: 200,
height: 200,
padding: defaultFontSize / 2,
// 必须拆分true否则中文不会换行。splitByGrapheme:true 约等于 wordBreak: break-all
splitByGrapheme: true,
},
[Mode.RECT]: shapeProps,
[Mode.CIRCLE]: {
...shapeProps,
rx: shapeProps.width / 2,
ry: shapeProps.height / 2,
},
[Mode.TRIANGLE]: shapeProps,
[Mode.STRAIGHT_LINE]: {
strokeWidth: 1,
stroke: '#ccccccff',
strokeLineCap: 'round',
},
[Mode.PENCIL]: {
strokeWidth: 1,
stroke: '#000000ff',
},
[Mode.IMAGE]: {
customFixedType: ImageFixedType.FILL,
stroke: '#000000ff',
strokeWidth: 0,
width: 400,
height: 400,
opacity: 1,
},
[Mode.GROUP]: {},
};

View File

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

View File

@@ -0,0 +1,173 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type Canvas,
type FabricObject,
type Point,
type TMat2D,
type Textbox,
} from 'fabric';
import { Mode, type FabricObjectWithCustomProps } from '../typings';
/**
* 缩放到指定点
*/
export const zoomToPoint = ({
canvas,
point,
zoomLevel,
minZoom,
maxZoom,
}: {
point: Point;
zoomLevel: number;
canvas?: Canvas;
minZoom: number;
maxZoom: number;
}): TMat2D => {
// 设置缩放级别的限制
zoomLevel = Math.max(zoomLevel, minZoom); // 最小缩放级别
zoomLevel = Math.min(zoomLevel, maxZoom); // 最大缩放级别
// 以鼠标位置为中心进行缩放
canvas?.zoomToPoint(point, zoomLevel);
return [...(canvas?.viewportTransform as TMat2D)];
};
/**
* 设置 canvas 视图
*/
export const setViewport = ({
canvas,
vpt,
}: {
vpt: TMat2D;
canvas?: Canvas;
}): TMat2D => {
canvas?.setViewportTransform(vpt);
canvas?.requestRenderAll();
return [...(canvas?.viewportTransform as TMat2D)];
};
/**
* 画布坐标点距离画布左上角距离单位px
*/
export const canvasXYToScreen = ({
canvas,
scale,
point,
}: {
canvas: Canvas;
scale: number;
point: { x: number; y: number };
}) => {
// 获取画布的变换矩阵
const transform = canvas.viewportTransform;
// 应用缩放和平移
const zoomX = transform[0];
const zoomY = transform[3];
const translateX = transform[4];
const translateY = transform[5];
const screenX = (point.x * zoomX + translateX) * scale;
const screenY = (point.y * zoomY + translateY) * scale;
// 获取画布在屏幕上的位置
const x = screenX;
const y = screenY;
// 不做限制
return {
x,
y,
};
};
/**
* 得到选中元素的屏幕坐标(左上 tl、右下 br
*/
export const getPopPosition = ({
canvas,
scale,
}: {
canvas: Canvas;
scale: number;
}) => {
const selection = canvas?.getActiveObject();
if (canvas && selection) {
const boundingRect = selection.getBoundingRect();
// 左上角坐标
const tl = {
x: boundingRect.left,
y: boundingRect.top,
};
// 右下角坐标
const br = {
x: boundingRect.left + boundingRect.width,
y: boundingRect.top + boundingRect.height,
};
return {
tl: canvasXYToScreen({ canvas, scale, point: tl }),
br: canvasXYToScreen({ canvas, scale, point: br }),
};
}
return {
tl: {
x: -9999,
y: -9999,
},
br: {
x: -9999,
y: -9999,
},
};
};
export const resetElementClip = ({ element }: { element: FabricObject }) => {
if (!element.clipPath) {
return;
}
const clipRect = element.clipPath;
const padding = (element as Textbox).padding ?? 0;
const { height, width } = element as FabricObject;
const _height = height + padding * 2;
const _width = width + padding * 2;
const newPosition = {
originX: 'left',
originY: 'top',
left: -_width / 2,
top: -_height / 2,
height: _height,
width: _width,
absolutePositioned: false,
};
clipRect?.set(newPosition);
};
export const isGroupElement = (obj?: FabricObject) =>
(obj as FabricObjectWithCustomProps)?.customType === Mode.GROUP;

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type Canvas, type IText } from 'fabric';
import { QueryClient } from '@tanstack/react-query';
import { Mode, type FabricSchema } from '../typings';
import { getFontUrl, supportFonts } from '../assert/font';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
});
export const loadFont = async (font: string): Promise<void> => {
await queryClient.fetchQuery({
queryKey: [font],
queryFn: async () => {
if (supportFonts.includes(font)) {
const url = getFontUrl(font);
const fontFace = new FontFace(font, `url(${url})`);
document.fonts.add(fontFace);
await fontFace.load();
}
return font;
},
});
};
export const loadFontWithSchema = ({
schema,
canvas,
fontFamily,
}: {
schema?: FabricSchema;
canvas?: Canvas;
fontFamily?: string;
}) => {
let fonts: string[] = fontFamily ? [fontFamily] : [];
if (schema) {
fonts = schema.objects
.filter(o => [Mode.INLINE_TEXT, Mode.BLOCK_TEXT].includes(o.customType))
.map(o => o.fontFamily) as string[];
fonts = Array.from(new Set(fonts));
}
fonts.forEach(async font => {
await loadFont(font);
canvas
?.getObjects()
.filter(o => (o as IText)?.fontFamily === font)
.forEach(o => {
o.set({
fontFamily: font,
});
});
canvas?.requestRenderAll();
});
};

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
createElement,
setElementAfterLoad,
createCommonObjectOptions,
} from './element-factory';
export {
canvasXYToScreen,
getPopPosition,
setViewport,
zoomToPoint,
} from './fabric-utils';
export { schemaToFormValue } from './schema-to-form-value';
export { loadFontWithSchema, loadFont } from './font-loader';
export { defaultProps, selectedBorderProps } from './default-props';
export { getNumberBetween } from './common';

Some files were not shown because too many files have changed in this diff Show More