feat: manually mirror opencoze's code from bytedance

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

View File

@@ -0,0 +1,163 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Menu } from '@coze-arch/coze-design';
import { PopInScreen } from '../pop-in-screen';
import { CopyMode } from '../../typings';
interface IProps {
left?: number;
top?: number;
offsetY?: number;
offsetX?: number;
cancelMenu?: () => void;
hasActiveObject?: boolean;
copy?: (mode: CopyMode) => void;
paste?: (options: { mode?: CopyMode }) => void;
disabledPaste?: boolean;
moveToFront?: () => void;
moveToBackend?: () => void;
moveToFrontOne?: () => void;
moveToBackendOne?: () => void;
isActiveObjectsInBack?: boolean;
isActiveObjectsInFront?: boolean;
limitRect?: {
width?: number;
height?: number;
};
}
export const ContentMenu: FC<IProps> = props => {
const {
left = 0,
top = 0,
offsetX = 0,
offsetY = 0,
cancelMenu,
hasActiveObject,
copy,
paste,
moveToFront,
moveToBackend,
moveToFrontOne,
moveToBackendOne,
isActiveObjectsInBack,
isActiveObjectsInFront,
disabledPaste,
limitRect,
} = props;
const isMac = navigator.platform.toLowerCase().includes('mac');
const ctrlKey = isMac ? '⌘' : 'ctrl';
const menuItems = [
{
label: I18n.t('imageflow_canvas_copy'),
suffix: `${ctrlKey} + C`,
onClick: () => {
copy?.(CopyMode.CtrlCV);
},
},
{
label: I18n.t('imageflow_canvas_paste'),
suffix: `${ctrlKey} + V`,
onClick: () => {
paste?.({ mode: CopyMode.CtrlCV });
},
disabled: disabledPaste,
alwaysShow: true,
},
{
label: I18n.t('Copy'),
suffix: `${ctrlKey} + D`,
onClick: async () => {
await copy?.(CopyMode.CtrlD);
paste?.({ mode: CopyMode.CtrlD });
},
},
{
key: 'divider1',
divider: true,
},
{
label: I18n.t('imageflow_canvas_top_1'),
suffix: ']',
onClick: () => {
moveToFrontOne?.();
},
disabled: isActiveObjectsInFront,
},
{
label: I18n.t('imageflow_canvas_down_1'),
suffix: '[',
onClick: () => {
moveToBackendOne?.();
},
disabled: isActiveObjectsInBack,
},
{
label: I18n.t('imageflow_canvas_to_front'),
suffix: `${ctrlKey} + ]`,
onClick: () => {
moveToFront?.();
},
disabled: isActiveObjectsInFront,
},
{
label: I18n.t('imageflow_canvas_to_back'),
suffix: `${ctrlKey} + [`,
onClick: () => {
moveToBackend?.();
},
disabled: isActiveObjectsInBack,
},
].filter(item => item.alwaysShow ?? hasActiveObject);
return (
<PopInScreen
position="bottom-right"
left={left + offsetX}
top={top + offsetY}
zIndex={1001}
onClick={e => {
e.stopPropagation();
cancelMenu?.();
}}
limitRect={limitRect}
>
<Menu.SubMenu mode="menu">
{menuItems.map(d => {
if (d.divider) {
return <Menu.Divider key={d.key} />;
}
return (
<Menu.Item
itemKey={d.label}
onClick={d.onClick}
disabled={d.disabled}
>
<div className="w-[120px] flex justify-between">
<div>{d.label}</div>
<div className="coz-fg-secondary">{d.suffix}</div>
</div>
</Menu.Item>
);
})}
</Menu.SubMenu>
</PopInScreen>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useRef, type FC } from 'react';
import { useSize } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { type FabricSchema } from '../../typings';
import { useFabricPreview } from '../../hooks';
export interface IFabricPreview {
schema: FabricSchema;
showPlaceholder?: boolean;
}
export const FabricPreview: FC<IFabricPreview> = props => {
const { schema, showPlaceholder } = props;
const ref = useRef<HTMLCanvasElement>(null);
const sizeRef = useRef(null);
const size = useSize(sizeRef);
const oldWidth = useRef(0);
useEffect(() => {
if (size?.width && !oldWidth.current) {
oldWidth.current = size?.width || 0;
}
// 防止抖动,当宽度变化 > 20 时才更新宽度
if (size?.width && size.width - oldWidth.current > 20) {
oldWidth.current = size?.width || 0;
}
}, [size?.width]);
const maxWidth = oldWidth.current;
const maxHeight = 456;
useFabricPreview({
schema,
ref,
maxWidth,
maxHeight,
startInit: !!size?.width,
});
const isEmpty = schema.objects.length === 0;
return (
<div className="w-full relative">
<div ref={sizeRef} className="w-full"></div>
<canvas ref={ref} className="h-[0px]" />
{isEmpty && showPlaceholder ? (
<div className="w-full h-full absolute top-0 left-0 flex items-center justify-center">
<div className="text-[14px] coz-fg-secondary">
{I18n.t('imageflow_canvas_double_click', {}, '双击开始编辑')}
</div>
</div>
) : (
<></>
)}
</div>
);
};

View File

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

View File

@@ -0,0 +1,397 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import { I18n } from '@coze-arch/i18n';
import {
IconCozArrowDown,
IconCozPalette,
} from '@coze-arch/coze-design/icons';
import { Tooltip } from '@coze-arch/coze-design';
import { MyIconButton } from '../icon-button';
import { defaultProps } from '../../utils';
import { ColorMode, ImageFixedType, Mode, type FormMeta } from '../../typings';
import { fontTreeData } from '../../assert/font';
const createTextMeta = (
textType: Mode.BLOCK_TEXT | Mode.INLINE_TEXT,
): FormMeta => ({
display: 'row',
style: {
padding: '8px',
},
content: [
{
name: 'customId',
setter: 'RefSelect',
setterProps: {
label: I18n.t('imageflow_canvas_reference', {}, '引用'),
labelInside: true,
className: 'w-[160px]',
},
},
{
name: 'fontFamily',
splitLine: false,
setter: 'TextFamily',
setterProps: {
treeData: fontTreeData,
defaultValue: defaultProps[textType].fontFamily,
},
},
{
name: 'fontSize',
setter: 'FontSize',
setterProps: {
min: 10,
max: 300,
optionList: [12, 16, 20, 24, 32, 40, 48, 56, 72, 92, 120, 160, 220].map(
d => ({
value: d,
label: `${d}`,
}),
),
defaultValue: defaultProps[textType].fontSize,
},
},
{
name: 'lineHeight',
splitLine: false,
setter: 'LineHeight',
setterProps: {
optionList: [10, 50, 100, 120, 150, 200, 250, 300, 350, 400].map(d => ({
value: d,
label: `${d}%`,
})),
min: 10,
max: 400,
defaultValue: defaultProps[textType].lineHeight,
},
},
{
setter: ({ tooltipVisible }) => (
<Tooltip
mouseEnterDelay={300}
mouseLeaveDelay={300}
content={I18n.t('imageflow_canvas_style_tooltip')}
>
<MyIconButton
inForm
className="!w-[48px]"
color={tooltipVisible ? 'highlight' : 'secondary'}
icon={
<div className="flex flex-row items-center gap-[2px]">
<IconCozPalette className="text-[16px]" />
<IconCozArrowDown className="text-[16px]" />
</div>
}
/>
</Tooltip>
),
tooltip: {
content: [
{
name: 'colorMode',
cacheSave: true,
setter: 'SingleSelect',
setterProps: {
options: [
{
value: ColorMode.FILL,
label: I18n.t('imageflow_canvas_fill'),
},
{
value: ColorMode.STROKE,
label: I18n.t('imageflow_canvas_stroke'),
},
],
layout: 'fill',
defaultValue: ColorMode.FILL,
},
},
{
name: 'strokeWidth',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode ===
ColorMode.STROKE,
setter: 'BorderWidth',
setterProps: {
min: 0,
max: 20,
defaultValue: defaultProps[textType].strokeWidth,
},
splitLine: true,
},
{
name: 'stroke',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode ===
ColorMode.STROKE,
setter: 'ColorPicker',
setterProps: {
showOpacity: false,
defaultValue: defaultProps[textType].stroke,
},
},
{
name: 'fill',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode !==
ColorMode.STROKE,
setter: 'ColorPicker',
setterProps: {
defaultValue: defaultProps[textType].fill,
},
},
],
},
},
{
name: 'textAlign',
setter: 'TextAlign',
setterProps: {
defaultValue: defaultProps[textType].textAlign,
},
},
{
name: 'customType',
setter: 'TextType',
setterProps: {
defaultValue: textType,
},
},
],
});
const createShapeMeta = (
shapeType: Mode.RECT | Mode.CIRCLE | Mode.TRIANGLE,
): FormMeta => ({
display: 'col',
style: {
padding: '16px',
},
content: [
{
name: 'colorMode',
cacheSave: true,
setter: 'SingleSelect',
setterProps: {
options: [
{
value: ColorMode.FILL,
label: I18n.t('imageflow_canvas_fill'),
},
{
value: ColorMode.STROKE,
label: I18n.t('imageflow_canvas_stroke'),
},
],
layout: 'fill',
defaultValue: ColorMode.FILL,
},
},
{
title: I18n.t('imageflow_canvas_fill'),
name: 'fill',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
setter: 'ColorPicker',
setterProps: {
defaultValue: defaultProps[shapeType].fill,
},
},
{
title: I18n.t('imageflow_canvas_stroke'),
name: 'strokeWidth',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.STROKE,
setter: 'BorderWidth',
setterProps: {
min: 0,
max: 50,
defaultValue: defaultProps[shapeType].strokeWidth,
},
splitLine: true,
},
{
name: 'stroke',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.STROKE,
setter: 'ColorPicker',
setterProps: {
showOpacity: false,
defaultValue: defaultProps[shapeType].stroke,
},
},
],
});
const createLineMeta = (
lineType: Mode.STRAIGHT_LINE | Mode.PENCIL,
): FormMeta => ({
display: 'col',
style: {
padding: '16px',
},
content: [
{
title: I18n.t('imageflow_canvas_line_style'),
name: 'strokeWidth',
setter: 'BorderWidth',
setterProps: {
min: 0,
max: 20,
defaultValue: defaultProps[lineType].strokeWidth,
},
splitLine: true,
},
{
name: 'stroke',
setter: 'ColorPicker',
setterProps: {
showOpacity: false,
defaultValue: defaultProps[lineType].stroke,
},
},
],
});
type IFormMeta = Partial<Record<Mode, FormMeta>>;
export const formMetas: IFormMeta = {
[Mode.BLOCK_TEXT]: createTextMeta(Mode.BLOCK_TEXT),
[Mode.INLINE_TEXT]: createTextMeta(Mode.INLINE_TEXT),
[Mode.RECT]: createShapeMeta(Mode.RECT),
[Mode.CIRCLE]: createShapeMeta(Mode.CIRCLE),
[Mode.TRIANGLE]: createShapeMeta(Mode.TRIANGLE),
[Mode.STRAIGHT_LINE]: createLineMeta(Mode.STRAIGHT_LINE),
[Mode.PENCIL]: createLineMeta(Mode.PENCIL),
[Mode.IMAGE]: {
display: 'col',
style: {
padding: '16px',
},
content: [
{
name: 'customId',
setter: 'RefSelect',
setterProps: {
label: I18n.t('imageflow_canvas_reference', {}, '引用'),
labelInside: false,
className: 'flex-1 overflow-hidden max-w-[320px]',
},
splitLine: true,
},
{
name: 'colorMode',
cacheSave: true,
setter: 'SingleSelect',
setterProps: {
options: [
{
value: ColorMode.FILL,
label: I18n.t('imageflow_canvas_fill'),
},
{
value: ColorMode.STROKE,
label: I18n.t('imageflow_canvas_stroke'),
},
],
layout: 'fill',
defaultValue: ColorMode.FILL,
},
},
{
name: 'src',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
setter: 'Uploader',
setterProps: {
getLabel: (isRefElement: boolean) => {
if (isRefElement) {
return I18n.t('imageflow_canvas_fill_preview', {}, '内容预览');
} else {
return I18n.t('imageflow_canvas_fill_image', {}, '内容');
}
},
},
},
{
name: 'customFixedType',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
setter: 'LabelSelect',
setterProps: {
className: 'flex-1',
label: I18n.t('imageflow_canvas_fill_mode'),
optionList: [
{
value: ImageFixedType.AUTO,
label: I18n.t('imageflow_canvas_fill1'),
},
{
value: ImageFixedType.FILL,
label: I18n.t('imageflow_canvas_fill2'),
},
{
value: ImageFixedType.FULL,
label: I18n.t('imageflow_canvas_fill3'),
},
],
defaultValue: ImageFixedType.FILL,
},
},
{
name: 'opacity',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
setter: 'ColorPicker',
setterProps: {
showColor: false,
defaultValue: defaultProps[Mode.IMAGE].opacity,
},
},
{
title: I18n.t('imageflow_canvas_stroke'),
name: 'strokeWidth',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode ===
ColorMode.STROKE,
setter: 'BorderWidth',
setterProps: {
min: 0,
max: 50,
defaultValue: defaultProps[Mode.IMAGE].strokeWidth,
},
splitLine: true,
},
{
name: 'stroke',
visible: formValue =>
(formValue as { colorMode: ColorMode })?.colorMode ===
ColorMode.STROKE,
setter: 'ColorPicker',
setterProps: {
showOpacity: false,
defaultValue: defaultProps[Mode.IMAGE].stroke,
},
},
],
},
};

View File

@@ -0,0 +1,324 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
memo,
useCallback,
useMemo,
useState,
type FC,
type ReactElement,
} from 'react';
import classNames from 'classnames';
import { useLatest } from 'ahooks';
import { ConfigProvider, Tooltip } from '@coze-arch/coze-design';
import { setters } from '../setters';
import { PopInScreen } from '../pop-in-screen';
import { schemaToFormValue } from '../../utils';
import {
REF_VARIABLE_ID_PREFIX,
type FabricObjectSchema,
type FabricObjectWithCustomProps,
type FabricSchema,
type FormMeta,
type FormMetaItem,
} from '../../typings';
import { formMetas } from './form-meta';
const schemaItemToNode = (props: {
metaItem: FormMetaItem;
value: unknown;
tooltipVisible?: boolean;
isRefElement: boolean;
onChange: (v: unknown) => void;
}) => {
const { metaItem, value, onChange, tooltipVisible, isRefElement } = props;
const { setter, setterProps, title } = metaItem;
let dom: ReactElement | string = setter as string;
if (typeof setter === 'function') {
dom = setter({
value,
onChange,
tooltipVisible,
});
}
if (typeof setter === 'string' && setters[setter]) {
const Setter = setters[setter];
dom = (
<Setter
value={value}
onChange={onChange}
isRefElement={isRefElement}
{...setterProps}
/>
);
}
return [
title ? (
<div className="w-full text-[14px] font-medium">{title}</div>
) : undefined,
<div className="flex items-center gap-[2px] text-[16px]">{dom}</div>,
];
};
const FormItem = memo(
(props: {
metaItem: FormMetaItem;
isLast: boolean;
isRow: boolean;
// 给图片上传组件特化的,需要根据是否为引用元素,设置不同的 label
isRefElement: boolean;
formValue: Partial<FabricObjectSchema>;
onChange: (v: Partial<FabricObjectSchema>, cacheSave?: boolean) => void;
}) => {
const { metaItem, isLast, isRow, formValue, onChange, isRefElement } =
props;
const { name = '', tooltip, splitLine, visible } = metaItem;
const _splitLine = splitLine ?? (isRow && !isLast ? true : false);
const [tooltipVisible, setTooltipVisible] = useState(false);
const _visible = visible?.(formValue) ?? true;
if (!_visible) {
return <></>;
}
return (
<>
<div key={`form-item-${name}`} className="flex flex-col gap-[12px]">
{tooltip ? (
<Tooltip
onVisibleChange={setTooltipVisible}
showArrow={false}
position={'bottom'}
trigger="click"
style={{
maxWidth: 'unset',
}}
spacing={{
y: 12,
x: 0,
}}
content={
<div
key={`tooltip-${name}`}
className="flex flex-col gap-[12px]"
>
{tooltip.content
.filter(d => {
const _v = d.visible?.(formValue) ?? true;
return _v;
})
.map(d =>
schemaItemToNode({
metaItem: d,
value: formValue[d.name ?? ''],
isRefElement,
onChange: v => {
onChange(
{
[d.name ?? '']: v,
},
d.cacheSave,
);
},
}),
)}
</div>
}
>
{schemaItemToNode({
metaItem,
value: formValue[name],
isRefElement,
tooltipVisible,
onChange: v => {
onChange(
{
[name]: v,
},
metaItem.cacheSave,
);
},
})}
</Tooltip>
) : (
schemaItemToNode({
metaItem,
value: formValue[name],
isRefElement,
onChange: v => {
onChange(
{
[name]: v,
},
metaItem.cacheSave,
);
},
})
)}
</div>
{_splitLine ? (
isRow ? (
<div
key={`split-${name}`}
className="w-[1px] h-[24px] coz-mg-primary-pressed"
/>
) : (
<div
key={`split-${name}`}
className="w-full h-[1px] coz-mg-primary-pressed"
/>
)
) : undefined}
</>
);
},
);
interface IProps {
position: { tl: { x: number; y: number }; br: { x: number; y: number } };
onChange: (value: Partial<FabricObjectSchema>) => void;
offsetY?: number;
offsetX?: number;
schema: FabricSchema;
activeObjects: FabricObjectWithCustomProps[];
canvasHeight?: number;
limitRect?: {
width: number;
height: number;
};
}
export const Form: FC<IProps> = props => {
const {
position,
offsetY = 10,
offsetX = 0,
schema,
activeObjects,
onChange,
limitRect,
canvasHeight,
} = props;
const { tl, br } = position;
const x = tl.x + (br.x - tl.x) / 2;
let { y } = br;
let showPositionY: 'bottom-center' | 'top-center' = 'bottom-center';
if (canvasHeight && tl.y + (br.y - tl.y) / 2 > canvasHeight / 2) {
y = tl.y;
showPositionY = 'top-center';
}
const formMeta = useMemo<FormMeta>(
() =>
formMetas[
(activeObjects[0] as FabricObjectWithCustomProps).customType
] as FormMeta,
[activeObjects],
);
// 临时保存不需要保存到 schema 中的表单值
const [cacheFormValue, setCacheFormValue] = useState<
Partial<FabricObjectSchema>
>({});
const formValue = {
...schemaToFormValue({
schema,
activeObjectId: activeObjects[0].customId,
formMeta,
}),
...cacheFormValue,
};
const isRow = formMeta.display === 'row';
const isCol = formMeta.display === 'col';
const latestCacheFromValue = useLatest(cacheFormValue);
const _onChange = useCallback(
(v: Partial<FabricObjectSchema>, cacheSave?: boolean) => {
if (!cacheSave) {
onChange(v);
}
setCacheFormValue({
...latestCacheFromValue.current,
...v,
});
},
[onChange],
);
const fields = useMemo(
() =>
formMeta.content.map((metaItem, i) => {
const isLast = i === formMeta.content.length - 1;
return (
<FormItem
key={metaItem.name}
formValue={formValue}
metaItem={metaItem}
isLast={isLast}
isRow={isRow}
onChange={_onChange}
isRefElement={activeObjects[0].customId.startsWith(
REF_VARIABLE_ID_PREFIX,
)}
/>
);
}),
[formMeta, isRow, isCol, _onChange, formValue],
);
return (
<ConfigProvider getPopupContainer={() => document.body}>
<PopInScreen
left={x + offsetX}
top={y + offsetY + (showPositionY === 'top-center' ? -10 : 10)}
position={showPositionY}
limitRect={limitRect}
>
<div
tabIndex={0}
style={{
...(formMeta.style ?? {}),
}}
onClick={e => {
e.stopPropagation();
}}
>
<div
className={classNames([
'flex gap-[12px]',
{
'flex-col': isCol,
'flex-row items-center': isRow,
},
])}
>
{fields}
</div>
</div>
</PopInScreen>
</ConfigProvider>
);
};

