feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Menu } from '@coze-arch/coze-design';
|
||||
|
||||
import { PopInScreen } from '../pop-in-screen';
|
||||
import { CopyMode } from '../../typings';
|
||||
|
||||
interface IProps {
|
||||
left?: number;
|
||||
top?: number;
|
||||
offsetY?: number;
|
||||
offsetX?: number;
|
||||
cancelMenu?: () => void;
|
||||
hasActiveObject?: boolean;
|
||||
copy?: (mode: CopyMode) => void;
|
||||
paste?: (options: { mode?: CopyMode }) => void;
|
||||
disabledPaste?: boolean;
|
||||
moveToFront?: () => void;
|
||||
moveToBackend?: () => void;
|
||||
moveToFrontOne?: () => void;
|
||||
moveToBackendOne?: () => void;
|
||||
isActiveObjectsInBack?: boolean;
|
||||
isActiveObjectsInFront?: boolean;
|
||||
limitRect?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const ContentMenu: FC<IProps> = props => {
|
||||
const {
|
||||
left = 0,
|
||||
top = 0,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
cancelMenu,
|
||||
hasActiveObject,
|
||||
copy,
|
||||
paste,
|
||||
moveToFront,
|
||||
moveToBackend,
|
||||
moveToFrontOne,
|
||||
moveToBackendOne,
|
||||
isActiveObjectsInBack,
|
||||
isActiveObjectsInFront,
|
||||
disabledPaste,
|
||||
limitRect,
|
||||
} = props;
|
||||
const isMac = navigator.platform.toLowerCase().includes('mac');
|
||||
const ctrlKey = isMac ? '⌘' : 'ctrl';
|
||||
const menuItems = [
|
||||
{
|
||||
label: I18n.t('imageflow_canvas_copy'),
|
||||
suffix: `${ctrlKey} + C`,
|
||||
onClick: () => {
|
||||
copy?.(CopyMode.CtrlCV);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: I18n.t('imageflow_canvas_paste'),
|
||||
suffix: `${ctrlKey} + V`,
|
||||
onClick: () => {
|
||||
paste?.({ mode: CopyMode.CtrlCV });
|
||||
},
|
||||
disabled: disabledPaste,
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
label: I18n.t('Copy'),
|
||||
suffix: `${ctrlKey} + D`,
|
||||
onClick: async () => {
|
||||
await copy?.(CopyMode.CtrlD);
|
||||
paste?.({ mode: CopyMode.CtrlD });
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'divider1',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: I18n.t('imageflow_canvas_top_1'),
|
||||
suffix: ']',
|
||||
onClick: () => {
|
||||
moveToFrontOne?.();
|
||||
},
|
||||
disabled: isActiveObjectsInFront,
|
||||
},
|
||||
{
|
||||
label: I18n.t('imageflow_canvas_down_1'),
|
||||
suffix: '[',
|
||||
onClick: () => {
|
||||
moveToBackendOne?.();
|
||||
},
|
||||
disabled: isActiveObjectsInBack,
|
||||
},
|
||||
{
|
||||
label: I18n.t('imageflow_canvas_to_front'),
|
||||
suffix: `${ctrlKey} + ]`,
|
||||
onClick: () => {
|
||||
moveToFront?.();
|
||||
},
|
||||
disabled: isActiveObjectsInFront,
|
||||
},
|
||||
{
|
||||
label: I18n.t('imageflow_canvas_to_back'),
|
||||
suffix: `${ctrlKey} + [`,
|
||||
onClick: () => {
|
||||
moveToBackend?.();
|
||||
},
|
||||
disabled: isActiveObjectsInBack,
|
||||
},
|
||||
].filter(item => item.alwaysShow ?? hasActiveObject);
|
||||
return (
|
||||
<PopInScreen
|
||||
position="bottom-right"
|
||||
left={left + offsetX}
|
||||
top={top + offsetY}
|
||||
zIndex={1001}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
cancelMenu?.();
|
||||
}}
|
||||
limitRect={limitRect}
|
||||
>
|
||||
<Menu.SubMenu mode="menu">
|
||||
{menuItems.map(d => {
|
||||
if (d.divider) {
|
||||
return <Menu.Divider key={d.key} />;
|
||||
}
|
||||
return (
|
||||
<Menu.Item
|
||||
itemKey={d.label}
|
||||
onClick={d.onClick}
|
||||
disabled={d.disabled}
|
||||
>
|
||||
<div className="w-[120px] flex justify-between">
|
||||
<div>{d.label}</div>
|
||||
<div className="coz-fg-secondary">{d.suffix}</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.SubMenu>
|
||||
</PopInScreen>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 画布最大缩放
|
||||
*/
|
||||
export const MAX_ZOOM = 3;
|
||||
/**
|
||||
* 画布最小缩放
|
||||
*/
|
||||
export const MIN_ZOOM = 1;
|
||||
/**
|
||||
* 画布最大宽度
|
||||
*/
|
||||
export const MAX_WIDTH = 10000;
|
||||
/**
|
||||
* 画布最小宽度
|
||||
*/
|
||||
export const MIN_WIDTH = 1;
|
||||
/**
|
||||
* 画布最大高度
|
||||
*/
|
||||
export const MAX_HEIGHT = 10000;
|
||||
/**
|
||||
* 画布最小高度
|
||||
*/
|
||||
export const MIN_HEIGHT = 1;
|
||||
|
||||
/**
|
||||
* 画布最大面积
|
||||
*/
|
||||
export const MAX_AREA = 3840 * 2160;
|
||||
@@ -0,0 +1,778 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable complexity */
|
||||
/* eslint-disable max-lines */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
/* eslint-disable max-lines-per-function */
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FC,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { pick } from 'lodash-es';
|
||||
import { type IText, type Point, type TMat2D } from 'fabric';
|
||||
import classNames from 'classnames';
|
||||
import { useDebounce, useLatest, useSize } from 'ahooks';
|
||||
import { createUseGesture, pinchAction, wheelAction } from '@use-gesture/react';
|
||||
import { type InputVariable } from '@coze-workflow/base/types';
|
||||
import { IconCozCross } from '@coze-arch/coze-design/icons';
|
||||
import { ConfigProvider } from '@coze-arch/coze-design';
|
||||
|
||||
import { TopBar } from '../topbar';
|
||||
import { RefTitle } from '../ref-title';
|
||||
import { MyIconButton } from '../icon-button';
|
||||
import { Form } from '../form';
|
||||
import { ContentMenu } from '../content-menu';
|
||||
import {
|
||||
AlignMode,
|
||||
Mode,
|
||||
type FabricObjectWithCustomProps,
|
||||
type FabricSchema,
|
||||
} from '../../typings';
|
||||
import s from '../../index.module.less';
|
||||
import { useFabricEditor } from '../../hooks';
|
||||
import { GlobalContext } from '../../context';
|
||||
import { useShortcut } from './use-shortcut';
|
||||
import {
|
||||
MAX_AREA,
|
||||
MAX_WIDTH,
|
||||
MAX_ZOOM,
|
||||
MIN_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
MIN_ZOOM,
|
||||
MAX_HEIGHT,
|
||||
} from './const';
|
||||
|
||||
interface IProps {
|
||||
onClose: () => void;
|
||||
icon: string;
|
||||
title: string;
|
||||
schema: FabricSchema;
|
||||
readonly?: boolean;
|
||||
variables?: InputVariable[];
|
||||
onChange: (schema: FabricSchema) => void;
|
||||
className?: string;
|
||||
/**
|
||||
* 不强制,用来当做 redo/undo 操作栈保存到内存的 key
|
||||
* 不传的话,不会保存操作栈到内存,表现:关闭侧拉窗,丢失操作栈
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const useGesture = createUseGesture([pinchAction, wheelAction]);
|
||||
|
||||
export const FabricEditor: FC<IProps> = props => {
|
||||
const {
|
||||
onClose,
|
||||
icon,
|
||||
title,
|
||||
schema: _schema,
|
||||
onChange: _onChange,
|
||||
readonly,
|
||||
variables: _variables,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const variables = useMemo(() => {
|
||||
const _v = _variables?.filter(d => d.type);
|
||||
return _v;
|
||||
}, [_variables]);
|
||||
|
||||
/**
|
||||
* props.onChange 是异步,这个异步导致 schema 的状态很难管理。
|
||||
* 因此此处用 state 来管理 schema,后续消费 onChange 的地方可以当同步处理
|
||||
*
|
||||
* 副作用:外界引发的 schema 变化,不会同步到画布(暂时没这个场景)
|
||||
*/
|
||||
const [schema, setSchema] = useState<FabricSchema>(_schema);
|
||||
const onChange = useCallback(
|
||||
(data: FabricSchema) => {
|
||||
setSchema(data);
|
||||
_onChange(data);
|
||||
},
|
||||
[_onChange],
|
||||
);
|
||||
|
||||
const [id] = useState<string>(props.id ?? nanoid());
|
||||
const helpLineLayerId = `help-line-${id}`;
|
||||
|
||||
// 快捷点监听区域
|
||||
const shortcutRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Popover 渲染至 dom
|
||||
const popRef = useRef(null);
|
||||
// Popover 渲染至 dom,作用于 select ,dropdown 右对齐
|
||||
const popRefAlignRight = useRef<HTMLDivElement>(null);
|
||||
|
||||
// canvas 可渲染区域 dom
|
||||
const sizeRef = useRef<HTMLDivElement>(null);
|
||||
const size = useSize(sizeRef);
|
||||
|
||||
// popover 渲染至 dom
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const popoverSize = useSize(popoverRef);
|
||||
|
||||
// fabric canvas 渲染 dom
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// 模式
|
||||
const [drawMode, setDrawMode] = useState<Mode | undefined>();
|
||||
const latestDrawMode = useLatest(drawMode);
|
||||
|
||||
const [contentMenuPosition, setContentMenuPosition] = useState<
|
||||
| {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
||||
// 监听鼠标是否处于按下状态,松手时才显示属性设置面板
|
||||
const [isMousePressing, setIsMousePressing] = useState(false);
|
||||
|
||||
const cancelContentMenu = useCallback(() => {
|
||||
setContentMenuPosition(undefined);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
state: {
|
||||
activeObjects,
|
||||
activeObjectsPopPosition,
|
||||
viewport,
|
||||
couldAddNewObject,
|
||||
disabledUndo,
|
||||
disabledRedo,
|
||||
redoUndoing,
|
||||
disabledPaste,
|
||||
isActiveObjectsInBack,
|
||||
isActiveObjectsInFront,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
customVariableRefs,
|
||||
allObjectsPositionInScreen,
|
||||
},
|
||||
sdk: {
|
||||
setActiveObjectsProps,
|
||||
moveToFront,
|
||||
moveToBackend,
|
||||
moveToFrontOne,
|
||||
moveToBackendOne,
|
||||
addImage,
|
||||
removeActiveObjects,
|
||||
moveActiveObject,
|
||||
enterFreePencil,
|
||||
exitFreePencil,
|
||||
enterDragAddElement,
|
||||
exitDragAddElement,
|
||||
enterAddInlineText,
|
||||
exitAddInlineText,
|
||||
zoomToPoint,
|
||||
setViewport,
|
||||
setBackgroundColor,
|
||||
discardActiveObject,
|
||||
redo,
|
||||
undo,
|
||||
copy,
|
||||
paste,
|
||||
group,
|
||||
unGroup,
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenter,
|
||||
alignTop,
|
||||
alignBottom,
|
||||
alignMiddle,
|
||||
verticalAverage,
|
||||
horizontalAverage,
|
||||
resetWidthHeight,
|
||||
addRefObjectByVariable,
|
||||
updateRefByObjectId,
|
||||
},
|
||||
canvasSettings: { backgroundColor },
|
||||
canvas,
|
||||
} = useFabricEditor({
|
||||
id,
|
||||
helpLineLayerId,
|
||||
onChange,
|
||||
ref: canvasRef,
|
||||
variables,
|
||||
schema,
|
||||
maxWidth: size?.width || 0,
|
||||
maxHeight: size?.height ? size.height - 2 : 0,
|
||||
startInit: !!size?.width,
|
||||
maxZoom: MAX_ZOOM,
|
||||
minZoom: MIN_ZOOM,
|
||||
readonly,
|
||||
onShapeAdded: () => {
|
||||
if (latestDrawMode.current) {
|
||||
modeSetting[latestDrawMode.current as Mode]?.exitFn();
|
||||
setDrawMode(undefined);
|
||||
}
|
||||
},
|
||||
onClick: cancelContentMenu,
|
||||
});
|
||||
|
||||
const modeSetting: Partial<
|
||||
Record<
|
||||
Mode,
|
||||
{
|
||||
enterFn: () => void;
|
||||
exitFn: () => void;
|
||||
}
|
||||
>
|
||||
> = {
|
||||
[Mode.INLINE_TEXT]: {
|
||||
enterFn: () => {
|
||||
enterAddInlineText();
|
||||
},
|
||||
exitFn: () => {
|
||||
exitAddInlineText();
|
||||
},
|
||||
},
|
||||
[Mode.BLOCK_TEXT]: {
|
||||
enterFn: () => {
|
||||
enterDragAddElement(Mode.BLOCK_TEXT);
|
||||
},
|
||||
exitFn: () => {
|
||||
exitDragAddElement();
|
||||
},
|
||||
},
|
||||
[Mode.RECT]: {
|
||||
enterFn: () => {
|
||||
enterDragAddElement(Mode.RECT);
|
||||
},
|
||||
exitFn: () => {
|
||||
exitDragAddElement();
|
||||
},
|
||||
},
|
||||
[Mode.CIRCLE]: {
|
||||
enterFn: () => {
|
||||
enterDragAddElement(Mode.CIRCLE);
|
||||
},
|
||||
exitFn: () => {
|
||||
exitDragAddElement();
|
||||
},
|
||||
},
|
||||
[Mode.STRAIGHT_LINE]: {
|
||||
enterFn: () => {
|
||||
enterDragAddElement(Mode.STRAIGHT_LINE);
|
||||
},
|
||||
exitFn: () => {
|
||||
exitDragAddElement();
|
||||
},
|
||||
},
|
||||
[Mode.TRIANGLE]: {
|
||||
enterFn: () => {
|
||||
enterDragAddElement(Mode.TRIANGLE);
|
||||
},
|
||||
exitFn: () => {
|
||||
exitDragAddElement();
|
||||
},
|
||||
},
|
||||
[Mode.PENCIL]: {
|
||||
enterFn: () => {
|
||||
enterFreePencil();
|
||||
},
|
||||
exitFn: () => {
|
||||
exitFreePencil();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 针对画笔模式,达到上限后,主动退出绘画模式
|
||||
useEffect(() => {
|
||||
if (drawMode && !couldAddNewObject) {
|
||||
modeSetting[drawMode]?.exitFn();
|
||||
setDrawMode(undefined);
|
||||
}
|
||||
}, [couldAddNewObject, drawMode]);
|
||||
|
||||
const zoomStartPointer = useRef<Point>();
|
||||
// 鼠标滚轮缩放
|
||||
const onWheelZoom = (e: WheelEvent, isFirst: boolean) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const zoomStep = 0.05;
|
||||
let zoomLevel = viewport[0];
|
||||
|
||||
const delta = e.deltaY;
|
||||
if (isFirst) {
|
||||
const pointer = canvas.getViewportPoint(e);
|
||||
zoomStartPointer.current = pointer;
|
||||
}
|
||||
|
||||
// 根据滚轮方向确定是放大还是缩小
|
||||
if (delta < 0) {
|
||||
zoomLevel += zoomStep;
|
||||
} else {
|
||||
zoomLevel -= zoomStep;
|
||||
}
|
||||
zoomToPoint(
|
||||
zoomStartPointer.current as Point,
|
||||
Number(zoomLevel.toFixed(2)),
|
||||
);
|
||||
};
|
||||
|
||||
// 鼠标位移
|
||||
const onWheelTransform = (deltaX: number, deltaY: number) => {
|
||||
const vpt: TMat2D = [...viewport];
|
||||
vpt[4] -= deltaX;
|
||||
vpt[5] -= deltaY;
|
||||
setViewport(vpt);
|
||||
};
|
||||
|
||||
// 触摸板手势缩放、位移
|
||||
const gestureBind = useGesture(
|
||||
{
|
||||
onPinch: state => {
|
||||
const e = state.event as WheelEvent;
|
||||
e.preventDefault();
|
||||
onWheelZoom(e, state.first);
|
||||
if (state.first) {
|
||||
setIsMousePressing(true);
|
||||
} else if (state.last) {
|
||||
setIsMousePressing(false);
|
||||
}
|
||||
},
|
||||
onWheel: state => {
|
||||
const e = state.event;
|
||||
e.preventDefault();
|
||||
|
||||
if (!state.pinching) {
|
||||
if (state.metaKey) {
|
||||
onWheelZoom(e, state.first);
|
||||
} else {
|
||||
onWheelTransform(e.deltaX, e.deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.first) {
|
||||
setIsMousePressing(true);
|
||||
} else if (state.last) {
|
||||
setIsMousePressing(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
eventOptions: {
|
||||
passive: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 当用户编辑文本时,按删除键不应该执行删除元素操作
|
||||
const [isTextEditing, setIsTextEditing] = useState(false);
|
||||
useEffect(() => {
|
||||
let disposers: (() => void)[] = [];
|
||||
if (
|
||||
activeObjects?.length === 1 &&
|
||||
[Mode.BLOCK_TEXT, Mode.INLINE_TEXT].includes(
|
||||
(activeObjects[0] as FabricObjectWithCustomProps).customType,
|
||||
)
|
||||
) {
|
||||
disposers.push(
|
||||
(activeObjects[0] as IText).on('editing:entered', () => {
|
||||
setIsTextEditing(true);
|
||||
}),
|
||||
);
|
||||
disposers.push(
|
||||
(activeObjects[0] as IText).on('editing:exited', () => {
|
||||
setIsTextEditing(false);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
disposers.forEach(dispose => dispose());
|
||||
disposers = [];
|
||||
};
|
||||
}, [activeObjects]);
|
||||
|
||||
useEffect(() => {
|
||||
const openMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const sizeRect = sizeRef.current?.getBoundingClientRect();
|
||||
setContentMenuPosition({
|
||||
left: e.clientX - (sizeRect?.left ?? 0),
|
||||
top: e.clientY - (sizeRect?.top ?? 0),
|
||||
});
|
||||
};
|
||||
if (sizeRef.current) {
|
||||
sizeRef.current.addEventListener('contextmenu', openMenu);
|
||||
}
|
||||
return () => {
|
||||
if (sizeRef.current) {
|
||||
sizeRef.current.removeEventListener('contextmenu', openMenu);
|
||||
}
|
||||
};
|
||||
}, [sizeRef]);
|
||||
|
||||
// 点击画布外侧,取消选中
|
||||
useEffect(() => {
|
||||
const clickOutside = (e: MouseEvent) => {
|
||||
setContentMenuPosition(undefined);
|
||||
discardActiveObject();
|
||||
};
|
||||
document.addEventListener('click', clickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('click', clickOutside);
|
||||
};
|
||||
}, [discardActiveObject]);
|
||||
|
||||
// 注册快捷键
|
||||
useShortcut({
|
||||
ref: shortcutRef,
|
||||
state: {
|
||||
isTextEditing,
|
||||
disabledPaste,
|
||||
},
|
||||
sdk: {
|
||||
moveActiveObject,
|
||||
removeActiveObjects,
|
||||
undo,
|
||||
redo,
|
||||
copy,
|
||||
paste,
|
||||
group,
|
||||
unGroup,
|
||||
moveToFront,
|
||||
moveToBackend,
|
||||
moveToFrontOne,
|
||||
moveToBackendOne,
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenter,
|
||||
alignTop,
|
||||
alignBottom,
|
||||
alignMiddle,
|
||||
verticalAverage,
|
||||
horizontalAverage,
|
||||
},
|
||||
});
|
||||
|
||||
const isContentMenuShow = !readonly && contentMenuPosition;
|
||||
|
||||
// 选中元素是否为同一类型(包含框选)
|
||||
const isSameActiveObjects =
|
||||
Array.from(
|
||||
new Set(
|
||||
activeObjects?.map(
|
||||
obj => (obj as FabricObjectWithCustomProps).customType,
|
||||
),
|
||||
),
|
||||
).length === 1;
|
||||
|
||||
/**
|
||||
* 属性菜单没有展示 &&
|
||||
* 鼠标右键没有按下(拖拽 ing)&&
|
||||
* isSameActiveObjects &&
|
||||
*/
|
||||
const isFormShow =
|
||||
!isContentMenuShow && !isMousePressing && isSameActiveObjects;
|
||||
|
||||
// 最大宽高有两层限制 1. 面积 2. 固定最大值
|
||||
const { canvasMaxWidth, canvasMaxHeight } = useMemo(
|
||||
() => ({
|
||||
canvasMaxWidth: Math.min(MAX_AREA / schema.height, MAX_WIDTH),
|
||||
canvasMaxHeight: Math.min(MAX_AREA / schema.width, MAX_HEIGHT),
|
||||
}),
|
||||
[schema.width, schema.height],
|
||||
);
|
||||
|
||||
const [focus, setFocus] = useState(false);
|
||||
const debouncedFocus = useDebounce(focus, {
|
||||
wait: 300,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
shortcutRef.current?.focus();
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={`flex flex-col w-full h-full relative ${className} min-w-[900px]`}
|
||||
ref={shortcutRef}
|
||||
onFocus={() => {
|
||||
setFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocus(false);
|
||||
}}
|
||||
>
|
||||
<GlobalContext.Provider
|
||||
value={{
|
||||
variables,
|
||||
customVariableRefs,
|
||||
allObjectsPositionInScreen,
|
||||
activeObjects,
|
||||
addRefObjectByVariable,
|
||||
updateRefByObjectId,
|
||||
}}
|
||||
>
|
||||
<div ref={popRef}></div>
|
||||
<div
|
||||
className={s['top-bar-pop-align-right']}
|
||||
ref={popRefAlignRight}
|
||||
></div>
|
||||
<ConfigProvider
|
||||
getPopupContainer={() => popRef.current ?? document.body}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
className={classNames([
|
||||
'flex gap-[8px] items-center',
|
||||
'w-full h-[55px]',
|
||||
'px-[16px]',
|
||||
])}
|
||||
>
|
||||
<img className="w-[20px] h-[20px] rounded-[2px]" src={icon}></img>
|
||||
|
||||
<div className="text-xxl font-semibold">{title}</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<TopBar
|
||||
redo={redo}
|
||||
undo={undo}
|
||||
disabledRedo={disabledRedo}
|
||||
disabledUndo={disabledUndo}
|
||||
redoUndoing={redoUndoing}
|
||||
popRefAlignRight={popRefAlignRight}
|
||||
readonly={readonly || !canvas}
|
||||
maxLimit={!couldAddNewObject}
|
||||
mode={drawMode}
|
||||
onModeChange={(currentMode, prevMode) => {
|
||||
setDrawMode(currentMode);
|
||||
if (prevMode) {
|
||||
modeSetting[prevMode]?.exitFn();
|
||||
}
|
||||
|
||||
if (currentMode) {
|
||||
modeSetting[currentMode]?.enterFn();
|
||||
}
|
||||
}}
|
||||
isActiveObjectsInBack={isActiveObjectsInBack}
|
||||
isActiveObjectsInFront={isActiveObjectsInFront}
|
||||
onMoveToTop={e => {
|
||||
(e as MouseEvent).stopPropagation();
|
||||
moveToFront();
|
||||
}}
|
||||
onMoveToBackend={e => {
|
||||
(e as MouseEvent).stopPropagation();
|
||||
moveToBackend();
|
||||
}}
|
||||
onAddImg={url => {
|
||||
addImage(url);
|
||||
}}
|
||||
zoomSettings={{
|
||||
reset: () => {
|
||||
setViewport([1, 0, 0, 1, 0, 0]);
|
||||
},
|
||||
zoom: viewport[0],
|
||||
onChange(value: number): void {
|
||||
if (isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
const vpt: TMat2D = [...viewport];
|
||||
let v = Number(value.toFixed(2));
|
||||
|
||||
if (v > MAX_ZOOM) {
|
||||
v = MAX_ZOOM;
|
||||
} else if (v < MIN_ZOOM) {
|
||||
v = MIN_ZOOM;
|
||||
}
|
||||
|
||||
vpt[0] = v;
|
||||
vpt[3] = v;
|
||||
setViewport(vpt);
|
||||
},
|
||||
max: MAX_ZOOM,
|
||||
min: MIN_ZOOM,
|
||||
}}
|
||||
aligns={{
|
||||
[AlignMode.Left]: alignLeft,
|
||||
[AlignMode.Right]: alignRight,
|
||||
[AlignMode.Center]: alignCenter,
|
||||
[AlignMode.Top]: alignTop,
|
||||
[AlignMode.Bottom]: alignBottom,
|
||||
[AlignMode.Middle]: alignMiddle,
|
||||
[AlignMode.VerticalAverage]: verticalAverage,
|
||||
[AlignMode.HorizontalAverage]: horizontalAverage,
|
||||
}}
|
||||
canvasSettings={{
|
||||
width: schema.width,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: canvasMaxWidth,
|
||||
height: schema.height,
|
||||
minHeight: MIN_HEIGHT,
|
||||
maxHeight: canvasMaxHeight,
|
||||
background: backgroundColor as string,
|
||||
onChange(value: {
|
||||
width?: number | undefined;
|
||||
height?: number | undefined;
|
||||
background?: string | undefined;
|
||||
}): void {
|
||||
if (value.background) {
|
||||
setBackgroundColor(value.background);
|
||||
return;
|
||||
}
|
||||
const _value = pick(value, ['width', 'height']);
|
||||
if (_value.width) {
|
||||
if (_value.width > canvasMaxWidth) {
|
||||
_value.width = canvasMaxWidth;
|
||||
}
|
||||
if (_value.width < MIN_WIDTH) {
|
||||
_value.width = MIN_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
if (_value.height) {
|
||||
if (_value.height > canvasMaxHeight) {
|
||||
_value.height = canvasMaxHeight;
|
||||
}
|
||||
if (_value.height < MIN_HEIGHT) {
|
||||
_value.height = MIN_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
resetWidthHeight({
|
||||
..._value,
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MyIconButton
|
||||
onClick={onClose}
|
||||
icon={<IconCozCross className="text-[16px]" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames([
|
||||
'flex-1 flex items-center justify-center',
|
||||
'p-[16px]',
|
||||
'overflow-hidden',
|
||||
'coz-bg-primary',
|
||||
'border-0 border-t coz-stroke-primary border-solid',
|
||||
'scale-100',
|
||||
])}
|
||||
ref={popoverRef}
|
||||
>
|
||||
<div
|
||||
onMouseDown={e => {
|
||||
if (e.button === 0) {
|
||||
setIsMousePressing(true);
|
||||
}
|
||||
}}
|
||||
onMouseUp={e => {
|
||||
if (e.button === 0) {
|
||||
setIsMousePressing(false);
|
||||
}
|
||||
}}
|
||||
ref={sizeRef}
|
||||
tabIndex={0}
|
||||
className={classNames([
|
||||
'flex items-center justify-center',
|
||||
'w-full h-full overflow-hidden',
|
||||
])}
|
||||
>
|
||||
<div
|
||||
{...gestureBind()}
|
||||
className={`border border-solid ${
|
||||
debouncedFocus ? 'coz-stroke-hglt' : 'coz-stroke-primary'
|
||||
} rounded-small overflow-hidden`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* 引用 tag */}
|
||||
<RefTitle visible={!isMousePressing} />
|
||||
<div className="w-fit h-fit overflow-hidden">
|
||||
<div
|
||||
id={helpLineLayerId}
|
||||
className="relative top-0 left-0 bg-red-500 z-[2] pointer-events-none"
|
||||
></div>
|
||||
<canvas ref={canvasRef} className="h-[0px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 右键菜单 */}
|
||||
{isContentMenuShow ? (
|
||||
<ContentMenu
|
||||
limitRect={popoverSize}
|
||||
left={contentMenuPosition.left}
|
||||
top={contentMenuPosition.top}
|
||||
cancelMenu={() => {
|
||||
setContentMenuPosition(undefined);
|
||||
}}
|
||||
hasActiveObject={!!activeObjects?.length}
|
||||
copy={copy}
|
||||
paste={paste}
|
||||
disabledPaste={disabledPaste}
|
||||
moveToFront={moveToFront}
|
||||
moveToBackend={moveToBackend}
|
||||
moveToFrontOne={moveToFrontOne}
|
||||
moveToBackendOne={moveToBackendOne}
|
||||
isActiveObjectsInBack={isActiveObjectsInBack}
|
||||
isActiveObjectsInFront={isActiveObjectsInFront}
|
||||
offsetX={8}
|
||||
offsetY={8}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{/* 属性面板 */}
|
||||
{isFormShow ? (
|
||||
<Form
|
||||
// 文本切换时,涉及字号变化,需要 rerender form 同步状态
|
||||
key={
|
||||
(activeObjects as FabricObjectWithCustomProps[])?.[0]
|
||||
?.customType
|
||||
}
|
||||
schema={schema}
|
||||
activeObjects={activeObjects as FabricObjectWithCustomProps[]}
|
||||
position={activeObjectsPopPosition}
|
||||
onChange={value => {
|
||||
setActiveObjectsProps(value);
|
||||
}}
|
||||
offsetX={((popoverSize?.width ?? 0) - (canvasWidth ?? 0)) / 2}
|
||||
offsetY={
|
||||
((popoverSize?.height ?? 0) - (canvasHeight ?? 0)) / 2
|
||||
}
|
||||
canvasHeight={canvasHeight}
|
||||
limitRect={popoverSize}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</ConfigProvider>
|
||||
</GlobalContext.Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FabricEditor } from './fabric-editor';
|
||||
export { FabricEditor };
|
||||
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable max-lines-per-function */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
import { useKeyPress } from 'ahooks';
|
||||
|
||||
import { CopyMode } from '../../typings';
|
||||
|
||||
export const useShortcut = ({
|
||||
ref,
|
||||
state: { isTextEditing, disabledPaste },
|
||||
sdk: {
|
||||
moveActiveObject,
|
||||
removeActiveObjects,
|
||||
undo,
|
||||
redo,
|
||||
copy,
|
||||
paste,
|
||||
group,
|
||||
unGroup,
|
||||
moveToFront,
|
||||
moveToBackend,
|
||||
moveToFrontOne,
|
||||
moveToBackendOne,
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignCenter,
|
||||
alignTop,
|
||||
alignBottom,
|
||||
alignMiddle,
|
||||
verticalAverage,
|
||||
horizontalAverage,
|
||||
},
|
||||
}: {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
state: {
|
||||
isTextEditing: boolean;
|
||||
disabledPaste: boolean;
|
||||
};
|
||||
sdk: {
|
||||
moveActiveObject: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
||||
removeActiveObjects: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
copy: (mode: CopyMode) => void;
|
||||
paste: (options?: { mode?: CopyMode }) => void;
|
||||
group: () => void;
|
||||
unGroup: () => void;
|
||||
moveToFront: () => void;
|
||||
moveToBackend: () => void;
|
||||
moveToFrontOne: () => void;
|
||||
moveToBackendOne: () => void;
|
||||
alignLeft: () => void;
|
||||
alignRight: () => void;
|
||||
alignCenter: () => void;
|
||||
alignTop: () => void;
|
||||
alignBottom: () => void;
|
||||
alignMiddle: () => void;
|
||||
verticalAverage: () => void;
|
||||
horizontalAverage: () => void;
|
||||
};
|
||||
}) => {
|
||||
// 上下左右微调元素位置
|
||||
useKeyPress(
|
||||
['uparrow', 'downarrow', 'leftarrow', 'rightarrow'],
|
||||
e => {
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
moveActiveObject('up');
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
moveActiveObject('down');
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
moveActiveObject('left');
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
moveActiveObject('right');
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
{
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 删除元素
|
||||
useKeyPress(
|
||||
['backspace', 'delete'],
|
||||
e => {
|
||||
if (!isTextEditing) {
|
||||
removeActiveObjects();
|
||||
}
|
||||
},
|
||||
{
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// redo undo
|
||||
useKeyPress(
|
||||
['ctrl.z', 'meta.z'],
|
||||
e => {
|
||||
// 一定要加,否则会命中浏览器乱七八糟的默认行为
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
redo();
|
||||
} else {
|
||||
undo();
|
||||
}
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 功能开发暂停了,原因详见 packages/workflow/fabric-canvas/src/hooks/use-group.tsx
|
||||
*/
|
||||
// useKeyPress(
|
||||
// ['ctrl.g', 'meta.g'],
|
||||
// e => {
|
||||
// e.preventDefault();
|
||||
// if (e.shiftKey) {
|
||||
// unGroup();
|
||||
// } else {
|
||||
// group();
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// events: ['keydown'],
|
||||
// target: ref,
|
||||
// },
|
||||
// );
|
||||
|
||||
// copy
|
||||
useKeyPress(
|
||||
['ctrl.c', 'meta.c'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
copy(CopyMode.CtrlCV);
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// paste
|
||||
useKeyPress(
|
||||
['ctrl.v', 'meta.v'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
if (!disabledPaste) {
|
||||
paste({ mode: CopyMode.CtrlCV });
|
||||
}
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 生成副本
|
||||
useKeyPress(
|
||||
['ctrl.d', 'meta.d'],
|
||||
async e => {
|
||||
// 必须阻止默认行为,否则会触发添加标签
|
||||
e.preventDefault();
|
||||
await copy(CopyMode.CtrlD);
|
||||
paste({
|
||||
mode: CopyMode.CtrlD,
|
||||
});
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// [ 下移一层
|
||||
useKeyPress(
|
||||
['openbracket'],
|
||||
e => {
|
||||
if (!isTextEditing) {
|
||||
moveToBackendOne();
|
||||
}
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// ] 上移一层
|
||||
useKeyPress(
|
||||
['closebracket'],
|
||||
e => {
|
||||
if (!isTextEditing) {
|
||||
moveToFrontOne();
|
||||
}
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
// ⌘ + [、⌘ + ] 禁止浏览器默认行为 前进、后退
|
||||
useKeyPress(
|
||||
['meta.openbracket', 'meta.closebracket'],
|
||||
e => {
|
||||
if (!isTextEditing) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
{
|
||||
events: ['keydown', 'keyup'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// ⌘ + [ 置底
|
||||
useKeyPress(
|
||||
['meta.openbracket'],
|
||||
e => {
|
||||
if (!isTextEditing) {
|
||||
moveToBackend();
|
||||
}
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// ⌘ + ] 置顶
|
||||
useKeyPress(
|
||||
['meta.closebracket'],
|
||||
e => {
|
||||
if (!isTextEditing) {
|
||||
moveToFront();
|
||||
}
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 水平居左
|
||||
useKeyPress(
|
||||
['alt.a'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
alignLeft();
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 水平居右
|
||||
useKeyPress(
|
||||
['alt.d'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
alignRight();
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 水平居中
|
||||
useKeyPress(
|
||||
['alt.h'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
alignCenter();
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 垂直居上
|
||||
useKeyPress(
|
||||
['alt.w'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
alignTop();
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 垂直居下
|
||||
useKeyPress(
|
||||
['alt.s'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
alignBottom();
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 垂直居中
|
||||
useKeyPress(
|
||||
['alt.v'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
alignMiddle();
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 水平均分
|
||||
useKeyPress(
|
||||
['alt.ctrl.h'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
horizontalAverage();
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
|
||||
// 垂直均分
|
||||
useKeyPress(
|
||||
['alt.ctrl.v'],
|
||||
e => {
|
||||
e.preventDefault();
|
||||
verticalAverage();
|
||||
},
|
||||
{
|
||||
events: ['keydown'],
|
||||
exactMatch: true,
|
||||
target: ref,
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type FC } from 'react';
|
||||
|
||||
import { useSize } from 'ahooks';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
|
||||
import { type FabricSchema } from '../../typings';
|
||||
import { useFabricPreview } from '../../hooks';
|
||||
|
||||
export interface IFabricPreview {
|
||||
schema: FabricSchema;
|
||||
showPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
export const FabricPreview: FC<IFabricPreview> = props => {
|
||||
const { schema, showPlaceholder } = props;
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const sizeRef = useRef(null);
|
||||
const size = useSize(sizeRef);
|
||||
|
||||
const oldWidth = useRef(0);
|
||||
useEffect(() => {
|
||||
if (size?.width && !oldWidth.current) {
|
||||
oldWidth.current = size?.width || 0;
|
||||
}
|
||||
|
||||
// 防止抖动,当宽度变化 > 20 时才更新宽度
|
||||
if (size?.width && size.width - oldWidth.current > 20) {
|
||||
oldWidth.current = size?.width || 0;
|
||||
}
|
||||
}, [size?.width]);
|
||||
|
||||
const maxWidth = oldWidth.current;
|
||||
const maxHeight = 456;
|
||||
|
||||
useFabricPreview({
|
||||
schema,
|
||||
ref,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
startInit: !!size?.width,
|
||||
});
|
||||
|
||||
const isEmpty = schema.objects.length === 0;
|
||||
return (
|
||||
<div className="w-full relative">
|
||||
<div ref={sizeRef} className="w-full"></div>
|
||||
<canvas ref={ref} className="h-[0px]" />
|
||||
{isEmpty && showPlaceholder ? (
|
||||
<div className="w-full h-full absolute top-0 left-0 flex items-center justify-center">
|
||||
<div className="text-[14px] coz-fg-secondary">
|
||||
{I18n.t('imageflow_canvas_double_click', {}, '双击开始编辑')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FabricPreview, IFabricPreview } from './fabric-preview';
|
||||
export { FabricPreview, IFabricPreview };
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozArrowDown,
|
||||
IconCozPalette,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { Tooltip } from '@coze-arch/coze-design';
|
||||
|
||||
import { MyIconButton } from '../icon-button';
|
||||
import { defaultProps } from '../../utils';
|
||||
import { ColorMode, ImageFixedType, Mode, type FormMeta } from '../../typings';
|
||||
import { fontTreeData } from '../../assert/font';
|
||||
|
||||
const createTextMeta = (
|
||||
textType: Mode.BLOCK_TEXT | Mode.INLINE_TEXT,
|
||||
): FormMeta => ({
|
||||
display: 'row',
|
||||
style: {
|
||||
padding: '8px',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
name: 'customId',
|
||||
setter: 'RefSelect',
|
||||
setterProps: {
|
||||
label: I18n.t('imageflow_canvas_reference', {}, '引用'),
|
||||
labelInside: true,
|
||||
className: 'w-[160px]',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fontFamily',
|
||||
splitLine: false,
|
||||
setter: 'TextFamily',
|
||||
setterProps: {
|
||||
treeData: fontTreeData,
|
||||
defaultValue: defaultProps[textType].fontFamily,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fontSize',
|
||||
setter: 'FontSize',
|
||||
setterProps: {
|
||||
min: 10,
|
||||
max: 300,
|
||||
optionList: [12, 16, 20, 24, 32, 40, 48, 56, 72, 92, 120, 160, 220].map(
|
||||
d => ({
|
||||
value: d,
|
||||
label: `${d}`,
|
||||
}),
|
||||
),
|
||||
defaultValue: defaultProps[textType].fontSize,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'lineHeight',
|
||||
splitLine: false,
|
||||
setter: 'LineHeight',
|
||||
setterProps: {
|
||||
optionList: [10, 50, 100, 120, 150, 200, 250, 300, 350, 400].map(d => ({
|
||||
value: d,
|
||||
label: `${d}%`,
|
||||
})),
|
||||
min: 10,
|
||||
max: 400,
|
||||
defaultValue: defaultProps[textType].lineHeight,
|
||||
},
|
||||
},
|
||||
{
|
||||
setter: ({ tooltipVisible }) => (
|
||||
<Tooltip
|
||||
mouseEnterDelay={300}
|
||||
mouseLeaveDelay={300}
|
||||
content={I18n.t('imageflow_canvas_style_tooltip')}
|
||||
>
|
||||
<MyIconButton
|
||||
inForm
|
||||
className="!w-[48px]"
|
||||
color={tooltipVisible ? 'highlight' : 'secondary'}
|
||||
icon={
|
||||
<div className="flex flex-row items-center gap-[2px]">
|
||||
<IconCozPalette className="text-[16px]" />
|
||||
<IconCozArrowDown className="text-[16px]" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
tooltip: {
|
||||
content: [
|
||||
{
|
||||
name: 'colorMode',
|
||||
cacheSave: true,
|
||||
setter: 'SingleSelect',
|
||||
setterProps: {
|
||||
options: [
|
||||
{
|
||||
value: ColorMode.FILL,
|
||||
label: I18n.t('imageflow_canvas_fill'),
|
||||
},
|
||||
{
|
||||
value: ColorMode.STROKE,
|
||||
label: I18n.t('imageflow_canvas_stroke'),
|
||||
},
|
||||
],
|
||||
layout: 'fill',
|
||||
defaultValue: ColorMode.FILL,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'strokeWidth',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode ===
|
||||
ColorMode.STROKE,
|
||||
setter: 'BorderWidth',
|
||||
setterProps: {
|
||||
min: 0,
|
||||
max: 20,
|
||||
defaultValue: defaultProps[textType].strokeWidth,
|
||||
},
|
||||
splitLine: true,
|
||||
},
|
||||
{
|
||||
name: 'stroke',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode ===
|
||||
ColorMode.STROKE,
|
||||
setter: 'ColorPicker',
|
||||
setterProps: {
|
||||
showOpacity: false,
|
||||
defaultValue: defaultProps[textType].stroke,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fill',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode !==
|
||||
ColorMode.STROKE,
|
||||
setter: 'ColorPicker',
|
||||
setterProps: {
|
||||
defaultValue: defaultProps[textType].fill,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'textAlign',
|
||||
setter: 'TextAlign',
|
||||
setterProps: {
|
||||
defaultValue: defaultProps[textType].textAlign,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customType',
|
||||
setter: 'TextType',
|
||||
setterProps: {
|
||||
defaultValue: textType,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createShapeMeta = (
|
||||
shapeType: Mode.RECT | Mode.CIRCLE | Mode.TRIANGLE,
|
||||
): FormMeta => ({
|
||||
display: 'col',
|
||||
style: {
|
||||
padding: '16px',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
name: 'colorMode',
|
||||
cacheSave: true,
|
||||
setter: 'SingleSelect',
|
||||
setterProps: {
|
||||
options: [
|
||||
{
|
||||
value: ColorMode.FILL,
|
||||
label: I18n.t('imageflow_canvas_fill'),
|
||||
},
|
||||
{
|
||||
value: ColorMode.STROKE,
|
||||
label: I18n.t('imageflow_canvas_stroke'),
|
||||
},
|
||||
],
|
||||
layout: 'fill',
|
||||
defaultValue: ColorMode.FILL,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: I18n.t('imageflow_canvas_fill'),
|
||||
name: 'fill',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
|
||||
setter: 'ColorPicker',
|
||||
setterProps: {
|
||||
defaultValue: defaultProps[shapeType].fill,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: I18n.t('imageflow_canvas_stroke'),
|
||||
name: 'strokeWidth',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.STROKE,
|
||||
setter: 'BorderWidth',
|
||||
setterProps: {
|
||||
min: 0,
|
||||
max: 50,
|
||||
defaultValue: defaultProps[shapeType].strokeWidth,
|
||||
},
|
||||
splitLine: true,
|
||||
},
|
||||
{
|
||||
name: 'stroke',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.STROKE,
|
||||
setter: 'ColorPicker',
|
||||
setterProps: {
|
||||
showOpacity: false,
|
||||
defaultValue: defaultProps[shapeType].stroke,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createLineMeta = (
|
||||
lineType: Mode.STRAIGHT_LINE | Mode.PENCIL,
|
||||
): FormMeta => ({
|
||||
display: 'col',
|
||||
style: {
|
||||
padding: '16px',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
title: I18n.t('imageflow_canvas_line_style'),
|
||||
name: 'strokeWidth',
|
||||
setter: 'BorderWidth',
|
||||
setterProps: {
|
||||
min: 0,
|
||||
max: 20,
|
||||
defaultValue: defaultProps[lineType].strokeWidth,
|
||||
},
|
||||
splitLine: true,
|
||||
},
|
||||
{
|
||||
name: 'stroke',
|
||||
setter: 'ColorPicker',
|
||||
setterProps: {
|
||||
showOpacity: false,
|
||||
defaultValue: defaultProps[lineType].stroke,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
type IFormMeta = Partial<Record<Mode, FormMeta>>;
|
||||
export const formMetas: IFormMeta = {
|
||||
[Mode.BLOCK_TEXT]: createTextMeta(Mode.BLOCK_TEXT),
|
||||
[Mode.INLINE_TEXT]: createTextMeta(Mode.INLINE_TEXT),
|
||||
[Mode.RECT]: createShapeMeta(Mode.RECT),
|
||||
[Mode.CIRCLE]: createShapeMeta(Mode.CIRCLE),
|
||||
[Mode.TRIANGLE]: createShapeMeta(Mode.TRIANGLE),
|
||||
[Mode.STRAIGHT_LINE]: createLineMeta(Mode.STRAIGHT_LINE),
|
||||
[Mode.PENCIL]: createLineMeta(Mode.PENCIL),
|
||||
[Mode.IMAGE]: {
|
||||
display: 'col',
|
||||
style: {
|
||||
padding: '16px',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
name: 'customId',
|
||||
setter: 'RefSelect',
|
||||
setterProps: {
|
||||
label: I18n.t('imageflow_canvas_reference', {}, '引用'),
|
||||
labelInside: false,
|
||||
className: 'flex-1 overflow-hidden max-w-[320px]',
|
||||
},
|
||||
splitLine: true,
|
||||
},
|
||||
{
|
||||
name: 'colorMode',
|
||||
cacheSave: true,
|
||||
setter: 'SingleSelect',
|
||||
setterProps: {
|
||||
options: [
|
||||
{
|
||||
value: ColorMode.FILL,
|
||||
label: I18n.t('imageflow_canvas_fill'),
|
||||
},
|
||||
{
|
||||
value: ColorMode.STROKE,
|
||||
label: I18n.t('imageflow_canvas_stroke'),
|
||||
},
|
||||
],
|
||||
layout: 'fill',
|
||||
defaultValue: ColorMode.FILL,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'src',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
|
||||
setter: 'Uploader',
|
||||
setterProps: {
|
||||
getLabel: (isRefElement: boolean) => {
|
||||
if (isRefElement) {
|
||||
return I18n.t('imageflow_canvas_fill_preview', {}, '内容预览');
|
||||
} else {
|
||||
return I18n.t('imageflow_canvas_fill_image', {}, '内容');
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'customFixedType',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
|
||||
setter: 'LabelSelect',
|
||||
setterProps: {
|
||||
className: 'flex-1',
|
||||
label: I18n.t('imageflow_canvas_fill_mode'),
|
||||
optionList: [
|
||||
{
|
||||
value: ImageFixedType.AUTO,
|
||||
label: I18n.t('imageflow_canvas_fill1'),
|
||||
},
|
||||
{
|
||||
value: ImageFixedType.FILL,
|
||||
label: I18n.t('imageflow_canvas_fill2'),
|
||||
},
|
||||
{
|
||||
value: ImageFixedType.FULL,
|
||||
label: I18n.t('imageflow_canvas_fill3'),
|
||||
},
|
||||
],
|
||||
defaultValue: ImageFixedType.FILL,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'opacity',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode === ColorMode.FILL,
|
||||
setter: 'ColorPicker',
|
||||
setterProps: {
|
||||
showColor: false,
|
||||
defaultValue: defaultProps[Mode.IMAGE].opacity,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: I18n.t('imageflow_canvas_stroke'),
|
||||
name: 'strokeWidth',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode ===
|
||||
ColorMode.STROKE,
|
||||
setter: 'BorderWidth',
|
||||
setterProps: {
|
||||
min: 0,
|
||||
max: 50,
|
||||
defaultValue: defaultProps[Mode.IMAGE].strokeWidth,
|
||||
},
|
||||
splitLine: true,
|
||||
},
|
||||
{
|
||||
name: 'stroke',
|
||||
visible: formValue =>
|
||||
(formValue as { colorMode: ColorMode })?.colorMode ===
|
||||
ColorMode.STROKE,
|
||||
setter: 'ColorPicker',
|
||||
setterProps: {
|
||||
showOpacity: false,
|
||||
defaultValue: defaultProps[Mode.IMAGE].stroke,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type FC,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useLatest } from 'ahooks';
|
||||
import { ConfigProvider, Tooltip } from '@coze-arch/coze-design';
|
||||
|
||||
import { setters } from '../setters';
|
||||
import { PopInScreen } from '../pop-in-screen';
|
||||
import { schemaToFormValue } from '../../utils';
|
||||
import {
|
||||
REF_VARIABLE_ID_PREFIX,
|
||||
type FabricObjectSchema,
|
||||
type FabricObjectWithCustomProps,
|
||||
type FabricSchema,
|
||||
type FormMeta,
|
||||
type FormMetaItem,
|
||||
} from '../../typings';
|
||||
import { formMetas } from './form-meta';
|
||||
|
||||
const schemaItemToNode = (props: {
|
||||
metaItem: FormMetaItem;
|
||||
value: unknown;
|
||||
tooltipVisible?: boolean;
|
||||
isRefElement: boolean;
|
||||
onChange: (v: unknown) => void;
|
||||
}) => {
|
||||
const { metaItem, value, onChange, tooltipVisible, isRefElement } = props;
|
||||
const { setter, setterProps, title } = metaItem;
|
||||
let dom: ReactElement | string = setter as string;
|
||||
|
||||
if (typeof setter === 'function') {
|
||||
dom = setter({
|
||||
value,
|
||||
onChange,
|
||||
tooltipVisible,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof setter === 'string' && setters[setter]) {
|
||||
const Setter = setters[setter];
|
||||
dom = (
|
||||
<Setter
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isRefElement={isRefElement}
|
||||
{...setterProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
title ? (
|
||||
<div className="w-full text-[14px] font-medium">{title}</div>
|
||||
) : undefined,
|
||||
<div className="flex items-center gap-[2px] text-[16px]">{dom}</div>,
|
||||
];
|
||||
};
|
||||
|
||||
const FormItem = memo(
|
||||
(props: {
|
||||
metaItem: FormMetaItem;
|
||||
isLast: boolean;
|
||||
isRow: boolean;
|
||||
// 给图片上传组件特化的,需要根据是否为引用元素,设置不同的 label
|
||||
isRefElement: boolean;
|
||||
formValue: Partial<FabricObjectSchema>;
|
||||
onChange: (v: Partial<FabricObjectSchema>, cacheSave?: boolean) => void;
|
||||
}) => {
|
||||
const { metaItem, isLast, isRow, formValue, onChange, isRefElement } =
|
||||
props;
|
||||
const { name = '', tooltip, splitLine, visible } = metaItem;
|
||||
const _splitLine = splitLine ?? (isRow && !isLast ? true : false);
|
||||
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
const _visible = visible?.(formValue) ?? true;
|
||||
if (!_visible) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div key={`form-item-${name}`} className="flex flex-col gap-[12px]">
|
||||
{tooltip ? (
|
||||
<Tooltip
|
||||
onVisibleChange={setTooltipVisible}
|
||||
showArrow={false}
|
||||
position={'bottom'}
|
||||
trigger="click"
|
||||
style={{
|
||||
maxWidth: 'unset',
|
||||
}}
|
||||
spacing={{
|
||||
y: 12,
|
||||
x: 0,
|
||||
}}
|
||||
content={
|
||||
<div
|
||||
key={`tooltip-${name}`}
|
||||
className="flex flex-col gap-[12px]"
|
||||
>
|
||||
{tooltip.content
|
||||
.filter(d => {
|
||||
const _v = d.visible?.(formValue) ?? true;
|
||||
return _v;
|
||||
})
|
||||
.map(d =>
|
||||
schemaItemToNode({
|
||||
metaItem: d,
|
||||
value: formValue[d.name ?? ''],
|
||||
isRefElement,
|
||||
onChange: v => {
|
||||
onChange(
|
||||
{
|
||||
[d.name ?? '']: v,
|
||||
},
|
||||
d.cacheSave,
|
||||
);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{schemaItemToNode({
|
||||
metaItem,
|
||||
value: formValue[name],
|
||||
isRefElement,
|
||||
tooltipVisible,
|
||||
onChange: v => {
|
||||
onChange(
|
||||
{
|
||||
[name]: v,
|
||||
},
|
||||
metaItem.cacheSave,
|
||||
);
|
||||
},
|
||||
})}
|
||||
</Tooltip>
|
||||
) : (
|
||||
schemaItemToNode({
|
||||
metaItem,
|
||||
value: formValue[name],
|
||||
isRefElement,
|
||||
onChange: v => {
|
||||
onChange(
|
||||
{
|
||||
[name]: v,
|
||||
},
|
||||
metaItem.cacheSave,
|
||||
);
|
||||
},
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{_splitLine ? (
|
||||
isRow ? (
|
||||
<div
|
||||
key={`split-${name}`}
|
||||
className="w-[1px] h-[24px] coz-mg-primary-pressed"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={`split-${name}`}
|
||||
className="w-full h-[1px] coz-mg-primary-pressed"
|
||||
/>
|
||||
)
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface IProps {
|
||||
position: { tl: { x: number; y: number }; br: { x: number; y: number } };
|
||||
onChange: (value: Partial<FabricObjectSchema>) => void;
|
||||
offsetY?: number;
|
||||
offsetX?: number;
|
||||
schema: FabricSchema;
|
||||
activeObjects: FabricObjectWithCustomProps[];
|
||||
canvasHeight?: number;
|
||||
limitRect?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const Form: FC<IProps> = props => {
|
||||
const {
|
||||
position,
|
||||
offsetY = 10,
|
||||
offsetX = 0,
|
||||
schema,
|
||||
activeObjects,
|
||||
onChange,
|
||||
limitRect,
|
||||
canvasHeight,
|
||||
} = props;
|
||||
const { tl, br } = position;
|
||||
const x = tl.x + (br.x - tl.x) / 2;
|
||||
let { y } = br;
|
||||
|
||||
let showPositionY: 'bottom-center' | 'top-center' = 'bottom-center';
|
||||
if (canvasHeight && tl.y + (br.y - tl.y) / 2 > canvasHeight / 2) {
|
||||
y = tl.y;
|
||||
showPositionY = 'top-center';
|
||||
}
|
||||
|
||||
const formMeta = useMemo<FormMeta>(
|
||||
() =>
|
||||
formMetas[
|
||||
(activeObjects[0] as FabricObjectWithCustomProps).customType
|
||||
] as FormMeta,
|
||||
[activeObjects],
|
||||
);
|
||||
|
||||
// 临时保存不需要保存到 schema 中的表单值
|
||||
const [cacheFormValue, setCacheFormValue] = useState<
|
||||
Partial<FabricObjectSchema>
|
||||
>({});
|
||||
|
||||
const formValue = {
|
||||
...schemaToFormValue({
|
||||
schema,
|
||||
activeObjectId: activeObjects[0].customId,
|
||||
formMeta,
|
||||
}),
|
||||
...cacheFormValue,
|
||||
};
|
||||
|
||||
const isRow = formMeta.display === 'row';
|
||||
const isCol = formMeta.display === 'col';
|
||||
|
||||
const latestCacheFromValue = useLatest(cacheFormValue);
|
||||
const _onChange = useCallback(
|
||||
(v: Partial<FabricObjectSchema>, cacheSave?: boolean) => {
|
||||
if (!cacheSave) {
|
||||
onChange(v);
|
||||
}
|
||||
|
||||
setCacheFormValue({
|
||||
...latestCacheFromValue.current,
|
||||
...v,
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const fields = useMemo(
|
||||
() =>
|
||||
formMeta.content.map((metaItem, i) => {
|
||||
const isLast = i === formMeta.content.length - 1;
|
||||
return (
|
||||
<FormItem
|
||||
key={metaItem.name}
|
||||
formValue={formValue}
|
||||
metaItem={metaItem}
|
||||
isLast={isLast}
|
||||
isRow={isRow}
|
||||
onChange={_onChange}
|
||||
isRefElement={activeObjects[0].customId.startsWith(
|
||||
REF_VARIABLE_ID_PREFIX,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[formMeta, isRow, isCol, _onChange, formValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigProvider getPopupContainer={() => document.body}>
|
||||
<PopInScreen
|
||||
left={x + offsetX}
|
||||
top={y + offsetY + (showPositionY === 'top-center' ? -10 : 10)}
|
||||
position={showPositionY}
|
||||
limitRect={limitRect}
|
||||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
style={{
|
||||
...(formMeta.style ?? {}),
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames([
|
||||
'flex gap-[12px]',
|
||||
{
|
||||
'flex-col': isCol,
|
||||
'flex-row items-center': isRow,
|
||||
},
|
||||
])}
|
||||
>
|
||||
{fields}
|
||||
</div>
|
||||
</div>
|
||||
</PopInScreen>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
.icon-button.coz-fg-secondary {
|
||||
@apply !coz-fg-primary;
|
||||
|
||||
&:disabled{
|
||||
:global{
|
||||
.semi-button-content{
|
||||
@apply coz-fg-dim;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
IconButton,
|
||||
type ButtonProps,
|
||||
type SemiButton,
|
||||
} from '@coze-arch/coze-design';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
/**
|
||||
* 在 size:small 的基础上,覆盖了 padding ,5px -> 4px
|
||||
*/
|
||||
export const MyIconButton = forwardRef<
|
||||
SemiButton,
|
||||
ButtonProps & { inForm?: boolean }
|
||||
>((props, ref) => {
|
||||
const {
|
||||
className = '',
|
||||
inForm = false,
|
||||
color = 'secondary',
|
||||
...rest
|
||||
} = props;
|
||||
return (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
[styles['icon-button']],
|
||||
{
|
||||
'!p-[4px]': !inForm,
|
||||
'!p-[8px] !w-[32px] !h-[32px]': inForm,
|
||||
[styles['coz-fg-secondary']]: color === 'secondary',
|
||||
},
|
||||
className,
|
||||
)}
|
||||
size="small"
|
||||
color={color}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
.pop-in-screen {
|
||||
*:focus{
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable complexity */
|
||||
import { useEffect, useRef, useState, type FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSize } from 'ahooks';
|
||||
|
||||
import { getNumberBetween } from '../../utils';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
interface IProps {
|
||||
left?: number;
|
||||
top?: number;
|
||||
offsetY?: number;
|
||||
offsetX?: number;
|
||||
children?: React.ReactNode;
|
||||
position?: 'bottom-center' | 'bottom-right' | 'top-center';
|
||||
zIndex?: number;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
limitRect?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
export const PopInScreen: FC<IProps> = props => {
|
||||
const ref = useRef(null);
|
||||
const {
|
||||
left = 0,
|
||||
top = 0,
|
||||
children,
|
||||
position = 'bottom-center',
|
||||
zIndex = 1000,
|
||||
onClick,
|
||||
className,
|
||||
limitRect,
|
||||
} = props;
|
||||
// const documentSize = useSize(document.body);
|
||||
const childrenSize = useSize(ref.current);
|
||||
let maxLeft = (limitRect?.width ?? Infinity) - (childrenSize?.width ?? 0) / 2;
|
||||
let minLeft = (childrenSize?.width ?? 0) / 2;
|
||||
let transform = 'translate(-50%, 0)';
|
||||
|
||||
if (position === 'bottom-right') {
|
||||
maxLeft = (limitRect?.width ?? Infinity) - (childrenSize?.width ?? 0);
|
||||
minLeft = 0;
|
||||
transform = 'translate(0, 0)';
|
||||
} else if (position === 'top-center') {
|
||||
transform = 'translate(-50%, -100%)';
|
||||
}
|
||||
|
||||
/**
|
||||
* ahooks useSize 初次执行会返回 undefined,导致组件位置计算错误
|
||||
* 这里监听 childrenSize ,如果为 undefined 则延迟 100ms 再渲染,以修正组件位置
|
||||
*/
|
||||
const [id, setId] = useState('');
|
||||
const timer = useRef<NodeJS.Timeout>();
|
||||
useEffect(() => {
|
||||
clearTimeout(timer.current);
|
||||
if (!childrenSize) {
|
||||
timer.current = setTimeout(() => {
|
||||
setId(`${Math.random()}`);
|
||||
}, 100);
|
||||
}
|
||||
}, [childrenSize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className={classNames([
|
||||
styles['pop-in-screen'],
|
||||
'!fixed',
|
||||
'coz-tooltip semi-tooltip-wrapper',
|
||||
'p-0',
|
||||
className,
|
||||
])}
|
||||
style={{
|
||||
left: getNumberBetween({
|
||||
value: left,
|
||||
max: maxLeft,
|
||||
min: minLeft,
|
||||
}),
|
||||
top: getNumberBetween({
|
||||
value: top,
|
||||
max: (limitRect?.height ?? Infinity) - (childrenSize?.height ?? 0),
|
||||
min: position === 'top-center' ? (childrenSize?.height ?? 0) : 0,
|
||||
}),
|
||||
|
||||
zIndex,
|
||||
opacity: 1,
|
||||
maxWidth: 'unset',
|
||||
transform,
|
||||
}}
|
||||
>
|
||||
{/* 为了触发二次渲染 */}
|
||||
<div className="hidden" id={id} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
|
||||
import {
|
||||
ViewVariableType,
|
||||
type InputVariable,
|
||||
} from '@coze-workflow/base/types';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozImage, IconCozString } from '@coze-arch/coze-design/icons';
|
||||
import { Tag } from '@coze-arch/coze-design';
|
||||
|
||||
import {
|
||||
type FabricObjectWithCustomProps,
|
||||
type IRefPosition,
|
||||
} from '../../typings';
|
||||
import { useGlobalContext } from '../../context';
|
||||
|
||||
const PADDING = 4;
|
||||
interface IProps {
|
||||
visible?: boolean;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
type Positions = IRefPosition & {
|
||||
zIndex: number;
|
||||
active: boolean;
|
||||
unused: boolean;
|
||||
variable?: InputVariable;
|
||||
};
|
||||
|
||||
export const RefTitle: FC<IProps> = props => {
|
||||
const { visible, offsetX, offsetY } = props;
|
||||
|
||||
const {
|
||||
allObjectsPositionInScreen,
|
||||
customVariableRefs,
|
||||
variables,
|
||||
activeObjects,
|
||||
} = useGlobalContext();
|
||||
|
||||
const refsPosition: Positions[] =
|
||||
allObjectsPositionInScreen
|
||||
?.filter(obj => customVariableRefs?.map(v => v.objectId).includes(obj.id))
|
||||
?.map((obj, i) => {
|
||||
const ref = customVariableRefs?.find(v => v.objectId === obj.id);
|
||||
const variable = variables?.find(d => d.id === ref?.variableId);
|
||||
return {
|
||||
...obj,
|
||||
active: !!activeObjects?.find(
|
||||
o => (o as FabricObjectWithCustomProps).customId === obj.id,
|
||||
),
|
||||
unused: !variable,
|
||||
zIndex: i + 1,
|
||||
variable,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full ${visible ? '' : 'hidden'}`}
|
||||
style={{
|
||||
top: offsetY ?? 0,
|
||||
left: offsetX ?? 0,
|
||||
}}
|
||||
>
|
||||
{refsPosition?.map(d => (
|
||||
<div
|
||||
key={d.id}
|
||||
style={{
|
||||
zIndex: d.active ? 999 : d.zIndex,
|
||||
position: 'absolute',
|
||||
width: 'fit-content',
|
||||
top: `${d.top - PADDING}px`,
|
||||
left: `${d.left}px`,
|
||||
transform: `${'translateY(-100%)'} rotate(${d.angle}deg) scale(1)`,
|
||||
transformOrigin: `0 calc(100% + ${PADDING}px)`,
|
||||
opacity: d.active ? 1 : 0.3,
|
||||
maxWidth: '200px',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
className="flex items-center gap-[3px]"
|
||||
>
|
||||
{d.unused ? (
|
||||
<Tag className="w-full" color="yellow">
|
||||
<div className="truncate w-full overflow-hidden">
|
||||
{I18n.t('imageflow_canvas_var_delete', {}, '变量被删除')}
|
||||
</div>
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
className="w-full"
|
||||
color="primary"
|
||||
prefixIcon={
|
||||
d.variable?.type === ViewVariableType.Image ? (
|
||||
<IconCozImage className="coz-fg-dim" />
|
||||
) : (
|
||||
<IconCozString className="coz-fg-dim" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="truncate w-full overflow-hidden">
|
||||
{d.variable?.name}
|
||||
</div>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
)) ?? undefined}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
.imageflow-canvas-border-width {
|
||||
:global{
|
||||
.semi-slider-handle{
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { Slider } from '@coze-arch/coze-design';
|
||||
|
||||
import styles from './border-width.module.less';
|
||||
|
||||
interface IProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
options?: [number, number, number];
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
export const BorderWidth: FC<IProps> = props => {
|
||||
const { value, onChange, min, max } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
'flex gap-[12px] text-[14px]',
|
||||
styles['imageflow-canvas-border-width'],
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex items-center gap-[8px]">
|
||||
<div className="min-w-[42px]">
|
||||
{I18n.t('imageflow_canvas_stroke_width')}
|
||||
</div>
|
||||
<div className="flex-1 min-w-[320px]">
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={1}
|
||||
showArrow={false}
|
||||
value={value}
|
||||
onChange={o => {
|
||||
onChange(o as number);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
.color-picker-slider{
|
||||
:global{
|
||||
.semi-slider{
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.semi-slider-handle{
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, type FC, useMemo } from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozCheckMarkFill } from '@coze-arch/coze-design/icons';
|
||||
import { Input, Slider } from '@coze-arch/coze-design';
|
||||
|
||||
import styles from './color-picker.module.less';
|
||||
interface IProps {
|
||||
// #ffffffff
|
||||
value: string | number;
|
||||
onChange: (value: string | number) => void;
|
||||
showOpacity?: boolean;
|
||||
showColor?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const colors = [
|
||||
'#000000',
|
||||
'#ffffff',
|
||||
'#C6C6CD',
|
||||
'#FF441E',
|
||||
'#3EC254',
|
||||
'#4D53E8',
|
||||
'#00B2B2',
|
||||
'#FF9600',
|
||||
];
|
||||
|
||||
const ColorRect = (props: {
|
||||
color: string;
|
||||
size?: number;
|
||||
onClick?: () => void;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { color, size = 24, onClick, selected, className } = props;
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`${className} rounded-[4px]`}
|
||||
style={{ backgroundColor: color, width: size, height: size }}
|
||||
>
|
||||
<div
|
||||
className={classnames([
|
||||
'relative top-0 left-0',
|
||||
'flex items-center justify-center',
|
||||
'rounded-[4px] border border-solid border-stroke',
|
||||
])}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
color: color !== '#ffffff' ? '#fff' : '#000',
|
||||
}}
|
||||
>
|
||||
{selected ? <IconCozCheckMarkFill /> : undefined}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isHexOpacityColor = (value: string): boolean =>
|
||||
/^#[0-9A-Fa-f]{8}$/.test(value);
|
||||
const isHexColor = (value: string): boolean => /^#[0-9A-Fa-f]{6}$/.test(value);
|
||||
const opacity16To255ScaleTo100 = (v: string): number => parseInt(v, 16) / 2.55;
|
||||
const opacity100ScaleTo255To16 = (v: number): string =>
|
||||
Math.floor(v * 2.55)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
|
||||
export const ColorPicker: FC<IProps> = props => {
|
||||
const {
|
||||
value = '#ffffffff',
|
||||
onChange,
|
||||
showOpacity = true,
|
||||
showColor = true,
|
||||
readonly = false,
|
||||
} = props;
|
||||
|
||||
const { color, opacity } = useMemo(() => {
|
||||
if (!showColor) {
|
||||
return {
|
||||
opacity: (value as number) * 100,
|
||||
};
|
||||
}
|
||||
return {
|
||||
color: (value as string).substring(0, 7),
|
||||
opacity: opacity16To255ScaleTo100((value as string).substring(7, 9)),
|
||||
};
|
||||
}, [value, showColor]);
|
||||
|
||||
const _onChange = useCallback(
|
||||
(v: string) => {
|
||||
if (isHexOpacityColor(v)) {
|
||||
onChange(v);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-[12px] text-[14px]">
|
||||
{showColor ? (
|
||||
<div className="flex items-center w-full gap-[16px]">
|
||||
<div className="flex items-center flex-1 gap-[12px]">
|
||||
{colors.map(c => {
|
||||
const selected =
|
||||
c.toUpperCase() === (color as string).toUpperCase();
|
||||
return (
|
||||
<ColorRect
|
||||
key={`rect-${c}`}
|
||||
className={`${readonly ? '' : 'cursor-pointer'}`}
|
||||
selected={selected}
|
||||
onClick={() => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
_onChange(`${c}${opacity100ScaleTo255To16(opacity)}`);
|
||||
}}
|
||||
color={c}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Input
|
||||
// 因为是不受控模式,当点击色块时,需要重置 input.value。所以这里以 color 为 key
|
||||
key={`input-${color}`}
|
||||
disabled={readonly}
|
||||
prefix={<ColorRect color={color as string} size={16} />}
|
||||
type="text"
|
||||
className="w-[110px]"
|
||||
// 为什么不使用受控模式?使用受控模式,用户输入过程中触发的格式校验处理起来比较麻烦
|
||||
defaultValue={color}
|
||||
onChange={v => {
|
||||
if (isHexColor(v)) {
|
||||
_onChange(`${v}${opacity100ScaleTo255To16(opacity)}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : undefined}
|
||||
{showOpacity ? (
|
||||
<div className="w-full flex items-center gap-[8px]">
|
||||
<div className="min-w-[80px]">
|
||||
{I18n.t('imageflow_canvas_transparency')}
|
||||
</div>
|
||||
<div
|
||||
className={classnames(
|
||||
'flex-1 min-w-[320px]',
|
||||
styles['color-picker-slider'],
|
||||
)}
|
||||
>
|
||||
<Slider
|
||||
min={0}
|
||||
showArrow={false}
|
||||
max={100}
|
||||
step={1}
|
||||
value={opacity}
|
||||
disabled={readonly}
|
||||
onChange={o => {
|
||||
if (!showColor) {
|
||||
onChange((o as number) / 100);
|
||||
} else {
|
||||
_onChange(`${color}${opacity100ScaleTo255To16(o as number)}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC, useCallback, useMemo } from 'react';
|
||||
|
||||
import { clamp } from 'lodash-es';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozFontSize } from '@coze-arch/coze-design/icons';
|
||||
import { Select, type SelectProps, Tooltip } from '@coze-arch/coze-design';
|
||||
|
||||
interface IProps extends Omit<SelectProps, 'onChange'> {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export const FontSize: FC<IProps> = props => {
|
||||
const { onChange, min, max, optionList, value, ...rest } = props;
|
||||
const _onChange = useCallback(
|
||||
(v: number) => {
|
||||
if (isFinite(v)) {
|
||||
onChange?.(clamp(v, min, max));
|
||||
}
|
||||
},
|
||||
[onChange, min, max],
|
||||
);
|
||||
|
||||
const _optionsList = useMemo(() => {
|
||||
const _options = [...(optionList ?? [])];
|
||||
if (!_options.map(o => o.value).includes(value)) {
|
||||
_options.unshift({
|
||||
label: `${value}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
return _options;
|
||||
}, [optionList, value]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-[8px] items-center">
|
||||
{/* <IconCozFontSize className="text-[16px] m-[8px]" /> */}
|
||||
<Tooltip
|
||||
content={I18n.t('imageflow_canvas_text_tooltip1')}
|
||||
mouseEnterDelay={300}
|
||||
mouseLeaveDelay={300}
|
||||
>
|
||||
<Select
|
||||
{...rest}
|
||||
prefix={
|
||||
<IconCozFontSize className="text-[16px] coz-fg-secondary m-[8px]" />
|
||||
}
|
||||
/**
|
||||
* 因为开启了 allowCreate,所以 optionList 不会再响应动态变化
|
||||
* 这里给个 key ,重新渲染 select,保证 optionList 符合预期
|
||||
*/
|
||||
key={_optionsList.map(d => d.label).join()}
|
||||
value={value}
|
||||
optionList={_optionsList}
|
||||
filter
|
||||
allowCreate
|
||||
onChange={v => {
|
||||
_onChange(v as number);
|
||||
}}
|
||||
style={{ width: '98px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { Select } from '@coze-arch/coze-design';
|
||||
|
||||
import { Uploader } from './uploader';
|
||||
import { TextType } from './text-type';
|
||||
import { TextFamily } from './text-family';
|
||||
import { TextAlign } from './text-align';
|
||||
import { SingleSelect } from './single-select';
|
||||
import { RefSelect } from './ref-select';
|
||||
import { LineHeight } from './line-height';
|
||||
import { LabelSelect } from './label-select';
|
||||
import { InputNumber } from './input-number';
|
||||
import { FontSize } from './font-size';
|
||||
import { ColorPicker } from './color-picker';
|
||||
import { BorderWidth } from './border-width';
|
||||
|
||||
export const setters: Record<string, FC<any>> = {
|
||||
ColorPicker,
|
||||
TextAlign,
|
||||
InputNumber,
|
||||
TextType,
|
||||
SingleSelect,
|
||||
BorderWidth,
|
||||
Select,
|
||||
TextFamily,
|
||||
FontSize,
|
||||
LineHeight,
|
||||
LabelSelect,
|
||||
Uploader,
|
||||
RefSelect,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import {
|
||||
InputNumber as CozeInputNumber,
|
||||
type InputNumberProps,
|
||||
} from '@coze-arch/coze-design';
|
||||
|
||||
export const InputNumber = forwardRef<InputNumberProps, InputNumberProps>(
|
||||
props => {
|
||||
const { onChange, min, max, value, ...rest } = props;
|
||||
return (
|
||||
<CozeInputNumber
|
||||
{...rest}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
// InputNumber 长按 + - 时,会一直触发变化。这里有 bug,有时定时器清不掉,会鬼畜(一直增加/减小)。
|
||||
// 把 pressInterval 设置成 24h ,变相禁用长按增减
|
||||
pressInterval={1000 * 60 * 60 * 24}
|
||||
onNumberChange={v => {
|
||||
if (Number.isFinite(v)) {
|
||||
if (typeof min === 'number' && (v as number) < min) {
|
||||
onChange?.(min);
|
||||
} else if (typeof max === 'number' && (v as number) > max) {
|
||||
onChange?.(max);
|
||||
} else {
|
||||
const _v = Number((v as number).toFixed(1));
|
||||
if (_v !== value) {
|
||||
onChange?.(Number((v as number).toFixed(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { Select, type SelectProps } from '@coze-arch/coze-design';
|
||||
|
||||
type IProps = SelectProps & { label: string };
|
||||
export const LabelSelect: FC<IProps> = props => {
|
||||
const { label, ...rest } = props;
|
||||
return (
|
||||
<div className="w-full flex gap-[8px] justify-between items-center text-[14px]">
|
||||
<div className="min-w-[80px]">{label}</div>
|
||||
<Select {...rest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC, useCallback, useMemo } from 'react';
|
||||
|
||||
import { clamp } from 'lodash-es';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozFontHeight } from '@coze-arch/coze-design/icons';
|
||||
import { Select, Tooltip, type SelectProps } from '@coze-arch/coze-design';
|
||||
|
||||
interface IProps extends Omit<SelectProps, 'onChange'> {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export const LineHeight: FC<IProps> = props => {
|
||||
const { onChange, min, max, value, optionList, ...rest } = props;
|
||||
const _onChange = useCallback(
|
||||
(v: string) => {
|
||||
const _v = Number(`${v}`.replace('%', ''));
|
||||
if (isFinite(_v)) {
|
||||
onChange?.(Number((clamp(_v, min, max) / 100).toFixed(2)));
|
||||
}
|
||||
},
|
||||
[onChange, min, max],
|
||||
);
|
||||
|
||||
const _optionsList = useMemo(() => {
|
||||
const _options = [...(optionList ?? [])];
|
||||
if (
|
||||
!_options
|
||||
.map(o => o.value)
|
||||
.includes(
|
||||
Number((Number(`${value}`.replace('%', '')) * 100).toFixed(0)),
|
||||
)
|
||||
) {
|
||||
_options.unshift({
|
||||
label: `${Number((value * 100).toFixed(0))}%`,
|
||||
value: Number((value * 100).toFixed(0)),
|
||||
});
|
||||
}
|
||||
return _options;
|
||||
}, [optionList, value]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-[8px] items-center">
|
||||
{/* <IconCozFontHeight className="text-[16px] m-[8px]" /> */}
|
||||
<Tooltip
|
||||
content={I18n.t('imageflow_canvas_text_tooltip2')}
|
||||
mouseEnterDelay={300}
|
||||
mouseLeaveDelay={300}
|
||||
>
|
||||
<Select
|
||||
prefix={
|
||||
<IconCozFontHeight className="text-[16px] coz-fg-secondary m-[8px]" />
|
||||
}
|
||||
{...rest}
|
||||
/**
|
||||
* 因为开启了 allowCreate,所以 optionList 不会再响应动态变化
|
||||
* 这里给个 key ,重新渲染 select,保证 optionList 符合预期
|
||||
*/
|
||||
key={_optionsList.map(d => d.label).join()}
|
||||
filter
|
||||
value={Number((value * 100).toFixed(0))}
|
||||
allowCreate
|
||||
onChange={v => {
|
||||
_onChange(v as string);
|
||||
}}
|
||||
optionList={_optionsList}
|
||||
style={{ width: '104px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
.ref-select{
|
||||
// :global(.semi-select-content-wrapper){
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
:global(.semi-select-selection){
|
||||
@apply !ml-[4px];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
|
||||
import cls from 'classnames';
|
||||
import { ViewVariableType } from '@coze-workflow/base/types';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozImage, IconCozString } from '@coze-arch/coze-design/icons';
|
||||
import {
|
||||
type RenderSelectedItemFn,
|
||||
Select,
|
||||
type SelectProps,
|
||||
Tag,
|
||||
} from '@coze-arch/coze-design';
|
||||
|
||||
import { useGlobalContext } from '../../context';
|
||||
|
||||
import s from './ref-select.module.less';
|
||||
|
||||
type IProps = SelectProps & {
|
||||
value: string;
|
||||
label: string;
|
||||
labelInside: boolean;
|
||||
};
|
||||
export const RefSelect: FC<IProps> = props => {
|
||||
const { value: objectId, label, labelInside, className } = props;
|
||||
const { customVariableRefs, variables, updateRefByObjectId } =
|
||||
useGlobalContext();
|
||||
|
||||
const targetRef = customVariableRefs?.find(ref => ref.objectId === objectId);
|
||||
const value = targetRef?.variableId;
|
||||
const targetVariable = variables?.find(v => v.id === value);
|
||||
|
||||
return (
|
||||
<div className="w-full text-[14px] flex flex-row gap-[8px] items-center">
|
||||
{!labelInside && label ? (
|
||||
<div className="text-[14px] min-w-[80px]">{label}</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<Select
|
||||
prefix={
|
||||
labelInside && label ? (
|
||||
<div className="text-[14px] pl-[8px] pr-[4px] py-[2px] min-w-[40px]">
|
||||
{label}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
showClear
|
||||
showTick={false}
|
||||
placeholder={I18n.t('imageflow_canvas_select_var', {}, '选择变量')}
|
||||
value={targetRef?.variableId}
|
||||
className={cls(className, s['ref-select'])}
|
||||
onChange={d => {
|
||||
updateRefByObjectId?.({
|
||||
objectId,
|
||||
variable: d ? variables?.find(v => v.id === d) : undefined,
|
||||
});
|
||||
}}
|
||||
renderSelectedItem={
|
||||
((options: { value: string; label: React.ReactNode }) => {
|
||||
const { value: _value, label: _label } = options;
|
||||
const variable = variables?.find(v => v.id === _value);
|
||||
if (variable) {
|
||||
return (
|
||||
<Tag color="primary" className="w-full">
|
||||
<div className="flex flex-row items-center gap-[4px] w-full">
|
||||
{variable.type === ViewVariableType.String ? (
|
||||
<IconCozString className="coz-fg-dim" />
|
||||
) : (
|
||||
<IconCozImage className="coz-fg-dim" />
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="truncate w-full overflow-hidden">
|
||||
{variable.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return _label;
|
||||
}) as RenderSelectedItemFn
|
||||
}
|
||||
>
|
||||
{value && !targetVariable ? (
|
||||
<Select.Option value={value}>
|
||||
<Tag className="max-w-full m-[8px]" color="yellow">
|
||||
<div className="truncate overflow-hidden">
|
||||
{I18n.t('imageflow_canvas_var_delete', {}, '变量被删除')}
|
||||
</div>
|
||||
</Tag>
|
||||
</Select.Option>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{variables?.map(v => (
|
||||
<Select.Option value={v.id}>
|
||||
<div className="flex flex-row items-center gap-[4px] w-full p-[8px] max-w-[400px]">
|
||||
{v.type === ViewVariableType.String ? (
|
||||
<IconCozString className="coz-fg-dim" />
|
||||
) : (
|
||||
<IconCozImage className="coz-fg-dim" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="truncate w-full overflow-hidden">{v.name}</div>
|
||||
</div>
|
||||
{v.id === value ? (
|
||||
<Tag color="primary">
|
||||
{I18n.t('eval_status_referenced', {}, '已引用')}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import {
|
||||
SingleSelect as CozeSingleSelect,
|
||||
type SingleSelectProps,
|
||||
} from '@coze-arch/coze-design';
|
||||
|
||||
export const SingleSelect = forwardRef<SingleSelectProps, SingleSelectProps>(
|
||||
props => {
|
||||
// (props, ref) => {
|
||||
const { onChange, ...rest } = props;
|
||||
return (
|
||||
<CozeSingleSelect
|
||||
{...rest}
|
||||
// ref={ref}
|
||||
onChange={v => {
|
||||
onChange?.(v.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozTextAlignCenter,
|
||||
IconCozTextAlignLeft,
|
||||
IconCozTextAlignRight,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { Select, type RenderSelectedItemFn } from '@coze-arch/coze-design';
|
||||
|
||||
import { TextAlign as TextAlignEnum } from '../../typings';
|
||||
|
||||
interface IProps {
|
||||
value: TextAlignEnum;
|
||||
onChange: (value: TextAlignEnum) => void;
|
||||
}
|
||||
export const TextAlign: FC<IProps> = props => {
|
||||
const { value, onChange } = props;
|
||||
|
||||
return (
|
||||
<Select
|
||||
// borderless
|
||||
className="border-0 hover:border-0 focus:border-0"
|
||||
value={value}
|
||||
onChange={v => {
|
||||
onChange(v as TextAlignEnum);
|
||||
}}
|
||||
optionList={[
|
||||
{
|
||||
icon: <IconCozTextAlignLeft className="text-[16px]" />,
|
||||
label: I18n.t('card_builder_hover_align_left'),
|
||||
value: TextAlignEnum.LEFT,
|
||||
},
|
||||
{
|
||||
icon: <IconCozTextAlignCenter className="text-[16px]" />,
|
||||
label: I18n.t('card_builder_hover_align_horizontal'),
|
||||
value: TextAlignEnum.CENTER,
|
||||
},
|
||||
{
|
||||
icon: <IconCozTextAlignRight className="text-[16px]" />,
|
||||
label: I18n.t('card_builder_hover_align_right'),
|
||||
value: TextAlignEnum.RIGHT,
|
||||
},
|
||||
].map(d => ({
|
||||
...d,
|
||||
label: (
|
||||
<div className="flex flex-row items-center gap-[4px]">
|
||||
{d.icon}
|
||||
{d.label}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
renderSelectedItem={
|
||||
((option: { icon: React.ReactNode }) => {
|
||||
const { icon } = option;
|
||||
return <div className="flex flex-row items-center">{icon}</div>;
|
||||
}) as RenderSelectedItemFn
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.imageflow-canvas-font-family-cascader {
|
||||
:global{
|
||||
.semi-cascader-option-lists{
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { Cascader } from '@coze-arch/coze-design';
|
||||
|
||||
import s from './text-family.module.less';
|
||||
|
||||
interface IProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const TextFamily: FC<IProps> = props => {
|
||||
// (props, ref) => {
|
||||
const { onChange, value, ...rest } = props;
|
||||
return (
|
||||
<Cascader
|
||||
{...rest}
|
||||
value={value?.split('-')?.reverse()}
|
||||
onChange={v => {
|
||||
onChange?.((v as string[])?.reverse()?.join('-'));
|
||||
}}
|
||||
dropdownClassName={s['imageflow-canvas-font-family-cascader']}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozFixedSize,
|
||||
IconCozAutoWidth,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { Tooltip } from '@coze-arch/coze-design';
|
||||
|
||||
import { MyIconButton } from '../icon-button';
|
||||
import { Mode } from '../../typings';
|
||||
|
||||
interface IProps {
|
||||
value: Mode;
|
||||
onChange: (value: Mode) => void;
|
||||
}
|
||||
export const TextType: FC<IProps> = props => {
|
||||
const { value, onChange } = props;
|
||||
return (
|
||||
<div className="flex gap-[12px]">
|
||||
<Tooltip
|
||||
mouseEnterDelay={300}
|
||||
mouseLeaveDelay={300}
|
||||
content={I18n.t('imageflow_canvas_text1')}
|
||||
>
|
||||
<MyIconButton
|
||||
inForm
|
||||
color={value === Mode.INLINE_TEXT ? 'highlight' : 'secondary'}
|
||||
onClick={() => {
|
||||
onChange(Mode.INLINE_TEXT);
|
||||
}}
|
||||
icon={<IconCozAutoWidth className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
mouseEnterDelay={300}
|
||||
mouseLeaveDelay={300}
|
||||
content={I18n.t('imageflow_canvas_text2')}
|
||||
>
|
||||
<MyIconButton
|
||||
inForm
|
||||
color={value === Mode.BLOCK_TEXT ? 'highlight' : 'secondary'}
|
||||
onClick={() => {
|
||||
onChange(Mode.BLOCK_TEXT);
|
||||
}}
|
||||
icon={<IconCozFixedSize className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC } from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozLoading, IconCozUpload } from '@coze-arch/coze-design/icons';
|
||||
import { Button } from '@coze-arch/coze-design';
|
||||
|
||||
import { ImageUpload } from '../topbar/image-upload';
|
||||
|
||||
interface IProps {
|
||||
getLabel: (isRefElement: boolean) => string;
|
||||
onChange: (url: string) => void;
|
||||
isRefElement: boolean;
|
||||
}
|
||||
export const Uploader: FC<IProps> = props => {
|
||||
const { getLabel, onChange, isRefElement } = props;
|
||||
return (
|
||||
<div className="w-full flex gap-[8px] justify-between items-center text-[14px]">
|
||||
<div className="min-w-[80px]">{getLabel(isRefElement)}</div>
|
||||
<ImageUpload
|
||||
disabledTooltip
|
||||
onChange={onChange}
|
||||
tooltip={I18n.t('card_builder_image')}
|
||||
className="flex-1"
|
||||
>
|
||||
{({ loading, cancel }) => (
|
||||
<Button
|
||||
className="w-full"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
loading && cancel();
|
||||
}}
|
||||
icon={
|
||||
loading ? (
|
||||
<IconCozLoading className={'loading coz-fg-dim'} />
|
||||
) : (
|
||||
<IconCozUpload />
|
||||
)
|
||||
}
|
||||
>
|
||||
{loading
|
||||
? I18n.t('imageflow_canvas_cancel_change', {}, '取消上传')
|
||||
: I18n.t('imageflow_canvas_change_img', {}, '更换图片')}
|
||||
</Button>
|
||||
)}
|
||||
</ImageUpload>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type FC, type RefObject } from 'react';
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozAlignBottom,
|
||||
IconCozAlignCenterHorizontal,
|
||||
IconCozAlignCenterVertical,
|
||||
IconCozAlignLeft,
|
||||
IconCozAlignRight,
|
||||
IconCozAlignTop,
|
||||
IconCozDistributeHorizontal,
|
||||
IconCozDistributeVertical,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import { Select } from '@coze-arch/coze-design';
|
||||
|
||||
import { AlignMode } from '../../typings';
|
||||
import styles from '../../index.module.less';
|
||||
|
||||
interface IProps {
|
||||
readonly?: boolean;
|
||||
popRefAlignRight: RefObject<HTMLDivElement> | null;
|
||||
onChange: (v: AlignMode) => void;
|
||||
}
|
||||
|
||||
export const Align: FC<IProps> = props => {
|
||||
const { readonly, onChange, popRefAlignRight } = props;
|
||||
|
||||
const renderItem = ({
|
||||
name,
|
||||
value,
|
||||
icon,
|
||||
suffix,
|
||||
}: {
|
||||
name: string;
|
||||
value: AlignMode;
|
||||
icon: JSX.Element;
|
||||
suffix: string;
|
||||
}) => (
|
||||
<Select.Option value={value}>
|
||||
<div className="w-[172px] px-[8px] flex gap-[4px] align-center coz-fg-primary">
|
||||
<div className="text-[16px] flex items-center">{icon}</div>
|
||||
<div className="flex-1 text-[14px]">{name}</div>
|
||||
<div className="coz-fg-secondary text-[12px]">{suffix}</div>
|
||||
</div>
|
||||
</Select.Option>
|
||||
);
|
||||
return (
|
||||
// 禁止冒泡,防止点击对齐时,canvas 的选中状态被清空
|
||||
<div
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
disabled={readonly}
|
||||
className={'hide-selected-label hide-border'}
|
||||
dropdownClassName={styles['select-hidden-group-label']}
|
||||
showTick={false}
|
||||
size="small"
|
||||
getPopupContainer={() => popRefAlignRight?.current ?? document.body}
|
||||
onSelect={v => {
|
||||
onChange(v as AlignMode);
|
||||
}}
|
||||
maxHeight={300}
|
||||
restTagsPopoverProps={{
|
||||
trigger: 'hover',
|
||||
}}
|
||||
>
|
||||
<Select.OptGroup label="a">
|
||||
{[
|
||||
{
|
||||
name: I18n.t('imageflow_canvas_align1', {}, '左对齐'),
|
||||
value: AlignMode.Left,
|
||||
icon: <IconCozAlignLeft />,
|
||||
suffix: '⌥ + A',
|
||||
},
|
||||
{
|
||||
name: I18n.t('imageflow_canvas_align2', {}, '水平居中'),
|
||||
value: AlignMode.Center,
|
||||
icon: <IconCozAlignCenterVertical />,
|
||||
suffix: '⌥ + H',
|
||||
},
|
||||
{
|
||||
name: I18n.t('imageflow_canvas_align3', {}, '右对齐'),
|
||||
value: AlignMode.Right,
|
||||
icon: <IconCozAlignRight />,
|
||||
suffix: '⌥ + D',
|
||||
},
|
||||
].map(renderItem)}
|
||||
</Select.OptGroup>
|
||||
<Select.OptGroup label="b">
|
||||
{[
|
||||
{
|
||||
name: I18n.t('imageflow_canvas_align4', {}, '顶部对齐'),
|
||||
value: AlignMode.Top,
|
||||
icon: <IconCozAlignTop />,
|
||||
suffix: '⌥ + W',
|
||||
},
|
||||
{
|
||||
name: I18n.t('imageflow_canvas_align5', {}, '垂直居中'),
|
||||
value: AlignMode.Middle,
|
||||
icon: <IconCozAlignCenterHorizontal />,
|
||||
suffix: '⌥ + V',
|
||||
},
|
||||
{
|
||||
name: I18n.t('imageflow_canvas_align6', {}, '底部对齐'),
|
||||
value: AlignMode.Bottom,
|
||||
icon: <IconCozAlignBottom />,
|
||||
suffix: '⌥ + S',
|
||||
},
|
||||
].map(renderItem)}
|
||||
</Select.OptGroup>
|
||||
|
||||
<Select.OptGroup label="c">
|
||||
{[
|
||||
{
|
||||
name: I18n.t('imageflow_canvas_align7', {}, '水平均分'),
|
||||
value: AlignMode.VerticalAverage,
|
||||
icon: <IconCozDistributeHorizontal />,
|
||||
suffix: '^ + ⌥ + H',
|
||||
},
|
||||
{
|
||||
name: I18n.t('imageflow_canvas_align8', {}, '垂直均分'),
|
||||
value: AlignMode.HorizontalAverage,
|
||||
icon: <IconCozDistributeVertical />,
|
||||
suffix: '^ + ⌥ + V',
|
||||
},
|
||||
].map(renderItem)}
|
||||
</Select.OptGroup>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
.loading-container{
|
||||
:global{
|
||||
.loading {
|
||||
animation: semi-animation-rotate 0.6s linear infinite;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.hover-hidden{
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.hover-visible{
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
&:hover{
|
||||
.hover-hidden{
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.hover-visible{
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useImageUploader, type ImageRule } from '@coze-workflow/components';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozCrossCircle,
|
||||
IconCozLoading,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import {
|
||||
Toast,
|
||||
Tooltip,
|
||||
Upload,
|
||||
type customRequestArgs,
|
||||
} from '@coze-arch/coze-design';
|
||||
|
||||
import styles from './image-upload.module.less';
|
||||
|
||||
const imageRules: ImageRule = {
|
||||
suffix: ['jpg', 'jpeg', 'png', 'webp'],
|
||||
maxSize: 1024 * 1024 * 5,
|
||||
};
|
||||
|
||||
export const ImageUpload = (props: {
|
||||
onChange?: (url: string) => void;
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((data: { loading: boolean; cancel: () => void }) => React.ReactNode);
|
||||
tooltip?: string;
|
||||
disabledTooltip?: boolean;
|
||||
key?: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { onChange, children, className, tooltip, key, disabledTooltip } =
|
||||
props;
|
||||
// const focusRef = useRef(false);
|
||||
const { uploadImg, clearImg, loading } = useImageUploader({
|
||||
rules: imageRules,
|
||||
});
|
||||
|
||||
const handleUpload: (object: customRequestArgs) => void = async ({
|
||||
fileInstance,
|
||||
}) => {
|
||||
clearImg();
|
||||
const res = await uploadImg(fileInstance);
|
||||
if (res?.isSuccess) {
|
||||
onChange?.(res.url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptInvalid = () => {
|
||||
Toast.error(
|
||||
I18n.t('imageflow_upload_error_type', {
|
||||
type: imageRules.suffix?.join('/'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className={classNames([styles['loading-container'], className])}>
|
||||
<Upload
|
||||
action=""
|
||||
disabled={loading}
|
||||
customRequest={handleUpload}
|
||||
draggable={true}
|
||||
accept={imageRules.suffix?.map(item => `.${item}`).join(',')}
|
||||
showUploadList={false}
|
||||
onAcceptInvalid={handleAcceptInvalid}
|
||||
>
|
||||
{typeof children === 'function' ? (
|
||||
children({ loading, cancel: clearImg })
|
||||
) : loading ? (
|
||||
<div>
|
||||
<IconCozLoading
|
||||
className={`loading coz-fg-dim ${styles['hover-hidden']}`}
|
||||
/>
|
||||
<IconCozCrossCircle
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
clearImg();
|
||||
}}
|
||||
className={`coz-fg-dim hover-visible ${styles['hover-visible']}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Upload>
|
||||
</div>
|
||||
);
|
||||
|
||||
return disabledTooltip ? (
|
||||
content
|
||||
) : (
|
||||
<Tooltip
|
||||
key={key ?? 'image'}
|
||||
content={loading ? I18n.t('Cancel') : tooltip}
|
||||
mouseEnterDelay={300}
|
||||
mouseLeaveDelay={300}
|
||||
getPopupContainer={() => document.body}
|
||||
>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,678 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable complexity */
|
||||
/* eslint-disable max-lines */
|
||||
/* eslint-disable @coze-arch/max-line-per-function */
|
||||
/* eslint-disable max-lines-per-function */
|
||||
|
||||
import { useCallback, useRef, useState, type FC, type RefObject } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useKeyPress } from 'ahooks';
|
||||
import { SizeSelect, Text } from '@coze-workflow/components';
|
||||
import { ViewVariableType } from '@coze-workflow/base/types';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import {
|
||||
IconCozAlignBottom,
|
||||
IconCozAlignCenterHorizontal,
|
||||
IconCozAlignCenterVertical,
|
||||
IconCozAlignLeft,
|
||||
IconCozAlignRight,
|
||||
IconCozAlignTop,
|
||||
IconCozArrowBack,
|
||||
IconCozArrowForward,
|
||||
IconCozAutoView,
|
||||
IconCozDistributeHorizontal,
|
||||
IconCozDistributeVertical,
|
||||
IconCozEllipse,
|
||||
IconCozEmpty,
|
||||
IconCozFixedSize,
|
||||
IconCozImage,
|
||||
IconCozLine,
|
||||
IconCozMinus,
|
||||
IconCozMoveToBottomFill,
|
||||
IconCozMoveToTopFill,
|
||||
IconCozParagraph,
|
||||
IconCozPencil,
|
||||
IconCozPlus,
|
||||
IconCozRectangle,
|
||||
IconCozRectangleSetting,
|
||||
IconCozString,
|
||||
IconCozTriangle,
|
||||
IconCozVariables,
|
||||
type OriginIconProps,
|
||||
} from '@coze-arch/coze-design/icons';
|
||||
import {
|
||||
ConfigProvider,
|
||||
EmptyState,
|
||||
InputNumber,
|
||||
Menu,
|
||||
Select,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from '@coze-arch/coze-design';
|
||||
|
||||
import { ColorPicker } from '../setters/color-picker';
|
||||
import { MyIconButton } from '../icon-button';
|
||||
import { AlignMode, Mode } from '../../typings';
|
||||
import styles from '../../index.module.less';
|
||||
import { useGlobalContext } from '../../context';
|
||||
import { ImageUpload } from './image-upload';
|
||||
import { Align } from './align';
|
||||
|
||||
interface IProps {
|
||||
popRefAlignRight: RefObject<HTMLDivElement> | null;
|
||||
readonly?: boolean;
|
||||
mode?: Mode;
|
||||
maxLimit?: boolean;
|
||||
onModeChange: (currentMode?: Mode, prevMode?: Mode) => void;
|
||||
onMoveToTop: (e: unknown) => void;
|
||||
onMoveToBackend: (e: unknown) => void;
|
||||
isActiveObjectsInBack?: boolean;
|
||||
isActiveObjectsInFront?: boolean;
|
||||
onAddImg: (url: string) => void;
|
||||
zoomSettings: {
|
||||
zoom: number;
|
||||
onChange: (value: number) => void;
|
||||
reset: () => void;
|
||||
max: number;
|
||||
min: number;
|
||||
};
|
||||
redo: () => void;
|
||||
undo: () => void;
|
||||
disabledUndo: boolean;
|
||||
disabledRedo: boolean;
|
||||
redoUndoing: boolean;
|
||||
canvasSettings: {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
background: string;
|
||||
onChange: (value: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
type?: string;
|
||||
background?: string;
|
||||
}) => void;
|
||||
};
|
||||
aligns: Record<AlignMode, () => void>;
|
||||
}
|
||||
|
||||
type Icon = React.ForwardRefExoticComponent<
|
||||
Omit<OriginIconProps, 'ref'> & React.RefAttributes<SVGSVGElement>
|
||||
>;
|
||||
|
||||
const SplitLine = () => (
|
||||
<div className="h-[24px] w-[1px] coz-mg-primary-pressed"></div>
|
||||
);
|
||||
|
||||
const textIcons: Partial<Record<Mode, { icon: Icon; text: string }>> = {
|
||||
[Mode.INLINE_TEXT]: {
|
||||
icon: IconCozParagraph,
|
||||
text: I18n.t('imageflow_canvas_text1'),
|
||||
},
|
||||
[Mode.BLOCK_TEXT]: {
|
||||
icon: IconCozFixedSize,
|
||||
text: I18n.t('imageflow_canvas_text2'),
|
||||
},
|
||||
};
|
||||
|
||||
const shapeIcons: Partial<Record<Mode, { icon: Icon; text: string }>> = {
|
||||
[Mode.RECT]: {
|
||||
icon: IconCozRectangle,
|
||||
text: I18n.t('imageflow_canvas_rect'),
|
||||
},
|
||||
[Mode.CIRCLE]: {
|
||||
icon: IconCozEllipse,
|
||||
text: I18n.t('imageflow_canvas_circle'),
|
||||
},
|
||||
[Mode.TRIANGLE]: {
|
||||
icon: IconCozTriangle,
|
||||
text: I18n.t('imageflow_canvas_trian'),
|
||||
},
|
||||
[Mode.STRAIGHT_LINE]: {
|
||||
icon: IconCozLine,
|
||||
text: I18n.t('imageflow_canvas_line'),
|
||||
},
|
||||
};
|
||||
|
||||
const alignIcons: Record<AlignMode, Icon> = {
|
||||
[AlignMode.Bottom]: IconCozAlignBottom,
|
||||
[AlignMode.Center]: IconCozAlignCenterHorizontal,
|
||||
[AlignMode.Middle]: IconCozAlignCenterVertical,
|
||||
[AlignMode.Left]: IconCozAlignLeft,
|
||||
[AlignMode.Right]: IconCozAlignRight,
|
||||
[AlignMode.Top]: IconCozAlignTop,
|
||||
[AlignMode.HorizontalAverage]: IconCozDistributeHorizontal,
|
||||
[AlignMode.VerticalAverage]: IconCozDistributeVertical,
|
||||
};
|
||||
|
||||
const addTextPrefix = I18n.t('add');
|
||||
const commonTooltipProps = {
|
||||
mouseEnterDelay: 300,
|
||||
mouseLeaveDelay: 300,
|
||||
getPopupContainer: () => document.body,
|
||||
};
|
||||
|
||||
export const TopBar: FC<IProps> = props => {
|
||||
const {
|
||||
popRefAlignRight,
|
||||
readonly,
|
||||
maxLimit,
|
||||
mode,
|
||||
onModeChange: _onModeChange,
|
||||
onMoveToTop,
|
||||
onMoveToBackend,
|
||||
onAddImg,
|
||||
zoomSettings,
|
||||
canvasSettings,
|
||||
redo,
|
||||
undo,
|
||||
disabledUndo,
|
||||
disabledRedo,
|
||||
redoUndoing,
|
||||
isActiveObjectsInBack,
|
||||
isActiveObjectsInFront,
|
||||
aligns,
|
||||
} = props;
|
||||
|
||||
// 点击已选中的,则取消选中
|
||||
const onModeChange = useCallback(
|
||||
(m: Mode | undefined) => {
|
||||
if (m === mode) {
|
||||
_onModeChange(undefined, mode);
|
||||
} else {
|
||||
_onModeChange(m, mode);
|
||||
}
|
||||
},
|
||||
[_onModeChange, mode],
|
||||
);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [textType, setTextType] = useState<Mode>(Mode.INLINE_TEXT);
|
||||
const TextIcon = textIcons[textType]?.icon as Icon;
|
||||
|
||||
const [shapeType, setShapeType] = useState<Mode>(Mode.RECT);
|
||||
const ShapeIcon = shapeIcons[shapeType]?.icon as Icon;
|
||||
|
||||
const [alignType, setAlignType] = useState<AlignMode>(AlignMode.Left);
|
||||
const AlignIcon = alignIcons[alignType];
|
||||
|
||||
useKeyPress(
|
||||
'esc',
|
||||
() => {
|
||||
onModeChange(undefined);
|
||||
},
|
||||
{
|
||||
events: ['keyup'],
|
||||
},
|
||||
);
|
||||
|
||||
const { variables, addRefObjectByVariable, customVariableRefs } =
|
||||
useGlobalContext();
|
||||
return (
|
||||
<div
|
||||
className={classNames([
|
||||
styles['top-bar'],
|
||||
'flex justify-center items-center gap-[12px]',
|
||||
])}
|
||||
>
|
||||
{/* 引用变量 */}
|
||||
<Tooltip
|
||||
key="ref-variable"
|
||||
content={I18n.t('workflow_detail_condition_reference')}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<div>
|
||||
<Menu
|
||||
trigger="click"
|
||||
position="bottomLeft"
|
||||
className="max-h-[300px] overflow-y-auto"
|
||||
render={
|
||||
<Menu.SubMenu mode="menu">
|
||||
{(variables ?? []).length > 0 ? (
|
||||
<>
|
||||
<div className="p-[8px] pt-[4px] coz-fg-secondary text-[12px]">
|
||||
{I18n.t('imageflow_canvas_var_add', {}, '添加变量')}
|
||||
</div>
|
||||
{variables?.map(v => {
|
||||
const counts = customVariableRefs?.filter(
|
||||
d => d.variableId === v.id,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
itemKey={v.name}
|
||||
key={v.name}
|
||||
disabled={!v.type}
|
||||
onClick={(_, e) => {
|
||||
e.stopPropagation();
|
||||
addRefObjectByVariable?.(v);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row gap-[4px] items-center w-[220px] h-[32px]">
|
||||
<div className="flex flex-row gap-[4px] items-center flex-1 overflow-hidden w-full">
|
||||
{v.type ? (
|
||||
<>
|
||||
{v.type === ViewVariableType.String ? (
|
||||
<IconCozString className="coz-fg-dim" />
|
||||
) : (
|
||||
<IconCozImage className="coz-fg-dim" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden flex flex-row gap-[4px] items-center">
|
||||
<Text text={v.name} />
|
||||
</div>
|
||||
</div>
|
||||
{counts && counts > 0 ? (
|
||||
<Tag size="mini" color="primary">
|
||||
{I18n.t(
|
||||
'imageflow_canvas_var_reference',
|
||||
{
|
||||
n: counts,
|
||||
},
|
||||
`引用 ${counts} 次`,
|
||||
)}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
className="py-[16px] w-[200px]"
|
||||
size="default"
|
||||
icon={<IconCozEmpty />}
|
||||
darkModeIcon={<IconCozEmpty />}
|
||||
title={I18n.t('imageflow_canvas_var_no', {}, '暂无变量')}
|
||||
/>
|
||||
)}
|
||||
</Menu.SubMenu>
|
||||
}
|
||||
>
|
||||
<MyIconButton icon={<IconCozVariables className="text-[16px]" />} />
|
||||
</Menu>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<SplitLine />
|
||||
|
||||
{/* 画布基础设置 */}
|
||||
<Tooltip
|
||||
key="canvas-setting"
|
||||
position="bottom"
|
||||
trigger="click"
|
||||
getPopupContainer={() => document.body}
|
||||
className="!max-w-[600px]"
|
||||
content={
|
||||
<>
|
||||
<div ref={ref}></div>
|
||||
<div className="flex flex-col gap-[12px] px-[4px] py-[8px] w-[410px] rounded-[12px] relative">
|
||||
<ConfigProvider
|
||||
getPopupContainer={() => ref.current ?? document.body}
|
||||
>
|
||||
<div className="text-[16px] font-semibold">
|
||||
{I18n.t('imageflow_canvas_setting')}
|
||||
</div>
|
||||
<div>{I18n.t('imageflow_canvas_frame')}</div>
|
||||
<SizeSelect
|
||||
selectClassName="w-[120px]"
|
||||
readonly={readonly}
|
||||
value={{
|
||||
width: canvasSettings.width,
|
||||
height: canvasSettings.height,
|
||||
}}
|
||||
minHeight={canvasSettings.minHeight}
|
||||
minWidth={canvasSettings.minWidth}
|
||||
maxHeight={canvasSettings.maxHeight}
|
||||
maxWidth={canvasSettings.maxWidth}
|
||||
onChange={v => {
|
||||
canvasSettings.onChange(v);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
label: '16:9',
|
||||
value: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '9:16',
|
||||
value: {
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '1:1',
|
||||
value: {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: I18n.t('imageflow_canvas_a41'),
|
||||
value: {
|
||||
width: 1485,
|
||||
height: 1050,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: I18n.t('imageflow_canvas_a42'),
|
||||
value: {
|
||||
width: 1050,
|
||||
height: 1485,
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div>{I18n.t('imageflow_canvas_color')}</div>
|
||||
<ColorPicker
|
||||
readonly={readonly}
|
||||
value={canvasSettings.background}
|
||||
onChange={color => {
|
||||
canvasSettings.onChange({
|
||||
background: color as string,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MyIconButton
|
||||
icon={<IconCozRectangleSetting className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* 重置视图 */}
|
||||
<Tooltip
|
||||
key="reset-view"
|
||||
content={I18n.t('imageflow_canvas_restart')}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
{/* zoom */}
|
||||
<MyIconButton
|
||||
onClick={() => {
|
||||
zoomSettings.reset();
|
||||
}}
|
||||
icon={<IconCozAutoView className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* zoom + - */}
|
||||
<MyIconButton
|
||||
disabled={readonly}
|
||||
onClick={() => {
|
||||
zoomSettings.onChange(
|
||||
Math.max(zoomSettings.zoom - 0.1, zoomSettings.min),
|
||||
);
|
||||
}}
|
||||
icon={<IconCozMinus className="text-[16px]" />}
|
||||
/>
|
||||
<InputNumber
|
||||
disabled={readonly}
|
||||
className="w-[60px]"
|
||||
suffix="%"
|
||||
min={zoomSettings.min * 100}
|
||||
max={zoomSettings.max * 100}
|
||||
hideButtons
|
||||
precision={0}
|
||||
onNumberChange={v => {
|
||||
zoomSettings.onChange((v as number) / 100);
|
||||
}}
|
||||
value={zoomSettings.zoom * 100}
|
||||
/>
|
||||
<MyIconButton
|
||||
disabled={readonly}
|
||||
onClick={() => {
|
||||
zoomSettings.onChange(
|
||||
Math.min(zoomSettings.zoom + 0.1, zoomSettings.max),
|
||||
);
|
||||
}}
|
||||
icon={<IconCozPlus className="text-[16px]" />}
|
||||
/>
|
||||
|
||||
<SplitLine />
|
||||
|
||||
{/* undo redo */}
|
||||
<Tooltip
|
||||
key="undo"
|
||||
content={I18n.t('card_builder_redoUndo_undo')}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<MyIconButton
|
||||
loading={redoUndoing}
|
||||
disabled={readonly || disabledUndo}
|
||||
onClick={() => {
|
||||
undo();
|
||||
}}
|
||||
icon={<IconCozArrowBack className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
key="redo"
|
||||
content={I18n.t('card_builder_redoUndo_redo')}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<MyIconButton
|
||||
loading={redoUndoing}
|
||||
disabled={readonly || disabledRedo}
|
||||
onClick={() => {
|
||||
redo();
|
||||
}}
|
||||
icon={<IconCozArrowForward className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<SplitLine />
|
||||
|
||||
{/* 置底 置顶 */}
|
||||
<Tooltip
|
||||
key="move-to-bottom"
|
||||
content={I18n.t('card_builder_move_to_bottom')}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<MyIconButton
|
||||
disabled={readonly || isActiveObjectsInBack}
|
||||
onClick={onMoveToBackend}
|
||||
icon={<IconCozMoveToBottomFill className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
key="move-to-top"
|
||||
content={I18n.t('card_builder_move_to_top')}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<MyIconButton
|
||||
disabled={readonly || isActiveObjectsInFront}
|
||||
onClick={onMoveToTop}
|
||||
icon={<IconCozMoveToTopFill className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* 对齐 */}
|
||||
<div className="flex">
|
||||
<MyIconButton
|
||||
disabled={readonly}
|
||||
onClick={e => {
|
||||
// 禁止冒泡,防止点击对齐时,canvas 的选中状态被清空
|
||||
e.stopPropagation();
|
||||
aligns[alignType]();
|
||||
}}
|
||||
icon={<AlignIcon className="text-[16px]" />}
|
||||
/>
|
||||
|
||||
<Align
|
||||
readonly={readonly}
|
||||
onChange={alignMode => {
|
||||
setAlignType(alignMode);
|
||||
aligns[alignMode]();
|
||||
}}
|
||||
popRefAlignRight={popRefAlignRight}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文本 */}
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
key="text"
|
||||
content={
|
||||
textType === Mode.INLINE_TEXT
|
||||
? `${addTextPrefix}${I18n.t('imageflow_canvas_text1')}`
|
||||
: `${addTextPrefix}${I18n.t('imageflow_canvas_text2')}`
|
||||
}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<MyIconButton
|
||||
disabled={readonly || maxLimit}
|
||||
onClick={() => {
|
||||
onModeChange(textType);
|
||||
}}
|
||||
className={classNames({
|
||||
'!coz-mg-secondary-pressed':
|
||||
mode && [Mode.INLINE_TEXT, Mode.BLOCK_TEXT].includes(mode),
|
||||
})}
|
||||
icon={<TextIcon className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Select
|
||||
disabled={readonly || maxLimit}
|
||||
className="hide-selected-label hide-border"
|
||||
// showTick={false}
|
||||
value={textType}
|
||||
size="small"
|
||||
getPopupContainer={() => popRefAlignRight?.current ?? document.body}
|
||||
optionList={Object.entries(textIcons).map(([key, value]) => {
|
||||
const Icon = value.icon;
|
||||
const { text } = value;
|
||||
return {
|
||||
value: key,
|
||||
label: (
|
||||
<div className="px-[8px] flex gap-[8px] items-center">
|
||||
<Icon className="text-[16px]" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
onSelect={v => {
|
||||
setTextType(v as Mode);
|
||||
onModeChange(v as Mode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 图片 */}
|
||||
|
||||
<ImageUpload
|
||||
onChange={onAddImg}
|
||||
tooltip={`${addTextPrefix}${I18n.t('card_builder_image')}`}
|
||||
>
|
||||
<MyIconButton
|
||||
disabled={readonly || maxLimit}
|
||||
icon={<IconCozImage className="text-[16px]" />}
|
||||
/>
|
||||
</ImageUpload>
|
||||
|
||||
{/* 形状 */}
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
key="shape"
|
||||
content={(() => {
|
||||
if (shapeType === Mode.CIRCLE) {
|
||||
return `${addTextPrefix}${I18n.t('imageflow_canvas_circle')}`;
|
||||
} else if (shapeType === Mode.TRIANGLE) {
|
||||
return `${addTextPrefix}${I18n.t('imageflow_canvas_trian')}`;
|
||||
} else if (shapeType === Mode.STRAIGHT_LINE) {
|
||||
return `${addTextPrefix}${I18n.t('imageflow_canvas_line')}`;
|
||||
}
|
||||
return `${addTextPrefix}${I18n.t('imageflow_canvas_rect')}`;
|
||||
})()}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<MyIconButton
|
||||
disabled={readonly || maxLimit}
|
||||
onClick={() => {
|
||||
onModeChange(shapeType);
|
||||
}}
|
||||
className={classNames({
|
||||
'!coz-mg-secondary-pressed':
|
||||
mode &&
|
||||
[
|
||||
Mode.RECT,
|
||||
Mode.CIRCLE,
|
||||
Mode.TRIANGLE,
|
||||
Mode.STRAIGHT_LINE,
|
||||
].includes(mode),
|
||||
})}
|
||||
icon={<ShapeIcon className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Select
|
||||
disabled={readonly || maxLimit}
|
||||
className="hide-selected-label hide-border"
|
||||
// showTick={false}
|
||||
value={shapeType}
|
||||
size="small"
|
||||
getPopupContainer={() => popRefAlignRight?.current ?? document.body}
|
||||
optionList={Object.entries(shapeIcons).map(([key, value]) => {
|
||||
const Icon = value.icon;
|
||||
const { text } = value;
|
||||
return {
|
||||
value: key,
|
||||
label: (
|
||||
<div className="px-[8px] flex gap-[8px] items-center">
|
||||
<Icon className="text-[16px]" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
onSelect={v => {
|
||||
setShapeType(v as Mode);
|
||||
onModeChange(v as Mode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 自由画笔 */}
|
||||
<Tooltip
|
||||
key="pencil"
|
||||
content={I18n.t('imageflow_canvas_draw')}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<MyIconButton
|
||||
disabled={readonly || maxLimit}
|
||||
onClick={() => {
|
||||
onModeChange(Mode.PENCIL);
|
||||
}}
|
||||
className={classNames({
|
||||
'!coz-mg-secondary-pressed': mode && [Mode.PENCIL].includes(mode),
|
||||
})}
|
||||
icon={<IconCozPencil className="text-[16px]" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user