(null);
18 |
19 | const handleMouseEnter = useCallback(() => {
20 | if (item.children) {
21 | setIsSubMenuOpen(true);
22 | }
23 | }, [item.children]);
24 |
25 | const handleMouseLeave = useCallback(() => {
26 | if (item.children) {
27 | setIsSubMenuOpen(false);
28 | }
29 | }, [item.children]);
30 |
31 | const handleClick = () => {
32 | if (item.disabled || item.children) return;
33 | item.onClick?.();
34 | onClose?.();
35 | };
36 |
37 | return (
38 |
48 |
49 | {item.icon}
50 | {item.label}
51 |
52 |
53 | {item.shortcut && (
54 |
55 | {item.shortcut}
56 |
57 | )}
58 | {item.children && (
59 | <>
60 |
61 |
62 |
63 | {isSubMenuOpen && (
64 |
69 | )}
70 | >
71 | )}
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/layout/Setting/Text/TextAlign.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from 'antd';
2 | import cls from 'classnames';
3 | import { observer } from 'mobx-react';
4 | import { TextProps } from './Text';
5 | import { TextAlignEnum } from '@/constants/Font';
6 |
7 | import Style from './Text.module.less';
8 |
9 | function TextAlign(props: TextProps) {
10 | const { model } = props;
11 |
12 | const { textAlign } = model;
13 |
14 | const setTextAlign = (textAlign: TextAlignEnum) => {
15 | model.update({ textAlign });
16 | };
17 |
18 | return (
19 |
20 |
21 | setTextAlign(TextAlignEnum.Left)}
23 | className={cls(
24 | 'iconfont icon-09zuoduiqi',
25 | 'icon-item',
26 | Style.icon_item,
27 | { [Style.text_style_active]: textAlign === TextAlignEnum.Left }
28 | )}
29 | />
30 |
31 |
32 |
33 | setTextAlign(TextAlignEnum.Center)}
35 | className={cls(
36 | 'iconfont icon-11juzhongduiqi',
37 | 'icon-item',
38 | Style.icon_item,
39 | { [Style.text_style_active]: textAlign === TextAlignEnum.Center }
40 | )}
41 | />
42 |
43 |
44 |
45 | setTextAlign(TextAlignEnum.Right)}
47 | className={cls(
48 | 'iconfont icon-10youduiqi',
49 | 'icon-item',
50 | Style.icon_item,
51 | { [Style.text_style_active]: textAlign === TextAlignEnum.Right }
52 | )}
53 | />
54 |
55 |
56 |
57 | setTextAlign(TextAlignEnum.Justify)}
59 | className={cls(
60 | 'iconfont icon-12liangduanduiqi',
61 | 'icon-item',
62 | Style.icon_item,
63 | { [Style.text_style_active]: textAlign === TextAlignEnum.Justify }
64 | )}
65 | />
66 |
67 |
68 | );
69 | }
70 |
71 | export default observer(TextAlign);
72 |
--------------------------------------------------------------------------------
/src/components/ContextMenu/ContextMenu.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 | import {
3 | createContainerById,
4 | removeContainerById,
5 | render,
6 | } from '@/utils/portalRender';
7 | import { useEscapeClose, useGlobalClick } from '@/hooks';
8 | import ContextMenuContent from './ContextMenuContent';
9 | import Style from './ContextMenu.module.less';
10 | import { MenuItem } from './props';
11 |
12 | export interface ContextMenuProps {
13 | items: MenuItem[];
14 | x: number;
15 | y: number;
16 | }
17 |
18 | export default function ContextMenu(props: ContextMenuProps) {
19 | const { items, x, y } = props;
20 |
21 | const [position, setPosition] = useState({ x, y });
22 | const menuRef = useRef(null);
23 |
24 | const handleClose = () => {
25 | ContextMenu.hide();
26 | };
27 |
28 | useEscapeClose(handleClose, true, true);
29 | useGlobalClick(handleClose, true, menuRef);
30 |
31 | const getPosition = () => {
32 | if (menuRef.current) {
33 | /** 边界处理 */
34 | const rect = menuRef.current.getBoundingClientRect();
35 | const newPosition = { x, y };
36 |
37 | if (rect.right > window.innerWidth) {
38 | newPosition.x = window.innerWidth - rect.width;
39 | }
40 | if (rect.bottom > window.innerHeight) {
41 | newPosition.y = window.innerHeight - rect.height;
42 | }
43 | newPosition.x = Math.max(0, newPosition.x);
44 | newPosition.y = Math.max(0, newPosition.y);
45 | setPosition(newPosition);
46 | }
47 | };
48 |
49 | useEffect(() => {
50 | getPosition();
51 | }, [x, y]);
52 |
53 | return (
54 |
65 | );
66 | }
67 |
68 | const CONTEXT_MENU_ID = 'magic-context-menu';
69 |
70 | ContextMenu.show = function show(props: ContextMenuProps) {
71 | const container = createContainerById(CONTEXT_MENU_ID);
72 | const content = ;
73 | render(content, container);
74 | };
75 |
76 | ContextMenu.hide = function hide() {
77 | removeContainerById(CONTEXT_MENU_ID);
78 | };
79 |
--------------------------------------------------------------------------------
/src/layout/Stage/Scenes/Scene/Scene.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo, forwardRef, Ref, CSSProperties } from 'react';
2 | import { observer } from 'mobx-react';
3 | import cls from 'classnames';
4 | import MenuPopover from '@/components/MenuPopover';
5 | import SceneStruc from '@/models/SceneStruc';
6 | import { magic } from '@/store';
7 | import Renderer from '@/components/Renderer';
8 | import Style from './Scene.module.less';
9 |
10 | export interface SceneProps {
11 | scene: SceneStruc;
12 | actived?: boolean;
13 | style?: CSSProperties;
14 | disableRemove?: boolean;
15 | addEmptyScene?: () => void;
16 | removeScene?: () => void;
17 | copyScene?: () => void;
18 | }
19 | /**
20 | * 预览场景大小
21 | */
22 | const SIZE = 130;
23 |
24 | function Scene(props: SceneProps, ref: Ref) {
25 | const {
26 | actived = false,
27 | scene,
28 | style,
29 | disableRemove = false,
30 | addEmptyScene,
31 | removeScene,
32 | copyScene,
33 | ...otherProps
34 | } = props;
35 |
36 | const { width = 0, height = 0 } = scene;
37 |
38 | const ratio = useMemo(() => SIZE / Math.max(width, height), [width, height]);
39 |
40 | const sceneWrapperStyle = {
41 | width: width * ratio,
42 | height: height * ratio,
43 | };
44 |
45 | const sceneStyle = {
46 | width,
47 | height,
48 | transform: `scale(${ratio})`,
49 | };
50 |
51 | const actions = [
52 | {
53 | name: '增加页面',
54 | handle: addEmptyScene,
55 | },
56 | {
57 | name: '复制',
58 | handle: copyScene,
59 | },
60 | {
61 | disable: disableRemove,
62 | name: '删除',
63 | handle: removeScene,
64 | },
65 | ];
66 |
67 | return (
68 |
69 |
magic.activeScene(scene)}
72 | className={cls(actived && Style.actived)}
73 | style={sceneWrapperStyle}
74 | {...otherProps}
75 | >
76 |
77 |
78 |
79 |
80 |
81 |
87 |
88 | );
89 | }
90 |
91 | export default observer(forwardRef(Scene));
92 |
--------------------------------------------------------------------------------
/packages/EditorTools/helper/magneticLine.ts:
--------------------------------------------------------------------------------
1 | import { Coordinate, Size } from '../types/Editor';
2 | import { Range, LineData } from '../types/MagneticLine';
3 |
4 | /**
5 | * 将一组对齐吸附线进行去重:同位置的的多条对齐吸附线仅留下一条,取该位置所有对齐吸附线的最大值和最小值为新的范围
6 | * @param lines 一组对齐吸附线信息
7 | */
8 | export function uniqAlignLines(lines: LineData[]) {
9 | const map: Record = {};
10 | const uniqLines = lines.reduce((pre: LineData[], cur: LineData) => {
11 | if (!Reflect.has(map, cur.value)) {
12 | pre.push(cur);
13 | map[cur.value] = pre.length - 1;
14 | return pre;
15 | }
16 |
17 | const index = map[cur.value];
18 | const preLine = pre[index];
19 | const rangeMin = Math.min(preLine.range[0], cur.range[0]);
20 | const rangeMax = Math.max(preLine.range[1], cur.range[1]);
21 | const range: Range = [rangeMin, rangeMax];
22 | const line: LineData = { value: cur.value, range };
23 | pre[index] = line;
24 | return pre;
25 | }, []);
26 | return uniqLines;
27 | }
28 |
29 | /**
30 | * 获取矩形的自身的六条磁力线
31 | * @param leftTop 左上角坐标
32 | * @param rightBottom 右上角坐标
33 | * @param width 宽
34 | * @param height 高
35 | * @returns horizontal 横轴线 vertical 纵轴线
36 | */
37 | export function getRectMagneticLines(
38 | leftTop: Coordinate,
39 | rightBottom: Coordinate,
40 | rectSize: Size
41 | ): { horizontal: LineData[]; vertical: LineData[] } {
42 | const center = {
43 | x: leftTop.x + rectSize.width / 2,
44 | y: leftTop.y + rectSize.height / 2,
45 | };
46 |
47 | const topLine: LineData = {
48 | value: leftTop.y,
49 | range: [leftTop.x, rightBottom.x],
50 | };
51 |
52 | const bottomLine: LineData = {
53 | value: rightBottom.y,
54 | range: [leftTop.x, rightBottom.x],
55 | };
56 |
57 | const horizontalCenterLine: LineData = {
58 | value: center.y,
59 | range: [leftTop.x, rightBottom.x],
60 | };
61 |
62 | const leftLine: LineData = {
63 | value: leftTop.x,
64 | range: [leftTop.y, rightBottom.y],
65 | };
66 |
67 | const rightLine: LineData = {
68 | value: rightBottom.x,
69 | range: [leftTop.y, rightBottom.y],
70 | };
71 |
72 | const verticalCenterLine: LineData = {
73 | value: center.x,
74 | range: [leftTop.y, rightBottom.y],
75 | };
76 |
77 | return {
78 | horizontal: [topLine, bottomLine, horizontalCenterLine],
79 | vertical: [leftLine, rightLine, verticalCenterLine],
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/packages/EditorTools/EditorBox/props.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { RectData } from '../types/Editor';
3 | import { ScaleHandlerOptions } from '../core/ScaleHandler';
4 | import { POINT_TYPE } from '../enum/point-type';
5 |
6 | /**
7 | * 鼠标事件属性
8 | */
9 | export interface MouseEventProps {
10 | /** 鼠标点击时 */
11 | onClick?: (e: React.MouseEvent) => void;
12 | /** 鼠标双击时 */
13 | onDoubleClick?: (e: React.MouseEvent) => void;
14 | /** 鼠标右击时 */
15 | onContextMenu?: (e: React.MouseEvent) => void;
16 | /** 鼠标按下时 */
17 | onMouseDown?: (e: React.MouseEvent) => void;
18 | /** 鼠标弹起时 */
19 | onMouseUp?: (e: React.MouseEvent) => void;
20 | /** 鼠标到达时 */
21 | onMouseEnter?: (e: React.MouseEvent) => void;
22 | /** 鼠标移出时 */
23 | onMouseLeave?: (e: React.MouseEvent) => void;
24 | /** 鼠标移动时 */
25 | onMouseMove?: (e: React.MouseEvent) => void;
26 | /** 鼠标移入时 */
27 | onMouseOver?: (e: React.MouseEvent) => void;
28 | /** 鼠标移开时 */
29 | onMouseOut?: (e: React.MouseEvent) => void;
30 | }
31 |
32 | export interface EditorBoxProps
33 | extends Partial,
34 | MouseEventProps {
35 | className?: string;
36 | editorPanelStyle?: React.CSSProperties;
37 | style?: React.CSSProperties;
38 | /** 是否显示旋转点 */
39 | isShowRotate?: boolean;
40 | /** 矩形信息 */
41 | rectInfo: RectData;
42 | /** 拉伸点集合,默认所有点 */
43 | points?: POINT_TYPE[];
44 | /** 是否显示拉伸点以及旋转点 */
45 | isShowPoint?: boolean;
46 | /** 拉伸类型 default 默认拉伸; mask-cover铺满; mask-contain 平铺;
47 | * mask-cover 和 mask-contain 只有rectInfo 存在mask才生效*/
48 | scaleType?: 'default' | 'mask-cover' | 'mask-contain';
49 | /** 缩放倍数 */
50 | zoomLevel?: number;
51 | /** 自定义元素 */
52 | extra?: React.ReactNode | ((rectData: RectData) => React.ReactNode);
53 | /** 开始拉伸
54 | * @param point 当前拉伸的点
55 | * @param e 事件对象
56 | */
57 | onStartScale?: (
58 | point: POINT_TYPE,
59 | e: MouseEvent
60 | ) => ScaleHandlerOptions | void;
61 | /** 拉伸中
62 | * @param point 当前拉伸的点
63 | * @param result 拉伸计算的结果
64 | * @param e 事件对象
65 | */
66 | onScale?: (result: RectData, point: POINT_TYPE, e: MouseEvent) => void;
67 | /** 结束拉伸 */
68 | onEndScale?: (point: POINT_TYPE, e: MouseEvent) => void;
69 | /** 开始旋转 */
70 | onRotateStart?: (e: MouseEvent) => void;
71 | /** 旋转中 */
72 | onRotate?: (rotate: number, e: MouseEvent) => void;
73 | /** 旋转结束 */
74 | onRotateEnd?: (e: MouseEvent) => void;
75 | }
76 |
--------------------------------------------------------------------------------
/src/constants/KeyCode.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 键盘值对照表
3 | */
4 | const KeyCodeMap = {
5 | // 字母
6 | A: 65,
7 | B: 66,
8 | C: 67,
9 | D: 68,
10 | E: 69,
11 | F: 70,
12 | G: 71,
13 | H: 72,
14 | I: 73,
15 | J: 74,
16 | K: 75,
17 | L: 76,
18 | M: 77,
19 | N: 78,
20 | O: 79,
21 | P: 80,
22 | Q: 81,
23 | R: 82,
24 | S: 83,
25 | T: 84,
26 | U: 85,
27 | V: 86,
28 | W: 87,
29 | X: 88,
30 | Y: 89,
31 | Z: 90,
32 |
33 | // 横排数字键
34 | 0: 48,
35 | 1: 49,
36 | 2: 50,
37 | 3: 51,
38 | 4: 52,
39 | 5: 53,
40 | 6: 54,
41 | 7: 55,
42 | 8: 56,
43 | 9: 57,
44 |
45 | // 小键盘
46 | /** 小键盘0 */
47 | MIN_0: 96,
48 | /** 小键盘1 */
49 | MIN_1: 97,
50 | /** 小键盘2 */
51 | MIN_2: 98,
52 | /** 小键盘3 */
53 | MIN_3: 99,
54 | /** 小键盘4 */
55 | MIN_4: 100,
56 | /** 小键盘5 */
57 | MIN_5: 101,
58 | /** 小键盘6 */
59 | MIN_6: 102,
60 | /** 小键盘7 */
61 | MIN_7: 103,
62 | /** 小键盘8 */
63 | MIN_8: 104,
64 | /** 小键盘9 */
65 | MIN_9: 105,
66 | /** 小键盘* */
67 | '*': 106,
68 | /** 小键盘+ */
69 | '+': 107,
70 | /** 小键盘回车 */
71 | MIN_ENTER: 108,
72 | /** 小键盘- */
73 | '-': 109,
74 | /** 小键盘 小数点 . */
75 | '.': 110,
76 | /** 小键盘/ */
77 | '/': 111,
78 |
79 | // F键位
80 | F1: 112,
81 | F2: 113,
82 | F3: 114,
83 | F4: 115,
84 | F5: 116,
85 | F6: 117,
86 | F7: 118,
87 | F8: 119,
88 | F9: 120,
89 | F10: 121,
90 | F11: 122,
91 | F12: 123,
92 |
93 | // 控制键
94 | BACKSPACE: 8,
95 | TAB: 9,
96 | CLEAR: 12,
97 | ENTER: 13,
98 | SHIFT: 16,
99 | CTRL: 17,
100 | ALT: 18,
101 | CAPE_LOCK: 20,
102 | ESC: 27,
103 | SPACEBAR: 32,
104 | PAGE_UP: 33,
105 | PAGE_DOWN: 34,
106 | END: 35,
107 | HOME: 36,
108 | LEFT: 37,
109 | UP: 38,
110 | RIGHT: 39,
111 | DOWN: 40,
112 | INSERT: 45,
113 | DELETE: 46,
114 | NUM_LOCK: 144,
115 |
116 | // 标点符号键
117 | ';:': 186,
118 | '=+': 187,
119 | ',<': 188,
120 | '-_': 189,
121 | '.>': 190,
122 | '/?': 191,
123 | '`~': 192,
124 | '[{': 219,
125 | '|': 220,
126 | ']}': 221,
127 | '"': 222,
128 |
129 | // 多媒体按键
130 | /** 音量加 */
131 | VOLUME_UP: 175,
132 | /** 音量减 */
133 | VOLUME_DOWN: 174,
134 | /** 停止 */
135 | STOP: 179,
136 | /** 静音 */
137 | MUTE: 173,
138 | /** 浏览器 */
139 | BROWSER: 172,
140 | /** 邮件 */
141 | EMAIL: 180,
142 | /** 搜索 */
143 | SEARCH: 170,
144 | /** 收藏 */
145 | COLLECT: 171,
146 | };
147 |
148 | export default KeyCodeMap;
149 |
--------------------------------------------------------------------------------
/packages/EditorTools/helper/utils.ts:
--------------------------------------------------------------------------------
1 | import { POINT_TYPE } from '../enum/point-type';
2 | import { Coordinate, RectData } from '../types/Editor';
3 | import { CENTER_POINT } from '../constants/Points';
4 | import {
5 | valuesToDivide,
6 | valuesToMultiply,
7 | pointToAnchor,
8 | pointToTopLeft,
9 | } from './math';
10 |
11 | /**
12 | * 判断当前的拉动点是否是中心点
13 | * @param {POINT_TYPE} pointType
14 | * @return {Boolean} 否是中心点
15 | */
16 | export const isCenterPoint = (pointType: POINT_TYPE): boolean =>
17 | CENTER_POINT.includes(pointType);
18 |
19 | /**
20 | * 检测 p0 是否在 p1 与 p2 建立的矩形内
21 | * @param {Coordinate} p0 被检测的坐标
22 | * @param {Coordinate} p1 点1坐标
23 | * @param {Coordinate} p2 点2坐标
24 | * @return {Boolean} 检测结果
25 | */
26 | export const pointInRect = (
27 | p0: Coordinate,
28 | p1: Coordinate,
29 | p2: Coordinate
30 | ): boolean => {
31 | if (p1.x > p2.x) {
32 | if (p0.x < p2.x) {
33 | return false;
34 | }
35 | } else if (p0.x > p2.x) {
36 | return false;
37 | }
38 |
39 | if (p1.y > p2.y) {
40 | if (p0.y < p2.y) {
41 | return false;
42 | }
43 | } else if (p0.y > p2.y) {
44 | return false;
45 | }
46 |
47 | return true;
48 | };
49 |
50 | /**
51 | * 保留小数
52 | * @param num 浮点数
53 | * @param unit 保留小数的位数
54 | * @returns
55 | */
56 | export function keepDecimal(num: number, unit: number) {
57 | return Math.floor(num * 10 ** unit) / 10 ** unit;
58 | }
59 |
60 | /**
61 | * 处理成可编辑的数据,转换缩放值,和移动锚点位置
62 | * @param data
63 | * @param zoomLevel
64 | * @returns
65 | */
66 | export function processToEditableData(
67 | data: RectData,
68 | zoomLevel: number
69 | ): RectData {
70 | let result = { ...data, ...pointToTopLeft(data) };
71 |
72 | const { x, y, width, height } = result;
73 | result = {
74 | ...result,
75 | ...valuesToMultiply({ x, y, width, height }, zoomLevel),
76 | };
77 |
78 | if (data.mask) {
79 | result.mask = valuesToMultiply(data.mask, zoomLevel);
80 | }
81 | return result;
82 | }
83 |
84 | /**
85 | * 处理成原始数据
86 | * @param data
87 | * @param zoomLevel
88 | * @returns
89 | */
90 | export function processToRawData(data: RectData, zoomLevel: number): RectData {
91 | let result = { ...data, ...pointToAnchor(data) };
92 | const { x, y, width, height } = result;
93 |
94 | result = { ...result, ...valuesToDivide({ x, y, width, height }, zoomLevel) };
95 |
96 | if (data.mask) {
97 | result.mask = valuesToDivide(data.mask, zoomLevel);
98 | }
99 | return result;
100 | }
101 |
--------------------------------------------------------------------------------
/src/components/Renderer/Layer/Text/Text.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, useState } from 'react';
2 | import { observer } from 'mobx-react';
3 | import Quill from 'quill';
4 | import Delta from 'quill-delta';
5 | import { TextStruc } from '@/models/LayerStruc';
6 | import { LayerProps } from '../Layer';
7 | import Style from './Text.module.less';
8 |
9 | interface TextProps extends LayerProps {}
10 |
11 | function Text(props: TextProps) {
12 | const { model, style } = props;
13 |
14 | const { content, charAttrs, isEditing } = model;
15 |
16 | const [textValue, setTextValue] = useState('');
17 |
18 | const textMainRef = useRef(null);
19 | const quillRef = useRef(null);
20 | /**
21 | * 实例化quill
22 | * */
23 | const initEditor = () => {
24 | if (!textMainRef.current) return;
25 | quillRef.current = new Quill(textMainRef.current, {});
26 | quillRef.current?.enable(false);
27 | quillRef.current?.blur();
28 | };
29 |
30 | const formatTitleText = (val: string): Delta => {
31 | quillRef.current?.setText(val);
32 | if (charAttrs?.length) {
33 | charAttrs.forEach((i: Delta) => {
34 | const { bgColor, color, start, endPos } = i;
35 | if (bgColor) {
36 | quillRef.current?.formatText(start, endPos - start, {
37 | background: bgColor,
38 | });
39 | }
40 | if (color) {
41 | quillRef.current?.formatText(start, endPos - start, {
42 | color,
43 | });
44 | }
45 | });
46 | }
47 | return quillRef.current?.getContents();
48 | };
49 |
50 | /**
51 | * 初始化富文本内容
52 | * */
53 | const initTextContent = (val: string) => {
54 | const delta = formatTitleText(val);
55 | quillRef.current?.setContents(delta);
56 | };
57 |
58 | /** 获取文字 */
59 | const getTextValue = () => content || '双击编辑文字';
60 |
61 | /** 初始化文字内容 */
62 | useEffect(() => {
63 | setTextValue(getTextValue());
64 | }, [content]);
65 |
66 | /** 初始化富文本 */
67 | useEffect(() => {
68 | initEditor();
69 | }, []);
70 |
71 | useEffect(() => {
72 | initTextContent(textValue);
73 | }, [textValue, charAttrs]);
74 |
75 | return (
76 |
77 | {/* 文字主体 */}
78 |
86 |
87 | );
88 | }
89 |
90 | export default observer(Text);
91 |
--------------------------------------------------------------------------------
/src/layout/Stage/Stage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useRef } from 'react';
2 | import { observer } from 'mobx-react';
3 | import Canvas from './Canvas';
4 | import Scenes from './Scenes/Scenes';
5 | import Crop from '@/components/Crop';
6 | import useResizeObserver from '@/hooks/useResizeObserver';
7 | import { CANVAS_WRAPPER } from '@/constants/Refs';
8 | import { NodeNameplate } from '@/constants/NodeNamePlate';
9 | import { useStores } from '@/store';
10 | import Style from './Stage.module.less';
11 |
12 | function Stage() {
13 | const { OS, magic } = useStores();
14 |
15 | const { activedLayers, activedScene, isOpenImageCrop } = magic;
16 |
17 | const { zoomLevel } = OS;
18 |
19 | const cropRef = useRef(null);
20 | const canvasRef = useRef(null);
21 |
22 | const [entry] = useResizeObserver(CANVAS_WRAPPER);
23 |
24 | const templateWidth = activedScene?.width || 0;
25 | const templateHeight = activedScene?.height || 0;
26 |
27 | const canvasStyle = useMemo(
28 | () => ({
29 | width: templateWidth * zoomLevel,
30 | height: templateHeight * zoomLevel,
31 | }),
32 | [zoomLevel]
33 | );
34 |
35 | const adaptZoomLevel = (entry: ResizeObserverEntry) => {
36 | const { width, height } = entry.contentRect;
37 | const rateW = width / templateWidth;
38 | const rateH = height / templateHeight;
39 | OS.setZoomLevel(Math.min(rateH, rateW));
40 | };
41 |
42 | const handleStageMousedown = (e: React.MouseEvent) => {
43 | if (e.button !== 0 || !activedLayers.length) return;
44 | /** 尽量不使用阻止冒泡 */
45 | if (cropRef.current?.contains(e.target as Node)) return;
46 | if (canvasRef.current?.contains(e.target as Node)) return;
47 |
48 | magic.releaseAllLayers();
49 | };
50 |
51 | useEffect(() => {
52 | entry && adaptZoomLevel(entry);
53 | }, [entry, templateHeight, templateWidth]);
54 |
55 | if (!activedScene) return null;
56 |
57 | return (
58 |
59 |
65 |
71 |
72 | {isOpenImageCrop && (
73 |
74 |
75 |
76 | )}
77 |
78 |
79 |
80 | );
81 | }
82 |
83 | export default observer(Stage);
84 |
--------------------------------------------------------------------------------
/src/components/MenuPopover/MenuPopover.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useState, useEffect } from 'react';
2 | import { Popover, PopoverProps } from 'antd';
3 | import cls from 'classnames';
4 |
5 | import Style from './MenuPopover.module.less';
6 |
7 | interface Action {
8 | status?: string | boolean;
9 | name: string;
10 | handle?: (data?: T) => void;
11 | icon?: ReactNode;
12 | disable?: boolean;
13 | }
14 |
15 | interface MenuPopoverProps
16 | extends Omit {
17 | className?: string;
18 | children?: ReactNode;
19 | actionList: Action[];
20 | itemClassName?: string;
21 | }
22 |
23 | export default function MenuPopover(props: MenuPopoverProps) {
24 | const {
25 | className,
26 | children,
27 | open = false,
28 | actionList,
29 | onOpenChange,
30 | overlayInnerStyle,
31 | itemClassName,
32 | ...otherProps
33 | } = props;
34 |
35 | const [visible, setVisible] = useState(open);
36 |
37 | useEffect(() => {
38 | setVisible(open);
39 | }, [open]);
40 |
41 | const handleClickMenu = (action: Action) => {
42 | if (action.disable) return;
43 | action.handle?.();
44 | setVisible(false);
45 | onOpenChange?.(false);
46 | };
47 |
48 | const openChange = (open: boolean) => {
49 | setVisible(open);
50 | onOpenChange?.(open);
51 | };
52 |
53 | /** popover 框内容渲染 */
54 | const renderContent = (
55 |
56 | {actionList.map(action => (
57 |
{
63 | handleClickMenu(action);
64 | }}
65 | >
66 | {action.icon && (
67 |
{action.icon}
68 | )}
69 | {action.name}
70 |
71 | ))}
72 |
73 | );
74 |
75 | /** popover 的元素渲染 */
76 | const renderChildren = children || (
77 |
78 | {Array.from({ length: 3 }, (_, index) => (
79 |
80 | ))}
81 |
82 | );
83 |
84 | return (
85 |
97 | {renderChildren}
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/src/models/LayerStruc/ImageStruc.ts:
--------------------------------------------------------------------------------
1 | import { makeObservable, observable } from 'mobx';
2 | import { pointToAnchor } from '@p/EditorTools';
3 | import LayerStruc from './LayerStruc';
4 |
5 | export default class ImageStruc
6 | extends LayerStruc
7 | implements LayerModel.Image
8 | {
9 | url?: string;
10 |
11 | originalWidth?: number;
12 |
13 | originalHeight?: number;
14 |
15 | mimeType?: string;
16 |
17 | constructor(data: LayerModel.Image) {
18 | super(data);
19 | makeObservable(this, {
20 | url: observable,
21 | originalWidth: observable,
22 | originalHeight: observable,
23 | mimeType: observable,
24 | });
25 |
26 | this.url = data.url;
27 | this.originalWidth = data.originalWidth;
28 | this.originalHeight = data.originalHeight;
29 | this.mimeType = data?.mimeType;
30 | }
31 |
32 | model(): LayerModel.Image {
33 | const model = super.model();
34 |
35 | return {
36 | ...model,
37 | url: this.url,
38 | originalWidth: this.originalWidth,
39 | originalHeight: this.originalHeight,
40 | mimeType: this.mimeType,
41 | };
42 | }
43 |
44 | /**
45 | *
46 | * @param url 图片地址
47 | * @param size 图片的原始宽高
48 | */
49 | replaceUrl(url: string, size?: Size) {
50 | let updateDate: Partial = { url };
51 |
52 | if (size) {
53 | const { x, y } = this.getPointAtTopLeft();
54 |
55 | const { mask, anchor } = this.getSafetyModalData();
56 | const { width: maskW, height: maskH, x: maskX, y: maskY } = mask;
57 |
58 | const ratioW = size.width / maskW;
59 | const ratioH = size.height / maskH;
60 | const ratio = Math.min(ratioH, ratioW);
61 |
62 | const newWidth = size.width / ratio;
63 | const newHeight = size.height / ratio;
64 | /**
65 | * 偏移坐标,保证图片显示在蒙层的中间位置
66 | * 保证mask 物理位置不动,图层的位置需要重新计算
67 | * */
68 | const newMaskX = (newWidth - maskW) / 2;
69 | const newMaskY = (newHeight - maskH) / 2;
70 |
71 | const rectData = {
72 | width: newWidth,
73 | height: newHeight,
74 | x: x + maskX - newMaskX,
75 | y: y + maskY - newMaskY,
76 | anchor,
77 | };
78 |
79 | const position = pointToAnchor(rectData);
80 |
81 | updateDate = {
82 | ...updateDate,
83 |
84 | mask: {
85 | width: maskW,
86 | height: maskH,
87 | x: newMaskX,
88 | y: newMaskY,
89 | },
90 | originalHeight: size.height,
91 | originalWidth: size.width,
92 | ...rectData,
93 | ...position,
94 | };
95 | }
96 |
97 | this.update(updateDate);
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/config/Cmd.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 执行终端
3 | */
4 |
5 | import CmdEnum from '@/constants/CmdEnum';
6 |
7 | export type CmdHandler = (data?: any) => void;
8 |
9 | export interface CmdItem {
10 | /** 命令名称 */
11 | name: CmdEnum;
12 |
13 | /** 命令标签 */
14 | label: string;
15 |
16 | /** 逆向操作命令 */
17 | reverseLabel?: string;
18 |
19 | /** 命令简介 */
20 | desc?: string;
21 | }
22 |
23 | const cmdMaps: Record = {
24 | [CmdEnum.COPY]: {
25 | name: CmdEnum.COPY,
26 | label: '复制',
27 | },
28 | [CmdEnum.CUT]: {
29 | name: CmdEnum.CUT,
30 | label: '剪切',
31 | },
32 | [CmdEnum.PASTE]: {
33 | name: CmdEnum.PASTE,
34 | label: '粘贴',
35 | },
36 | [CmdEnum.UNDO]: {
37 | name: CmdEnum.UNDO,
38 | label: '撤销',
39 | },
40 | [CmdEnum.REDO]: {
41 | name: CmdEnum.REDO,
42 | label: '恢复',
43 | },
44 | [CmdEnum.DELETE]: {
45 | name: CmdEnum.DELETE,
46 | label: '删除',
47 | },
48 | [CmdEnum.ESC]: {
49 | name: CmdEnum.ESC,
50 | label: '取消',
51 | },
52 | [CmdEnum.SAVE]: {
53 | name: CmdEnum.SAVE,
54 | label: '保存',
55 | },
56 | [CmdEnum.LOCK]: {
57 | name: CmdEnum.LOCK,
58 | label: '锁定',
59 | },
60 | [CmdEnum.UNLOCK]: {
61 | name: CmdEnum.UNLOCK,
62 | label: '解锁',
63 | },
64 | [CmdEnum.PREVIEW]: {
65 | name: CmdEnum.PREVIEW,
66 | label: '预览',
67 | },
68 | [CmdEnum['TO UP']]: {
69 | name: CmdEnum['TO UP'],
70 | label: '上移',
71 | },
72 | [CmdEnum['TO BUTTOM']]: {
73 | name: CmdEnum['TO BUTTOM'],
74 | label: '下移',
75 | },
76 | [CmdEnum['TO LEFT']]: {
77 | name: CmdEnum['TO LEFT'],
78 | label: '左移',
79 | },
80 | [CmdEnum['TO RIGHT']]: {
81 | name: CmdEnum['TO RIGHT'],
82 | label: '右移',
83 | },
84 | [CmdEnum['TO UP 10PX']]: {
85 | name: CmdEnum['TO UP 10PX'],
86 | label: '上移10px',
87 | },
88 | [CmdEnum['TO BUTTOM 10PX']]: {
89 | name: CmdEnum['TO BUTTOM 10PX'],
90 | label: '下移10px',
91 | },
92 | [CmdEnum['TO LEFT 10PX']]: {
93 | name: CmdEnum['TO LEFT 10PX'],
94 | label: '左移10px',
95 | },
96 | [CmdEnum['TO RIGHT 10PX']]: {
97 | name: CmdEnum['TO RIGHT 10PX'],
98 | label: '右移10px',
99 | },
100 | [CmdEnum['SELECT ALL']]: {
101 | name: CmdEnum['SELECT ALL'],
102 | label: '全选',
103 | },
104 | [CmdEnum['SELECT MULTI']]: {
105 | name: CmdEnum['SELECT MULTI'],
106 | label: '多选',
107 | },
108 | [CmdEnum['ZOOM IN']]: {
109 | name: CmdEnum['ZOOM IN'],
110 | label: '放大',
111 | },
112 | [CmdEnum['ZOOM OUT']]: {
113 | name: CmdEnum['ZOOM OUT'],
114 | label: '缩小',
115 | },
116 | [CmdEnum.GROUP]: {
117 | name: CmdEnum.GROUP,
118 | label: '组合',
119 | },
120 | [CmdEnum['BREAK GROUP']]: {
121 | name: CmdEnum['BREAK GROUP'],
122 | label: '打散',
123 | },
124 | };
125 |
126 | export default cmdMaps;
127 |
--------------------------------------------------------------------------------
/src/components/Renderer/Layer/Layer.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentType, CSSProperties } from 'react';
2 | import { observer } from 'mobx-react';
3 | import { LayerTypeEnum } from '@/constants/LayerTypeEnum';
4 | import { LayerStrucType } from '@/types/model';
5 | import Image from './Image';
6 | import Text from './Text';
7 | import Group from './Group';
8 | import Back from './Back';
9 | import Shape from './Shape';
10 | import Style from './Layer.module.less';
11 | import { useStores } from '@/store';
12 | import { getLayerOuterStyles, getLayerInnerStyles } from '@/helpers/Styles';
13 | import { moveHandle } from '@/utils/move';
14 | import { NodeNameplate } from '@/constants/NodeNamePlate';
15 |
16 | const LayerCmpMap = {
17 | [LayerTypeEnum.BACKGROUND]: Back,
18 | [LayerTypeEnum.GROUP]: Group,
19 | [LayerTypeEnum.TEXT]: Text,
20 | [LayerTypeEnum.IMAGE]: Image,
21 | [LayerTypeEnum.SHAPE]: Shape,
22 | };
23 |
24 | export interface LayerProps {
25 | model: M;
26 | /**
27 | * 画布缩放级别
28 | */
29 | zoomLevel?: number;
30 |
31 | style?: CSSProperties;
32 | }
33 |
34 | function Layer(
35 | props: LayerProps
36 | ) {
37 | const { model, zoomLevel = 1 } = props;
38 | const { magic, OS } = useStores();
39 | const LayerCmp = LayerCmpMap[model.type] as ComponentType<
40 | LayerProps
41 | >;
42 |
43 | if (!LayerCmp || !model.visible) return null;
44 | const outerStyle = getLayerOuterStyles(model);
45 | const innerStyle = getLayerInnerStyles(model);
46 |
47 | /**
48 | * 鼠标按下时触发
49 | */
50 | const handleMouseDown = (e: React.MouseEvent) => {
51 | // 0 左键 2 右键
52 | if (![0, 2].includes(e.button) || model.actived) return;
53 |
54 | /** 如果是右键,并且已经存在活动组件,不做选择行为 */
55 | if (e.button === 2 && magic.activedLayers.length) {
56 | return;
57 | }
58 |
59 | magic.activeLayer(model, e.shiftKey);
60 |
61 | if (model.isLock) return;
62 | moveHandle(e.nativeEvent, model, zoomLevel);
63 | };
64 |
65 | /**
66 | * 鼠标进入时触发
67 | */
68 | const handleMouseEnter = () => {
69 | if (OS.isEditing || model.isBack()) return;
70 | magic.hoverLayer(model);
71 | };
72 |
73 | /**
74 | * 鼠标离开时触发
75 | */
76 | const handleMouseLeave = () => {
77 | if (OS.isEditing || model.isBack()) return;
78 | magic.hoverLayer(null);
79 | };
80 |
81 | return (
82 |
94 |
95 |
96 | );
97 | }
98 |
99 | export default observer(Layer);
100 |
--------------------------------------------------------------------------------
/src/utils/equals.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 对比两个对象全等
3 | * @param a 对比项
4 | * @param b 被对比项
5 | * @returns {boolean}
6 | */
7 | function isEqualsObject(a: Record, b: Record) {
8 | const keys1 = Object.keys(a);
9 | const keys2 = Object.keys(b);
10 | let len1 = keys1.length;
11 |
12 | if (len1 !== keys2.length) return false;
13 |
14 | while ((len1 -= 1)) {
15 | const key = keys1[len1];
16 | if (!isEquals(a[key], b[key])) return false;
17 | }
18 |
19 | if (
20 | 'constructor' in a &&
21 | 'constructor' in b &&
22 | a.constructor !== b.constructor
23 | ) {
24 | return false;
25 | }
26 |
27 | return true;
28 | }
29 |
30 | function isEqualsArray(a: any[], b: any[]) {
31 | if (a.length !== b.length) return false;
32 | let len = a.length;
33 | while ((len -= 1)) {
34 | if (!isEquals(a[len], b[len])) return false;
35 | }
36 | return true;
37 | }
38 |
39 | function isEqualsMap(a: Map, b: Map) {
40 | if (a.size !== b.size) return false;
41 | for (const key of a.keys()) {
42 | if (!isEquals(a.get(key), b.get(key))) return false;
43 | }
44 | return true;
45 | }
46 |
47 | function isEqualsSet(a: Set, b: Set) {
48 | if (a.size !== b.size) return false;
49 | const arr = Array.from(b);
50 | for (const v1 of a.values()) {
51 | let found = false;
52 | for (let i = 0; i < arr.length; i += 1) {
53 | if (isEquals(v1, arr[i])) {
54 | found = true;
55 | arr.splice(i, 1);
56 | break;
57 | }
58 | }
59 | if (!found) return false;
60 | }
61 | return true;
62 | }
63 |
64 | function isEqualsByTag(a: any, b: any, tag: string) {
65 | switch (tag) {
66 | case '[object Boolean]':
67 | case '[object Number]':
68 | case '[object Date]': {
69 | const v1 = +a;
70 | const v2 = +b;
71 | return v1 === v2;
72 | }
73 | case '[object String]':
74 | case '[object RegExp]': {
75 | return a === `${b}`;
76 | }
77 | case '[object Symbol]': {
78 | const { valueOf } = Symbol.prototype;
79 | return valueOf.call(a) === valueOf.call(b);
80 | }
81 | case '[object Map]': {
82 | return isEqualsMap(a, b);
83 | }
84 | case '[object Set]': {
85 | return isEqualsSet(a, b);
86 | }
87 | default:
88 | break;
89 | }
90 | return false;
91 | }
92 |
93 | export default function isEquals(a: any, b: any) {
94 | if (a === b) return true;
95 | if (a == null || b == null) return a === b;
96 |
97 | const tag1 = Object.prototype.toString.call(a);
98 | const tag2 = Object.prototype.toString.call(b);
99 |
100 | if (tag1 !== tag2) return false;
101 |
102 | if (tag1 !== '[object Array]' && tag1 !== '[object Object]') {
103 | return isEqualsByTag(a, b, tag1);
104 | }
105 |
106 | return Array.isArray(a) ? isEqualsArray(a, b) : isEqualsObject(a, b);
107 | }
108 |
--------------------------------------------------------------------------------
/src/layout/Setting/Text/TextStyle.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from 'antd';
2 | import cls from 'classnames';
3 | import { observer } from 'mobx-react';
4 |
5 | import { TextProps } from './Text';
6 | import Style from './Text.module.less';
7 | import {
8 | FontWeightEnum,
9 | FontStyleEnum,
10 | TextDecorationEnum,
11 | } from '@/constants/Font';
12 |
13 | function TextStyle(props: TextProps) {
14 | const { model } = props;
15 |
16 | const { fontWeight, fontStyle, textDecoration } = model;
17 |
18 | const setFontWeight = () => {
19 | const newFontWeight =
20 | fontWeight === FontWeightEnum.Bold
21 | ? FontWeightEnum.Normal
22 | : FontWeightEnum.Bold;
23 | model.update({ fontWeight: newFontWeight });
24 | };
25 |
26 | const setFontStyle = () => {
27 | const newFontStyle =
28 | fontStyle === FontStyleEnum.Italic
29 | ? FontStyleEnum.Normal
30 | : FontStyleEnum.Italic;
31 | model.update({ fontStyle: newFontStyle });
32 | };
33 |
34 | const setTextDecoration = (type: TextDecorationEnum) => {
35 | const newTextDecoration =
36 | textDecoration !== type ? type : TextDecorationEnum.None;
37 |
38 | model.update({ textDecoration: newTextDecoration });
39 | };
40 |
41 | return (
42 |
43 |
44 |
53 |
54 |
55 |
56 |
65 |
66 |
67 |
68 | setTextDecoration(TextDecorationEnum.Underline)}
70 | className={cls(
71 | 'iconfont icon-03xiahuaxian',
72 | 'icon-item',
73 | Style.icon_item,
74 | {
75 | [Style.text_style_active]:
76 | textDecoration === TextDecorationEnum.Underline,
77 | }
78 | )}
79 | />
80 |
81 |
82 |
83 | setTextDecoration(TextDecorationEnum.LineThrough)}
85 | className={cls(
86 | 'iconfont icon-04shanchuxian',
87 | 'icon-item',
88 | Style.icon_item,
89 | {
90 | [Style.text_style_active]:
91 | textDecoration === TextDecorationEnum.LineThrough,
92 | }
93 | )}
94 | />
95 |
96 |
97 | );
98 | }
99 |
100 | export default observer(TextStyle);
101 |
--------------------------------------------------------------------------------
/src/store/OS.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 | import { MagneticLineType } from '@p/EditorTools';
3 | import LocalCache from '@/core/Manager/LocalCache';
4 | import { CANVAS_ZOOM_LEVEL } from '@/constants/CacheKeys';
5 | import {
6 | CANVAS_MAX_ZOOM_LEVEL,
7 | CANVAS_MIN_ZOOM_LEVEL,
8 | } from '@/constants/ZoomLevel';
9 |
10 | export default class OSStore {
11 | /** 画布缩放等级 */
12 | zoomLevel = LocalCache.get(CANVAS_ZOOM_LEVEL, 'number') ?? 1;
13 |
14 | /** 是否在移动中 */
15 | isMoveing = false;
16 |
17 | /** 是否旋转中 */
18 | isRotateing = false;
19 |
20 | /** 是否拉伸中 */
21 | isScaleing = false;
22 |
23 | /** 磁力线集合 */
24 | magneticLines: MagneticLineType[] | null = null;
25 |
26 | constructor() {
27 | makeAutoObservable(this);
28 | }
29 |
30 | /**
31 | * 是否可以放大
32 | * @readonly
33 | * @memberof OSStore
34 | */
35 | get canZoomIn() {
36 | return this.zoomLevel < CANVAS_MAX_ZOOM_LEVEL;
37 | }
38 |
39 | /**
40 | * 是否可以缩小
41 | * @readonly
42 | * @memberof OSStore
43 | */
44 | get canZoomOut() {
45 | return this.zoomLevel > CANVAS_MIN_ZOOM_LEVEL;
46 | }
47 |
48 | /**
49 | * 设置画布缩放登记
50 | * @param level 传入的等级
51 | */
52 | setZoomLevel(level: number) {
53 | this.handleSetZoomLevel(level);
54 | }
55 |
56 | /**
57 | * 放大画布
58 | */
59 | zoomIn() {
60 | this.setZoomLevel(this.zoomLevel + 0.1);
61 | }
62 |
63 | /**
64 | * 缩小画布
65 | */
66 | zoomOut() {
67 | this.setZoomLevel(this.zoomLevel - 0.1);
68 | }
69 |
70 | /**
71 | * 缩到最小
72 | */
73 | zoomMin() {
74 | this.setZoomLevel(CANVAS_MIN_ZOOM_LEVEL);
75 | }
76 |
77 | /**
78 | * 缩到最大
79 | */
80 | zoomMax() {
81 | this.setZoomLevel(CANVAS_MAX_ZOOM_LEVEL);
82 | }
83 |
84 | /**
85 | * 恢复默认
86 | */
87 | zoomReset() {
88 | this.setZoomLevel(1);
89 | }
90 |
91 | protected handleSetZoomLevel(level: number) {
92 | level = +level.toFixed(2);
93 | this.zoomLevel = Math.max(
94 | CANVAS_MIN_ZOOM_LEVEL,
95 | Math.min(CANVAS_MAX_ZOOM_LEVEL, level)
96 | );
97 | LocalCache.set(CANVAS_ZOOM_LEVEL, this.zoomLevel);
98 | }
99 |
100 | /**
101 | * 设置移动状态
102 | * @memberof OSStore
103 | */
104 | setMoveState(isMoveing: boolean) {
105 | this.isMoveing = isMoveing;
106 | }
107 |
108 | /**
109 | * 设置旋转状态
110 | * @memberof OSStore
111 | */
112 | setRotateState(isRotateing: boolean) {
113 | this.isRotateing = isRotateing;
114 | }
115 |
116 | /**
117 | * 设置移动状态
118 | * @memberof OSStore
119 | */
120 | setScaleState(isScaleing: boolean) {
121 | this.isScaleing = isScaleing;
122 | }
123 |
124 | /**
125 | * 设置磁力线
126 | */
127 | setMagneticLine(lines: MagneticLineType[]) {
128 | this.magneticLines = lines;
129 | }
130 |
131 | /**
132 | * 清除磁力线
133 | */
134 | clearMagneticLines() {
135 | this.magneticLines = null;
136 | }
137 |
138 | /**
139 | * 是否在编辑中
140 | * @readonly
141 | * @memberof OSStore
142 | */
143 | get isEditing() {
144 | return this.isMoveing || this.isRotateing || this.isScaleing;
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/helpers/Styles.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 | import { LayerStrucType } from '@/types/model';
3 |
4 | /**
5 | * 应用在容器上的样式
6 | */
7 | const onContainerKeys = ['width', 'height', 'opacity'];
8 |
9 | /**
10 | * 在图层上的样式
11 | */
12 | const onLayerKeys = [
13 | 'fontFamily',
14 | 'color',
15 | 'fontSize',
16 | 'lineHeight',
17 | 'letterSpacing',
18 | 'fontWeight',
19 | 'fontStyle',
20 | 'textDecoration',
21 | 'textAlign',
22 | 'backgroundColor',
23 | ];
24 |
25 | /**
26 | * 获取图层容器上的样式
27 | * @param model 组件数据
28 | * @param zoomLevel 缩放比例
29 | * @returns CSSProperties
30 | */
31 | export function getLayerOuterStyles(
32 | model: M,
33 | zoomLevel = 1
34 | ): CSSProperties {
35 | let containeStyle: CSSProperties = onContainerKeys.reduce((styles, key) => {
36 | if (Reflect.has(model, key)) {
37 | return {
38 | ...styles,
39 | [key]: model[key],
40 | };
41 | }
42 | return styles;
43 | }, {});
44 |
45 | /** 禁用 */
46 | if (model.disabled) {
47 | const { opacity = 1 } = model;
48 | containeStyle.opacity = +opacity * 0.5;
49 | }
50 | containeStyle = {
51 | ...containeStyle,
52 | ...getLayerRectStyles(model, zoomLevel),
53 | };
54 |
55 | return containeStyle;
56 | }
57 |
58 | /**
59 | * 单独拼接transform
60 | * @param style 组件样式属性
61 | * @zoomLevel zoomLevel 缩放比例
62 | * @returns 图层的transform
63 | */
64 | export function getLayerRectStyles(
65 | model: M,
66 | zoomLevel = 1
67 | ): CSSProperties {
68 | const { x, y, rotate, width, height, scale } = model.getRectData();
69 |
70 | return {
71 | width: width * zoomLevel,
72 | height: height * zoomLevel,
73 | transform: `translate(${x * zoomLevel}px,${
74 | y * zoomLevel
75 | }px) rotate(${rotate}deg) scale(${scale.x},${scale.y}) `,
76 | };
77 | }
78 |
79 | /**
80 | * 获取图层的内部样式
81 | * @param model
82 | * @param zoomLevel
83 | * @returns {CSSProperties}
84 | */
85 | export function getLayerInnerStyles(
86 | model: M,
87 | zoomLevel = 1
88 | ): CSSProperties {
89 | let styles = onLayerKeys.reduce((styles, key) => {
90 | if (Reflect.has(model, key)) {
91 | return {
92 | ...styles,
93 | [key]: model[key],
94 | };
95 | }
96 | return styles;
97 | }, {});
98 |
99 | if (model.isImage()) {
100 | styles = {
101 | ...styles,
102 | ...getMaskStyle(model, zoomLevel),
103 | };
104 | }
105 |
106 | return styles;
107 | }
108 |
109 | /**
110 | * 获取蒙层样式
111 | * @param model
112 | * @param zoomLevel
113 | * @returns {CSSProperties}
114 | */
115 | export function getMaskStyle(
116 | model: M,
117 | zoomLevel = 1
118 | ): CSSProperties {
119 | const { mask, width = 0, height = 0 } = model;
120 | if (!mask) return {};
121 | const { x, y } = mask;
122 |
123 | return {
124 | width: width * zoomLevel,
125 | height: height * zoomLevel,
126 | transform: `translate(${-x * zoomLevel}px,${-y * zoomLevel}px)`,
127 | };
128 | }
129 |
--------------------------------------------------------------------------------