View File

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

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef } from 'react';
import classNames from 'classnames';
import {
IconButton,
type ButtonProps,
type SemiButton,
} from '@coze-arch/coze-design';
import styles from './index.module.less';
/**
* 在 size:small 的基础上,覆盖了 padding 5px -> 4px
*/
export const MyIconButton = forwardRef<
SemiButton,
ButtonProps & { inForm?: boolean }
>((props, ref) => {
const {
className = '',
inForm = false,
color = 'secondary',
...rest
} = props;
return (
<IconButton
ref={ref}
className={classNames(
[styles['icon-button']],
{
'!p-[4px]': !inForm,
'!p-[8px] !w-[32px] !h-[32px]': inForm,
[styles['coz-fg-secondary']]: color === 'secondary',
},
className,
)}
size="small"
color={color}
{...rest}
/>
);
});

View File

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

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable complexity */
import { useEffect, useRef, useState, type FC } from 'react';
import classNames from 'classnames';
import { useSize } from 'ahooks';
import { getNumberBetween } from '../../utils';
import styles from './index.module.less';
interface IProps {
left?: number;
top?: number;
offsetY?: number;
offsetX?: number;
children?: React.ReactNode;
position?: 'bottom-center' | 'bottom-right' | 'top-center';
zIndex?: number;
onClick?: (e: React.MouseEvent) => void;
className?: string;
limitRect?: {
width?: number;
height?: number;
};
}
export const PopInScreen: FC<IProps> = props => {
const ref = useRef(null);
const {
left = 0,
top = 0,
children,
position = 'bottom-center',
zIndex = 1000,
onClick,
className,
limitRect,
} = props;
// const documentSize = useSize(document.body);
const childrenSize = useSize(ref.current);
let maxLeft = (limitRect?.width ?? Infinity) - (childrenSize?.width ?? 0) / 2;
let minLeft = (childrenSize?.width ?? 0) / 2;
let transform = 'translate(-50%, 0)';
if (position === 'bottom-right') {
maxLeft = (limitRect?.width ?? Infinity) - (childrenSize?.width ?? 0);
minLeft = 0;
transform = 'translate(0, 0)';
} else if (position === 'top-center') {
transform = 'translate(-50%, -100%)';
}
/**
* ahooks useSize 初次执行会返回 undefined导致组件位置计算错误
* 这里监听 childrenSize ,如果为 undefined 则延迟 100ms 再渲染,以修正组件位置
*/
const [id, setId] = useState('');
const timer = useRef<NodeJS.Timeout>();
useEffect(() => {
clearTimeout(timer.current);
if (!childrenSize) {
timer.current = setTimeout(() => {
setId(`${Math.random()}`);
}, 100);
}
}, [childrenSize]);
return (
<div
ref={ref}
onClick={onClick}
className={classNames([
styles['pop-in-screen'],
'!fixed',
'coz-tooltip semi-tooltip-wrapper',
'p-0',
className,
])}
style={{
left: getNumberBetween({
value: left,
max: maxLeft,
min: minLeft,
}),
top: getNumberBetween({
value: top,
max: (limitRect?.height ?? Infinity) - (childrenSize?.height ?? 0),
min: position === 'top-center' ? (childrenSize?.height ?? 0) : 0,
}),
zIndex,
opacity: 1,
maxWidth: 'unset',
transform,
}}
>
{/* 为了触发二次渲染 */}
<div className="hidden" id={id} />
{children}
</div>
);
};

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import {
ViewVariableType,
type InputVariable,
} from '@coze-workflow/base/types';
import { I18n } from '@coze-arch/i18n';
import { IconCozImage, IconCozString } from '@coze-arch/coze-design/icons';
import { Tag } from '@coze-arch/coze-design';
import {
type FabricObjectWithCustomProps,
type IRefPosition,
} from '../../typings';
import { useGlobalContext } from '../../context';
const PADDING = 4;
interface IProps {
visible?: boolean;
offsetX?: number;
offsetY?: number;
}
type Positions = IRefPosition & {
zIndex: number;
active: boolean;
unused: boolean;
variable?: InputVariable;
};
export const RefTitle: FC<IProps> = props => {
const { visible, offsetX, offsetY } = props;
const {
allObjectsPositionInScreen,
customVariableRefs,
variables,
activeObjects,
} = useGlobalContext();
const refsPosition: Positions[] =
allObjectsPositionInScreen
?.filter(obj => customVariableRefs?.map(v => v.objectId).includes(obj.id))
?.map((obj, i) => {
const ref = customVariableRefs?.find(v => v.objectId === obj.id);
const variable = variables?.find(d => d.id === ref?.variableId);
return {
...obj,
active: !!activeObjects?.find(
o => (o as FabricObjectWithCustomProps).customId === obj.id,
),
unused: !variable,
zIndex: i + 1,
variable,
};
}) ?? [];
return (
<div
className={`relative w-full ${visible ? '' : 'hidden'}`}
style={{
top: offsetY ?? 0,
left: offsetX ?? 0,
}}
>
{refsPosition?.map(d => (
<div
key={d.id}
style={{
zIndex: d.active ? 999 : d.zIndex,
position: 'absolute',
width: 'fit-content',
top: `${d.top - PADDING}px`,
left: `${d.left}px`,
transform: `${'translateY(-100%)'} rotate(${d.angle}deg) scale(1)`,
transformOrigin: `0 calc(100% + ${PADDING}px)`,
opacity: d.active ? 1 : 0.3,
maxWidth: '200px',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
className="flex items-center gap-[3px]"
>
{d.unused ? (
<Tag className="w-full" color="yellow">
<div className="truncate w-full overflow-hidden">
{I18n.t('imageflow_canvas_var_delete', {}, '变量被删除')}
</div>
</Tag>
) : (
<Tag
className="w-full"
color="primary"
prefixIcon={
d.variable?.type === ViewVariableType.Image ? (
<IconCozImage className="coz-fg-dim" />
) : (
<IconCozString className="coz-fg-dim" />
)
}
>
<div className="truncate w-full overflow-hidden">
{d.variable?.name}
</div>
</Tag>
)}
</div>
)) ?? undefined}
</div>
);
};

View File

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

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import classnames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Slider } from '@coze-arch/coze-design';
import styles from './border-width.module.less';
interface IProps {
value: number;
onChange: (value: number) => void;
options?: [number, number, number];
min?: number;
max?: number;
}
export const BorderWidth: FC<IProps> = props => {
const { value, onChange, min, max } = props;
return (
<div
className={classnames(
'flex gap-[12px] text-[14px]',
styles['imageflow-canvas-border-width'],
)}
>
<div className="w-full flex items-center gap-[8px]">
<div className="min-w-[42px]">
{I18n.t('imageflow_canvas_stroke_width')}
</div>
<div className="flex-1 min-w-[320px]">
<Slider
min={min}
max={max}
step={1}
showArrow={false}
value={value}
onChange={o => {
onChange(o as number);
}}
/>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,188 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, type FC, useMemo } from 'react';
import classnames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozCheckMarkFill } from '@coze-arch/coze-design/icons';
import { Input, Slider } from '@coze-arch/coze-design';
import styles from './color-picker.module.less';
interface IProps {
// #ffffffff
value: string | number;
onChange: (value: string | number) => void;
showOpacity?: boolean;
showColor?: boolean;
readonly?: boolean;
}
const colors = [
'#000000',
'#ffffff',
'#C6C6CD',
'#FF441E',
'#3EC254',
'#4D53E8',
'#00B2B2',
'#FF9600',
];
const ColorRect = (props: {
color: string;
size?: number;
onClick?: () => void;
selected?: boolean;
className?: string;
}) => {
const { color, size = 24, onClick, selected, className } = props;
return (
<div
onClick={onClick}
className={`${className} rounded-[4px]`}
style={{ backgroundColor: color, width: size, height: size }}
>
<div
className={classnames([
'relative top-0 left-0',
'flex items-center justify-center',
'rounded-[4px] border border-solid border-stroke',
])}
style={{
width: size,
height: size,
color: color !== '#ffffff' ? '#fff' : '#000',
}}
>
{selected ? <IconCozCheckMarkFill /> : undefined}
</div>
</div>
);
};
const isHexOpacityColor = (value: string): boolean =>
/^#[0-9A-Fa-f]{8}$/.test(value);
const isHexColor = (value: string): boolean => /^#[0-9A-Fa-f]{6}$/.test(value);
const opacity16To255ScaleTo100 = (v: string): number => parseInt(v, 16) / 2.55;
const opacity100ScaleTo255To16 = (v: number): string =>
Math.floor(v * 2.55)
.toString(16)
.padStart(2, '0');
export const ColorPicker: FC<IProps> = props => {
const {
value = '#ffffffff',
onChange,
showOpacity = true,
showColor = true,
readonly = false,
} = props;
const { color, opacity } = useMemo(() => {
if (!showColor) {
return {
opacity: (value as number) * 100,
};
}
return {
color: (value as string).substring(0, 7),
opacity: opacity16To255ScaleTo100((value as string).substring(7, 9)),
};
}, [value, showColor]);
const _onChange = useCallback(
(v: string) => {
if (isHexOpacityColor(v)) {
onChange(v);
}
},
[onChange],
);
return (
<div className="flex flex-col w-full gap-[12px] text-[14px]">
{showColor ? (
<div className="flex items-center w-full gap-[16px]">
<div className="flex items-center flex-1 gap-[12px]">
{colors.map(c => {
const selected =
c.toUpperCase() === (color as string).toUpperCase();
return (
<ColorRect
key={`rect-${c}`}
className={`${readonly ? '' : 'cursor-pointer'}`}
selected={selected}
onClick={() => {
if (readonly) {
return;
}
_onChange(`${c}${opacity100ScaleTo255To16(opacity)}`);
}}
color={c}
/>
);
})}
</div>
<Input
// 因为是不受控模式,当点击色块时,需要重置 input.value。所以这里以 color 为 key
key={`input-${color}`}
disabled={readonly}
prefix={<ColorRect color={color as string} size={16} />}
type="text"
className="w-[110px]"
// 为什么不使用受控模式?使用受控模式,用户输入过程中触发的格式校验处理起来比较麻烦
defaultValue={color}
onChange={v => {
if (isHexColor(v)) {
_onChange(`${v}${opacity100ScaleTo255To16(opacity)}`);
}
}}
/>
</div>
) : undefined}
{showOpacity ? (
<div className="w-full flex items-center gap-[8px]">
<div className="min-w-[80px]">
{I18n.t('imageflow_canvas_transparency')}
</div>
<div
className={classnames(
'flex-1 min-w-[320px]',
styles['color-picker-slider'],
)}
>
<Slider
min={0}
showArrow={false}
max={100}
step={1}
value={opacity}
disabled={readonly}
onChange={o => {
if (!showColor) {
onChange((o as number) / 100);
} else {
_onChange(`${color}${opacity100ScaleTo255To16(o as number)}`);
}
}}
/>
</div>
</div>
) : undefined}
</div>
);
};

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, useCallback, useMemo } from 'react';
import { clamp } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import { IconCozFontSize } from '@coze-arch/coze-design/icons';
import { Select, type SelectProps, Tooltip } from '@coze-arch/coze-design';
interface IProps extends Omit<SelectProps, 'onChange'> {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
}
export const FontSize: FC<IProps> = props => {
const { onChange, min, max, optionList, value, ...rest } = props;
const _onChange = useCallback(
(v: number) => {
if (isFinite(v)) {
onChange?.(clamp(v, min, max));
}
},
[onChange, min, max],
);
const _optionsList = useMemo(() => {
const _options = [...(optionList ?? [])];
if (!_options.map(o => o.value).includes(value)) {
_options.unshift({
label: `${value}`,
value,
});
}
return _options;
}, [optionList, value]);
return (
<div className="flex gap-[8px] items-center">
{/* <IconCozFontSize className="text-[16px] m-[8px]" /> */}
<Tooltip
content={I18n.t('imageflow_canvas_text_tooltip1')}
mouseEnterDelay={300}
mouseLeaveDelay={300}
>
<Select
{...rest}
prefix={
<IconCozFontSize className="text-[16px] coz-fg-secondary m-[8px]" />
}
/**
* 因为开启了 allowCreate所以 optionList 不会再响应动态变化
* 这里给个 key ,重新渲染 select保证 optionList 符合预期
*/
key={_optionsList.map(d => d.label).join()}
value={value}
optionList={_optionsList}
filter
allowCreate
onChange={v => {
_onChange(v as number);
}}
style={{ width: '98px' }}
/>
</Tooltip>
</div>
);
};

