feat: manually mirror opencoze's code from bytedance

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

View File

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

View File

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

View File

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

View File

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