feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
31
frontend/packages/workflow/fabric-canvas/.storybook/main.js
Normal file
31
frontend/packages/workflow/fabric-canvas/.storybook/main.js
Normal 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;
|
||||
@@ -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;
|
||||
5
frontend/packages/workflow/fabric-canvas/.stylelintrc.js
Normal file
5
frontend/packages/workflow/fabric-canvas/.stylelintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { defineConfig } = require('@coze-arch/stylelint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
extends: [],
|
||||
});
|
||||
16
frontend/packages/workflow/fabric-canvas/README.md
Normal file
16
frontend/packages/workflow/fabric-canvas/README.md
Normal 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`
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"operationSettings": [
|
||||
{
|
||||
"operationName": "test:cov",
|
||||
"outputFolderNames": ["coverage"]
|
||||
},
|
||||
{
|
||||
"operationName": "ts-check",
|
||||
"outputFolderNames": ["./dist"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const { defineConfig } = require('@coze-arch/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
packageRoot: __dirname,
|
||||
preset: 'web',
|
||||
rules: {},
|
||||
});
|
||||
69
frontend/packages/workflow/fabric-canvas/package.json
Normal file
69
frontend/packages/workflow/fabric-canvas/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
75
frontend/packages/workflow/fabric-canvas/src/assert/font.tsx
Normal file
75
frontend/packages/workflow/fabric-canvas/src/assert/font.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
.icon-button.coz-fg-secondary {
|
||||
@apply !coz-fg-primary;
|
||||
|
||||
&:disabled{
|
||||
:global{
|
||||
.semi-button-content{
|
||||
@apply coz-fg-dim;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
.pop-in-screen {
|
||||
*:focus{
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
.imageflow-canvas-border-width {
|
||||
:global{
|
||||
.semi-slider-handle{
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
.color-picker-slider{
|
||||
:global{
|
||||
.semi-slider{
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.semi-slider-handle{
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
.ref-select{
|
||||
// :global(.semi-select-content-wrapper){
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
:global(.semi-select-selection){
|
||||
@apply !ml-[4px];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.imageflow-canvas-font-family-cascader {
|
||||
:global{
|
||||
.semi-cascader-option-lists{
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
26
frontend/packages/workflow/fabric-canvas/src/global.d.ts
vendored
Normal file
26
frontend/packages/workflow/fabric-canvas/src/global.d.ts
vendored
Normal 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;
|
||||
}
|
||||
18
frontend/packages/workflow/fabric-canvas/src/hooks/index.ts
Normal file
18
frontend/packages/workflow/fabric-canvas/src/hooks/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { useFabricPreview } from './use-fabric-preview';
|
||||
export { useFabricEditor } from './use-fabric-editor';
|
||||
@@ -0,0 +1,479 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable complexity */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import {
|
||||
type Canvas,
|
||||
type FabricImage,
|
||||
type FabricObject,
|
||||
type Group,
|
||||
type IText,
|
||||
type Rect,
|
||||
} from 'fabric';
|
||||
|
||||
import { resetElementClip } from '../utils/fabric-utils';
|
||||
import {
|
||||
createElement,
|
||||
getPopPosition,
|
||||
loadFont,
|
||||
selectedBorderProps,
|
||||
} from '../utils';
|
||||
import {
|
||||
Mode,
|
||||
type FabricObjectSchema,
|
||||
type FabricObjectWithCustomProps,
|
||||
} from '../typings';
|
||||
import { setImageFixed } from '../share';
|
||||
import { useCanvasChange } from './use-canvas-change';
|
||||
|
||||
// 设置元素属性
|
||||
const setElementProps = async ({
|
||||
element,
|
||||
props,
|
||||
canvas,
|
||||
}: {
|
||||
element: FabricObject;
|
||||
props: Partial<FabricObjectSchema>;
|
||||
canvas?: Canvas;
|
||||
}): Promise<void> => {
|
||||
// 特化一:img 的属性设置需要设置到 img 元素上,而不是外层包裹的 group
|
||||
if (
|
||||
element?.isType('group') &&
|
||||
(element as Group)?.getObjects()?.[0]?.isType('image')
|
||||
) {
|
||||
const { stroke, strokeWidth, src, ...rest } = props;
|
||||
const group = element as Group;
|
||||
const img = group.getObjects()[0] as FabricImage;
|
||||
const borderRect = group.getObjects()[1] as Rect;
|
||||
|
||||
// 边框颜色设置到 borderRect 上
|
||||
if (stroke) {
|
||||
borderRect.set({
|
||||
stroke,
|
||||
});
|
||||
}
|
||||
|
||||
// 边框粗细设置到 borderRect 上
|
||||
if (typeof strokeWidth === 'number') {
|
||||
borderRect.set({
|
||||
strokeWidth,
|
||||
});
|
||||
}
|
||||
|
||||
// 替换图片
|
||||
if (src) {
|
||||
const newImg = document.createElement('img');
|
||||
await new Promise((done, reject) => {
|
||||
newImg.onload = () => {
|
||||
img.setElement(newImg);
|
||||
done(0);
|
||||
};
|
||||
newImg.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(rest).length > 0) {
|
||||
img.set(rest);
|
||||
}
|
||||
|
||||
setImageFixed({ element: group });
|
||||
} else {
|
||||
// 特化二:文本与段落切换,需要特化处理
|
||||
const { customType, ...rest } = props;
|
||||
if (
|
||||
customType &&
|
||||
[Mode.BLOCK_TEXT, Mode.INLINE_TEXT].includes(customType)
|
||||
) {
|
||||
const oldElement = element;
|
||||
let newLeft = oldElement.left;
|
||||
if (newLeft < 0) {
|
||||
newLeft = 0;
|
||||
} else if (newLeft > (canvas?.width as number)) {
|
||||
newLeft = canvas?.width as number;
|
||||
}
|
||||
let newTop = oldElement.top;
|
||||
if (newTop < 0) {
|
||||
newTop = 0;
|
||||
} else if (newTop > (canvas?.height as number)) {
|
||||
newTop = canvas?.height as number;
|
||||
}
|
||||
|
||||
const newFontSize = Math.round(
|
||||
(oldElement as IText).fontSize * (oldElement as IText).scaleY,
|
||||
);
|
||||
const needExtendPropKeys = [
|
||||
'customId',
|
||||
'text',
|
||||
'fontSize',
|
||||
'fontFamily',
|
||||
'fill',
|
||||
'stroke',
|
||||
'strokeWidth',
|
||||
'textAlign',
|
||||
'lineHeight',
|
||||
'editable',
|
||||
];
|
||||
|
||||
const extendsProps: Record<string, unknown> = {};
|
||||
needExtendPropKeys.forEach(key => {
|
||||
if ((oldElement as FabricObjectWithCustomProps)[key]) {
|
||||
extendsProps[key] = (oldElement as FabricObjectWithCustomProps)[key];
|
||||
}
|
||||
});
|
||||
const newElement = await createElement({
|
||||
mode: customType,
|
||||
canvas,
|
||||
position: [newLeft, newTop],
|
||||
elementProps: {
|
||||
...extendsProps,
|
||||
...(props.customType === Mode.INLINE_TEXT
|
||||
? // 块状 -> 单行
|
||||
{}
|
||||
: // 单行 -> 块状
|
||||
{
|
||||
// 单行切块状,尽量保持字体大小不变化
|
||||
fontSize: newFontSize,
|
||||
padding: newFontSize / 4,
|
||||
width: 200,
|
||||
height: 200,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// 如果还有别的属性,设置到新 element 上
|
||||
if (Object.keys(rest).length > 0) {
|
||||
newElement?.set(rest);
|
||||
}
|
||||
|
||||
// 添加新的,顺序不能错,否则删除时引用关系会被判定为无用关系而被删除掉
|
||||
canvas?.add(newElement as FabricObject);
|
||||
// 删掉老的
|
||||
canvas?.remove(oldElement);
|
||||
|
||||
canvas?.discardActiveObject();
|
||||
canvas?.setActiveObject(newElement as FabricObject);
|
||||
canvas?.requestRenderAll();
|
||||
|
||||
// 普通的属性设置
|
||||
} else {
|
||||
const { fontFamily } = props;
|
||||
// 特化三: 字体需要异步加载
|
||||
if (fontFamily) {
|
||||
await loadFont(fontFamily);
|
||||
}
|
||||
/**
|
||||
* textBox 比较恶心,不知道什么时机会给每个字都生成样式文件(对应 styles)
|
||||
* 这里主动清除下,否则字体相关的设置(fontSize、fontFamily...)不生效
|
||||
*/
|
||||
if (element?.isType('textbox')) {
|
||||
element?.set({
|
||||
styles: {},
|
||||
});
|
||||
}
|
||||
|
||||
// 特化四:padding = fontSize/2 , 避免文本上下被截断
|
||||
if (element?.isType('textbox') && typeof props.fontSize === 'number') {
|
||||
element?.set({
|
||||
padding: props.fontSize / 4,
|
||||
});
|
||||
resetElementClip({
|
||||
element: element as FabricObject,
|
||||
});
|
||||
}
|
||||
|
||||
element?.set(props);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const useActiveObjectChange = ({
|
||||
canvas,
|
||||
scale,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
scale: number;
|
||||
}) => {
|
||||
const [activeObjects, setActiveObjects] = useState<
|
||||
FabricObject[] | undefined
|
||||
>();
|
||||
|
||||
const [activeObjectsPopPosition, setActiveObjectsPopPosition] = useState<{
|
||||
tl: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
br: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}>({
|
||||
tl: {
|
||||
x: -9999,
|
||||
y: -9999,
|
||||
},
|
||||
br: {
|
||||
x: -9999,
|
||||
y: -9999,
|
||||
},
|
||||
});
|
||||
|
||||
const [isActiveObjectsInFront, setIsActiveObjectsInFront] =
|
||||
useState<boolean>(false);
|
||||
const [isActiveObjectsInBack, setIsActiveObjectsInBack] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const _setActiveObjectsState = useCallback(() => {
|
||||
const objects = canvas?.getObjects();
|
||||
setIsActiveObjectsInFront(
|
||||
activeObjects?.length === 1 &&
|
||||
objects?.[objects.length - 1] === activeObjects?.[0],
|
||||
);
|
||||
setIsActiveObjectsInBack(
|
||||
activeObjects?.length === 1 && objects?.[0] === activeObjects?.[0],
|
||||
);
|
||||
}, [canvas, activeObjects]);
|
||||
|
||||
useCanvasChange({
|
||||
canvas,
|
||||
onChange: _setActiveObjectsState,
|
||||
listenerEvents: ['object:modified-zIndex'],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
_setActiveObjectsState();
|
||||
}, [activeObjects, _setActiveObjectsState]);
|
||||
|
||||
const _setActiveObjectsPopPosition = () => {
|
||||
if (canvas) {
|
||||
setActiveObjectsPopPosition(
|
||||
getPopPosition({
|
||||
canvas,
|
||||
scale,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const disposers = (
|
||||
['selection:created', 'selection:updated'] as (
|
||||
| 'selection:created'
|
||||
| 'selection:updated'
|
||||
)[]
|
||||
).map(eventName =>
|
||||
canvas?.on(eventName, e => {
|
||||
setActiveObjects(canvas?.getActiveObjects());
|
||||
_setActiveObjectsPopPosition();
|
||||
|
||||
const selected = canvas?.getActiveObject();
|
||||
if (selected) {
|
||||
selected.set(selectedBorderProps);
|
||||
/**
|
||||
* 为什么禁用选中多元素的控制点?
|
||||
* 因为直线不期望有旋转,旋转会影响控制点的计算逻辑。
|
||||
* 想要放开这个限制,需要在直线的控制点内考虑旋转 & 缩放因素
|
||||
*/
|
||||
if (selected.isType('activeselection')) {
|
||||
selected.setControlsVisibility({
|
||||
tl: false,
|
||||
tr: false,
|
||||
bl: false,
|
||||
br: false,
|
||||
ml: false,
|
||||
mt: false,
|
||||
mr: false,
|
||||
mb: false,
|
||||
mtr: false,
|
||||
});
|
||||
}
|
||||
canvas?.requestRenderAll();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const disposerCleared = canvas?.on('selection:cleared', e => {
|
||||
setActiveObjects(undefined);
|
||||
_setActiveObjectsPopPosition();
|
||||
});
|
||||
disposers.push(disposerCleared);
|
||||
|
||||
return () => {
|
||||
disposers.forEach(disposer => disposer?.());
|
||||
};
|
||||
}, [canvas]);
|
||||
|
||||
// 窗口大小变化时,修正下位置
|
||||
useEffect(() => {
|
||||
_setActiveObjectsPopPosition();
|
||||
}, [scale]);
|
||||
|
||||
useCanvasChange({
|
||||
canvas,
|
||||
onChange: _setActiveObjectsPopPosition,
|
||||
listenerEvents: [
|
||||
'object:modified',
|
||||
'object:added',
|
||||
'object:removed',
|
||||
'object:moving',
|
||||
],
|
||||
});
|
||||
|
||||
const setActiveObjectsProps = async (
|
||||
props: Partial<FabricObjectSchema>,
|
||||
customId?: string,
|
||||
) => {
|
||||
let elements = activeObjects;
|
||||
if (customId) {
|
||||
const element = canvas
|
||||
?.getObjects()
|
||||
.find(d => (d as FabricObjectWithCustomProps).customId === customId);
|
||||
if (element) {
|
||||
elements = [element];
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
(elements ?? []).map(element =>
|
||||
setElementProps({
|
||||
element,
|
||||
props,
|
||||
canvas,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
canvas?.requestRenderAll();
|
||||
canvas?.fire('object:modified');
|
||||
};
|
||||
|
||||
// 实现 shift 水平/垂直移动
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
let originalPos = { left: 0, top: 0 };
|
||||
|
||||
const disposers = [
|
||||
// 监听对象移动开始事件
|
||||
canvas.on('object:moving', function (e) {
|
||||
const obj = e.target;
|
||||
// 手动 canvas.fire('object:moving') 获取不到 obj
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是第一次移动,记录对象的原始位置
|
||||
if (originalPos.left === 0 && originalPos.top === 0) {
|
||||
originalPos = { left: obj.left, top: obj.top };
|
||||
}
|
||||
|
||||
// 检查是否按下了Shift键
|
||||
if (e?.e?.shiftKey) {
|
||||
// 计算从开始移动以来的水平和垂直距离
|
||||
const distanceX = obj.left - originalPos.left;
|
||||
const distanceY = obj.top - originalPos.top;
|
||||
|
||||
// 根据移动距离的绝对值判断是水平移动还是垂直移动
|
||||
if (Math.abs(distanceX) > Math.abs(distanceY)) {
|
||||
// 水平移动:保持垂直位置不变
|
||||
obj.set('top', originalPos.top);
|
||||
} else {
|
||||
// 垂直移动:保持水平位置不变
|
||||
obj.set('left', originalPos.left);
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// 监听对象移动结束事件
|
||||
canvas.on('object:modified', function (e) {
|
||||
// 移动结束后重置原始位置
|
||||
originalPos = { left: 0, top: 0 };
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
disposers.forEach(disposer => disposer?.());
|
||||
};
|
||||
}, [canvas]);
|
||||
|
||||
const controlsVisibility = useRef<
|
||||
| {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
||||
// 元素移动过程中,隐藏控制点
|
||||
useEffect(() => {
|
||||
const disposers: (() => void)[] = [];
|
||||
if (activeObjects?.length === 1) {
|
||||
const element = activeObjects[0];
|
||||
disposers.push(
|
||||
element.on('moving', () => {
|
||||
if (!controlsVisibility.current) {
|
||||
controlsVisibility.current = Object.assign(
|
||||
// fabric 规则: undefined 认为是 true
|
||||
{
|
||||
ml: true, // 中点左
|
||||
mr: true, // 中点右
|
||||
mt: true, // 中点上
|
||||
mb: true, // 中点下
|
||||
bl: true, // 底部左
|
||||
br: true, // 底部右
|
||||
tl: true, // 顶部左
|
||||
tr: true, // 顶部右
|
||||
},
|
||||
element._controlsVisibility,
|
||||
);
|
||||
}
|
||||
element.setControlsVisibility({
|
||||
ml: false, // 中点左
|
||||
mr: false, // 中点右
|
||||
mt: false, // 中点上
|
||||
mb: false, // 中点下
|
||||
bl: false, // 底部左
|
||||
br: false, // 底部右
|
||||
tl: false, // 顶部左
|
||||
tr: false, // 顶部右
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
disposers.push(
|
||||
element.on('mouseup', () => {
|
||||
if (controlsVisibility.current) {
|
||||
element.setControlsVisibility(controlsVisibility.current);
|
||||
controlsVisibility.current = undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposers.forEach(dispose => dispose());
|
||||
};
|
||||
}, [activeObjects]);
|
||||
|
||||
return {
|
||||
activeObjects,
|
||||
activeObjectsPopPosition,
|
||||
setActiveObjectsProps,
|
||||
isActiveObjectsInBack,
|
||||
isActiveObjectsInFront,
|
||||
};
|
||||
};
|
||||
213
frontend/packages/workflow/fabric-canvas/src/hooks/use-align.tsx
Normal file
213
frontend/packages/workflow/fabric-canvas/src/hooks/use-align.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { type FabricObject, type Canvas } from 'fabric';
|
||||
|
||||
export const useAlign = ({
|
||||
canvas,
|
||||
selectObjects = [],
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
selectObjects?: FabricObject[];
|
||||
}) => {
|
||||
// 水平居左
|
||||
const alignLeft = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
left: -activeObject.width / 2,
|
||||
});
|
||||
obj.setCoords();
|
||||
});
|
||||
activeObject.setCoords();
|
||||
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 水平居右
|
||||
const alignRight = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
left: activeObject.width / 2 - obj.getBoundingRect().width,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 水平居中
|
||||
const alignCenter = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
left: -obj.getBoundingRect().width / 2,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 垂直居上
|
||||
const alignTop = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
top: -activeObject.height / 2,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 垂直居中
|
||||
const alignMiddle = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
top: -obj.getBoundingRect().height / 2,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 垂直居下
|
||||
const alignBottom = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
selectObjects.forEach(obj => {
|
||||
obj.set({
|
||||
top: activeObject.height / 2 - obj.getBoundingRect().height,
|
||||
});
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 水平均分
|
||||
const verticalAverage = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalWidth = selectObjects.reduce(
|
||||
(sum, obj) => sum + obj.getBoundingRect().width,
|
||||
0,
|
||||
);
|
||||
const spacing =
|
||||
(activeObject.width - totalWidth) / (selectObjects.length - 1);
|
||||
|
||||
let currentLeft = -activeObject.width / 2; // 初始位置
|
||||
|
||||
selectObjects
|
||||
.sort((a, b) => a.getBoundingRect().left - b.getBoundingRect().left)
|
||||
.forEach(obj => {
|
||||
obj.set({
|
||||
left: currentLeft,
|
||||
});
|
||||
currentLeft += obj.getBoundingRect().width + spacing;
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
// 垂直均分
|
||||
const horizontalAverage = useCallback(() => {
|
||||
if (!canvas || selectObjects.length < 2) {
|
||||
return;
|
||||
}
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalHeight = selectObjects.reduce(
|
||||
(sum, obj) => sum + obj.getBoundingRect().height,
|
||||
0,
|
||||
);
|
||||
const spacing =
|
||||
(activeObject.height - totalHeight) / (selectObjects.length - 1);
|
||||
|
||||
let currentTop = -activeObject.height / 2; // 初始位置
|
||||
|
||||
selectObjects
|
||||
.sort((a, b) => a.getBoundingRect().top - b.getBoundingRect().top)
|
||||
.forEach(obj => {
|
||||
obj.set({
|
||||
top: currentTop,
|
||||
});
|
||||
currentTop += obj.getBoundingRect().height + spacing;
|
||||
});
|
||||
canvas.fire('object:moving');
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, selectObjects]);
|
||||
|
||||
return {
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenter,
|
||||
alignTop,
|
||||
alignMiddle,
|
||||
alignBottom,
|
||||
horizontalAverage,
|
||||
verticalAverage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
import { useDebounceEffect } from 'ahooks';
|
||||
|
||||
import { type FabricSchema } from '../typings';
|
||||
|
||||
export const useBackground = ({
|
||||
canvas,
|
||||
schema,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
schema: FabricSchema;
|
||||
}) => {
|
||||
const [backgroundColor, setBackgroundColor] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBackgroundColor(
|
||||
(canvas as unknown as { backgroundColor: string }).backgroundColor,
|
||||
);
|
||||
}, [canvas]);
|
||||
|
||||
// 防抖的作用在于,form.schema.backgroundColor 的变化是异步的,setBackgroundColor 是同步的,两者可能会打架
|
||||
useDebounceEffect(
|
||||
() => {
|
||||
setBackgroundColor(schema.backgroundColor as string);
|
||||
},
|
||||
[schema.backgroundColor],
|
||||
{
|
||||
wait: 300,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
backgroundColor &&
|
||||
canvas &&
|
||||
(canvas as unknown as { backgroundColor: string }).backgroundColor !==
|
||||
backgroundColor
|
||||
) {
|
||||
canvas.set({
|
||||
backgroundColor,
|
||||
});
|
||||
canvas.fire('object:modified');
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
}, [backgroundColor, canvas]);
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
setBackgroundColor,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { type Canvas, type CanvasEvents, type FabricObject } from 'fabric';
|
||||
import { useLatest } from 'ahooks';
|
||||
import {
|
||||
ViewVariableType,
|
||||
type InputVariable,
|
||||
} from '@coze-workflow/base/types';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { getUploadCDNAsset } from '@coze-workflow/base-adapter';
|
||||
|
||||
import { createElement, defaultProps } from '../utils';
|
||||
import {
|
||||
Mode,
|
||||
UNKNOWN_VARIABLE_NAME,
|
||||
type FabricObjectWithCustomProps,
|
||||
type FabricSchema,
|
||||
type VariableRef,
|
||||
} from '../typings';
|
||||
|
||||
const ImagePlaceholder = `${getUploadCDNAsset('')}/workflow/fabric-canvas/img-placeholder.png`;
|
||||
|
||||
// 需要额外保存的属性
|
||||
export const saveProps = [
|
||||
'width',
|
||||
'height',
|
||||
'editable',
|
||||
'text',
|
||||
'backgroundColor',
|
||||
'padding',
|
||||
// 自定义参数
|
||||
// textBox 的真实高度
|
||||
'customFixedHeight',
|
||||
// 元素 id
|
||||
'customId',
|
||||
// 元素类型
|
||||
'customType',
|
||||
// image 的适应模式
|
||||
'customFixedType',
|
||||
// // 由变量生成元素的 title
|
||||
// 引用关系
|
||||
'customVariableRefs',
|
||||
];
|
||||
|
||||
export const useCanvasChange = ({
|
||||
variables,
|
||||
canvas,
|
||||
onChange,
|
||||
schema,
|
||||
listenerEvents = [
|
||||
'object:modified',
|
||||
'object:added',
|
||||
'object:removed',
|
||||
'object:moving',
|
||||
'object:modified-zIndex',
|
||||
],
|
||||
}: {
|
||||
variables?: InputVariable[];
|
||||
canvas?: Canvas;
|
||||
onChange?: (schema: FabricSchema) => void;
|
||||
schema?: FabricSchema;
|
||||
listenerEvents?: (
|
||||
| 'object:modified'
|
||||
| 'object:added'
|
||||
| 'object:removed'
|
||||
| 'object:moving'
|
||||
| 'object:modified-zIndex'
|
||||
)[];
|
||||
}) => {
|
||||
const eventDisposers = useRef<(() => void)[]>([]);
|
||||
|
||||
const [isListen, setIsListener] = useState(true);
|
||||
const onChangeLatest = useLatest(onChange);
|
||||
const schemaLatest = useLatest(schema);
|
||||
const cacheCustomVariableRefs = useRef<VariableRef[]>(
|
||||
schema?.customVariableRefs ?? [],
|
||||
);
|
||||
|
||||
// 删除画布中不存在的引用关系
|
||||
const resetCustomVariableRefs = useCallback(
|
||||
({ schema: _schema }: { schema: FabricSchema }) => {
|
||||
let newCustomVariableRefs = cacheCustomVariableRefs.current;
|
||||
|
||||
const allObjectIds = _schema.objects.map(d => d.customId);
|
||||
newCustomVariableRefs = newCustomVariableRefs?.filter(d =>
|
||||
allObjectIds.includes(d.objectId),
|
||||
);
|
||||
cacheCustomVariableRefs.current = newCustomVariableRefs;
|
||||
|
||||
return newCustomVariableRefs;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 监听画布变化
|
||||
useEffect(() => {
|
||||
if (canvas && onChangeLatest.current && isListen) {
|
||||
const _onChange = ({ isRemove }: { isRemove: boolean }) => {
|
||||
const json = canvas.toObject(saveProps) as FabricSchema;
|
||||
// 删除时,顺便删掉无效 ref
|
||||
if (isRemove) {
|
||||
json.customVariableRefs = resetCustomVariableRefs({
|
||||
schema: json,
|
||||
});
|
||||
} else {
|
||||
json.customVariableRefs = cloneDeep(cacheCustomVariableRefs.current);
|
||||
}
|
||||
|
||||
onChangeLatest.current?.(json);
|
||||
};
|
||||
|
||||
eventDisposers.current.forEach(disposer => disposer());
|
||||
eventDisposers.current = [];
|
||||
|
||||
listenerEvents.forEach(event => {
|
||||
const disposer = canvas.on(event as keyof CanvasEvents, function (e) {
|
||||
_onChange({
|
||||
isRemove: event === 'object:removed',
|
||||
});
|
||||
});
|
||||
eventDisposers.current.push(disposer);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
eventDisposers.current.forEach(disposer => disposer?.());
|
||||
eventDisposers.current = [];
|
||||
};
|
||||
}, [canvas, isListen]);
|
||||
|
||||
/**
|
||||
* 生成带引用的新元素
|
||||
*/
|
||||
const addRefObjectByVariable = useCallback(
|
||||
async (variable: InputVariable, element?: FabricObject) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
customVariableRefs = [],
|
||||
width = 0,
|
||||
height = 0,
|
||||
} = schemaLatest.current ?? {};
|
||||
|
||||
const { id, name, type } = variable;
|
||||
const centerXY = [
|
||||
width / 2 + customVariableRefs.length * 16,
|
||||
height / 2 + customVariableRefs.length * 16,
|
||||
];
|
||||
|
||||
let _element: FabricObject | undefined = element;
|
||||
|
||||
// 如果没有传入现有元素,则创建新元素
|
||||
if (!_element) {
|
||||
if (type === ViewVariableType.Image) {
|
||||
_element = await createElement({
|
||||
mode: Mode.IMAGE,
|
||||
position: [
|
||||
centerXY[0] - (defaultProps[Mode.IMAGE].width as number) / 2,
|
||||
centerXY[1] - (defaultProps[Mode.IMAGE].height as number) / 2,
|
||||
],
|
||||
elementProps: {
|
||||
width: defaultProps[Mode.IMAGE].width,
|
||||
height: defaultProps[Mode.IMAGE].width,
|
||||
editable: false,
|
||||
src: ImagePlaceholder,
|
||||
},
|
||||
});
|
||||
} else if (type === ViewVariableType.String) {
|
||||
_element = await createElement({
|
||||
mode: Mode.BLOCK_TEXT,
|
||||
position: [
|
||||
centerXY[0] - (defaultProps[Mode.BLOCK_TEXT].width as number) / 2,
|
||||
centerXY[1] -
|
||||
(defaultProps[Mode.BLOCK_TEXT].height as number) / 2,
|
||||
],
|
||||
elementProps: {
|
||||
text: I18n.t(
|
||||
'imageflow_canvas_change_text',
|
||||
{},
|
||||
'点击编辑文本预览',
|
||||
),
|
||||
width: defaultProps[Mode.BLOCK_TEXT].width,
|
||||
height: defaultProps[Mode.BLOCK_TEXT].height,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (_element) {
|
||||
// 更新引用关系
|
||||
cacheCustomVariableRefs.current.push({
|
||||
variableId: id as string,
|
||||
objectId: (_element as FabricObjectWithCustomProps)
|
||||
.customId as string,
|
||||
variableName: name,
|
||||
});
|
||||
|
||||
// 添加到画布并激活
|
||||
canvas.add(_element);
|
||||
canvas.setActiveObject(_element);
|
||||
}
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新指定 objectId 的元素的引用关系
|
||||
* 如果 variable 为空,则删除引用
|
||||
* 如果 variable 不为空 && customVariableRefs 已存在对应关系,则更新引用
|
||||
* 如果 variable 不为空 && customVariableRefs 不存在对应关系,则新增引用
|
||||
*
|
||||
*/
|
||||
const updateRefByObjectId = useCallback(
|
||||
({
|
||||
objectId,
|
||||
variable,
|
||||
}: {
|
||||
objectId: string;
|
||||
variable?: InputVariable;
|
||||
}) => {
|
||||
const customVariableRefs = cacheCustomVariableRefs.current;
|
||||
const targetRef = customVariableRefs.find(d => d.objectId === objectId);
|
||||
let newCustomVariableRefs = [];
|
||||
// 如果 variable 为空,则删除引用
|
||||
if (!variable) {
|
||||
newCustomVariableRefs = customVariableRefs.filter(
|
||||
d => d.objectId !== objectId,
|
||||
);
|
||||
// 如果 variable 不为空 && customVariableRefs 不存在对应关系,则新增引用
|
||||
} else if (!targetRef) {
|
||||
newCustomVariableRefs = [
|
||||
...customVariableRefs,
|
||||
{
|
||||
variableId: variable.id as string,
|
||||
objectId,
|
||||
variableName: variable.name,
|
||||
},
|
||||
];
|
||||
// 如果 variable 不为空 && customVariableRefs 已存在对应关系,则更新引用
|
||||
} else {
|
||||
newCustomVariableRefs = customVariableRefs.map(d => {
|
||||
if (d.objectId === objectId) {
|
||||
return {
|
||||
...d,
|
||||
variableId: variable.id as string,
|
||||
variableName: variable.name,
|
||||
};
|
||||
}
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
cacheCustomVariableRefs.current = newCustomVariableRefs;
|
||||
onChangeLatest.current?.({
|
||||
...(schemaLatest.current as FabricSchema),
|
||||
customVariableRefs: newCustomVariableRefs,
|
||||
});
|
||||
},
|
||||
[onChangeLatest, schemaLatest],
|
||||
);
|
||||
|
||||
/**
|
||||
* variables 变化时,更新引用关系中的变量名
|
||||
*/
|
||||
useEffect(() => {
|
||||
const { customVariableRefs = [] } = schemaLatest.current ?? {};
|
||||
const needsUpdate = customVariableRefs.some(ref => {
|
||||
const variable = variables?.find(v => v.id === ref.variableId);
|
||||
return ref.variableName !== (variable?.name ?? UNKNOWN_VARIABLE_NAME);
|
||||
});
|
||||
|
||||
if (needsUpdate) {
|
||||
const newCustomVariableRefs = customVariableRefs.map(ref => {
|
||||
const variable = variables?.find(v => v.id === ref.variableId);
|
||||
return {
|
||||
...ref,
|
||||
variableName: variable?.name ?? UNKNOWN_VARIABLE_NAME,
|
||||
};
|
||||
});
|
||||
|
||||
cacheCustomVariableRefs.current = newCustomVariableRefs;
|
||||
onChangeLatest.current?.({
|
||||
...(schemaLatest.current as FabricSchema),
|
||||
customVariableRefs: newCustomVariableRefs,
|
||||
});
|
||||
}
|
||||
}, [variables]);
|
||||
|
||||
const stopListen = useCallback(() => {
|
||||
setIsListener(false);
|
||||
}, []);
|
||||
|
||||
const startListen = useCallback(() => {
|
||||
setIsListener(true);
|
||||
// redo undo 完成后,更新引用关系
|
||||
cacheCustomVariableRefs.current =
|
||||
schemaLatest.current?.customVariableRefs ?? [];
|
||||
}, []);
|
||||
|
||||
return {
|
||||
customVariableRefs: cacheCustomVariableRefs.current,
|
||||
addRefObjectByVariable,
|
||||
updateRefByObjectId,
|
||||
stopListen,
|
||||
startListen,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Rect, type Canvas } from 'fabric';
|
||||
|
||||
import { type FabricSchema } from '../typings';
|
||||
|
||||
export const useCanvasClip = ({
|
||||
canvas,
|
||||
schema,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
schema: FabricSchema;
|
||||
}) => {
|
||||
const addClip = useCallback(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
canvas.clipPath = new Rect({
|
||||
absolutePositioned: true,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: schema.width,
|
||||
height: schema.height,
|
||||
});
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas, schema]);
|
||||
|
||||
const removeClip = useCallback(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
canvas.clipPath = undefined;
|
||||
canvas.requestRenderAll();
|
||||
}, [canvas]);
|
||||
|
||||
return {
|
||||
addClip,
|
||||
removeClip,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
|
||||
export const useCanvasResize = ({
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const scale = Math.min(maxWidth / width, maxHeight / height);
|
||||
|
||||
const resize = useCallback(
|
||||
(canvas: Canvas | undefined) => {
|
||||
if (!maxWidth || !maxHeight || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas?.setDimensions({
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
canvas?.setDimensions(
|
||||
{
|
||||
width: `${width * scale}px`,
|
||||
height: `${height * scale}px`,
|
||||
},
|
||||
{ cssOnly: true },
|
||||
);
|
||||
},
|
||||
[maxWidth, maxHeight, width, height, scale],
|
||||
);
|
||||
|
||||
return { resize, scale };
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable complexity */
|
||||
import {
|
||||
type BasicTransformEvent,
|
||||
type Canvas,
|
||||
type CanvasEvents,
|
||||
type FabricObject,
|
||||
type FabricObjectProps,
|
||||
type ObjectEvents,
|
||||
type SerializedObjectProps,
|
||||
type TPointerEvent,
|
||||
} from 'fabric';
|
||||
|
||||
type Event = BasicTransformEvent<TPointerEvent> & {
|
||||
target: FabricObject<
|
||||
Partial<FabricObjectProps>,
|
||||
SerializedObjectProps,
|
||||
ObjectEvents
|
||||
>;
|
||||
};
|
||||
export const useCommonOperation = ({ canvas }: { canvas?: Canvas }) => {
|
||||
const moveActiveObject = (
|
||||
direct: 'left' | 'right' | 'up' | 'down',
|
||||
offsetValue = 1,
|
||||
) => {
|
||||
// 这里不用额外考虑框选 case ,框选时会形成一个临时的组,对组做位移,会影响到组内的每一个元素
|
||||
const activeSelection = canvas?.getActiveObject();
|
||||
|
||||
switch (direct) {
|
||||
case 'left':
|
||||
activeSelection?.set({ left: activeSelection.left - offsetValue });
|
||||
break;
|
||||
case 'right':
|
||||
activeSelection?.set({ left: activeSelection.left + offsetValue });
|
||||
break;
|
||||
case 'up':
|
||||
activeSelection?.set({ top: activeSelection.top - offsetValue });
|
||||
break;
|
||||
case 'down':
|
||||
activeSelection?.set({ top: activeSelection.top + offsetValue });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* 键盘上下左右触发的图形位移,需要主动触发
|
||||
* 1. moving
|
||||
* if (activeSelection) canvas.fire('object:moving')
|
||||
* else activeSelection.fire('moving')
|
||||
*
|
||||
* 2. object:modified ,用来触发保存
|
||||
*/
|
||||
const isActiveSelection = activeSelection?.isType('activeselection');
|
||||
const fabricObject = (
|
||||
isActiveSelection ? canvas : activeSelection
|
||||
) as FabricObject;
|
||||
const eventName = (
|
||||
isActiveSelection ? 'object:moving' : 'moving'
|
||||
) as keyof ObjectEvents;
|
||||
|
||||
fabricObject?.fire(eventName, {
|
||||
target: activeSelection,
|
||||
} as unknown as Event);
|
||||
canvas?.fire('object:modified');
|
||||
canvas?.requestRenderAll();
|
||||
};
|
||||
|
||||
const discardActiveObject = () => {
|
||||
canvas?.discardActiveObject();
|
||||
canvas?.requestRenderAll();
|
||||
};
|
||||
|
||||
const removeActiveObjects = () => {
|
||||
const activeObjects = canvas?.getActiveObjects() ?? [];
|
||||
if (canvas && activeObjects.length > 0) {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.remove(obj);
|
||||
});
|
||||
discardActiveObject();
|
||||
}
|
||||
};
|
||||
|
||||
const moveTo = (type: 'front' | 'backend' | 'front-one' | 'backend-one') => {
|
||||
const activeObjects = canvas?.getActiveObjects() ?? [];
|
||||
if (canvas && activeObjects.length > 0) {
|
||||
if (type === 'front') {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.bringObjectToFront(obj);
|
||||
});
|
||||
} else if (type === 'backend') {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.sendObjectToBack(obj);
|
||||
});
|
||||
} else if (type === 'front-one') {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.bringObjectForward(obj);
|
||||
});
|
||||
} else if (type === 'backend-one') {
|
||||
activeObjects.forEach(obj => {
|
||||
canvas.sendObjectBackwards(obj);
|
||||
});
|
||||
}
|
||||
// 主动触发一次自定义事件:zIndex 变化
|
||||
canvas.fire('object:modified-zIndex' as keyof CanvasEvents);
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
};
|
||||
|
||||
const resetWidthHeight = ({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) => {
|
||||
width && canvas?.setWidth(width);
|
||||
height && canvas?.setHeight(height);
|
||||
canvas?.fire('object:modified');
|
||||
canvas?.requestRenderAll();
|
||||
};
|
||||
|
||||
return {
|
||||
moveActiveObject,
|
||||
removeActiveObjects,
|
||||
discardActiveObject,
|
||||
moveTo,
|
||||
resetWidthHeight,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable complexity */
|
||||
/* eslint-disable max-lines-per-function */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ActiveSelection, type Canvas, type FabricObject } from 'fabric';
|
||||
import { useLatest } from 'ahooks';
|
||||
import { type InputVariable } from '@coze-workflow/base/types';
|
||||
import { Toast } from '@coze-arch/coze-design';
|
||||
|
||||
import { snap } from '../utils/snap/snap';
|
||||
import { createElement, getNumberBetween } from '../utils';
|
||||
import {
|
||||
CopyMode,
|
||||
type VariableRef,
|
||||
type FabricObjectWithCustomProps,
|
||||
} from '../typings';
|
||||
import { saveProps } from './use-canvas-change';
|
||||
|
||||
/**
|
||||
* 粘贴后的默认偏移
|
||||
*/
|
||||
const staff = 16;
|
||||
export const useCopyPaste = ({
|
||||
canvas,
|
||||
mousePosition,
|
||||
couldAddNewObject,
|
||||
customVariableRefs,
|
||||
variables,
|
||||
addRefObjectByVariable,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
mousePosition: {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
couldAddNewObject: boolean;
|
||||
variables?: InputVariable[];
|
||||
customVariableRefs: VariableRef[];
|
||||
addRefObjectByVariable: (
|
||||
variable: InputVariable,
|
||||
element?: FabricObject,
|
||||
) => void;
|
||||
}) => {
|
||||
// ctrlCV 复制的元素
|
||||
const copiedObject1 = useRef<FabricObject>();
|
||||
// ctrlD 复制的元素
|
||||
const copiedObject2 = useRef<FabricObject>();
|
||||
// dragCopy 拖拽复制的元素
|
||||
const copiedObject3 = useRef<FabricObject>();
|
||||
|
||||
const latestCustomVariableRefs = useLatest(customVariableRefs);
|
||||
const latestVariables = useLatest(variables);
|
||||
|
||||
const [position, setPosition] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
}>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
const latestPosition = useLatest(position);
|
||||
const latestCouldAddNewObject = useLatest(couldAddNewObject);
|
||||
|
||||
const [ignoreMousePosition, setIgnoreMousePosition] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
}>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
const latestIgnoreMousePosition = useLatest(ignoreMousePosition);
|
||||
|
||||
// 如果鼠标动了,就以鼠标位置为准。仅影响 CopyMode.CtrlD 的粘贴
|
||||
useEffect(() => {
|
||||
// 默认 left top 对应元素的左上角。需要实现元素中点对齐鼠标位置,因此做偏移
|
||||
setPosition({
|
||||
left: mousePosition.left - (copiedObject1.current?.width ?? 0) / 2,
|
||||
top: mousePosition.top - (copiedObject1.current?.height ?? 0) / 2,
|
||||
});
|
||||
}, [mousePosition]);
|
||||
|
||||
const handleElement = async (element: FabricObject): Promise<void> => {
|
||||
const oldObjectId = (element as FabricObjectWithCustomProps).customId;
|
||||
const newObjectId = nanoid();
|
||||
// 设置新的 id
|
||||
element.set({
|
||||
customId: newObjectId,
|
||||
});
|
||||
|
||||
// 走统一的创建元素逻辑
|
||||
const rs = await createElement({
|
||||
element: element as FabricObjectWithCustomProps,
|
||||
canvas,
|
||||
});
|
||||
|
||||
const ref = latestCustomVariableRefs.current?.find(
|
||||
d => d.objectId === oldObjectId,
|
||||
);
|
||||
const variable = latestVariables.current?.find(
|
||||
v => v.id === ref?.variableId,
|
||||
);
|
||||
if (variable) {
|
||||
addRefObjectByVariable(variable, rs);
|
||||
} else {
|
||||
canvas?.add(rs as FabricObject);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* mode 分为三种:'ctrlCV' | 'ctrlD' | 'dragCopy'
|
||||
* 行为一致,区别就是三种行为的复制源隔离,互不影响
|
||||
*/
|
||||
const copy = useCallback(
|
||||
async (mode: CopyMode = CopyMode.CtrlCV) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (!activeObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIgnoreMousePosition({
|
||||
left: activeObject.left + staff,
|
||||
top: activeObject.top + staff,
|
||||
});
|
||||
|
||||
setPosition({
|
||||
left: activeObject.left + staff,
|
||||
top: activeObject.top + staff,
|
||||
});
|
||||
|
||||
switch (mode) {
|
||||
case CopyMode.CtrlCV:
|
||||
copiedObject1.current = await activeObject.clone(saveProps);
|
||||
break;
|
||||
case CopyMode.CtrlD:
|
||||
copiedObject2.current = await activeObject.clone(saveProps);
|
||||
break;
|
||||
case CopyMode.DragCV:
|
||||
copiedObject3.current = await activeObject.clone(saveProps);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
const paste = useCallback(
|
||||
async (options?: { mode?: CopyMode }) => {
|
||||
if (!latestCouldAddNewObject.current) {
|
||||
Toast.warning({
|
||||
content: '元素数量已达上限,无法添加新元素',
|
||||
duration: 3,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = options?.mode ?? CopyMode.CtrlCV;
|
||||
let copiedObject;
|
||||
switch (mode) {
|
||||
case CopyMode.CtrlCV:
|
||||
copiedObject = copiedObject1.current;
|
||||
break;
|
||||
case CopyMode.CtrlD:
|
||||
copiedObject = copiedObject2.current;
|
||||
break;
|
||||
case CopyMode.DragCV:
|
||||
copiedObject = copiedObject3.current;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
if (!canvas || !copiedObject) {
|
||||
return;
|
||||
}
|
||||
const cloneObj = await copiedObject.clone(saveProps);
|
||||
|
||||
// ctrlCV 需要考虑鼠标位置,其他的不用
|
||||
const isIgnoreMousePosition = mode !== CopyMode.CtrlCV;
|
||||
|
||||
const { left, top } = isIgnoreMousePosition
|
||||
? latestIgnoreMousePosition.current
|
||||
: latestPosition.current;
|
||||
|
||||
// 计算下次粘贴位置,向 left top 各偏移 staff
|
||||
if (isIgnoreMousePosition) {
|
||||
setIgnoreMousePosition({
|
||||
left: left + staff,
|
||||
top: top + staff,
|
||||
});
|
||||
} else {
|
||||
setPosition({
|
||||
left: left + staff,
|
||||
top: top + staff,
|
||||
});
|
||||
}
|
||||
|
||||
cloneObj.set({
|
||||
left: getNumberBetween({
|
||||
value: left,
|
||||
min: 0,
|
||||
max: canvas.width - cloneObj.getBoundingRect().width,
|
||||
}),
|
||||
top: getNumberBetween({
|
||||
value: top,
|
||||
min: 0,
|
||||
max: canvas.height - cloneObj.getBoundingRect().height,
|
||||
}),
|
||||
});
|
||||
|
||||
// 把需要复制的元素都拿出来,多选
|
||||
const allPasteObjects: FabricObject[] = [];
|
||||
const originXY = {
|
||||
left: cloneObj.left + cloneObj.width / 2,
|
||||
top: cloneObj.top + cloneObj.height / 2,
|
||||
};
|
||||
if (cloneObj.isType('activeselection')) {
|
||||
(cloneObj as ActiveSelection).getObjects().forEach(o => {
|
||||
o.set({
|
||||
left: o.left + originXY.left,
|
||||
top: o.top + originXY.top,
|
||||
});
|
||||
allPasteObjects.push(o);
|
||||
});
|
||||
// 把需要复制的元素都拿出来,单选
|
||||
} else {
|
||||
allPasteObjects.push(cloneObj);
|
||||
}
|
||||
|
||||
// 挨着调用 handleElement 处理元素
|
||||
await Promise.all(allPasteObjects.map(async o => handleElement(o)));
|
||||
|
||||
// 如果是多选,需要创新新的多选框,并激活
|
||||
let allPasteObjectsActiveSelection: ActiveSelection | undefined;
|
||||
if (cloneObj.isType('activeselection')) {
|
||||
allPasteObjectsActiveSelection = new ActiveSelection(
|
||||
// 很恶心,这里激活选框,并不会自动转换坐标,需要手动转一下
|
||||
allPasteObjects.map(o => {
|
||||
o.set({
|
||||
left: o.left - originXY.left,
|
||||
top: o.top - originXY.top,
|
||||
});
|
||||
return o;
|
||||
}),
|
||||
);
|
||||
}
|
||||
canvas.discardActiveObject();
|
||||
canvas.setActiveObject(allPasteObjectsActiveSelection ?? cloneObj);
|
||||
|
||||
canvas.requestRenderAll();
|
||||
|
||||
return allPasteObjectsActiveSelection ?? cloneObj;
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isAltPressing = false;
|
||||
const keyCodes = ['Alt'];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (keyCodes.includes(e.key)) {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
isAltPressing = true; // 标记 alt 已按下
|
||||
}
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (keyCodes.includes(e.key)) {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
isAltPressing = false; // 标记 alt 已松开
|
||||
}
|
||||
};
|
||||
|
||||
const onWindowBlur = () => {
|
||||
isAltPressing = false; // 标记 alt 已松开
|
||||
};
|
||||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
document.addEventListener('contextmenu', onContextMenu);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
|
||||
let isDragCopying = false;
|
||||
let pasteObj: FabricObject | undefined;
|
||||
let originalPos = { left: 0, top: 0 };
|
||||
|
||||
const disposers = [
|
||||
// 复制时机:按下 alt 键 & 鼠标按下激活元素
|
||||
canvas?.on('mouse:down', async e => {
|
||||
if (isAltPressing) {
|
||||
if (!latestCouldAddNewObject.current) {
|
||||
Toast.warning({
|
||||
content: '元素数量已达上限,无法添加新元素',
|
||||
duration: 3,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isDragCopying = true;
|
||||
const activeObject = canvas.getActiveObject();
|
||||
// 创建元素副本期间,锁定 xy 方向的移动
|
||||
activeObject?.set({
|
||||
lockMovementX: true,
|
||||
lockMovementY: true,
|
||||
});
|
||||
try {
|
||||
await copy(CopyMode.DragCV);
|
||||
pasteObj = await paste({
|
||||
mode: CopyMode.DragCV,
|
||||
});
|
||||
|
||||
// 记录对象的原始位置,实现 shift 垂直、水平移动
|
||||
originalPos = {
|
||||
left: pasteObj?.left ?? 0,
|
||||
top: pasteObj?.top ?? 0,
|
||||
};
|
||||
} finally {
|
||||
activeObject?.set({
|
||||
lockMovementX: false,
|
||||
lockMovementY: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// 因为 copy 是异步的,所以这里会有一些延迟(大图片比较明显),没啥好办法
|
||||
canvas?.on('mouse:move', event => {
|
||||
if (isAltPressing && isDragCopying && pasteObj) {
|
||||
const pointer = canvas.getScenePoint(event.e);
|
||||
|
||||
// 检查是否按下了Shift键
|
||||
if (event.e.shiftKey) {
|
||||
// 计算从开始移动以来的水平和垂直距离
|
||||
const distanceX = pointer.x - originalPos.left;
|
||||
const distanceY = pointer.y - originalPos.top;
|
||||
|
||||
// 根据移动距离的绝对值判断是水平移动还是垂直移动
|
||||
if (Math.abs(distanceX) > Math.abs(distanceY)) {
|
||||
// 水平移动:保持垂直位置不变
|
||||
pasteObj?.set({
|
||||
left: pointer.x - (pasteObj?.width ?? 0) / 2,
|
||||
top: originalPos.top,
|
||||
});
|
||||
} else {
|
||||
// 垂直移动:保持水平位置不变
|
||||
pasteObj?.set({
|
||||
left: originalPos.left,
|
||||
top: pointer.y - (pasteObj?.height ?? 0) / 2,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
pasteObj?.set({
|
||||
left: pointer.x - (pasteObj?.width ?? 0) / 2,
|
||||
top: pointer.y - (pasteObj?.height ?? 0) / 2,
|
||||
});
|
||||
}
|
||||
|
||||
snap.move(pasteObj);
|
||||
canvas.requestRenderAll();
|
||||
canvas.fire('object:moving');
|
||||
}
|
||||
}),
|
||||
|
||||
canvas?.on('mouse:up', () => {
|
||||
isDragCopying = false;
|
||||
pasteObj = undefined;
|
||||
// 释放拖拽复制对象,避免对下次拖拽(按着 alt 不松手)造成干扰
|
||||
copiedObject3.current = undefined;
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
document.removeEventListener('contextmenu', onContextMenu);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
disposers.forEach(disposer => disposer?.());
|
||||
};
|
||||
}, [canvas, copy, paste]);
|
||||
|
||||
// 拖拽复制
|
||||
return {
|
||||
copy,
|
||||
paste,
|
||||
disabledPaste: !copiedObject1.current,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
type ObjectEvents,
|
||||
type Canvas,
|
||||
type FabricObject,
|
||||
type Line,
|
||||
} from 'fabric';
|
||||
|
||||
import { snap } from '../utils/snap/snap';
|
||||
import { resetElementClip } from '../utils/fabric-utils';
|
||||
import { createElement, defaultProps } from '../utils';
|
||||
import { type FabricObjectWithCustomProps, Mode, Snap } from '../typings';
|
||||
|
||||
const modeElementMap: Partial<
|
||||
Record<
|
||||
Mode,
|
||||
{
|
||||
down: (props: {
|
||||
left: number;
|
||||
top: number;
|
||||
canvas?: Canvas;
|
||||
}) => Promise<FabricObject | undefined>;
|
||||
move: (e: { element: FabricObject; dx: number; dy: number }) => void;
|
||||
up?: (e: { element: FabricObject }) => void;
|
||||
}
|
||||
>
|
||||
> = {
|
||||
[Mode.RECT]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.RECT,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
width: dx,
|
||||
height: dy,
|
||||
});
|
||||
snap.resize(element, Snap.ControlType.BottomRight);
|
||||
},
|
||||
up: ({ element }) => {
|
||||
element.set({
|
||||
width: defaultProps[Mode.RECT].width,
|
||||
height: defaultProps[Mode.RECT].height,
|
||||
});
|
||||
},
|
||||
},
|
||||
[Mode.CIRCLE]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.CIRCLE,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
rx: Math.max(dx / 2, 0),
|
||||
ry: Math.max(dy / 2, 0),
|
||||
});
|
||||
snap.resize(element, Snap.ControlType.BottomRight);
|
||||
},
|
||||
up: ({ element }) => {
|
||||
element.set({
|
||||
rx: defaultProps[Mode.CIRCLE].rx,
|
||||
ry: defaultProps[Mode.CIRCLE].ry,
|
||||
});
|
||||
},
|
||||
},
|
||||
[Mode.TRIANGLE]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.TRIANGLE,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
width: dx,
|
||||
height: dy,
|
||||
});
|
||||
snap.resize(element, Snap.ControlType.BottomRight);
|
||||
},
|
||||
up: ({ element }) => {
|
||||
element.set({
|
||||
width: defaultProps[Mode.TRIANGLE].width,
|
||||
height: defaultProps[Mode.TRIANGLE].height,
|
||||
});
|
||||
},
|
||||
},
|
||||
[Mode.STRAIGHT_LINE]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.STRAIGHT_LINE,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
x2: dx + (element as Line).x1,
|
||||
y2: dy + (element as Line).y1,
|
||||
});
|
||||
|
||||
// 创建直线时的终点位置修改,需要主动 fire 影响控制点的显示
|
||||
element.fire('start-end:modified' as keyof ObjectEvents);
|
||||
},
|
||||
},
|
||||
|
||||
[Mode.BLOCK_TEXT]: {
|
||||
down: ({ left, top, canvas }) =>
|
||||
createElement({
|
||||
mode: Mode.BLOCK_TEXT,
|
||||
position: [left, top],
|
||||
canvas,
|
||||
}),
|
||||
|
||||
move: ({ element, dx, dy }) => {
|
||||
element.set({
|
||||
customFixedHeight: dy,
|
||||
width: dx,
|
||||
height: dy,
|
||||
});
|
||||
snap.resize(element, Snap.ControlType.BottomRight);
|
||||
resetElementClip({ element });
|
||||
},
|
||||
|
||||
up: ({ element }) => {
|
||||
element.set({
|
||||
width: defaultProps[Mode.BLOCK_TEXT].width,
|
||||
height: defaultProps[Mode.BLOCK_TEXT].height,
|
||||
customFixedHeight: defaultProps[Mode.BLOCK_TEXT].height,
|
||||
});
|
||||
resetElementClip({ element });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const useDragAdd = ({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
onShapeAdded?: (data: { element: FabricObjectWithCustomProps }) => void;
|
||||
}): {
|
||||
enterDragAddElement: (mode: Mode) => void;
|
||||
exitDragAddElement: () => void;
|
||||
} => {
|
||||
const newElement = useRef<
|
||||
{ element: FabricObject; x: number; y: number; moved: boolean } | undefined
|
||||
>();
|
||||
|
||||
const disposers = useRef<(() => void)[]>([]);
|
||||
const enterDragAddElement = (mode: Mode) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mouseDownDisposer = canvas.on('mouse:down', async function ({ e }) {
|
||||
canvas.selection = false;
|
||||
const pointer = canvas.getScenePoint(e);
|
||||
e.preventDefault();
|
||||
|
||||
const element = await modeElementMap[mode]?.down({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
canvas,
|
||||
});
|
||||
|
||||
if (element) {
|
||||
canvas.add(element);
|
||||
canvas.setActiveObject(element);
|
||||
newElement.current = {
|
||||
element,
|
||||
x: pointer.x,
|
||||
y: pointer.y,
|
||||
moved: false,
|
||||
};
|
||||
|
||||
// 隐藏控制点,否则 onmouseup 可能被控制点截胡
|
||||
element.set('hasControls', false);
|
||||
}
|
||||
});
|
||||
|
||||
const mouseMoveDisposer = canvas.on('mouse:move', function ({ e }) {
|
||||
e.preventDefault();
|
||||
if (newElement.current) {
|
||||
const { element, x, y } = newElement.current;
|
||||
const pointer = canvas.getScenePoint(e);
|
||||
const dx = pointer.x - x;
|
||||
const dy = pointer.y - y;
|
||||
|
||||
modeElementMap[mode]?.move({
|
||||
element,
|
||||
dx,
|
||||
dy,
|
||||
});
|
||||
|
||||
// 修正元素坐标信息
|
||||
element.setCoords();
|
||||
|
||||
newElement.current.moved = true;
|
||||
canvas.fire('object:modified');
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
const mouseUpDisposer = canvas.on('mouse:up', function ({ e }) {
|
||||
e.preventDefault();
|
||||
if (newElement.current) {
|
||||
const { element } = newElement.current;
|
||||
if (!newElement.current.moved) {
|
||||
modeElementMap[mode]?.up?.({
|
||||
element,
|
||||
});
|
||||
}
|
||||
|
||||
onShapeAdded?.({ element: element as FabricObjectWithCustomProps });
|
||||
|
||||
// 恢复控制点
|
||||
element.set('hasControls', true);
|
||||
newElement.current = undefined;
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
disposers.current.push(
|
||||
mouseDownDisposer,
|
||||
mouseMoveDisposer,
|
||||
mouseUpDisposer,
|
||||
);
|
||||
};
|
||||
|
||||
const exitDragAddElement = () => {
|
||||
if (canvas) {
|
||||
canvas.selection = true;
|
||||
}
|
||||
|
||||
if (disposers.current.length > 0) {
|
||||
disposers.current.forEach(disposer => disposer());
|
||||
disposers.current = [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enterDragAddElement,
|
||||
exitDragAddElement,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { type InputVariable } from '@coze-workflow/base/types';
|
||||
|
||||
import {
|
||||
REF_VARIABLE_ID_PREFIX,
|
||||
type FabricClickEvent,
|
||||
type FabricObjectWithCustomProps,
|
||||
type FabricSchema,
|
||||
type VariableRef,
|
||||
} from '../typings';
|
||||
import { useViewport } from './use-viewport';
|
||||
import { useSnapMove } from './use-snap-move';
|
||||
import { useRedoUndo } from './use-redo-undo';
|
||||
import { usePosition } from './use-position';
|
||||
import { useMousePosition } from './use-mouse-position';
|
||||
import { useInlineTextAdd } from './use-inline-text-add';
|
||||
import { useInitCanvas } from './use-init-canvas';
|
||||
import { useImagAdd } from './use-img-add';
|
||||
import { useGroup } from './use-group';
|
||||
import { useFreePencil } from './use-free-pencil';
|
||||
import { useDragAdd } from './use-drag-add';
|
||||
import { useCopyPaste } from './use-copy-paste';
|
||||
import { useCommonOperation } from './use-common-operation';
|
||||
import { useCanvasResize } from './use-canvas-resize';
|
||||
import { useCanvasChange } from './use-canvas-change';
|
||||
import { useBackground } from './use-background';
|
||||
import { useAlign } from './use-align';
|
||||
import { useActiveObjectChange } from './use-active-object-change';
|
||||
|
||||
export const useFabricEditor = ({
|
||||
ref,
|
||||
schema: _schema,
|
||||
onChange,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
startInit,
|
||||
maxZoom = 3,
|
||||
minZoom = 0.3,
|
||||
readonly = false,
|
||||
onShapeAdded,
|
||||
variables,
|
||||
id,
|
||||
helpLineLayerId,
|
||||
onClick,
|
||||
}: {
|
||||
id?: string;
|
||||
helpLineLayerId: string;
|
||||
ref: React.RefObject<HTMLCanvasElement>;
|
||||
schema: FabricSchema;
|
||||
onChange?: (schema: FabricSchema) => void;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
startInit: boolean;
|
||||
maxZoom?: number;
|
||||
minZoom?: number;
|
||||
readonly?: boolean;
|
||||
onShapeAdded?: (data: { element: FabricObjectWithCustomProps }) => void;
|
||||
variables?: InputVariable[];
|
||||
onClick?: (e: FabricClickEvent) => void;
|
||||
}) => {
|
||||
const schema: FabricSchema = useMemo(() => {
|
||||
/**
|
||||
* 兼容历史数据
|
||||
* 删除时机,见 apps/fabric-canvas-node-render/utils/replace-ref-value.ts 注释
|
||||
*/
|
||||
if (
|
||||
!_schema?.customVariableRefs &&
|
||||
(_schema?.objects?.filter(d => d.customVariableName)?.length ?? 0) > 0
|
||||
) {
|
||||
const refObjects = _schema?.objects?.filter(d => d.customVariableName);
|
||||
const newRefs: VariableRef[] =
|
||||
refObjects?.map(d => ({
|
||||
variableId: d.customId
|
||||
.replace(`${REF_VARIABLE_ID_PREFIX}-img-`, '')
|
||||
.replace(`${REF_VARIABLE_ID_PREFIX}-text-`, ''),
|
||||
objectId: d.customId,
|
||||
variableName: d.customVariableName as string,
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
..._schema,
|
||||
customVariableRefs: newRefs,
|
||||
};
|
||||
}
|
||||
return _schema;
|
||||
}, [_schema]);
|
||||
|
||||
const objectLength = useMemo(() => schema.objects.length, [schema]);
|
||||
|
||||
/**
|
||||
* 最大可添加元素数量限制
|
||||
*/
|
||||
const MAX_OBJECT_LENGTH = 50;
|
||||
const couldAddNewObject = useMemo(
|
||||
() => objectLength < MAX_OBJECT_LENGTH,
|
||||
[objectLength],
|
||||
);
|
||||
|
||||
const { resize, scale } = useCanvasResize({
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
width: schema.width,
|
||||
height: schema.height,
|
||||
});
|
||||
|
||||
// 初始化 fabric canvas
|
||||
const { canvas, loadFromJSON } = useInitCanvas({
|
||||
startInit,
|
||||
ref: ref.current,
|
||||
schema,
|
||||
resize,
|
||||
scale,
|
||||
readonly,
|
||||
onClick,
|
||||
});
|
||||
|
||||
const { viewport, setViewport, zoomToPoint } = useViewport({
|
||||
canvas,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
schema,
|
||||
});
|
||||
|
||||
const { mousePosition } = useMousePosition({ canvas });
|
||||
|
||||
const { group, unGroup } = useGroup({
|
||||
canvas,
|
||||
});
|
||||
|
||||
const {
|
||||
startListen,
|
||||
stopListen,
|
||||
addRefObjectByVariable,
|
||||
updateRefByObjectId,
|
||||
customVariableRefs,
|
||||
} = useCanvasChange({
|
||||
variables,
|
||||
canvas,
|
||||
schema,
|
||||
onChange: json => {
|
||||
onChange?.(json);
|
||||
pushOperation(json);
|
||||
},
|
||||
});
|
||||
|
||||
useSnapMove({ canvas, helpLineLayerId, scale });
|
||||
|
||||
const { copy, paste, disabledPaste } = useCopyPaste({
|
||||
canvas,
|
||||
mousePosition,
|
||||
couldAddNewObject,
|
||||
customVariableRefs,
|
||||
addRefObjectByVariable,
|
||||
variables,
|
||||
});
|
||||
|
||||
const { pushOperation, undo, redo, disabledRedo, disabledUndo, redoUndoing } =
|
||||
useRedoUndo({
|
||||
id,
|
||||
schema,
|
||||
loadFromJSON,
|
||||
startListen,
|
||||
stopListen,
|
||||
onChange,
|
||||
});
|
||||
|
||||
const {
|
||||
activeObjects,
|
||||
activeObjectsPopPosition,
|
||||
setActiveObjectsProps,
|
||||
isActiveObjectsInBack,
|
||||
isActiveObjectsInFront,
|
||||
} = useActiveObjectChange({
|
||||
canvas,
|
||||
scale,
|
||||
});
|
||||
|
||||
const {
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenter,
|
||||
alignTop,
|
||||
alignBottom,
|
||||
alignMiddle,
|
||||
verticalAverage,
|
||||
horizontalAverage,
|
||||
} = useAlign({
|
||||
canvas,
|
||||
selectObjects: activeObjects,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (canvas) {
|
||||
resize(canvas);
|
||||
}
|
||||
}, [resize, canvas]);
|
||||
|
||||
const { backgroundColor, setBackgroundColor } = useBackground({
|
||||
canvas,
|
||||
schema,
|
||||
});
|
||||
|
||||
const {
|
||||
moveActiveObject,
|
||||
removeActiveObjects,
|
||||
moveTo,
|
||||
discardActiveObject,
|
||||
resetWidthHeight,
|
||||
} = useCommonOperation({
|
||||
canvas,
|
||||
});
|
||||
|
||||
const { addImage } = useImagAdd({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
});
|
||||
|
||||
const { enterDragAddElement, exitDragAddElement } = useDragAdd({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
});
|
||||
|
||||
const { enterFreePencil, exitFreePencil } = useFreePencil({
|
||||
canvas,
|
||||
});
|
||||
|
||||
const { enterAddInlineText, exitAddInlineText } = useInlineTextAdd({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
});
|
||||
|
||||
const { allObjectsPositionInScreen } = usePosition({
|
||||
canvas,
|
||||
scale,
|
||||
viewport,
|
||||
});
|
||||
|
||||
return {
|
||||
canvas,
|
||||
canvasSettings: {
|
||||
width: schema.width,
|
||||
height: schema.height,
|
||||
backgroundColor,
|
||||
},
|
||||
state: {
|
||||
viewport,
|
||||
cssScale: scale,
|
||||
activeObjects,
|
||||
activeObjectsPopPosition,
|
||||
objectLength,
|
||||
couldAddNewObject,
|
||||
disabledUndo,
|
||||
disabledRedo,
|
||||
redoUndoing,
|
||||
disabledPaste,
|
||||
isActiveObjectsInBack,
|
||||
isActiveObjectsInFront,
|
||||
canvasWidth: canvas?.getElement().getBoundingClientRect().width,
|
||||
canvasHeight: canvas?.getElement().getBoundingClientRect().height,
|
||||
customVariableRefs,
|
||||
allObjectsPositionInScreen,
|
||||
},
|
||||
sdk: {
|
||||
discardActiveObject,
|
||||
setActiveObjectsProps,
|
||||
setBackgroundColor,
|
||||
moveToFront: () => {
|
||||
moveTo('front');
|
||||
},
|
||||
moveToBackend: () => {
|
||||
moveTo('backend');
|
||||
},
|
||||
moveToFrontOne: () => {
|
||||
moveTo('front-one');
|
||||
},
|
||||
moveToBackendOne: () => {
|
||||
moveTo('backend-one');
|
||||
},
|
||||
zoomToPoint,
|
||||
setViewport,
|
||||
moveActiveObject,
|
||||
removeActiveObjects,
|
||||
enterDragAddElement,
|
||||
exitDragAddElement,
|
||||
enterFreePencil,
|
||||
exitFreePencil,
|
||||
enterAddInlineText,
|
||||
exitAddInlineText,
|
||||
addImage,
|
||||
undo,
|
||||
redo,
|
||||
copy,
|
||||
paste,
|
||||
group,
|
||||
unGroup,
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenter,
|
||||
alignTop,
|
||||
alignBottom,
|
||||
alignMiddle,
|
||||
verticalAverage,
|
||||
horizontalAverage,
|
||||
resetWidthHeight,
|
||||
addRefObjectByVariable,
|
||||
updateRefByObjectId,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { type FabricSchema } from '../typings';
|
||||
import { useSchemaChange } from './use-schema-change';
|
||||
import { useInitCanvas } from './use-init-canvas';
|
||||
import { useCanvasResize } from './use-canvas-resize';
|
||||
|
||||
export const useFabricPreview = ({
|
||||
ref,
|
||||
schema,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
startInit,
|
||||
}: {
|
||||
ref: React.RefObject<HTMLCanvasElement>;
|
||||
schema: FabricSchema;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
startInit: boolean;
|
||||
}) => {
|
||||
const { resize, scale } = useCanvasResize({
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
width: schema.width,
|
||||
height: schema.height,
|
||||
});
|
||||
|
||||
const { canvas } = useInitCanvas({
|
||||
ref: ref.current,
|
||||
schema,
|
||||
startInit,
|
||||
readonly: true,
|
||||
resize,
|
||||
scale,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (canvas) {
|
||||
resize(canvas);
|
||||
}
|
||||
}, [resize, canvas]);
|
||||
|
||||
useSchemaChange({
|
||||
canvas,
|
||||
schema,
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
return {
|
||||
state: {
|
||||
cssScale: scale,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { PencilBrush, type Canvas } from 'fabric';
|
||||
|
||||
import { createControls } from '../utils/create-controls';
|
||||
import { defaultProps, createCommonObjectOptions } from '../utils';
|
||||
import { Mode } from '../typings';
|
||||
|
||||
export const useFreePencil = ({ canvas }: { canvas?: Canvas }) => {
|
||||
const enterFreePencil = () => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
// 启用自由绘图模式
|
||||
canvas.isDrawingMode = true;
|
||||
|
||||
// 设置 PencilBrush 为当前的画笔
|
||||
canvas.freeDrawingBrush = new PencilBrush(canvas);
|
||||
|
||||
// 设置画笔的一些属性
|
||||
canvas.freeDrawingBrush.color = defaultProps[Mode.PENCIL].stroke as string; // 画笔颜色
|
||||
canvas.freeDrawingBrush.width = defaultProps[Mode.PENCIL]
|
||||
.strokeWidth as number; // 画笔宽度
|
||||
|
||||
// 你也可以设置其他属性,比如 opacity (不透明度)
|
||||
// canvas.freeDrawingBrush.opacity = 0.6;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const disposer = canvas.on('path:created', function (event) {
|
||||
const { path } = event;
|
||||
const commonOptions = createCommonObjectOptions(Mode.PENCIL);
|
||||
path.set({ ...commonOptions, ...defaultProps[Mode.PENCIL] });
|
||||
|
||||
createControls[Mode.PENCIL]?.({
|
||||
element: path,
|
||||
});
|
||||
|
||||
// 得触发一次 object:added ,以触发 onSave,否则 schema 里并不会包含 commonOptions
|
||||
canvas.fire('object:modified');
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposer();
|
||||
};
|
||||
}, [canvas]);
|
||||
|
||||
const exitFreePencil = () => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
// 禁用自由绘图模式
|
||||
canvas.isDrawingMode = false;
|
||||
};
|
||||
|
||||
return {
|
||||
enterFreePencil,
|
||||
exitFreePencil,
|
||||
};
|
||||
};
|
||||
102
frontend/packages/workflow/fabric-canvas/src/hooks/use-group.tsx
Normal file
102
frontend/packages/workflow/fabric-canvas/src/hooks/use-group.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 这是个半成品,暂时不做了,后面再考虑
|
||||
* 1. 成组后要支持下钻继续选择
|
||||
* 实现思路:
|
||||
* a.双击解组,并记录组关系;
|
||||
* b.下钻选择子组,继续解组,并记录组关系;
|
||||
* c.点击画布(没有任何选中元素时),恢复组(要注意 z-index)。
|
||||
*
|
||||
* 2. 复制粘贴组时,需要排除掉引用元素
|
||||
* 3. 删除组是,也需要排除引用元素
|
||||
* 4. 因为组的引入,打破了所有元素都是拍平的原则,要注意这个改动的破坏性。
|
||||
* eg:
|
||||
* a. 获取所有元素
|
||||
* b. 元素的位置计算是由每层父元素叠加来的
|
||||
* c. 服务端渲染:遍历找所有的图片元素。完成图片下载后恢复组
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
ActiveSelection,
|
||||
type Canvas,
|
||||
type FabricObject,
|
||||
type Group,
|
||||
} from 'fabric';
|
||||
|
||||
import { isGroupElement } from '../utils/fabric-utils';
|
||||
import { createElement } from '../utils';
|
||||
import { Mode, type FabricObjectWithCustomProps } from '../typings';
|
||||
|
||||
export const useGroup = ({ canvas }: { canvas?: Canvas }) => {
|
||||
const group = useCallback(async () => {
|
||||
const activeObject = canvas?.getActiveObject();
|
||||
const objects = (activeObject as ActiveSelection)?.getObjects();
|
||||
// 选中了多个元素时,才可以 group
|
||||
if ((objects?.length ?? 0) > 1) {
|
||||
const _group = await createElement({
|
||||
mode: Mode.GROUP,
|
||||
elementProps: {
|
||||
left: activeObject?.left,
|
||||
top: activeObject?.top,
|
||||
width: activeObject?.width,
|
||||
height: activeObject?.height,
|
||||
},
|
||||
});
|
||||
|
||||
(_group as Group).add(...objects);
|
||||
canvas?.add(_group as Group);
|
||||
canvas?.setActiveObject(_group as Group);
|
||||
canvas?.remove(...objects);
|
||||
}
|
||||
}, [canvas]);
|
||||
|
||||
const unGroup = useCallback(async () => {
|
||||
const activeObject = canvas?.getActiveObject();
|
||||
|
||||
// 仅选中了一个 group 元素时,才可以 ungroup
|
||||
if (isGroupElement(activeObject)) {
|
||||
const _group = activeObject as Group;
|
||||
const objects = _group.getObjects();
|
||||
|
||||
await Promise.all(
|
||||
objects.map(async d => {
|
||||
const element = await createElement({
|
||||
mode: (d as FabricObjectWithCustomProps).customType,
|
||||
element: d as FabricObjectWithCustomProps,
|
||||
});
|
||||
|
||||
_group.remove(d);
|
||||
canvas?.add(element as FabricObject);
|
||||
}),
|
||||
);
|
||||
|
||||
canvas?.discardActiveObject();
|
||||
canvas?.remove(_group);
|
||||
|
||||
const activeSelection = new ActiveSelection(objects);
|
||||
canvas?.setActiveObject(activeSelection);
|
||||
canvas?.requestRenderAll();
|
||||
}
|
||||
}, [canvas]);
|
||||
|
||||
return {
|
||||
group,
|
||||
unGroup,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
|
||||
import { createElement, defaultProps } from '../utils';
|
||||
import { type FabricObjectWithCustomProps, Mode } from '../typings';
|
||||
|
||||
export const useImagAdd = ({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
onShapeAdded?: (data: { element: FabricObjectWithCustomProps }) => void;
|
||||
}) => {
|
||||
const addImage = async (url: string) => {
|
||||
const img = await createElement({
|
||||
mode: Mode.IMAGE,
|
||||
position: [
|
||||
(canvas?.width as number) / 2 -
|
||||
(defaultProps[Mode.IMAGE].width as number) / 2,
|
||||
(canvas?.height as number) / 2 -
|
||||
(defaultProps[Mode.IMAGE].height as number) / 2,
|
||||
],
|
||||
canvas,
|
||||
elementProps: {
|
||||
src: url,
|
||||
},
|
||||
});
|
||||
if (img) {
|
||||
canvas?.add(img);
|
||||
canvas?.setActiveObject(img);
|
||||
onShapeAdded?.({ element: img as FabricObjectWithCustomProps });
|
||||
}
|
||||
};
|
||||
return {
|
||||
addImage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Canvas, type FabricObject } from 'fabric';
|
||||
import { useAsyncEffect, useUnmount } from 'ahooks';
|
||||
|
||||
import { loadFontWithSchema, setElementAfterLoad } from '../utils';
|
||||
import { type FabricClickEvent, type FabricSchema } from '../typings';
|
||||
|
||||
export const useInitCanvas = ({
|
||||
startInit,
|
||||
ref,
|
||||
schema,
|
||||
readonly,
|
||||
resize,
|
||||
scale = 1,
|
||||
onClick,
|
||||
}: {
|
||||
startInit: boolean;
|
||||
ref: HTMLCanvasElement | null;
|
||||
schema: FabricSchema;
|
||||
readonly: boolean;
|
||||
resize?: (canvas: Canvas) => void;
|
||||
scale?: number;
|
||||
onClick?: (e: FabricClickEvent) => void;
|
||||
}) => {
|
||||
const [canvas, setCanvas] = useState<Canvas | undefined>(undefined);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!startInit || !ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按比例给个初始化高度,随后会通过 resize 修正为真正的宽高
|
||||
const _canvas = new Canvas(ref, {
|
||||
width: schema.width * scale,
|
||||
height: schema.height * scale,
|
||||
backgroundColor: schema.backgroundColor as string,
|
||||
selection: !readonly,
|
||||
preserveObjectStacking: true,
|
||||
});
|
||||
resize?.(_canvas);
|
||||
|
||||
await loadFromJSON(schema, _canvas);
|
||||
|
||||
setCanvas(_canvas);
|
||||
|
||||
loadFontWithSchema({
|
||||
schema,
|
||||
canvas: _canvas,
|
||||
});
|
||||
|
||||
if (!readonly) {
|
||||
(
|
||||
window as unknown as {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_fabric_canvas: Canvas;
|
||||
}
|
||||
)._fabric_canvas = _canvas;
|
||||
}
|
||||
}, [startInit]);
|
||||
|
||||
useUnmount(() => {
|
||||
canvas?.dispose();
|
||||
setCanvas(undefined);
|
||||
});
|
||||
|
||||
const loadFromJSON = useCallback(
|
||||
async (_schema: FabricSchema, _canvas?: Canvas) => {
|
||||
const fabricCanvas = _canvas ?? canvas;
|
||||
await fabricCanvas?.loadFromJSON(
|
||||
JSON.stringify(_schema),
|
||||
async (elementSchema, element) => {
|
||||
// 每个元素被加载后的回调
|
||||
await setElementAfterLoad({
|
||||
element: element as FabricObject,
|
||||
options: { readonly },
|
||||
canvas: fabricCanvas,
|
||||
});
|
||||
},
|
||||
);
|
||||
fabricCanvas?.requestRenderAll();
|
||||
},
|
||||
[canvas],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const disposers: (() => void)[] = [];
|
||||
if (canvas) {
|
||||
disposers.push(
|
||||
canvas.on('mouse:down', e => {
|
||||
onClick?.(e);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposers.forEach(disposer => disposer());
|
||||
};
|
||||
}, [canvas, onClick]);
|
||||
|
||||
return { canvas, loadFromJSON };
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
|
||||
import { createElement } from '../utils';
|
||||
import { type FabricObjectWithCustomProps, Mode } from '../typings';
|
||||
|
||||
export const useInlineTextAdd = ({
|
||||
canvas,
|
||||
onShapeAdded,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
onShapeAdded?: (data: { element: FabricObjectWithCustomProps }) => void;
|
||||
}) => {
|
||||
const disposers = useRef<(() => void)[]>([]);
|
||||
|
||||
const enterAddInlineText = () => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mouseDownDisposer = canvas.on('mouse:down', async ({ e }) => {
|
||||
const pointer = canvas.getScenePoint(e);
|
||||
e.preventDefault();
|
||||
|
||||
canvas.selection = false;
|
||||
const text = await createElement({
|
||||
mode: Mode.INLINE_TEXT,
|
||||
position: [pointer.x, pointer.y],
|
||||
canvas,
|
||||
});
|
||||
|
||||
if (text) {
|
||||
canvas.add(text);
|
||||
canvas.setActiveObject(text);
|
||||
|
||||
onShapeAdded?.({ element: text as FabricObjectWithCustomProps });
|
||||
}
|
||||
});
|
||||
disposers.current.push(mouseDownDisposer);
|
||||
};
|
||||
|
||||
const exitAddInlineText = () => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.selection = true;
|
||||
|
||||
if (disposers.current.length > 0) {
|
||||
disposers.current.forEach(disposer => disposer());
|
||||
disposers.current = [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enterAddInlineText,
|
||||
exitAddInlineText,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
|
||||
export const useMousePosition = ({ canvas }: { canvas?: Canvas }) => {
|
||||
const [position, setPosition] = useState<{ left: number; top: number }>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dispose = canvas.on('mouse:move', event => {
|
||||
const pointer = canvas.getScenePoint(event.e);
|
||||
setPosition({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
});
|
||||
});
|
||||
return dispose;
|
||||
}, [canvas]);
|
||||
|
||||
return {
|
||||
mousePosition: position,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
type Canvas,
|
||||
type FabricObject,
|
||||
type Group,
|
||||
type TMat2D,
|
||||
} from 'fabric';
|
||||
|
||||
import {
|
||||
type FabricObjectWithCustomProps,
|
||||
type IRefPosition,
|
||||
Mode,
|
||||
} from '../typings';
|
||||
import { useCanvasChange } from './use-canvas-change';
|
||||
|
||||
const getElementTitlePosition = ({
|
||||
element,
|
||||
scale,
|
||||
}: {
|
||||
element: FabricObjectWithCustomProps;
|
||||
scale: number;
|
||||
}): IRefPosition => {
|
||||
const isImg = (element as FabricObject).isType('group');
|
||||
const isInlineText = element.customType === Mode.INLINE_TEXT;
|
||||
|
||||
const targetElement = isImg
|
||||
? (element as unknown as Group).getObjects()[0]
|
||||
: element;
|
||||
const targetElementTopLeft = targetElement.calcOCoords().tl;
|
||||
const { width, scaleX = 1, padding = 0 } = targetElement;
|
||||
|
||||
let left = targetElementTopLeft.x * scale;
|
||||
let top = targetElementTopLeft.y * scale;
|
||||
|
||||
// 图片特化,需要考虑比例拉伸,位置限定在 group 范围内
|
||||
if (isImg) {
|
||||
const strokeWidth =
|
||||
(element as unknown as Group).getObjects()?.[1]?.strokeWidth ?? 0;
|
||||
top = top - strokeWidth / 2;
|
||||
left = left - strokeWidth / 2;
|
||||
|
||||
const groupTopLeft = element.calcOCoords().tl;
|
||||
left = Math.max(groupTopLeft.x * scale, left);
|
||||
top = Math.max(groupTopLeft.y * scale, top);
|
||||
}
|
||||
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
angle: element.angle,
|
||||
id: element.customId,
|
||||
maxWidth: isInlineText ? 999 : (width * scaleX + padding * 2) * scale,
|
||||
isImg,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePosition = ({
|
||||
canvas,
|
||||
scale,
|
||||
viewport,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
scale: number;
|
||||
viewport?: TMat2D;
|
||||
}) => {
|
||||
// const [objects, setObjects] = useState<FabricObjectWithCustomProps[]>([]);
|
||||
const [screenPositions, setScreenPositions] = useState<IRefPosition[]>([]);
|
||||
|
||||
const _setPositions = useCallback(() => {
|
||||
// 为什么要 setTimeout?批量时,需要延迟才能拿到正确的坐标
|
||||
setTimeout(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const _objects = canvas.getObjects() as FabricObjectWithCustomProps[];
|
||||
// setObjects(_objects);
|
||||
const _positions = _objects?.map(ref =>
|
||||
getElementTitlePosition({
|
||||
element: ref,
|
||||
scale,
|
||||
}),
|
||||
);
|
||||
setScreenPositions(_positions);
|
||||
}, 0);
|
||||
}, [canvas, scale, viewport]);
|
||||
|
||||
useEffect(() => {
|
||||
_setPositions();
|
||||
}, [_setPositions]);
|
||||
|
||||
useCanvasChange({
|
||||
canvas,
|
||||
onChange: _setPositions,
|
||||
});
|
||||
|
||||
return {
|
||||
allObjectsPositionInScreen: screenPositions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { cloneDeep, isUndefined } from 'lodash-es';
|
||||
import { useDebounceFn, useLatest } from 'ahooks';
|
||||
|
||||
import { type FabricSchema } from '../typings';
|
||||
import { useStorageState } from './use-storage';
|
||||
|
||||
export const useRedoUndo = ({
|
||||
schema,
|
||||
loadFromJSON,
|
||||
stopListen,
|
||||
startListen,
|
||||
onChange,
|
||||
id,
|
||||
}: {
|
||||
schema: FabricSchema;
|
||||
loadFromJSON?: (schema: FabricSchema) => void;
|
||||
stopListen: () => void;
|
||||
startListen: () => void;
|
||||
onChange?: (schema: FabricSchema) => void;
|
||||
id?: string;
|
||||
}) => {
|
||||
const [history, setHistory] = useStorageState<FabricSchema[]>(
|
||||
`${id}-history`,
|
||||
{
|
||||
defaultValue: [cloneDeep(schema)],
|
||||
},
|
||||
);
|
||||
const historyLatest = useLatest(history);
|
||||
const [step, setStep] = useStorageState<number>(`${id}-step`, {
|
||||
defaultValue: 0,
|
||||
});
|
||||
const stepLatest = useLatest(step);
|
||||
|
||||
const [redoUndoing, setRedoUndoing] = useState(false);
|
||||
|
||||
const push = useCallback((_schema: FabricSchema) => {
|
||||
if (
|
||||
isUndefined(historyLatest.current) ||
|
||||
isUndefined(stepLatest.current) ||
|
||||
JSON.stringify(_schema) ===
|
||||
JSON.stringify(historyLatest.current[stepLatest.current])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 保存多少步
|
||||
const max = 20;
|
||||
const end = stepLatest.current + 1;
|
||||
const start = Math.max(0, end - max);
|
||||
const newHistory = [...historyLatest.current.splice(start, end), _schema];
|
||||
setHistory(newHistory);
|
||||
setStep(newHistory.length - 1);
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
if (
|
||||
isUndefined(historyLatest.current) ||
|
||||
isUndefined(stepLatest.current) ||
|
||||
stepLatest.current === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStep = stepLatest.current - 1;
|
||||
const _schema = historyLatest.current[newStep];
|
||||
|
||||
// 开始执行 undo
|
||||
setRedoUndoing(true);
|
||||
|
||||
// 停止监听画布变化
|
||||
stopListen();
|
||||
|
||||
// 保存 schema
|
||||
onChange?.(_schema);
|
||||
|
||||
// 画布重新加载
|
||||
await loadFromJSON?.(_schema);
|
||||
|
||||
// 同步 step
|
||||
setStep(newStep);
|
||||
|
||||
// 恢复画布监听
|
||||
startListen();
|
||||
|
||||
// undo 执行完成
|
||||
setRedoUndoing(false);
|
||||
}, [loadFromJSON]);
|
||||
|
||||
const redo = useCallback(async () => {
|
||||
if (
|
||||
isUndefined(historyLatest.current) ||
|
||||
isUndefined(stepLatest.current) ||
|
||||
stepLatest.current === historyLatest.current.length - 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStep = stepLatest.current + 1;
|
||||
const _schema = historyLatest.current[newStep];
|
||||
|
||||
// 开始执行 redo
|
||||
setRedoUndoing(true);
|
||||
|
||||
// 停止监听画布变化
|
||||
stopListen();
|
||||
|
||||
// 保存 schema
|
||||
onChange?.(_schema);
|
||||
|
||||
// 画布重新加载
|
||||
await loadFromJSON?.(_schema);
|
||||
|
||||
// 同步 step
|
||||
setStep(newStep);
|
||||
|
||||
// 恢复画布监听
|
||||
startListen();
|
||||
|
||||
// redo 执行完成
|
||||
setRedoUndoing(false);
|
||||
}, [loadFromJSON]);
|
||||
|
||||
const { run: pushOperation } = useDebounceFn(
|
||||
(_schema: FabricSchema) => {
|
||||
push(cloneDeep(_schema));
|
||||
},
|
||||
{
|
||||
wait: 300,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
pushOperation,
|
||||
undo: async () => {
|
||||
if (!redoUndoing) {
|
||||
await undo();
|
||||
}
|
||||
},
|
||||
redo: async () => {
|
||||
if (!redoUndoing) {
|
||||
await redo();
|
||||
}
|
||||
},
|
||||
disabledUndo: step === 0,
|
||||
disabledRedo: history && step === history.length - 1,
|
||||
redoUndoing,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { type Canvas, type FabricObject } from 'fabric';
|
||||
|
||||
import { setElementAfterLoad } from '../utils';
|
||||
import { type FabricSchema } from '../typings';
|
||||
|
||||
/**
|
||||
* 监听 schema 变化,reload canvas
|
||||
* 仅只读态需要
|
||||
*/
|
||||
export const useSchemaChange = ({
|
||||
canvas,
|
||||
schema,
|
||||
readonly,
|
||||
}: {
|
||||
canvas: Canvas | undefined;
|
||||
schema: FabricSchema;
|
||||
readonly: boolean;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
canvas
|
||||
?.loadFromJSON(JSON.stringify(schema), (elementSchema, element) => {
|
||||
// 这里是 schema 中每个元素被加载后的回调
|
||||
setElementAfterLoad({
|
||||
element: element as FabricObject,
|
||||
options: { readonly },
|
||||
canvas,
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
canvas?.requestRenderAll();
|
||||
});
|
||||
}, [schema, canvas]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { type Canvas } from 'fabric';
|
||||
|
||||
import { createSnap, snap } from '../utils/snap/snap';
|
||||
|
||||
export const useSnapMove = ({
|
||||
canvas,
|
||||
helpLineLayerId,
|
||||
scale,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
helpLineLayerId: string;
|
||||
scale: number;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const _snap = createSnap(canvas, helpLineLayerId, scale);
|
||||
canvas.on('mouse:down', e => {
|
||||
snap.resetAllObjectsPosition(e.target);
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', e => {
|
||||
_snap.reset();
|
||||
});
|
||||
|
||||
canvas?.on('object:moving', function (e) {
|
||||
if (e.target) {
|
||||
_snap.move(e.target);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
_snap.destroy();
|
||||
};
|
||||
}, [canvas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (snap) {
|
||||
snap.helpline.resetScale(scale);
|
||||
}
|
||||
}, [scale]);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMemoizedFn, useUpdateEffect } from 'ahooks';
|
||||
|
||||
export interface Options<T> {
|
||||
defaultValue?: T | (() => T);
|
||||
}
|
||||
|
||||
const storage: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* 持久化保存到内存
|
||||
*/
|
||||
export function useStorageState<T>(key: string, options: Options<T> = {}) {
|
||||
function getStoredValue() {
|
||||
const raw = storage?.[key] ?? options?.defaultValue;
|
||||
return raw as T;
|
||||
}
|
||||
|
||||
const [state, setState] = useState<T>(getStoredValue);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
setState(getStoredValue());
|
||||
}, [key]);
|
||||
|
||||
const updateState = (value: T) => {
|
||||
setState(value);
|
||||
storage[key] = value;
|
||||
};
|
||||
|
||||
return [state, useMemoizedFn(updateState)] as const;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { type Point, type TMat2D, type Canvas } from 'fabric';
|
||||
|
||||
import { setViewport, zoomToPoint } from '../utils';
|
||||
import { type FabricSchema } from '../typings';
|
||||
|
||||
export const useViewport = ({
|
||||
canvas,
|
||||
schema,
|
||||
minZoom,
|
||||
maxZoom: maxZoom,
|
||||
}: {
|
||||
canvas?: Canvas;
|
||||
schema: FabricSchema;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
}) => {
|
||||
const [viewport, _setViewport] = useState<TMat2D>([1, 0, 0, 1, 0, 0]);
|
||||
|
||||
const setCanvasViewport = useCallback(
|
||||
(vpt: TMat2D) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const _vpt: TMat2D = [...vpt];
|
||||
// 限制 viewport 移动区域:不能移出画布
|
||||
if (_vpt[4] > 0) {
|
||||
_vpt[4] = 0;
|
||||
}
|
||||
|
||||
if (_vpt[4] < -schema.width * (_vpt[0] - 1)) {
|
||||
_vpt[4] = -schema.width * (_vpt[0] - 1);
|
||||
}
|
||||
|
||||
if (_vpt[5] > 0) {
|
||||
_vpt[5] = 0;
|
||||
}
|
||||
|
||||
if (_vpt[5] < -schema.height * (_vpt[0] - 1)) {
|
||||
_vpt[5] = -schema.height * (_vpt[0] - 1);
|
||||
}
|
||||
|
||||
setViewport({ canvas, vpt: _vpt });
|
||||
_setViewport(_vpt);
|
||||
canvas.fire('object:moving');
|
||||
},
|
||||
[canvas, schema, minZoom, maxZoom],
|
||||
);
|
||||
|
||||
const _zoomToPoint = useCallback(
|
||||
(point: Point, zoomLevel: number) => {
|
||||
const vpt = zoomToPoint({
|
||||
canvas,
|
||||
point,
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
});
|
||||
setCanvasViewport(vpt);
|
||||
},
|
||||
[setCanvasViewport],
|
||||
);
|
||||
|
||||
return {
|
||||
setViewport: setCanvasViewport,
|
||||
viewport,
|
||||
zoomToPoint: _zoomToPoint,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
25
frontend/packages/workflow/fabric-canvas/src/index.tsx
Normal file
25
frontend/packages/workflow/fabric-canvas/src/index.tsx
Normal 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';
|
||||
@@ -0,0 +1,3 @@
|
||||
这个目录放置了一些和 @flow-workflow/fabric-canvas-node-render 可以共用的函数、类型、常量...
|
||||
|
||||
因为 @flow-workflow/fabric-canvas-node-render 是一个 node 工程,要注意 share 中不要出现 tsx 文件,否则会导致 node 工程无法编译。
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
247
frontend/packages/workflow/fabric-canvas/src/share/font.ts
Normal file
247
frontend/packages/workflow/fabric-canvas/src/share/font.ts
Normal 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;
|
||||
};
|
||||
20
frontend/packages/workflow/fabric-canvas/src/share/index.ts
Normal file
20
frontend/packages/workflow/fabric-canvas/src/share/index.ts
Normal 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';
|
||||
109
frontend/packages/workflow/fabric-canvas/src/share/typings.ts
Normal file
109
frontend/packages/workflow/fabric-canvas/src/share/typings.ts
Normal 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__';
|
||||
142
frontend/packages/workflow/fabric-canvas/src/typings.ts
Normal file
142
frontend/packages/workflow/fabric-canvas/src/typings.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const getNumberBetween = ({
|
||||
value,
|
||||
max,
|
||||
min,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
min: number;
|
||||
}) => {
|
||||
if (value > max) {
|
||||
return max;
|
||||
}
|
||||
if (value < min) {
|
||||
return min;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
696
frontend/packages/workflow/fabric-canvas/src/utils/controls.tsx
Normal file
696
frontend/packages/workflow/fabric-canvas/src/utils/controls.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable max-lines */
|
||||
/* eslint-disable max-params */
|
||||
import {
|
||||
Control,
|
||||
controlsUtils,
|
||||
type FabricObject,
|
||||
type Transform,
|
||||
type TPointerEvent,
|
||||
type ControlCursorCallback,
|
||||
} from 'fabric';
|
||||
|
||||
import { Snap } from '../typings';
|
||||
import { snap } from './snap/snap';
|
||||
|
||||
const getAngle = (a: number) => {
|
||||
if (a >= 360) {
|
||||
return getAngle(a - 360);
|
||||
}
|
||||
if (a < 0) {
|
||||
return getAngle(a + 360);
|
||||
}
|
||||
return a;
|
||||
};
|
||||
|
||||
const svg0 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M12.87 22 10 19.13h1.99v-.659a6.483 6.483 0 0 1 6.482-6.482h.723V10l2.869 2.87-2.87 2.869v-1.99h-.722a4.722 4.722 0 0 0-4.722 4.722v.66h1.989L12.869 22" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M12.87 22 10 19.13h1.99v-.659a6.483 6.483 0 0 1 6.482-6.482h.723V10l2.869 2.87-2.87 2.869v-1.99h-.722a4.722 4.722 0 0 0-4.722 4.722v.66h1.989L12.869 22" clip-rule="evenodd"/><path fill="#fff" d="M10 19.13v-.8H8.068l1.366 1.367.566-.566M12.87 22l-.567.566.566.566.566-.566L12.87 22m-.88-2.87v.801h.8v-.8h-.8m6.482-7.141v-.8.8m.723 0v.8h.8v-.8h-.8m0-1.989.565-.566-1.366-1.366V10h.8m2.869 2.87.566.565.566-.566-.566-.566-.566.566m-2.87 2.869h-.8v1.932l1.366-1.366-.566-.566m0-1.99h.8v-.8h-.8v.8m-.722 0v-.8.8m-4.722 5.382h-.8v.8h.8v-.8m1.989 0 .566.566 1.366-1.367h-1.932v.8m-6.305.566 2.87 2.869 1.131-1.132-2.87-2.87-1.13 1.133m2.555-1.367H10v1.601h1.99v-1.6m-.8.142v.659h1.6v-.66h-1.6m7.283-7.284a7.283 7.283 0 0 0-7.283 7.283h1.6a5.682 5.682 0 0 1 5.683-5.682v-1.6m.723 0h-.723v1.601h.723v-1.6m.8.8V10h-1.6v1.989h1.6m-1.366-1.422 2.869 2.87 1.132-1.133-2.87-2.869-1.131 1.132m2.869 1.737-2.87 2.87 1.132 1.132 2.87-2.87-1.132-1.132m-1.503 3.436v-1.99h-1.6v1.99h1.6m-1.523-1.19h.723v-1.6h-.723v1.6m-3.922 3.922a3.922 3.922 0 0 1 3.922-3.921v-1.601a5.522 5.522 0 0 0-5.523 5.522h1.601m0 .66v-.66h-1.6v.66h1.6m1.189-.8h-1.99v1.6h1.99v-1.6m-2.304 4.235 2.87-2.87-1.132-1.131-2.87 2.87 1.132 1.13" mask="url(#b)"/></g><defs><filter id="a" width="18.728" height="18.665" x="6.268" y="7.268" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36430"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36430" result="shape"/></filter></defs></svg>';
|
||||
const svg90 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M10 12.87 12.87 10v1.99h.658a6.482 6.482 0 0 1 6.482 6.482v.722H22l-2.87 2.87-2.868-2.87h1.989v-.722a4.722 4.722 0 0 0-4.722-4.722h-.659v1.988L10 12.87" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M10 12.87 12.87 10v1.99h.658a6.482 6.482 0 0 1 6.482 6.482v.722H22l-2.87 2.87-2.868-2.87h1.989v-.722a4.722 4.722 0 0 0-4.722-4.722h-.659v1.988L10 12.87" clip-rule="evenodd"/><path fill="#fff" d="M12.87 10h.8V8.068l-1.367 1.366.566.566M10 12.87l-.566-.567-.566.566.566.566.566-.566m2.87-.88h-.801v.8h.8v-.8m7.14 7.204h-.8v.8h.8v-.8m1.989 0 .566.566 1.366-1.366H22v.8m-2.87 2.87-.565.565.566.566.566-.566-.566-.566m-2.868-2.87v-.8h-1.932l1.366 1.366.566-.566m1.989 0v.8h.8v-.8h-.8m-5.38-5.443v-.8h-.801v.8h.8m0 1.987-.567.566 1.366 1.366v-1.932h-.8m-.567-6.304-2.869 2.87 1.132 1.131 2.869-2.87-1.132-1.13m1.366 2.556V10h-1.6v1.99h1.6m-.141-.8h-.659v1.6h.659v-1.6m7.283 7.282a7.283 7.283 0 0 0-7.283-7.282v1.6a5.682 5.682 0 0 1 5.682 5.682h1.6m0 .722v-.722h-1.6v.722h1.6m-.8.8h1.988v-1.6H20.01v1.6m1.422-1.366-2.869 2.87 1.132 1.131 2.869-2.869-1.132-1.132m-1.737 2.87-2.87-2.87-1.131 1.132 2.869 2.87 1.132-1.132m-3.435-1.503h1.989v-1.6h-1.99v1.6m1.188-1.523v.722h1.601v-.722h-1.6m-3.921-3.921a3.921 3.921 0 0 1 3.921 3.921h1.601a5.522 5.522 0 0 0-5.522-5.522v1.6m-.659 0h.659v-1.6h-.659v1.6m.8 1.187v-1.987h-1.6v1.987h1.6m-4.235-2.303 2.87 2.87 1.131-1.133-2.87-2.869-1.13 1.132" mask="url(#b)"/></g><defs><filter id="a" width="18.663" height="18.727" x="7.068" y="7.268" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36425"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36425" result="shape"/></filter></defs></svg>';
|
||||
const svg180 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M19.13 10 22 12.87h-1.99v.659a6.483 6.483 0 0 1-6.482 6.482h-.723V22l-2.869-2.87 2.87-2.869v1.99h.722a4.722 4.722 0 0 0 4.722-4.723v-.659h-1.989L19.131 10" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M19.13 10 22 12.87h-1.99v.659a6.483 6.483 0 0 1-6.482 6.482h-.723V22l-2.869-2.87 2.87-2.869v1.99h.722a4.722 4.722 0 0 0 4.722-4.723v-.659h-1.989L19.131 10" clip-rule="evenodd"/><path fill="#fff" d="M22 12.87v.8h1.932l-1.366-1.367-.566.566M19.13 10l.567-.566-.566-.566-.566.566.566.566m.88 2.87v-.801h-.8v.8h.8m0 .659h-.8.8m-7.205 6.482v-.8h-.8v.8h.8m0 1.989-.565.566 1.366 1.366V22h-.8m-2.869-2.87-.566-.565-.566.566.566.566.566-.566m2.87-2.869h.8V14.33l-1.366 1.366.566.566m0 1.99h-.8v.8h.8v-.8m5.444-4.723h-.8.8m0-.659h.8v-.8h-.8v.8m-1.989 0-.566-.566-1.366 1.367h1.932v-.8m6.305-.566-2.87-2.869-1.131 1.132 2.87 2.87 1.13-1.133M20.01 13.67H22v-1.601h-1.99v1.6m.8-.142v-.659h-1.6v.66h1.6m-7.283 7.284a7.283 7.283 0 0 0 7.283-7.284h-1.6a5.682 5.682 0 0 1-5.683 5.683v1.6m-.723 0h.723V19.21h-.723v1.6m-.8-.8V22h1.6V20.01h-1.6m1.366 1.422-2.869-2.87-1.132 1.133 2.87 2.869 1.131-1.132m-2.869-1.737 2.87-2.87-1.132-1.132-2.87 2.87 1.132 1.132m1.503-3.436v1.99h1.6v-1.99h-1.6m1.523 1.19h-.723v1.6h.723v-1.6m3.922-3.923a3.922 3.922 0 0 1-3.922 3.922v1.601a5.522 5.522 0 0 0 5.522-5.522h-1.6m0-.659v.66h1.6v-.66h-1.6m-1.189.8h1.99v-1.6h-1.99v1.6m2.304-4.235-2.87 2.87 1.132 1.131 2.87-2.87-1.132-1.13" mask="url(#b)"/></g><defs><filter id="a" width="18.728" height="18.665" x="7.004" y="8.067" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36440"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36440" result="shape"/></filter></defs></svg>';
|
||||
const svg270 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><g filter="url(#a)"><mask id="b" width="16" height="16" x="8" y="8" fill="#000" maskUnits="userSpaceOnUse"><path fill="#fff" d="M8 8h16v16H8z"/><path fill-rule="evenodd" d="M22 19.13 19.13 22v-1.99h-.658a6.483 6.483 0 0 1-6.483-6.483v-.722H10l2.87-2.87 2.869 2.87h-1.99v.722a4.722 4.722 0 0 0 4.723 4.722h.659v-1.988L22 19.131" clip-rule="evenodd"/></mask><path fill="#000" fill-rule="evenodd" d="M22 19.13 19.13 22v-1.99h-.658a6.483 6.483 0 0 1-6.483-6.483v-.722H10l2.87-2.87 2.869 2.87h-1.99v.722a4.722 4.722 0 0 0 4.723 4.722h.659v-1.988L22 19.131" clip-rule="evenodd"/><path fill="#fff" d="M19.13 22h-.8v1.932l1.367-1.366L19.13 22M22 19.13l.566.567.566-.566-.566-.566-.566.566m-2.87.88h.801v-.8h-.8v.8m-7.141-6.483h.8-.8m0-.722h.8v-.8h-.8v.8m-1.989 0-.566-.566-1.366 1.366H10v-.8m2.87-2.87.565-.565-.566-.566-.566.566.566.566m2.869 2.87v.8h1.932l-1.366-1.366-.566.566m-1.99 0v-.8h-.8v.8h.8m0 .722h.801-.8m5.382 4.722v.8h.8v-.8h-.8m0-1.988.566-.566-1.367-1.366v1.932h.8m.566 6.305 2.869-2.87-1.132-1.131-2.87 2.87 1.133 1.13M18.33 20.01V22h1.601v-1.99h-1.6m.142.8h.659v-1.6h-.66v1.6m-7.284-7.283a7.283 7.283 0 0 0 7.284 7.283v-1.6a5.682 5.682 0 0 1-5.683-5.683h-1.6m0-.722v.722h1.601v-.722h-1.6m.8-.8H10v1.6h1.989v-1.6m-1.422 1.366 2.87-2.87-1.133-1.131-2.869 2.869 1.132 1.132m1.737-2.87 2.87 2.87 1.132-1.132-2.87-2.87-1.132 1.133m3.436 1.504h-1.99v1.6h1.99v-1.6m-1.189 1.522v-.722h-1.6v.722h1.6m3.922 3.922a3.922 3.922 0 0 1-3.922-3.922h-1.6a5.522 5.522 0 0 0 5.522 5.523v-1.601m.659 0h-.66v1.6h.66v-1.6m-.8-1.188v1.988h1.6v-1.988h-1.6m4.235 2.304-2.87-2.87-1.131 1.132 2.87 2.87 1.13-1.132" mask="url(#b)"/></g><defs><filter id="a" width="18.664" height="18.727" x="6.268" y="8.005" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1"/><feGaussianBlur stdDeviation=".9"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_184_36435"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_184_36435" result="shape"/></filter></defs></svg>';
|
||||
|
||||
const svg2Base64 = (svg: string) => `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||
|
||||
const cursorRotate0 = svg2Base64(svg0);
|
||||
const cursorRotate90 = svg2Base64(svg90);
|
||||
const cursorRotate180 = svg2Base64(svg180);
|
||||
const cursorRotate270 = svg2Base64(svg270);
|
||||
|
||||
const getCursor = (angle: number) => {
|
||||
const a = getAngle(angle);
|
||||
if (a >= 225 && a < 315) {
|
||||
return cursorRotate270;
|
||||
} else if (a >= 135 && a < 225) {
|
||||
return cursorRotate180;
|
||||
} else if (a >= 45 && a < 135) {
|
||||
return cursorRotate90;
|
||||
} else {
|
||||
return cursorRotate0;
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
scalingEqually,
|
||||
scaleCursorStyleHandler,
|
||||
rotationWithSnapping,
|
||||
scalingX,
|
||||
scalingY,
|
||||
} = controlsUtils;
|
||||
|
||||
type GetControls = (props?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
callback?: (data: { element: FabricObject }) => void;
|
||||
needResetScaleAndSnap?: boolean;
|
||||
}) => Control;
|
||||
/**
|
||||
* 直线起点控制点
|
||||
*/
|
||||
export const getLineStartControl: GetControls = (props = {}) => {
|
||||
const { x, y, callback } = props;
|
||||
return new Control({
|
||||
x,
|
||||
y,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
transformData.target.set({
|
||||
x1: _x,
|
||||
y1: _y,
|
||||
x2:
|
||||
transformData.lastX +
|
||||
transformData.width * (transformData.corner === 'tl' ? 1 : -1),
|
||||
y2: transformData.lastY + transformData.height,
|
||||
});
|
||||
|
||||
callback?.({ element: transformData.target });
|
||||
return true;
|
||||
},
|
||||
actionName: 'startControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 直线终点控制点
|
||||
*/
|
||||
export const getLineEndControl: GetControls = (props = {}) => {
|
||||
const { x, y, callback } = props;
|
||||
return new Control({
|
||||
x,
|
||||
y,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
transformData.target.set({
|
||||
x1:
|
||||
transformData.lastX -
|
||||
transformData.width * (transformData.corner === 'br' ? 1 : -1),
|
||||
y1: transformData.lastY - transformData.height,
|
||||
x2: _x,
|
||||
y2: _y,
|
||||
});
|
||||
|
||||
callback?.({ element: transformData.target });
|
||||
return true;
|
||||
},
|
||||
actionName: 'endControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
const originData = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
type LeftTopCalcFn = (originData: {
|
||||
angle: number;
|
||||
originTop: number;
|
||||
originLeft: number;
|
||||
originWidth: number;
|
||||
originHeight: number;
|
||||
newWidth: number;
|
||||
newHeight: number;
|
||||
}) => {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
const scaleToSize = (
|
||||
transformData: Transform,
|
||||
options?: {
|
||||
scaleEqual?: boolean;
|
||||
leftTopCalcFn?: LeftTopCalcFn;
|
||||
},
|
||||
) => {
|
||||
const { width, height, scaleX, scaleY, strokeWidth, angle } =
|
||||
transformData.target;
|
||||
|
||||
let targetWidth = Math.max((width + strokeWidth) * scaleX - strokeWidth, 1);
|
||||
let targetHeight = Math.max((height + strokeWidth) * scaleY - strokeWidth, 1);
|
||||
|
||||
if (options?.scaleEqual) {
|
||||
if (targetWidth - originData.width > targetHeight - originData.height) {
|
||||
targetHeight = (originData.height / originData.width) * targetWidth;
|
||||
} else {
|
||||
targetWidth = (originData.width / originData.height) * targetHeight;
|
||||
}
|
||||
}
|
||||
|
||||
let targetLeft = originData.left;
|
||||
let targetTop = originData.top;
|
||||
if (options?.leftTopCalcFn) {
|
||||
const rs = options.leftTopCalcFn({
|
||||
angle,
|
||||
originTop: originData.top,
|
||||
originLeft: originData.left,
|
||||
originWidth: originData.width,
|
||||
originHeight: originData.height,
|
||||
newWidth: targetWidth,
|
||||
newHeight: targetHeight,
|
||||
});
|
||||
targetLeft = rs.left;
|
||||
targetTop = rs.top;
|
||||
}
|
||||
|
||||
transformData.target.set({
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
// textBox 特化属性
|
||||
customFixedHeight: targetHeight,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
top: targetTop,
|
||||
left: targetLeft,
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 直接问 GPT:
|
||||
一个矩形,宽度 w ,高度 h
|
||||
将矩形顺时针旋转角度 a,左上角坐标为 x1 y1
|
||||
拉伸矩形左上角,使矩形右下角保持不变,宽度增加到 w1,高度增加到 h1
|
||||
求左上角坐标
|
||||
*/
|
||||
const calcLeftTopByTopLeft: LeftTopCalcFn = ({
|
||||
angle,
|
||||
originTop,
|
||||
originLeft,
|
||||
originWidth,
|
||||
originHeight,
|
||||
newWidth,
|
||||
newHeight,
|
||||
}) => {
|
||||
const anglePI = angle * (Math.PI / 180);
|
||||
return {
|
||||
left:
|
||||
originLeft +
|
||||
originWidth * Math.cos(anglePI) -
|
||||
originHeight * Math.sin(anglePI) -
|
||||
newWidth * Math.cos(anglePI) +
|
||||
newHeight * Math.sin(anglePI),
|
||||
top:
|
||||
originTop +
|
||||
originWidth * Math.sin(anglePI) +
|
||||
originData.height * Math.cos(anglePI) -
|
||||
newWidth * Math.sin(anglePI) -
|
||||
newHeight * Math.cos(anglePI),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 直接问 GPT:
|
||||
一个矩形,宽度 w ,高度 h
|
||||
将矩形顺时针旋转角度 a , 左上角坐标为 x1 y1
|
||||
拉伸矩形右上角,使矩形左下角保持不变,宽度增加到 w1,高度增加到 h1
|
||||
求左上角坐标
|
||||
(GPT 给的答案不准确,需要稍微理解下,修改加减)
|
||||
*/
|
||||
const calcLeftTopByTopRight: LeftTopCalcFn = ({
|
||||
angle,
|
||||
originTop,
|
||||
originLeft,
|
||||
originHeight,
|
||||
newHeight,
|
||||
}) => {
|
||||
const anglePI = angle * (Math.PI / 180);
|
||||
return {
|
||||
left: originLeft - (originHeight - newHeight) * Math.sin(anglePI),
|
||||
top: originTop + (originHeight - newHeight) * Math.cos(anglePI),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 直接问 GPT:
|
||||
一个矩形,宽度 w ,高度 h
|
||||
将矩形顺时针旋转角度 a , 左上角坐标为 x1 y1
|
||||
拉伸矩形左下角,使矩形右上角保持不变,宽度增加到 w1,高度增加到 h1
|
||||
求左上角坐标
|
||||
|
||||
GPT 给的答案不准确,这个比较麻烦,所以写出了每一步的推导过程
|
||||
*/
|
||||
const calcLeftTopByBottomLeft: LeftTopCalcFn = ({
|
||||
angle,
|
||||
originTop,
|
||||
originLeft,
|
||||
originWidth,
|
||||
newWidth,
|
||||
newHeight,
|
||||
}) => {
|
||||
// 将角度转换为弧度
|
||||
const aRad = (angle * Math.PI) / 180;
|
||||
|
||||
// 计算旋转后的右上角坐标
|
||||
const x2 = originLeft + originWidth * Math.cos(aRad);
|
||||
const y2 = originTop + originWidth * Math.sin(aRad);
|
||||
|
||||
// 计算拉伸后的左下角坐标
|
||||
const x3 = x2 - newHeight * Math.sin(aRad) - newWidth * Math.cos(aRad);
|
||||
const y3 = y2 + newHeight * Math.cos(aRad) - newWidth * Math.sin(aRad);
|
||||
|
||||
// 计算拉伸后的左上角坐标
|
||||
const x1New = x3 + newHeight * Math.sin(aRad);
|
||||
const y1New = y3 - newHeight * Math.cos(aRad);
|
||||
return {
|
||||
left: x1New,
|
||||
top: y1New,
|
||||
};
|
||||
};
|
||||
|
||||
const _mouseDownHandler = (
|
||||
e: TPointerEvent,
|
||||
transformData: Transform,
|
||||
): boolean => {
|
||||
originData.width = transformData.target.width;
|
||||
originData.height = transformData.target.height;
|
||||
originData.top = transformData.target.top;
|
||||
originData.left = transformData.target.left;
|
||||
return false;
|
||||
};
|
||||
|
||||
const cursorMap: Record<string, string> = {
|
||||
'e-resize': 'ew-resize',
|
||||
'w-resize': 'ew-resize',
|
||||
'n-resize': 'ns-resize',
|
||||
's-resize': 'ns-resize',
|
||||
'nw-resize': 'nwse-resize',
|
||||
'ne-resize': 'nesw-resize',
|
||||
'sw-resize': 'nesw-resize',
|
||||
'se-resize': 'nwse-resize',
|
||||
};
|
||||
const customCursorStyleHandler: ControlCursorCallback = (a, b, c) => {
|
||||
const cursor = scaleCursorStyleHandler(a, b, c);
|
||||
return cursorMap[cursor] ?? cursor;
|
||||
};
|
||||
|
||||
const _actionHandler = ({
|
||||
e,
|
||||
transformData,
|
||||
x,
|
||||
y,
|
||||
needResetScaleAndSnap,
|
||||
fn,
|
||||
callback,
|
||||
snapPosition,
|
||||
leftTopCalcFn,
|
||||
}: {
|
||||
e: TPointerEvent;
|
||||
transformData: Transform;
|
||||
x: number;
|
||||
y: number;
|
||||
needResetScaleAndSnap?: boolean;
|
||||
callback: ((data: { element: FabricObject }) => void) | undefined;
|
||||
fn: (
|
||||
eventData: TPointerEvent,
|
||||
transform: Transform,
|
||||
x: number,
|
||||
y: number,
|
||||
) => boolean;
|
||||
snapPosition: Snap.ControlType;
|
||||
leftTopCalcFn?: LeftTopCalcFn;
|
||||
}) => {
|
||||
const rs = fn(
|
||||
// 如果使用吸附则禁用默认缩放;否则取反
|
||||
{ ...e, shiftKey: needResetScaleAndSnap ? true : !e.shiftKey },
|
||||
transformData,
|
||||
x,
|
||||
y,
|
||||
);
|
||||
|
||||
if (needResetScaleAndSnap) {
|
||||
scaleToSize(transformData, {
|
||||
scaleEqual: e.shiftKey,
|
||||
leftTopCalcFn,
|
||||
});
|
||||
snap.resize(transformData.target, snapPosition);
|
||||
}
|
||||
callback?.({ element: transformData.target });
|
||||
return rs;
|
||||
};
|
||||
|
||||
/**
|
||||
* 上左
|
||||
*/
|
||||
export const getResizeTLControl: GetControls = (props = {}) => {
|
||||
const { callback, needResetScaleAndSnap = true } = props;
|
||||
return new Control({
|
||||
x: -0.5,
|
||||
y: -0.5,
|
||||
cursorStyleHandler: customCursorStyleHandler,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
const rs = _actionHandler({
|
||||
e,
|
||||
transformData,
|
||||
x: _x,
|
||||
y: _y,
|
||||
needResetScaleAndSnap,
|
||||
callback,
|
||||
fn: scalingEqually,
|
||||
leftTopCalcFn: calcLeftTopByTopLeft,
|
||||
snapPosition: Snap.ControlType.TopLeft,
|
||||
});
|
||||
return rs;
|
||||
},
|
||||
mouseDownHandler: _mouseDownHandler,
|
||||
actionName: 'resizeTLControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 上中
|
||||
*/
|
||||
export const getResizeMTControl: GetControls = (props = {}) => {
|
||||
const { callback, needResetScaleAndSnap = true } = props;
|
||||
return new Control({
|
||||
x: 0,
|
||||
y: -0.5,
|
||||
cursorStyleHandler: customCursorStyleHandler,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
const rs = _actionHandler({
|
||||
e,
|
||||
transformData,
|
||||
x: _x,
|
||||
y: _y,
|
||||
needResetScaleAndSnap,
|
||||
callback,
|
||||
fn: scalingY,
|
||||
leftTopCalcFn: calcLeftTopByTopLeft,
|
||||
snapPosition: Snap.ControlType.Top,
|
||||
});
|
||||
return rs;
|
||||
},
|
||||
mouseDownHandler: _mouseDownHandler,
|
||||
actionName: 'resizeMTControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 上右
|
||||
*/
|
||||
export const getResizeTRControl: GetControls = (props = {}) => {
|
||||
const { callback, needResetScaleAndSnap } = props;
|
||||
return new Control({
|
||||
x: 0.5,
|
||||
y: -0.5,
|
||||
cursorStyleHandler: customCursorStyleHandler,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
const rs = _actionHandler({
|
||||
e,
|
||||
transformData,
|
||||
x: _x,
|
||||
y: _y,
|
||||
needResetScaleAndSnap,
|
||||
callback,
|
||||
fn: scalingEqually,
|
||||
leftTopCalcFn: calcLeftTopByTopRight,
|
||||
snapPosition: Snap.ControlType.TopRight,
|
||||
});
|
||||
return rs;
|
||||
},
|
||||
mouseDownHandler: _mouseDownHandler,
|
||||
actionName: 'resizeTRControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 中左
|
||||
*/
|
||||
export const getResizeMLControl: GetControls = (props = {}) => {
|
||||
const { callback, needResetScaleAndSnap } = props;
|
||||
return new Control({
|
||||
x: -0.5,
|
||||
y: 0,
|
||||
cursorStyleHandler: customCursorStyleHandler,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
const rs = _actionHandler({
|
||||
e,
|
||||
transformData,
|
||||
x: _x,
|
||||
y: _y,
|
||||
needResetScaleAndSnap,
|
||||
callback,
|
||||
fn: scalingX,
|
||||
leftTopCalcFn: calcLeftTopByBottomLeft,
|
||||
snapPosition: Snap.ControlType.Left,
|
||||
});
|
||||
|
||||
return rs;
|
||||
},
|
||||
mouseDownHandler: _mouseDownHandler,
|
||||
actionName: 'resizeMLControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 中右
|
||||
*/
|
||||
export const getResizeMRControl: GetControls = (props = {}) => {
|
||||
const { callback, needResetScaleAndSnap } = props;
|
||||
return new Control({
|
||||
x: 0.5,
|
||||
y: 0,
|
||||
cursorStyleHandler: customCursorStyleHandler,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
const rs = _actionHandler({
|
||||
e,
|
||||
transformData,
|
||||
x: _x,
|
||||
y: _y,
|
||||
needResetScaleAndSnap,
|
||||
callback,
|
||||
fn: scalingX,
|
||||
snapPosition: Snap.ControlType.Right,
|
||||
});
|
||||
|
||||
return rs;
|
||||
},
|
||||
mouseDownHandler: _mouseDownHandler,
|
||||
actionName: 'resizeMRControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 下左
|
||||
*/
|
||||
export const getResizeBLControl: GetControls = (props = {}) => {
|
||||
const { callback, needResetScaleAndSnap } = props;
|
||||
return new Control({
|
||||
x: -0.5,
|
||||
y: 0.5,
|
||||
cursorStyleHandler: customCursorStyleHandler,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
const rs = _actionHandler({
|
||||
e,
|
||||
transformData,
|
||||
x: _x,
|
||||
y: _y,
|
||||
needResetScaleAndSnap,
|
||||
callback,
|
||||
fn: scalingEqually,
|
||||
leftTopCalcFn: calcLeftTopByBottomLeft,
|
||||
snapPosition: Snap.ControlType.BottomLeft,
|
||||
});
|
||||
|
||||
return rs;
|
||||
},
|
||||
mouseDownHandler: _mouseDownHandler,
|
||||
actionName: 'resizeBLControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 下中
|
||||
*/
|
||||
export const getResizeMBControl: GetControls = (props = {}) => {
|
||||
const { callback, needResetScaleAndSnap } = props;
|
||||
return new Control({
|
||||
x: 0,
|
||||
y: 0.5,
|
||||
cursorStyleHandler: customCursorStyleHandler,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
const rs = _actionHandler({
|
||||
e,
|
||||
transformData,
|
||||
x: _x,
|
||||
y: _y,
|
||||
needResetScaleAndSnap,
|
||||
callback,
|
||||
fn: scalingY,
|
||||
snapPosition: Snap.ControlType.Bottom,
|
||||
});
|
||||
|
||||
return rs;
|
||||
},
|
||||
mouseDownHandler: _mouseDownHandler,
|
||||
actionName: 'resizeMBControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 下右
|
||||
*/
|
||||
export const getResizeBRControl: GetControls = (props = {}) => {
|
||||
const { callback, needResetScaleAndSnap } = props;
|
||||
return new Control({
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
cursorStyleHandler: customCursorStyleHandler,
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
const rs = _actionHandler({
|
||||
e,
|
||||
transformData,
|
||||
x: _x,
|
||||
y: _y,
|
||||
needResetScaleAndSnap,
|
||||
callback,
|
||||
fn: scalingEqually,
|
||||
snapPosition: Snap.ControlType.BottomRight,
|
||||
});
|
||||
|
||||
return rs;
|
||||
},
|
||||
mouseDownHandler: _mouseDownHandler,
|
||||
actionName: 'resizeBRControl', // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
const _getRotateControl =
|
||||
({
|
||||
x,
|
||||
y,
|
||||
offsetY,
|
||||
offsetX,
|
||||
actionName,
|
||||
rotateStaff,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
offsetY: -1 | 1;
|
||||
offsetX: -1 | 1;
|
||||
actionName: string;
|
||||
rotateStaff: number;
|
||||
}): GetControls =>
|
||||
(props = {}) => {
|
||||
const { callback } = props;
|
||||
// 这个大小,取决于 resize 控制点的大小
|
||||
const offset = 12;
|
||||
return new Control({
|
||||
x,
|
||||
y,
|
||||
sizeX: 20,
|
||||
sizeY: 20,
|
||||
offsetY: offsetY * offset,
|
||||
offsetX: offsetX * offset,
|
||||
// 覆盖旋转控制点渲染,预期不显示,所以啥都没写
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
render: () => {},
|
||||
// 只能做到 hover 上时的 cursor,旋转过程中 cursor 无法修改
|
||||
cursorStyleHandler: (eventData, control, object) =>
|
||||
`url(${getCursor(object.angle + rotateStaff)}) 16 16, crosshair`,
|
||||
|
||||
actionHandler: (e, transformData, _x, _y) => {
|
||||
// 旋转吸附,单位:角度 一圈 = 360度
|
||||
if (e.shiftKey) {
|
||||
transformData.target.set({
|
||||
snapAngle: 15,
|
||||
});
|
||||
} else {
|
||||
transformData.target.set({
|
||||
snapAngle: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const rs = rotationWithSnapping(
|
||||
e,
|
||||
{ ...transformData, originX: 'center', originY: 'center' },
|
||||
_x,
|
||||
_y,
|
||||
);
|
||||
|
||||
// scaleToSize(transformData);
|
||||
|
||||
// transformData.target.canvas?.requestRenderAll();
|
||||
callback?.({ element: transformData.target });
|
||||
|
||||
return rs;
|
||||
},
|
||||
actionName, // 控制点的名称
|
||||
});
|
||||
};
|
||||
|
||||
// 上左旋转点
|
||||
export const getRotateTLControl: GetControls = (props = {}) =>
|
||||
_getRotateControl({
|
||||
x: -0.5,
|
||||
y: -0.5,
|
||||
offsetY: -1,
|
||||
offsetX: -1,
|
||||
rotateStaff: 0,
|
||||
actionName: 'rotateTLControl',
|
||||
})(props);
|
||||
|
||||
// 上右旋转点
|
||||
export const getRotateTRControl: GetControls = (props = {}) =>
|
||||
_getRotateControl({
|
||||
x: 0.5,
|
||||
y: -0.5,
|
||||
offsetY: -1,
|
||||
offsetX: 1,
|
||||
rotateStaff: 90,
|
||||
actionName: 'rotateTRControl',
|
||||
})(props);
|
||||
|
||||
// 下右旋转点
|
||||
export const getRotateBRControl: GetControls = (props = {}) =>
|
||||
_getRotateControl({
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
offsetY: 1,
|
||||
offsetX: 1,
|
||||
rotateStaff: 180,
|
||||
actionName: 'rotateBRControl',
|
||||
})(props);
|
||||
|
||||
// 下左旋转点
|
||||
export const getRotateBLControl: GetControls = (props = {}) =>
|
||||
_getRotateControl({
|
||||
x: -0.5,
|
||||
y: 0.5,
|
||||
offsetY: 1,
|
||||
offsetX: -1,
|
||||
rotateStaff: 270,
|
||||
actionName: 'rotateBLControl',
|
||||
})(props);
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Ellipse, type FabricObject, type Line } from 'fabric';
|
||||
|
||||
import { Mode } from '../typings';
|
||||
import { setImageFixed } from '../share';
|
||||
import { resetElementClip } from './fabric-utils';
|
||||
import {
|
||||
getLineEndControl,
|
||||
getLineStartControl,
|
||||
getResizeBLControl,
|
||||
getResizeBRControl,
|
||||
getResizeMBControl,
|
||||
getResizeMLControl,
|
||||
getResizeMRControl,
|
||||
getResizeMTControl,
|
||||
getResizeTLControl,
|
||||
getResizeTRControl,
|
||||
getRotateTLControl,
|
||||
getRotateTRControl,
|
||||
getRotateBLControl,
|
||||
getRotateBRControl,
|
||||
} from './controls';
|
||||
|
||||
export const setLineControlVisible = ({
|
||||
element,
|
||||
}: {
|
||||
element: FabricObject;
|
||||
}) => {
|
||||
const { x1, x2, y1, y2 } = element as Line;
|
||||
if ((x1 < x2 && y1 < y2) || (x1 > x2 && y1 > y2)) {
|
||||
element.setControlsVisibility({
|
||||
ml: false, // 中点左
|
||||
mr: false, // 中点右
|
||||
mt: false, // 中点上
|
||||
mb: false, // 中点下
|
||||
bl: false, // 底部左
|
||||
br: true, // 底部右
|
||||
tl: true, // 顶部左
|
||||
tr: false, // 顶部右
|
||||
mtr: false, // 旋转控制点
|
||||
});
|
||||
} else {
|
||||
element.setControlsVisibility({
|
||||
ml: false, // 中点左
|
||||
mr: false, // 中点右
|
||||
mt: false, // 中点上
|
||||
mb: false, // 中点下
|
||||
bl: true, // 底部左
|
||||
br: false, // 底部右
|
||||
tl: false, // 顶部左
|
||||
tr: true, // 顶部右
|
||||
mtr: false, // 旋转控制点
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setCircleRxRy = ({ element }: { element: FabricObject }) => {
|
||||
const { width, height } = element as Ellipse;
|
||||
element.set({ rx: width / 2, ry: height / 2 });
|
||||
};
|
||||
|
||||
const getCommonControl = ({
|
||||
element,
|
||||
needResetScaleAndSnap = true,
|
||||
}: {
|
||||
element: FabricObject;
|
||||
needResetScaleAndSnap?: boolean;
|
||||
}) => {
|
||||
element.setControlsVisibility({
|
||||
mtr: false, // 旋转控制点
|
||||
});
|
||||
// resize
|
||||
// 上左
|
||||
element.controls.tl = getResizeTLControl({
|
||||
needResetScaleAndSnap,
|
||||
});
|
||||
// 上中
|
||||
element.controls.mt = getResizeMTControl({
|
||||
needResetScaleAndSnap,
|
||||
});
|
||||
// 上右
|
||||
element.controls.tr = getResizeTRControl({
|
||||
needResetScaleAndSnap,
|
||||
});
|
||||
// 中左
|
||||
element.controls.ml = getResizeMLControl({
|
||||
needResetScaleAndSnap,
|
||||
});
|
||||
// 中右
|
||||
element.controls.mr = getResizeMRControl({
|
||||
needResetScaleAndSnap,
|
||||
});
|
||||
// 下左
|
||||
element.controls.bl = getResizeBLControl({
|
||||
needResetScaleAndSnap,
|
||||
});
|
||||
// 下中
|
||||
element.controls.mb = getResizeMBControl({
|
||||
needResetScaleAndSnap,
|
||||
});
|
||||
// 下右
|
||||
element.controls.br = getResizeBRControl({
|
||||
needResetScaleAndSnap,
|
||||
});
|
||||
|
||||
// rotate
|
||||
// 上左
|
||||
element.controls.tlr = getRotateTLControl();
|
||||
// 上右
|
||||
element.controls.trr = getRotateTRControl();
|
||||
// 下左
|
||||
element.controls.blr = getRotateBLControl();
|
||||
// 下右
|
||||
element.controls.brr = getRotateBRControl();
|
||||
};
|
||||
|
||||
export const createControls: Partial<
|
||||
Record<Mode, (data: { element: FabricObject }) => void>
|
||||
> = {
|
||||
[Mode.STRAIGHT_LINE]: ({ element }) => {
|
||||
setLineControlVisible({ element });
|
||||
|
||||
// 左上
|
||||
element.controls.tl = getLineStartControl({
|
||||
x: -0.5,
|
||||
y: -0.5,
|
||||
callback: setLineControlVisible,
|
||||
});
|
||||
// 右上
|
||||
element.controls.tr = getLineStartControl({
|
||||
x: 0.5,
|
||||
y: -0.5,
|
||||
callback: setLineControlVisible,
|
||||
});
|
||||
|
||||
// 左下
|
||||
element.controls.bl = getLineEndControl({
|
||||
x: -0.5,
|
||||
y: 0.5,
|
||||
callback: setLineControlVisible,
|
||||
});
|
||||
|
||||
// 右下
|
||||
element.controls.br = getLineEndControl({
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
callback: setLineControlVisible,
|
||||
});
|
||||
},
|
||||
|
||||
[Mode.RECT]: getCommonControl,
|
||||
[Mode.TRIANGLE]: getCommonControl,
|
||||
[Mode.PENCIL]: props => {
|
||||
getCommonControl({
|
||||
...props,
|
||||
needResetScaleAndSnap: false,
|
||||
});
|
||||
},
|
||||
[Mode.CIRCLE]: ({ element }) => {
|
||||
element.setControlsVisibility({
|
||||
mtr: false, // 旋转控制点
|
||||
});
|
||||
|
||||
const controlProps = {
|
||||
callback: setCircleRxRy,
|
||||
needResetScaleAndSnap: true,
|
||||
};
|
||||
element.controls.tl = getResizeTLControl(controlProps);
|
||||
element.controls.mt = getResizeMTControl(controlProps);
|
||||
element.controls.tr = getResizeTRControl(controlProps);
|
||||
element.controls.ml = getResizeMLControl(controlProps);
|
||||
element.controls.mr = getResizeMRControl(controlProps);
|
||||
element.controls.bl = getResizeBLControl(controlProps);
|
||||
element.controls.mb = getResizeMBControl(controlProps);
|
||||
element.controls.br = getResizeBRControl(controlProps);
|
||||
element.controls.tlr = getRotateTLControl(controlProps);
|
||||
element.controls.trr = getRotateTRControl(controlProps);
|
||||
element.controls.blr = getRotateBLControl(controlProps);
|
||||
element.controls.brr = getRotateBRControl(controlProps);
|
||||
},
|
||||
[Mode.BLOCK_TEXT]: ({ element }) => {
|
||||
element.setControlsVisibility({
|
||||
mtr: false, // 旋转控制点
|
||||
});
|
||||
|
||||
const controlProps = {
|
||||
callback: () => {
|
||||
resetElementClip({ element });
|
||||
element.fire('moving');
|
||||
},
|
||||
needResetScaleAndSnap: true,
|
||||
};
|
||||
element.controls.tl = getResizeTLControl(controlProps);
|
||||
element.controls.mt = getResizeMTControl(controlProps);
|
||||
element.controls.tr = getResizeTRControl(controlProps);
|
||||
element.controls.ml = getResizeMLControl(controlProps);
|
||||
element.controls.mr = getResizeMRControl(controlProps);
|
||||
element.controls.bl = getResizeBLControl(controlProps);
|
||||
element.controls.mb = getResizeMBControl(controlProps);
|
||||
element.controls.br = getResizeBRControl(controlProps);
|
||||
element.controls.tlr = getRotateTLControl(controlProps);
|
||||
element.controls.trr = getRotateTRControl(controlProps);
|
||||
element.controls.blr = getRotateBLControl(controlProps);
|
||||
element.controls.brr = getRotateBRControl(controlProps);
|
||||
},
|
||||
[Mode.INLINE_TEXT]: ({ element }) => {
|
||||
element.setControlsVisibility({
|
||||
mtr: false, // 旋转控制点
|
||||
});
|
||||
|
||||
element.controls.tlr = getRotateTLControl();
|
||||
element.controls.trr = getRotateTRControl();
|
||||
element.controls.blr = getRotateBLControl();
|
||||
element.controls.brr = getRotateBRControl();
|
||||
},
|
||||
[Mode.IMAGE]: ({ element }) => {
|
||||
element.setControlsVisibility({
|
||||
mtr: false, // 旋转控制点
|
||||
});
|
||||
|
||||
const controlProps = {
|
||||
callback: () => {
|
||||
setImageFixed({ element });
|
||||
resetElementClip({ element });
|
||||
element.fire('moving');
|
||||
},
|
||||
needResetScaleAndSnap: true,
|
||||
};
|
||||
element.controls.tl = getResizeTLControl(controlProps);
|
||||
element.controls.mt = getResizeMTControl(controlProps);
|
||||
element.controls.tr = getResizeTRControl(controlProps);
|
||||
element.controls.ml = getResizeMLControl(controlProps);
|
||||
element.controls.mr = getResizeMRControl(controlProps);
|
||||
element.controls.bl = getResizeBLControl(controlProps);
|
||||
element.controls.mb = getResizeMBControl(controlProps);
|
||||
element.controls.br = getResizeBRControl(controlProps);
|
||||
element.controls.tlr = getRotateTLControl(controlProps);
|
||||
element.controls.trr = getRotateTRControl(controlProps);
|
||||
element.controls.blr = getRotateBLControl(controlProps);
|
||||
element.controls.brr = getRotateBRControl(controlProps);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ImageFixedType,
|
||||
Mode,
|
||||
TextAlign,
|
||||
type FabricObjectSchema,
|
||||
} from '../typings';
|
||||
|
||||
/**
|
||||
* 选中态边框及控制点样式
|
||||
*/
|
||||
export const selectedBorderProps = {
|
||||
borderColor: '#4D53E8',
|
||||
borderWidth: 2,
|
||||
cornerStyle: 'circle',
|
||||
cornerColor: '#ffffff',
|
||||
cornerStrokeColor: '#4D53E8',
|
||||
transparentCorners: false,
|
||||
borderOpacityWhenMoving: 0.8,
|
||||
};
|
||||
|
||||
const defaultFontSize = 24;
|
||||
const textProps = {
|
||||
fontSize: defaultFontSize,
|
||||
fontFamily: '常规体-思源黑体',
|
||||
fill: '#000000ff',
|
||||
stroke: '#000000ff',
|
||||
strokeWidth: 0,
|
||||
textAlign: TextAlign.LEFT,
|
||||
lineHeight: 1.2,
|
||||
};
|
||||
|
||||
const shapeProps = {
|
||||
fill: '#ccccccff',
|
||||
stroke: '#000000ff',
|
||||
strokeWidth: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
};
|
||||
|
||||
export const defaultProps: Record<Mode, Partial<FabricObjectSchema>> = {
|
||||
[Mode.INLINE_TEXT]: textProps,
|
||||
[Mode.BLOCK_TEXT]: {
|
||||
...textProps,
|
||||
width: 200,
|
||||
height: 200,
|
||||
padding: defaultFontSize / 2,
|
||||
// 必须拆分(true),否则中文不会换行。splitByGrapheme:true 约等于 wordBreak: break-all
|
||||
splitByGrapheme: true,
|
||||
},
|
||||
[Mode.RECT]: shapeProps,
|
||||
[Mode.CIRCLE]: {
|
||||
...shapeProps,
|
||||
rx: shapeProps.width / 2,
|
||||
ry: shapeProps.height / 2,
|
||||
},
|
||||
[Mode.TRIANGLE]: shapeProps,
|
||||
[Mode.STRAIGHT_LINE]: {
|
||||
strokeWidth: 1,
|
||||
stroke: '#ccccccff',
|
||||
strokeLineCap: 'round',
|
||||
},
|
||||
[Mode.PENCIL]: {
|
||||
strokeWidth: 1,
|
||||
stroke: '#000000ff',
|
||||
},
|
||||
[Mode.IMAGE]: {
|
||||
customFixedType: ImageFixedType.FILL,
|
||||
stroke: '#000000ff',
|
||||
strokeWidth: 0,
|
||||
width: 400,
|
||||
height: 400,
|
||||
opacity: 1,
|
||||
},
|
||||
[Mode.GROUP]: {},
|
||||
};
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
/* eslint-disable complexity */
|
||||
/**
|
||||
* 托管所有的图形创建,赋予业务改造
|
||||
* 调用时机
|
||||
* 1. 初次创建
|
||||
* 2. loadFromSchema
|
||||
*/
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
Ellipse,
|
||||
FabricImage,
|
||||
Group,
|
||||
IText,
|
||||
Line,
|
||||
Rect,
|
||||
Textbox,
|
||||
Triangle,
|
||||
type Canvas,
|
||||
FabricObject,
|
||||
type ObjectEvents,
|
||||
} from 'fabric';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
|
||||
import {
|
||||
Mode,
|
||||
type FabricObjectWithCustomProps,
|
||||
type FabricObjectSchema,
|
||||
} from '../typings';
|
||||
import { setImageFixed } from '../share';
|
||||
import { resetElementClip } from './fabric-utils';
|
||||
import { defaultProps } from './default-props';
|
||||
import { createControls, setLineControlVisible } from './create-controls';
|
||||
|
||||
/**
|
||||
* 覆盖默认的 Textbox height 计算逻辑
|
||||
* 默认:根据内容,撑起 Textbox
|
||||
* 预期:严格按照给定高度渲染,溢出隐藏
|
||||
*/
|
||||
const _calcTextHeight = Textbox.prototype.calcTextHeight;
|
||||
Textbox.prototype.calcTextHeight = function () {
|
||||
return ((this as Textbox & { customFixedHeight?: number })
|
||||
.customFixedHeight ?? _calcTextHeight.call(this)) as number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 修复 fabric bug:使用某些自定义字体后,Text 宽度计算异常
|
||||
* 修复方案 from https://github.com/fabricjs/fabric.js/issues/9852
|
||||
*/
|
||||
IText.getDefaults = () => ({});
|
||||
// for each class in the chain that has a ownDefaults object:
|
||||
Object.assign(IText.prototype, Textbox.ownDefaults);
|
||||
Object.assign(Text.prototype, IText.ownDefaults);
|
||||
Object.assign(FabricObject.prototype, FabricObject.ownDefaults);
|
||||
|
||||
const textDefaultText = I18n.t('imageflow_canvas_text_default');
|
||||
const textBoxDefaultText = I18n.t('imageflow_canvas_text_default');
|
||||
|
||||
export const createCommonObjectOptions = (
|
||||
mode: Mode,
|
||||
): Partial<FabricObjectWithCustomProps> => ({
|
||||
customId: nanoid(),
|
||||
customType: mode as Mode,
|
||||
});
|
||||
|
||||
/**
|
||||
* 元素创建入口,所有的元素创建逻辑都走这里
|
||||
*/
|
||||
export const createElement = async ({
|
||||
mode,
|
||||
position,
|
||||
element,
|
||||
elementProps = {},
|
||||
canvas,
|
||||
}: {
|
||||
mode?: Mode;
|
||||
position?: [x?: number, y?: number];
|
||||
canvas?: Canvas;
|
||||
element?: FabricObjectWithCustomProps;
|
||||
elementProps?: Partial<FabricObjectSchema>;
|
||||
}): Promise<FabricObject | undefined> => {
|
||||
const left = element?.left ?? position?.[0] ?? 0;
|
||||
const top = element?.top ?? position?.[1] ?? 0;
|
||||
|
||||
const _mode = mode ?? element?.customType;
|
||||
|
||||
const commonNewObjectOptions = createCommonObjectOptions(_mode as Mode);
|
||||
|
||||
switch (_mode) {
|
||||
case Mode.RECT: {
|
||||
let _element: FabricObject | undefined = element;
|
||||
if (!_element) {
|
||||
_element = new Rect({
|
||||
...commonNewObjectOptions,
|
||||
...defaultProps[_mode],
|
||||
...elementProps,
|
||||
left,
|
||||
top,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
}
|
||||
|
||||
createControls[_mode]?.({
|
||||
element: _element,
|
||||
});
|
||||
return _element;
|
||||
}
|
||||
case Mode.CIRCLE: {
|
||||
let _element: FabricObject | undefined = element;
|
||||
if (!_element) {
|
||||
_element = new Ellipse({
|
||||
...commonNewObjectOptions,
|
||||
...defaultProps[_mode],
|
||||
...elementProps,
|
||||
left,
|
||||
top,
|
||||
rx: 1,
|
||||
ry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
createControls[_mode]?.({
|
||||
element: _element,
|
||||
});
|
||||
return _element;
|
||||
}
|
||||
case Mode.TRIANGLE: {
|
||||
let _element: FabricObject | undefined = element;
|
||||
if (!_element) {
|
||||
_element = new Triangle({
|
||||
...commonNewObjectOptions,
|
||||
...defaultProps[_mode],
|
||||
...elementProps,
|
||||
left,
|
||||
top,
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
}
|
||||
|
||||
createControls[_mode]?.({
|
||||
element: _element as Triangle,
|
||||
});
|
||||
return _element;
|
||||
}
|
||||
|
||||
case Mode.STRAIGHT_LINE: {
|
||||
let _element: FabricObject | undefined = element;
|
||||
if (!_element) {
|
||||
_element = new Line([left, top, left, top], {
|
||||
...commonNewObjectOptions,
|
||||
...defaultProps[_mode],
|
||||
...elementProps,
|
||||
});
|
||||
|
||||
// 创建直线时的起始点不是通过控制点触发的,需要额外监听
|
||||
_element.on('start-end:modified' as keyof ObjectEvents, () => {
|
||||
setLineControlVisible({
|
||||
element: _element as Line,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createControls[_mode]?.({
|
||||
element: _element as Line,
|
||||
});
|
||||
return _element;
|
||||
}
|
||||
|
||||
case Mode.INLINE_TEXT: {
|
||||
let _element: FabricObject | undefined = element;
|
||||
|
||||
if (!_element) {
|
||||
_element = new IText(elementProps?.text ?? textDefaultText, {
|
||||
...commonNewObjectOptions,
|
||||
...defaultProps[_mode],
|
||||
...elementProps,
|
||||
left,
|
||||
top,
|
||||
});
|
||||
}
|
||||
|
||||
createControls[_mode]?.({
|
||||
element: _element,
|
||||
});
|
||||
|
||||
return _element;
|
||||
}
|
||||
|
||||
case Mode.BLOCK_TEXT: {
|
||||
let _element: FabricObject | undefined = element;
|
||||
const width = elementProps?.width ?? _element?.width ?? 1;
|
||||
const height = elementProps?.height ?? _element?.height ?? 1;
|
||||
|
||||
if (!_element) {
|
||||
_element = new Textbox(
|
||||
(elementProps?.text as string) ?? textBoxDefaultText,
|
||||
{
|
||||
...commonNewObjectOptions,
|
||||
...defaultProps[_mode],
|
||||
customFixedHeight: height,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
...elementProps,
|
||||
},
|
||||
);
|
||||
const rect = new Rect();
|
||||
|
||||
_element.set({
|
||||
clipPath: rect,
|
||||
});
|
||||
}
|
||||
resetElementClip({ element: _element as FabricObject });
|
||||
|
||||
createControls[_mode]?.({
|
||||
element: _element,
|
||||
});
|
||||
|
||||
return _element;
|
||||
}
|
||||
|
||||
case Mode.IMAGE: {
|
||||
let _element: FabricObject | undefined = element;
|
||||
|
||||
/**
|
||||
* FabricImage 不支持拉伸/自适应
|
||||
* 需要用 Group 包一下,根据 Group 大小,计算 image 的位置
|
||||
* 1. customId customType 要给到 Group。(根其他元素保持一致,从 element 上能直接取到)
|
||||
* 2. 边框的相关设置要给到图片
|
||||
*
|
||||
* 因此而产生的 Hack:
|
||||
* 1. 属性表单根据 schema 解析成 formValue ,需要取 groupSchema.objects[0]
|
||||
* 2. 属性表单设置元素属性(边框),需要调用 group.getObjects()[0].set
|
||||
*/
|
||||
if (!_element) {
|
||||
if (elementProps?.src) {
|
||||
const img = await FabricImage.fromURL(elementProps?.src);
|
||||
|
||||
const defaultWidth = defaultProps[_mode].width as number;
|
||||
const defaultHeight = defaultProps[_mode].height as number;
|
||||
const defaultLeft =
|
||||
left ??
|
||||
((elementProps?.left ?? defaultProps[_mode].left) as number);
|
||||
const defaultTop =
|
||||
top ?? ((elementProps?.top ?? defaultProps[_mode].top) as number);
|
||||
|
||||
/**
|
||||
* stroke, strokeWidth 设置给 borderRect objects[1]
|
||||
*/
|
||||
const { stroke, strokeWidth, ...rest } = {
|
||||
...defaultProps[_mode],
|
||||
...elementProps,
|
||||
};
|
||||
img.set(rest);
|
||||
|
||||
_element = new Group([img]);
|
||||
|
||||
const groupProps = {
|
||||
...commonNewObjectOptions,
|
||||
left: defaultLeft,
|
||||
top: defaultTop,
|
||||
width: defaultWidth,
|
||||
height: defaultHeight,
|
||||
customId: elementProps?.customId ?? commonNewObjectOptions.customId,
|
||||
};
|
||||
|
||||
const borderRect = new Rect({
|
||||
width: groupProps.width,
|
||||
height: groupProps.height,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
fill: '#00000000',
|
||||
});
|
||||
|
||||
(_element as Group).add(borderRect);
|
||||
_element.set(groupProps);
|
||||
|
||||
// 比例填充时,图片会溢出,所以加了裁剪
|
||||
const clipRect = new Rect();
|
||||
_element.set({
|
||||
clipPath: clipRect,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resetElementClip({ element: _element as FabricObject });
|
||||
|
||||
// 计算 image 的渲染位置
|
||||
setImageFixed({
|
||||
element: _element as Group,
|
||||
});
|
||||
|
||||
createControls[_mode]?.({
|
||||
element: _element as FabricImage,
|
||||
});
|
||||
|
||||
return _element;
|
||||
}
|
||||
|
||||
case Mode.GROUP: {
|
||||
let _element: FabricObject | undefined = element;
|
||||
if (!_element) {
|
||||
const { objects = [] } = elementProps;
|
||||
_element = new Group(objects as unknown as FabricObject[], {
|
||||
...commonNewObjectOptions,
|
||||
...defaultProps[_mode],
|
||||
...elementProps,
|
||||
});
|
||||
}
|
||||
return _element;
|
||||
}
|
||||
|
||||
case Mode.PENCIL: {
|
||||
if (element) {
|
||||
createControls[_mode]?.({
|
||||
element,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
}
|
||||
default:
|
||||
return element;
|
||||
}
|
||||
};
|
||||
|
||||
// hook element 加载到画布
|
||||
export const setElementAfterLoad = async ({
|
||||
element,
|
||||
options: { readonly },
|
||||
canvas,
|
||||
}: {
|
||||
element: FabricObject;
|
||||
options: { readonly: boolean };
|
||||
canvas?: Canvas;
|
||||
}) => {
|
||||
element.selectable = !readonly;
|
||||
await createElement({
|
||||
element: element as FabricObjectWithCustomProps,
|
||||
canvas,
|
||||
});
|
||||
|
||||
if (readonly) {
|
||||
element.set({
|
||||
hoverCursor: 'default',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Canvas,
|
||||
type FabricObject,
|
||||
type Point,
|
||||
type TMat2D,
|
||||
type Textbox,
|
||||
} from 'fabric';
|
||||
|
||||
import { Mode, type FabricObjectWithCustomProps } from '../typings';
|
||||
|
||||
/**
|
||||
* 缩放到指定点
|
||||
*/
|
||||
export const zoomToPoint = ({
|
||||
canvas,
|
||||
point,
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
}: {
|
||||
point: Point;
|
||||
zoomLevel: number;
|
||||
canvas?: Canvas;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
}): TMat2D => {
|
||||
// 设置缩放级别的限制
|
||||
zoomLevel = Math.max(zoomLevel, minZoom); // 最小缩放级别
|
||||
zoomLevel = Math.min(zoomLevel, maxZoom); // 最大缩放级别
|
||||
|
||||
// 以鼠标位置为中心进行缩放
|
||||
canvas?.zoomToPoint(point, zoomLevel);
|
||||
return [...(canvas?.viewportTransform as TMat2D)];
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置 canvas 视图
|
||||
*/
|
||||
export const setViewport = ({
|
||||
canvas,
|
||||
vpt,
|
||||
}: {
|
||||
vpt: TMat2D;
|
||||
canvas?: Canvas;
|
||||
}): TMat2D => {
|
||||
canvas?.setViewportTransform(vpt);
|
||||
canvas?.requestRenderAll();
|
||||
return [...(canvas?.viewportTransform as TMat2D)];
|
||||
};
|
||||
|
||||
/**
|
||||
* 画布坐标点距离画布左上角距离(单位:px)
|
||||
*/
|
||||
export const canvasXYToScreen = ({
|
||||
canvas,
|
||||
scale,
|
||||
point,
|
||||
}: {
|
||||
canvas: Canvas;
|
||||
scale: number;
|
||||
point: { x: number; y: number };
|
||||
}) => {
|
||||
// 获取画布的变换矩阵
|
||||
const transform = canvas.viewportTransform;
|
||||
|
||||
// 应用缩放和平移
|
||||
const zoomX = transform[0];
|
||||
const zoomY = transform[3];
|
||||
const translateX = transform[4];
|
||||
const translateY = transform[5];
|
||||
|
||||
const screenX = (point.x * zoomX + translateX) * scale;
|
||||
const screenY = (point.y * zoomY + translateY) * scale;
|
||||
|
||||
// 获取画布在屏幕上的位置
|
||||
const x = screenX;
|
||||
const y = screenY;
|
||||
|
||||
// 不做限制
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 得到选中元素的屏幕坐标(左上 tl、右下 br)
|
||||
*/
|
||||
export const getPopPosition = ({
|
||||
canvas,
|
||||
scale,
|
||||
}: {
|
||||
canvas: Canvas;
|
||||
scale: number;
|
||||
}) => {
|
||||
const selection = canvas?.getActiveObject();
|
||||
if (canvas && selection) {
|
||||
const boundingRect = selection.getBoundingRect();
|
||||
|
||||
// 左上角坐标
|
||||
const tl = {
|
||||
x: boundingRect.left,
|
||||
y: boundingRect.top,
|
||||
};
|
||||
|
||||
// 右下角坐标
|
||||
const br = {
|
||||
x: boundingRect.left + boundingRect.width,
|
||||
y: boundingRect.top + boundingRect.height,
|
||||
};
|
||||
|
||||
return {
|
||||
tl: canvasXYToScreen({ canvas, scale, point: tl }),
|
||||
br: canvasXYToScreen({ canvas, scale, point: br }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tl: {
|
||||
x: -9999,
|
||||
y: -9999,
|
||||
},
|
||||
br: {
|
||||
x: -9999,
|
||||
y: -9999,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resetElementClip = ({ element }: { element: FabricObject }) => {
|
||||
if (!element.clipPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipRect = element.clipPath;
|
||||
const padding = (element as Textbox).padding ?? 0;
|
||||
|
||||
const { height, width } = element as FabricObject;
|
||||
|
||||
const _height = height + padding * 2;
|
||||
const _width = width + padding * 2;
|
||||
|
||||
const newPosition = {
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
left: -_width / 2,
|
||||
top: -_height / 2,
|
||||
height: _height,
|
||||
width: _width,
|
||||
absolutePositioned: false,
|
||||
};
|
||||
|
||||
clipRect?.set(newPosition);
|
||||
};
|
||||
|
||||
export const isGroupElement = (obj?: FabricObject) =>
|
||||
(obj as FabricObjectWithCustomProps)?.customType === Mode.GROUP;
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type Canvas, type IText } from 'fabric';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Mode, type FabricSchema } from '../typings';
|
||||
import { getFontUrl, supportFonts } from '../assert/font';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const loadFont = async (font: string): Promise<void> => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [font],
|
||||
queryFn: async () => {
|
||||
if (supportFonts.includes(font)) {
|
||||
const url = getFontUrl(font);
|
||||
const fontFace = new FontFace(font, `url(${url})`);
|
||||
document.fonts.add(fontFace);
|
||||
await fontFace.load();
|
||||
}
|
||||
return font;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const loadFontWithSchema = ({
|
||||
schema,
|
||||
canvas,
|
||||
fontFamily,
|
||||
}: {
|
||||
schema?: FabricSchema;
|
||||
canvas?: Canvas;
|
||||
fontFamily?: string;
|
||||
}) => {
|
||||
let fonts: string[] = fontFamily ? [fontFamily] : [];
|
||||
if (schema) {
|
||||
fonts = schema.objects
|
||||
.filter(o => [Mode.INLINE_TEXT, Mode.BLOCK_TEXT].includes(o.customType))
|
||||
.map(o => o.fontFamily) as string[];
|
||||
fonts = Array.from(new Set(fonts));
|
||||
}
|
||||
|
||||
fonts.forEach(async font => {
|
||||
await loadFont(font);
|
||||
canvas
|
||||
?.getObjects()
|
||||
.filter(o => (o as IText)?.fontFamily === font)
|
||||
.forEach(o => {
|
||||
o.set({
|
||||
fontFamily: font,
|
||||
});
|
||||
});
|
||||
canvas?.requestRenderAll();
|
||||
});
|
||||
};
|
||||
35
frontend/packages/workflow/fabric-canvas/src/utils/index.tsx
Normal file
35
frontend/packages/workflow/fabric-canvas/src/utils/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
createElement,
|
||||
setElementAfterLoad,
|
||||
createCommonObjectOptions,
|
||||
} from './element-factory';
|
||||
export {
|
||||
canvasXYToScreen,
|
||||
getPopPosition,
|
||||
setViewport,
|
||||
zoomToPoint,
|
||||
} from './fabric-utils';
|
||||
|
||||
export { schemaToFormValue } from './schema-to-form-value';
|
||||
|
||||
export { loadFontWithSchema, loadFont } from './font-loader';
|
||||
|
||||
export { defaultProps, selectedBorderProps } from './default-props';
|
||||
|
||||
export { getNumberBetween } from './common';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user