View File

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

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef } from 'react';
import {
InputNumber as CozeInputNumber,
type InputNumberProps,
} from '@coze-arch/coze-design';
export const InputNumber = forwardRef<InputNumberProps, InputNumberProps>(
props => {
const { onChange, min, max, value, ...rest } = props;
return (
<CozeInputNumber
{...rest}
min={min}
max={max}
value={value}
// InputNumber 长按 + - 时,会一直触发变化。这里有 bug有时定时器清不掉会鬼畜一直增加/减小)。
// 把 pressInterval 设置成 24h ,变相禁用长按增减
pressInterval={1000 * 60 * 60 * 24}
onNumberChange={v => {
if (Number.isFinite(v)) {
if (typeof min === 'number' && (v as number) < min) {
onChange?.(min);
} else if (typeof max === 'number' && (v as number) > max) {
onChange?.(max);
} else {
const _v = Number((v as number).toFixed(1));
if (_v !== value) {
onChange?.(Number((v as number).toFixed(1)));
}
}
}
}}
/>
);
},
);

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { Select, type SelectProps } from '@coze-arch/coze-design';
type IProps = SelectProps & { label: string };
export const LabelSelect: FC<IProps> = props => {
const { label, ...rest } = props;
return (
<div className="w-full flex gap-[8px] justify-between items-center text-[14px]">
<div className="min-w-[80px]">{label}</div>
<Select {...rest} />
</div>
);
};

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, useCallback, useMemo } from 'react';
import { clamp } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import { IconCozFontHeight } from '@coze-arch/coze-design/icons';
import { Select, Tooltip, type SelectProps } from '@coze-arch/coze-design';
interface IProps extends Omit<SelectProps, 'onChange'> {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
}
export const LineHeight: FC<IProps> = props => {
const { onChange, min, max, value, optionList, ...rest } = props;
const _onChange = useCallback(
(v: string) => {
const _v = Number(`${v}`.replace('%', ''));
if (isFinite(_v)) {
onChange?.(Number((clamp(_v, min, max) / 100).toFixed(2)));
}
},
[onChange, min, max],
);
const _optionsList = useMemo(() => {
const _options = [...(optionList ?? [])];
if (
!_options
.map(o => o.value)
.includes(
Number((Number(`${value}`.replace('%', '')) * 100).toFixed(0)),
)
) {
_options.unshift({
label: `${Number((value * 100).toFixed(0))}%`,
value: Number((value * 100).toFixed(0)),
});
}
return _options;
}, [optionList, value]);
return (
<div className="flex gap-[8px] items-center">
{/* <IconCozFontHeight className="text-[16px] m-[8px]" /> */}
<Tooltip
content={I18n.t('imageflow_canvas_text_tooltip2')}
mouseEnterDelay={300}
mouseLeaveDelay={300}
>
<Select
prefix={
<IconCozFontHeight className="text-[16px] coz-fg-secondary m-[8px]" />
}
{...rest}
/**
* 因为开启了 allowCreate所以 optionList 不会再响应动态变化
* 这里给个 key ,重新渲染 select保证 optionList 符合预期
*/
key={_optionsList.map(d => d.label).join()}
filter
value={Number((value * 100).toFixed(0))}
allowCreate
onChange={v => {
_onChange(v as string);
}}
optionList={_optionsList}
style={{ width: '104px' }}
/>
</Tooltip>
</div>
);
};

