Files
coze-studio/frontend/packages/workflow/fabric-canvas/src/hooks/use-active-object-change.tsx
2025-07-31 23:15:48 +08:00

480 lines
13 KiB
TypeScript

/*
* 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';
// Set element properties
const setElementProps = async ({
element,
props,
canvas,
}: {
element: FabricObject;
props: Partial<FabricObjectSchema>;
canvas?: Canvas;
}): Promise<void> => {
// Specialization 1: The attribute settings of img need to be set to the img element, not the outer wrapped 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;
// Set the border color to borderRect
if (stroke) {
borderRect.set({
stroke,
});
}
// The border thickness is set to borderRect
if (typeof strokeWidth === 'number') {
borderRect.set({
strokeWidth,
});
}
// Replace image
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 {
// Specialization 2: Text and paragraph switching requires specialized processing
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
? // Block - > single row
{}
: // Single Row - > Block
{
// Cut the block in a single line, and try to keep the font size unchanged.
fontSize: newFontSize,
padding: newFontSize / 4,
width: 200,
height: 200,
}),
},
});
// If there are other properties, set them to the new element
if (Object.keys(rest).length > 0) {
newElement?.set(rest);
}
// Add new ones in the correct order, otherwise the reference relationship will be determined to be useless and deleted when deleting.
canvas?.add(newElement as FabricObject);
// Delete the old one
canvas?.remove(oldElement);
canvas?.discardActiveObject();
canvas?.setActiveObject(newElement as FabricObject);
canvas?.requestRenderAll();
// Normal property settings
} else {
const { fontFamily } = props;
// Specialization 3: Fonts need to be loaded asynchronously
if (fontFamily) {
await loadFont(fontFamily);
}
/**
* textBox is disgusting. I don't know when to generate a style file for each word (corresponding styles).
* Take the initiative to clear it here, otherwise the font-related settings (fontSize, fontFamily...) will not take effect
*/
if (element?.isType('textbox')) {
element?.set({
styles: {},
});
}
// Specialization 4: padding = fontSize/2, to avoid text being truncated up and down
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);
/**
* Why disable control points with multiple elements selected?
* Since a straight line does not expect rotation, rotation affects the computational logic of the control points.
* To remove this restriction, you need to consider the rotation & scaling factor within the control points of the line
*/
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]);
// When the window size changes, correct the position
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');
};
// To shift horizontally/vertically
useEffect(() => {
if (!canvas) {
return;
}
let originalPos = { left: 0, top: 0 };
const disposers = [
// Listening object movement start event
canvas.on('object:moving', function (e) {
const obj = e.target;
// Manual canvas.fire ('object: moving') cannot get obj
if (!obj) {
return;
}
// If it is the first move, record the original position of the object
if (originalPos.left === 0 && originalPos.top === 0) {
originalPos = { left: obj.left, top: obj.top };
}
// Check if the Shift key is pressed
if (e?.e?.shiftKey) {
// Calculate the horizontal and vertical distance since the start of the movement
const distanceX = obj.left - originalPos.left;
const distanceY = obj.top - originalPos.top;
// Determine whether to move horizontally or vertically according to the absolute value of the moving distance
if (Math.abs(distanceX) > Math.abs(distanceY)) {
// Horizontal movement: maintain the same vertical position
obj.set('top', originalPos.top);
} else {
// Vertical movement: maintain the same horizontal position
obj.set('left', originalPos.left);
}
}
}),
// Listening Object Move End Event
canvas.on('object:modified', function (e) {
// Reset original position after move
originalPos = { left: 0, top: 0 };
}),
];
return () => {
disposers.forEach(disposer => disposer?.());
};
}, [canvas]);
const controlsVisibility = useRef<
| {
[key: string]: boolean;
}
| undefined
>();
// Hide control points during element movement
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 rule: undefined is considered true
{
ml: true, // Midpoint left
mr: true, // Midpoint right
mt: true, // midpoint
mb: true, // midpoint
bl: true, // Bottom left
br: true, // Bottom right
tl: true, // Top Left
tr: true, // Top right
},
element._controlsVisibility,
);
}
element.setControlsVisibility({
ml: false, // Midpoint left
mr: false, // Midpoint right
mt: false, // midpoint
mb: false, // midpoint
bl: false, // Bottom left
br: false, // Bottom right
tl: false, // Top Left
tr: false, // Top right
});
}),
);
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,
};
};