/* * 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`; // Properties that require additional saving export const saveProps = [ 'width', 'height', 'editable', 'text', 'backgroundColor', 'padding', // custom parameters // The true height of the textBox 'customFixedHeight', // Element ID 'customId', // element type 'customType', // Adaptive mode of image 'customFixedType', // The title of the element generated by the variable // reference relationship '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( schema?.customVariableRefs ?? [], ); // Delete reference relationships that do not exist in the canvas 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; }, [], ); // Monitor canvas changes useEffect(() => { if (canvas && onChangeLatest.current && isListen) { const _onChange = ({ isRemove }: { isRemove: boolean }) => { const json = canvas.toObject(saveProps) as FabricSchema; // When deleting, delete the invalid ref by the way. 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]); /** * Generate new elements with references */ 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 no existing element is passed in, a new element is created 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) { // Update reference relationship cacheCustomVariableRefs.current.push({ variableId: id as string, objectId: (_element as FabricObjectWithCustomProps) .customId as string, variableName: name, }); // Add to canvas and activate canvas.add(_element); canvas.setActiveObject(_element); } }, [canvas], ); /** * Update the reference relationship of the element specifying objectId * If the variable is empty, remove the reference * If variable is not empty & & customVariableRefs already has a correspondence, update the reference * If variable is not empty & & customVariableRefs does not have a correspondence, add a reference * */ const updateRefByObjectId = useCallback( ({ objectId, variable, }: { objectId: string; variable?: InputVariable; }) => { const customVariableRefs = cacheCustomVariableRefs.current; const targetRef = customVariableRefs.find(d => d.objectId === objectId); let newCustomVariableRefs = []; // If the variable is empty, remove the reference if (!variable) { newCustomVariableRefs = customVariableRefs.filter( d => d.objectId !== objectId, ); // If variable is not empty & & customVariableRefs does not have a correspondence, add a reference } else if (!targetRef) { newCustomVariableRefs = [ ...customVariableRefs, { variableId: variable.id as string, objectId, variableName: variable.name, }, ]; // If variable is not empty & & customVariableRefs already has a correspondence, update the reference } 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], ); /** * When variables change, update the variable names in the reference relationship */ 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); // After redo undo, update the reference relationship cacheCustomVariableRefs.current = schemaLatest.current?.customVariableRefs ?? []; }, []); return { customVariableRefs: cacheCustomVariableRefs.current, addRefObjectByVariable, updateRefByObjectId, stopListen, startListen, }; };