View File

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

View File

@@ -0,0 +1,135 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import cls from 'classnames';
import { ViewVariableType } from '@coze-workflow/base/types';
import { I18n } from '@coze-arch/i18n';
import { IconCozImage, IconCozString } from '@coze-arch/coze-design/icons';
import {
type RenderSelectedItemFn,
Select,
type SelectProps,
Tag,
} from '@coze-arch/coze-design';
import { useGlobalContext } from '../../context';
import s from './ref-select.module.less';
type IProps = SelectProps & {
value: string;
label: string;
labelInside: boolean;
};
export const RefSelect: FC<IProps> = props => {
const { value: objectId, label, labelInside, className } = props;
const { customVariableRefs, variables, updateRefByObjectId } =
useGlobalContext();
const targetRef = customVariableRefs?.find(ref => ref.objectId === objectId);
const value = targetRef?.variableId;
const targetVariable = variables?.find(v => v.id === value);
return (
<div className="w-full text-[14px] flex flex-row gap-[8px] items-center">
{!labelInside && label ? (
<div className="text-[14px] min-w-[80px]">{label}</div>
) : (
<></>
)}
<Select
prefix={
labelInside && label ? (
<div className="text-[14px] pl-[8px] pr-[4px] py-[2px] min-w-[40px]">
{label}
</div>
) : undefined
}
showClear
showTick={false}
placeholder={I18n.t('imageflow_canvas_select_var', {}, '选择变量')}
value={targetRef?.variableId}
className={cls(className, s['ref-select'])}
onChange={d => {
updateRefByObjectId?.({
objectId,
variable: d ? variables?.find(v => v.id === d) : undefined,
});
}}
renderSelectedItem={
((options: { value: string; label: React.ReactNode }) => {
const { value: _value, label: _label } = options;
const variable = variables?.find(v => v.id === _value);
if (variable) {
return (
<Tag color="primary" className="w-full">
<div className="flex flex-row items-center gap-[4px] w-full">
{variable.type === ViewVariableType.String ? (
<IconCozString className="coz-fg-dim" />
) : (
<IconCozImage className="coz-fg-dim" />
)}
<div className="flex-1 overflow-hidden">
<div className="truncate w-full overflow-hidden">
{variable.name}
</div>
</div>
</div>
</Tag>
);
}
return _label;
}) as RenderSelectedItemFn
}
>
{value && !targetVariable ? (
<Select.Option value={value}>
<Tag className="max-w-full m-[8px]" color="yellow">
<div className="truncate overflow-hidden">
{I18n.t('imageflow_canvas_var_delete', {}, '变量被删除')}
</div>
</Tag>
</Select.Option>
) : (
<></>
)}
{variables?.map(v => (
<Select.Option value={v.id}>
<div className="flex flex-row items-center gap-[4px] w-full p-[8px] max-w-[400px]">
{v.type === ViewVariableType.String ? (
<IconCozString className="coz-fg-dim" />
) : (
<IconCozImage className="coz-fg-dim" />
)}
<div className="flex-1 overflow-hidden">
<div className="truncate w-full overflow-hidden">{v.name}</div>
</div>
{v.id === value ? (
<Tag color="primary">
{I18n.t('eval_status_referenced', {}, '已引用')}
</Tag>
) : null}
</div>
</Select.Option>
))}
</Select>
</div>
);
};

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef } from 'react';
import {
SingleSelect as CozeSingleSelect,
type SingleSelectProps,
} from '@coze-arch/coze-design';
export const SingleSelect = forwardRef<SingleSelectProps, SingleSelectProps>(
props => {
// (props, ref) => {
const { onChange, ...rest } = props;
return (
<CozeSingleSelect
{...rest}
// ref={ref}
onChange={v => {
onChange?.(v.target.value);
}}
/>
);
},
);

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import {
IconCozTextAlignCenter,
IconCozTextAlignLeft,
IconCozTextAlignRight,
} from '@coze-arch/coze-design/icons';
import { Select, type RenderSelectedItemFn } from '@coze-arch/coze-design';
import { TextAlign as TextAlignEnum } from '../../typings';
interface IProps {
value: TextAlignEnum;
onChange: (value: TextAlignEnum) => void;
}
export const TextAlign: FC<IProps> = props => {
const { value, onChange } = props;
return (
<Select
// borderless
className="border-0 hover:border-0 focus:border-0"
value={value}
onChange={v => {
onChange(v as TextAlignEnum);
}}
optionList={[
{
icon: <IconCozTextAlignLeft className="text-[16px]" />,
label: I18n.t('card_builder_hover_align_left'),
value: TextAlignEnum.LEFT,
},
{
icon: <IconCozTextAlignCenter className="text-[16px]" />,
label: I18n.t('card_builder_hover_align_horizontal'),
value: TextAlignEnum.CENTER,
},
{
icon: <IconCozTextAlignRight className="text-[16px]" />,
label: I18n.t('card_builder_hover_align_right'),
value: TextAlignEnum.RIGHT,
},
].map(d => ({
...d,
label: (
<div className="flex flex-row items-center gap-[4px]">
{d.icon}
{d.label}
</div>
),
}))}
renderSelectedItem={
((option: { icon: React.ReactNode }) => {
const { icon } = option;
return <div className="flex flex-row items-center">{icon}</div>;
}) as RenderSelectedItemFn
}
/>
);
};

View File

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

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { Cascader } from '@coze-arch/coze-design';
import s from './text-family.module.less';
interface IProps {
value: string;
onChange: (value: string) => void;
}
export const TextFamily: FC<IProps> = props => {
// (props, ref) => {
const { onChange, value, ...rest } = props;
return (
<Cascader
{...rest}
value={value?.split('-')?.reverse()}
onChange={v => {
onChange?.((v as string[])?.reverse()?.join('-'));
}}
dropdownClassName={s['imageflow-canvas-font-family-cascader']}
/>
);
};

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC } from 'react';
import { I18n } from '@coze-arch/i18n';
import {
IconCozFixedSize,
IconCozAutoWidth,
} from '@coze-arch/coze-design/icons';
import { Tooltip } from '@coze-arch/coze-design';
import { MyIconButton } from '../icon-button';
import { Mode } from '../../typings';
interface IProps {
value: Mode;
onChange: (value: Mode) => void;
}
export const TextType: FC<IProps> = props => {
const { value, onChange } = props;
return (
<div className="flex gap-[12px]">
<Tooltip
mouseEnterDelay={300}
mouseLeaveDelay={300}
content={I18n.t('imageflow_canvas_text1')}
>
<MyIconButton
inForm
color={value === Mode.INLINE_TEXT ? 'highlight' : 'secondary'}
onClick={() => {
onChange(Mode.INLINE_TEXT);
}}
icon={<IconCozAutoWidth className="text-[16px]" />}
/>
</Tooltip>
<Tooltip
mouseEnterDelay={300}
mouseLeaveDelay={300}
content={I18n.t('imageflow_canvas_text2')}
>
<MyIconButton
inForm
color={value === Mode.BLOCK_TEXT ? 'highlight' : 'secondary'}
onClick={() => {
onChange(Mode.BLOCK_TEXT);
}}
icon={<IconCozFixedSize className="text-[16px]" />}
/>
</Tooltip>
</div>
);
};

View File

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

View File

@@ -0,0 +1,149 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FC, type RefObject } from 'react';
import { I18n } from '@coze-arch/i18n';
import {
IconCozAlignBottom,
IconCozAlignCenterHorizontal,
IconCozAlignCenterVertical,
IconCozAlignLeft,
IconCozAlignRight,
IconCozAlignTop,
IconCozDistributeHorizontal,
IconCozDistributeVertical,
} from '@coze-arch/coze-design/icons';
import { Select } from '@coze-arch/coze-design';
import { AlignMode } from '../../typings';
import styles from '../../index.module.less';
interface IProps {
readonly?: boolean;
popRefAlignRight: RefObject<HTMLDivElement> | null;
onChange: (v: AlignMode) => void;
}
export const Align: FC<IProps> = props => {
const { readonly, onChange, popRefAlignRight } = props;
const renderItem = ({
name,
value,
icon,
suffix,
}: {
name: string;
value: AlignMode;
icon: JSX.Element;
suffix: string;
}) => (
<Select.Option value={value}>
<div className="w-[172px] px-[8px] flex gap-[4px] align-center coz-fg-primary">
<div className="text-[16px] flex items-center">{icon}</div>
<div className="flex-1 text-[14px]">{name}</div>
<div className="coz-fg-secondary text-[12px]">{suffix}</div>
</div>
</Select.Option>
);
return (
// 禁止冒泡防止点击对齐时canvas 的选中状态被清空
<div
onClick={e => {
e.stopPropagation();
}}
>
<Select
disabled={readonly}
className={'hide-selected-label hide-border'}
dropdownClassName={styles['select-hidden-group-label']}
showTick={false}
size="small"
getPopupContainer={() => popRefAlignRight?.current ?? document.body}
onSelect={v => {
onChange(v as AlignMode);
}}
maxHeight={300}
restTagsPopoverProps={{
trigger: 'hover',
}}
>
<Select.OptGroup label="a">
{[
{
name: I18n.t('imageflow_canvas_align1', {}, '左对齐'),
value: AlignMode.Left,
icon: <IconCozAlignLeft />,
suffix: '⌥ + A',
},
{
name: I18n.t('imageflow_canvas_align2', {}, '水平居中'),
value: AlignMode.Center,
icon: <IconCozAlignCenterVertical />,
suffix: '⌥ + H',
},
{
name: I18n.t('imageflow_canvas_align3', {}, '右对齐'),
value: AlignMode.Right,
icon: <IconCozAlignRight />,
suffix: '⌥ + D',
},
].map(renderItem)}
</Select.OptGroup>
<Select.OptGroup label="b">
{[
{
name: I18n.t('imageflow_canvas_align4', {}, '顶部对齐'),
value: AlignMode.Top,
icon: <IconCozAlignTop />,
suffix: '⌥ + W',
},
{
name: I18n.t('imageflow_canvas_align5', {}, '垂直居中'),
value: AlignMode.Middle,
icon: <IconCozAlignCenterHorizontal />,
suffix: '⌥ + V',
},
{
name: I18n.t('imageflow_canvas_align6', {}, '底部对齐'),
value: AlignMode.Bottom,
icon: <IconCozAlignBottom />,
suffix: '⌥ + S',
},
].map(renderItem)}
</Select.OptGroup>
<Select.OptGroup label="c">
{[
{
name: I18n.t('imageflow_canvas_align7', {}, '水平均分'),
value: AlignMode.VerticalAverage,
icon: <IconCozDistributeHorizontal />,
suffix: '^ + ⌥ + H',
},
{
name: I18n.t('imageflow_canvas_align8', {}, '垂直均分'),
value: AlignMode.HorizontalAverage,
icon: <IconCozDistributeVertical />,
suffix: '^ + ⌥ + V',
},
].map(renderItem)}
</Select.OptGroup>
</Select>
</div>
);
};

View File

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

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classNames from 'classnames';
import { useImageUploader, type ImageRule } from '@coze-workflow/components';
import { I18n } from '@coze-arch/i18n';
import {
IconCozCrossCircle,
IconCozLoading,
} from '@coze-arch/coze-design/icons';
import {
Toast,
Tooltip,
Upload,
type customRequestArgs,
} from '@coze-arch/coze-design';
import styles from './image-upload.module.less';
const imageRules: ImageRule = {
suffix: ['jpg', 'jpeg', 'png', 'webp'],
maxSize: 1024 * 1024 * 5,
};
export const ImageUpload = (props: {
onChange?: (url: string) => void;
children?:
| React.ReactNode
| ((data: { loading: boolean; cancel: () => void }) => React.ReactNode);
tooltip?: string;
disabledTooltip?: boolean;
key?: string;
className?: string;
}) => {
const { onChange, children, className, tooltip, key, disabledTooltip } =
props;
// const focusRef = useRef(false);
const { uploadImg, clearImg, loading } = useImageUploader({
rules: imageRules,
});
const handleUpload: (object: customRequestArgs) => void = async ({
fileInstance,
}) => {
clearImg();
const res = await uploadImg(fileInstance);
if (res?.isSuccess) {
onChange?.(res.url);
}
};
const handleAcceptInvalid = () => {
Toast.error(
I18n.t('imageflow_upload_error_type', {
type: imageRules.suffix?.join('/'),
}),
);
};
const content = (
<div className={classNames([styles['loading-container'], className])}>
<Upload
action=""
disabled={loading}
customRequest={handleUpload}
draggable={true}
accept={imageRules.suffix?.map(item => `.${item}`).join(',')}
showUploadList={false}
onAcceptInvalid={handleAcceptInvalid}
>
{typeof children === 'function' ? (
children({ loading, cancel: clearImg })
) : loading ? (
<div>
<IconCozLoading
className={`loading coz-fg-dim ${styles['hover-hidden']}`}
/>
<IconCozCrossCircle
onClick={e => {
e.stopPropagation();
clearImg();
}}
className={`coz-fg-dim hover-visible ${styles['hover-visible']}`}
/>
</div>
) : (
children
)}
</Upload>
</div>
);
return disabledTooltip ? (
content
) : (
<Tooltip
key={key ?? 'image'}
content={loading ? I18n.t('Cancel') : tooltip}
mouseEnterDelay={300}
mouseLeaveDelay={300}
getPopupContainer={() => document.body}
>
{content}
</Tooltip>
);
};

View File

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