) => {
412 | // setSteps(newLayers)
413 | // }
414 |
415 | // 移动图层层级
416 | const moveLayerLevel = useCallback(
417 | (i: number) => {
418 | const current = [...steps];
419 | let isChanged = false;
420 | handleSelectItem((item: any, currentLayerIndex = -1) => {
421 | if (item && item.type === 'stage') {
422 | return;
423 | }
424 | if (Array.isArray(item)) {
425 | console.warn('暂不支持多选移动');
426 | return;
427 | }
428 | if (currentLayerIndex >= 0) {
429 | const tmp = item;
430 | if (i > 0) {
431 | if (currentLayerIndex < current.length - 1) {
432 | current[currentLayerIndex] = current[currentLayerIndex + 1];
433 | current[currentLayerIndex + 1] = tmp;
434 | isChanged = true;
435 | }
436 | } else if (i < 0) {
437 | if (currentLayerIndex > 0) {
438 | current[currentLayerIndex] = current[currentLayerIndex - 1];
439 | current[currentLayerIndex - 1] = tmp;
440 | isChanged = true;
441 | }
442 | }
443 | }
444 | if (isChanged && stepCached) {
445 | stepCached.enqueue(current);
446 | setSteps(stepCached.getCurrent());
447 | }
448 | });
449 | },
450 | [handleSelectItem, steps]
451 | );
452 |
453 | // 成组
454 | const madeGroup = useCallback(
455 | (layers: any) => {
456 | const infos:any = [...steps];
457 | if (Array.isArray(layers) && stepCached) {
458 | // 拿到最大索引,最终group所属层级为最高层
459 | const maxIndex = layers.reduce(
460 | (cur, _, index) => Math.max(cur, index),
461 | 0
462 | );
463 | const newId = new Date().getTime();
464 | // 删除索引
465 | const group = {
466 | type: 'group',
467 | elements: [...layers],
468 | id: newId,
469 | isNew: true,
470 | };
471 | infos.splice(maxIndex + 1, 0, group);
472 | // 删除原图层
473 | layers.forEach((layer) => {
474 | const { id } = layer;
475 | const index = infos.findIndex((oldLayer) => oldLayer.id === id);
476 | if (~index) {
477 | infos.splice(index, 1);
478 | }
479 | });
480 |
481 | stepCached.enqueue(infos);
482 | setSteps(stepCached.getCurrent());
483 | setSelected(newId);
484 | console.log('stepCached.getCurrent()', stepCached.getCurrent());
485 | }
486 | },
487 | [steps]
488 | );
489 |
490 | // 拆组
491 | const divideGroup = useCallback(
492 | (groupId: string) => {
493 | if (stepCached) {
494 | const infos = [...steps];
495 | const index = infos.findIndex((layer) => layer.id === groupId);
496 | if (~index) {
497 | const group = infos[index];
498 | if ((group as IgroupInfo)?.elements?.length) {
499 | const { elements } = group as IgroupInfo;
500 | infos.splice(index, 1, ...elements);
501 | stepCached.enqueue(infos);
502 | setSteps(stepCached.getCurrent());
503 | // @ts-ignore
504 | setSelected(elements[0].id);
505 | console.log('stepCached.getCurrent()', stepCached.getCurrent());
506 | }
507 | }
508 | }
509 | },
510 | [steps]
511 | );
512 |
513 | useEffect(() => {
514 | outerInstance.attach('stageRef', stageRef);
515 | outerInstance.attach('setSelected', setSelected);
516 | outerInstance.attach('setStageScale', setStageScale);
517 | outerInstance.attach('setSteps', setSteps);
518 | outerInstance.attach('toggleMultiSelected', setMultiSelected);
519 | }, []);
520 |
521 | useEffect(() => {
522 | outerInstance.attach('deleteItem', deleteItem);
523 | }, [deleteItem]);
524 |
525 | useEffect(() => {
526 | outerInstance.attach('copyItem', copyItem);
527 | }, [copyItem]);
528 |
529 | useEffect(() => {
530 | outerInstance.attach('moveLayerLevel', moveLayerLevel);
531 | }, [moveLayerLevel]);
532 |
533 | useEffect(() => {
534 | outerInstance.attach('madeGroup', madeGroup);
535 | }, [madeGroup]);
536 |
537 | useEffect(() => {
538 | outerInstance.attach('divideGroup', divideGroup);
539 | }, [divideGroup]);
540 |
541 | useEffect(() => {
542 | outerInstance.attach('moveLayer', moveLayer);
543 | }, [moveLayer]);
544 |
545 | useEffect(() => {
546 | outerInstance.attach('changeLayerInfoById', changeLayerInfoById);
547 | }, [changeLayerInfoById]);
548 |
549 | const renderGroup = (info: Iinfo, idx: number, inGroup: boolean = false) => {
550 | const { type } = info;
551 | if (type === 'group' && (info as IgroupInfo).elements) {
552 | return (
553 | // @ts-ignore
554 |
567 | {(info as IgroupInfo)?.elements.map((i: Iinfo, iidx: number) =>
568 | renderGroup(i, idx, true)
569 | )}
570 |
571 | );
572 | }
573 | if (type === 'image') {
574 | return (
575 |
590 | );
591 | }
592 |
593 | if (type === 'text') {
594 | return (
595 |
611 | );
612 | }
613 |
614 | if (type === 'shape') {
615 | return (
616 |
632 | );
633 | }
634 | return null;
635 | };
636 |
637 | return (
638 |
647 |
653 |
654 |
662 |
663 |
664 | {steps &&
665 | steps.map((info: Iinfo, idx: number) =>
666 | info ? (
667 | info.type === 'group' ? (
668 | // @ts-ignore
669 |
684 | {/* @ts-ignore */}
685 | {info?.elements?.map((i: Iinfo, iidx: number) =>
686 | renderGroup(i, idx, true)
687 | )}
688 |
689 | ) : (
690 | renderGroup(info, idx)
691 | )
692 | ) : null
693 | )}
694 |
695 |
696 |
697 | );
698 | };
699 |
700 | // 输出并下载图片
701 | Core.exportToImage = (
702 | filename = 'stage.jpg',
703 | options: { scale?: number; quality?: number; fileType?: string } = {
704 | scale: 1,
705 | quality: 1,
706 | }
707 | ) => {
708 | const { scale = 1, quality = 1, fileType = 'image/png' } = options;
709 | // 先把Transformer去掉
710 | const { stageRef, setSelected } = outerInstance.value;
711 | setSelected(0);
712 | setTimeout(() => {
713 | try {
714 | if (stageRef && stageRef.current) {
715 | const [, ext] = fileType.split('/');
716 | const FileName = filename + '.' + ext;
717 | const uri = stageRef.current.toDataURL({
718 | pixelRatio: scale,
719 | quality,
720 | mimeType: fileType,
721 | });
722 | downloadURI(uri, FileName);
723 | }
724 | } catch (err) {
725 | console.log('err in exportToImage', err);
726 | }
727 | }, 100);
728 | };
729 |
730 | // 输出base64
731 | Core.exportToBASE64 = () => {
732 | const { stageRef, setSelected } = outerInstance.value;
733 | // 先把Transformer去掉
734 | setSelected(0);
735 | return new Promise((resolve, reject) => {
736 | setTimeout(() => {
737 | if (stageRef && stageRef.current) {
738 | const b64 = stageRef.current.toDataURL();
739 | resolve(b64);
740 | } else {
741 | reject();
742 | }
743 | }, 1000);
744 | });
745 | };
746 |
747 | // 输出文件类型
748 | Core.exportToFile = (format = 'png', fileName) => {
749 | const { stageRef, setSelected } = outerInstance.value;
750 | function dataURLtoFile(dataurl: string, filename: string) {
751 | const arr = dataurl.split(',');
752 | if (arr[0]) {
753 | const reg = /:(.*?);/;
754 | const regString = arr[0].match(reg);
755 | if (regString) {
756 | const mime = regString[1];
757 | const bstr = atob(arr[1]);
758 | let n = bstr.length;
759 | const u8arr = new Uint8Array(n);
760 | while (n--) {
761 | u8arr[n] = bstr.charCodeAt(n);
762 | }
763 | return new File([u8arr], filename, { type: mime });
764 | }
765 | }
766 | }
767 | if (stageRef && stageRef.current) {
768 | // 先把Transformer去掉
769 | setSelected(-1);
770 | const b64 = stageRef.current.toDataURL();
771 | return dataURLtoFile(b64, fileName + '.' + format);
772 | }
773 | };
774 |
775 | // 撤销
776 | Core.withdraw = () => {
777 | const { setSteps } = outerInstance.value;
778 | if (stepCached && stepCached.canMoveBack) {
779 | stepCached.moveBack();
780 | const curr = stepCached.getCurrent();
781 | setSteps(curr);
782 | }
783 | };
784 |
785 | // 重做
786 | Core.redo = () => {
787 | const { setSteps } = outerInstance.value;
788 | if (stepCached && stepCached.canMoveForward) {
789 | stepCached.moveForward();
790 | setSteps(stepCached.getCurrent());
791 | }
792 | };
793 |
794 | // 画布缩放
795 | Core.canvasScale = (ratio: number) => {
796 | const { setStageScale } = outerInstance.value;
797 | // ratio属于[0.25,2]
798 | if (ratio <= 2.75 && ratio > 0) {
799 | setStageScale(ratio);
800 | }
801 | };
802 |
803 | // 删除选中元素
804 | Core.deleteItem = () => {
805 | const { deleteItem } = outerInstance.value;
806 | deleteItem();
807 | };
808 |
809 | // 复制图层
810 | Core.copyItem = () => {
811 | const { copyItem } = outerInstance.value;
812 | copyItem();
813 | };
814 |
815 | // 获取当前画布信息
816 | Core.getInfo = () => {
817 | if (stepCached) {
818 | /* Removing some private properties of the step information
819 | (especially _ignore,_isProportionalScaling etc.)
820 | to reduce redundant data.
821 | */
822 | const unHandledInfos = stepCached.getCurrent();
823 | const result = unHandledInfos.map((info: any) => {
824 | const res = { ...info };
825 | // 删除私有字段
826 | delete res._isProportionalScaling;
827 | delete res._ignore;
828 | delete res._isAdaptStage;
829 | delete res._isChangedCrop;
830 | return res;
831 | });
832 | return result;
833 | }
834 | };
835 |
836 | // i正数往上移动,负数往下移动
837 | Core.moveLayerLevel = (i: number) => {
838 | const { moveLayerLevel } = outerInstance.value;
839 | moveLayerLevel(i);
840 | };
841 |
842 | // 将图层向四个方向移动像素
843 | Core.moveLayer = (direction: string, delta: number) => {
844 | const { moveLayer } = outerInstance.value;
845 | moveLayer(direction, delta);
846 | };
847 |
848 | // 清空选项
849 | Core.clearSelected = () => {
850 | const { setSelected } = outerInstance.value;
851 | setSelected(-1);
852 | };
853 |
854 | // 设置选中图层
855 | Core.setSelectedIndex = (id: LayerIdType) => {
856 | const { setSelected } = outerInstance.value;
857 | setSelected(id);
858 | };
859 |
860 | // 多选图层开关
861 | Core.toggleMultiSelected = (state: boolean) => {
862 | const { toggleMultiSelected } = outerInstance.value;
863 | toggleMultiSelected(state);
864 | };
865 |
866 | // 锁定/解锁某个图层
867 | Core.toogleLock = (id: LayerIdType) => {
868 | const { setSteps } = outerInstance.value;
869 | if (stepCached) {
870 | const currentLayer = [...stepCached.getCurrent()];
871 | const index = currentLayer.findIndex((layer) => layer.id === id);
872 | const isBanDrag = currentLayer[index].banDrag;
873 | currentLayer[index].banDrag = !isBanDrag;
874 | setSteps(currentLayer);
875 | }
876 | };
877 | // // 成组
878 | Core.madeGroup = (layers: any) => {
879 | const { madeGroup } = outerInstance.value;
880 | madeGroup(layers);
881 | };
882 |
883 | // 拆组
884 | Core.divideGroup = (groupId: string) => {
885 | const { divideGroup } = outerInstance.value;
886 | divideGroup(groupId);
887 | };
888 | // 改变某个图层的某个属性
889 | Core.changeLayerInfoById = (id: LayerIdType, item: object) => {
890 | const { changeLayerInfoById } = outerInstance.value;
891 | changeLayerInfoById(id, item);
892 | };
893 |
894 | export default Core;
895 |
--------------------------------------------------------------------------------
/src/keyboardListener.ts:
--------------------------------------------------------------------------------
1 | import { createKeybindingsHandler } from 'tinykeys';
2 |
3 | /*
4 | 快捷键需适配win系统和mac系统
5 | 删除:delete
6 | 复制:Ctrl+C;苹果系统Cmd+c
7 | 粘贴:Ctrl+V;苹果系统Cmd+v
8 | 撤销:Ctrl+Z;苹果系统Cmd+z
9 | 恢复:shift+ctrl+z
10 | 置顶:shift+ctrl+向上键
11 | 置底:shift+ctrl+向下键
12 | 多选移动:按住shift,可以加选文本/图片/商品,一起移动
13 | */
14 |
15 | class KeyboardListener {
16 | handler: any;
17 | canvasInstance: any;
18 | multiHandlerOn: any;
19 | multiHandlerOff: any;
20 | constructor() {
21 | this.handler = undefined;
22 | this.canvasInstance = undefined;
23 | this.multiHandlerOn = undefined;
24 | this.multiHandlerOff = undefined;
25 | }
26 |
27 | init = (konvaCanvasPoint: any) => {
28 | console.log('init');
29 | this.canvasInstance = konvaCanvasPoint;
30 | if (!this.handler) {
31 | const handler = createKeybindingsHandler({
32 | Delete: () => {
33 | this.canvasInstance.deleteItem();
34 | },
35 | BackSpace: () => {
36 | this.canvasInstance.deleteItem();
37 | },
38 | '$mod+KeyV': (event) => {
39 | event.preventDefault();
40 | this.canvasInstance.copyItem();
41 | },
42 | '$mod+KeyZ': (event) => {
43 | event.preventDefault();
44 | this.canvasInstance.withdraw();
45 | },
46 | '$mod+Shift+KeyZ': (event) => {
47 | event.preventDefault();
48 | this.canvasInstance.redo();
49 | },
50 | ArrowUp: (event) => {
51 | event.preventDefault();
52 | this.canvasInstance.moveLayer('y', -1);
53 | },
54 | ArrowDown: (event) => {
55 | event.preventDefault();
56 | this.canvasInstance.moveLayer('y', 1);
57 | },
58 | ArrowLeft: (event) => {
59 | event.preventDefault();
60 | this.canvasInstance.moveLayer('x', -1);
61 | },
62 | ArrowRight: (event) => {
63 | event.preventDefault();
64 | this.canvasInstance.moveLayer('x', 1);
65 | },
66 | 'Shift+ArrowUp': (event) => {
67 | event.preventDefault();
68 | this.canvasInstance.moveLayer('y', -10);
69 | },
70 |
71 | 'Shift+ArrowDown': (event) => {
72 | event.preventDefault();
73 | this.canvasInstance.moveLayer('y', 10);
74 | },
75 | 'Shift+ArrowLeft': (event) => {
76 | event.preventDefault();
77 | this.canvasInstance.moveLayer('x', -10);
78 | },
79 | 'Shift+ArrowRight': (event) => {
80 | event.preventDefault();
81 | this.canvasInstance.moveLayer('x', 10);
82 | },
83 | '$mod+Shift+ArrowUp': (event) => {
84 | event.preventDefault();
85 | this.canvasInstance.moveLayerLevel(1);
86 | },
87 | '$mod+Shift+ArrowDown': (event) => {
88 | event.preventDefault();
89 | this.canvasInstance.moveLayerLevel(-1);
90 | },
91 | });
92 | this.handler = handler;
93 | }
94 | if (!this.multiHandlerOn) {
95 | this.multiHandlerOn = (e: KeyboardEvent) => {
96 | if (e.keyCode === 16) {
97 | console.log('shift on');
98 | // 打开multi
99 | // this.canvasInstance.toggleMultiSelected(true);
100 | }
101 | };
102 | }
103 |
104 | if (!this.multiHandlerOff) {
105 | this.multiHandlerOff = (e: KeyboardEvent) => {
106 | if (e.keyCode === 16) {
107 | console.log('shift off');
108 | // 打开multi
109 | // this.canvasInstance.toggleMultiSelected(false);
110 | }
111 | };
112 | }
113 | };
114 |
115 | listening = (target: any) => {
116 | target.addEventListener('keydown', this.handler);
117 | window.addEventListener('keydown', this.multiHandlerOn);
118 | window.addEventListener('keyup', this.multiHandlerOff);
119 | };
120 |
121 | destory = (target: any) => {
122 | target.removeEventListener('keydown', this.handler);
123 | window.removeEventListener('keydown', this.multiHandlerOn);
124 | window.removeEventListener('keyup', this.multiHandlerOff);
125 | this.handler = undefined;
126 | this.multiHandlerOn = undefined;
127 | this.multiHandlerOff = undefined;
128 | };
129 | }
130 |
131 | export default KeyboardListener;
132 |
--------------------------------------------------------------------------------
/src/readme.md:
--------------------------------------------------------------------------------
1 | ```tsx
2 | {}} // 撤销
6 | onStepForward={() => {}} // 重做
7 | onScale={(a: number) => {}} // 缩放倍率 a[10,200]
8 | addItem={KonvaItem} // 见下
9 | onDel={(id: number) => {}}
10 | saveImg={} //生成图片
11 | saveData={} // 存储信息
12 | />
13 | ```
14 |
15 | ## KonvaItem
16 |
17 | ```
18 | {
19 | type:'img'|'text',
20 | value:'' 是图片就传地址,文本类型就写个默认值
21 | }
22 | ```
23 |
24 | ## props
25 |
26 | ## 更新
27 |
28 | 增加文字特效组
29 |
30 | ## 字段说明
31 |
32 | ### 通用字段
33 |
34 | | 字段 | 类型 | 必填 | 含义 |
35 | | ------------- | ------------- | ------- | ---------------------------------------------------- | -------- |
36 | | id | String | √ | 唯一标识符 |
37 | | type | `"image" | "text"` | √ | 图层类型 |
38 | | elementName | string | √ | 图层名称 | |
39 | | x | number | | 水平位置定位(以画布左上角为原点)默认为 0 |
40 | | y | number | | 垂直位置定位(以画布左上角为原点)默认为 0 |
41 | | opacity | number [0,1] | | 图层透明度,默认为 1 |
42 | | scaleX | number | | 水平方向缩放倍率,默认:1;负数时向 x 轴的负方向缩放 |
43 | | scaleY | number | | 垂直方向缩放倍率,默认:1;负数时向 y 轴的负方向缩放 |
44 | | rotation | number | | 顺时针旋转角度 |
45 | | shadowOffsetX | number | | 阴影水平偏移 |
46 | | shadowOffsetY | number | | 阴影垂直偏移 |
47 | | shadowColor | string | | 阴影颜色 |
48 | | shadowBlur | number [0,40] | | 投影模糊扩散 |
49 | | shadowOpacity | number [0,1] | | 投影透明度度 |
50 | | stroke | string | | 描边颜色 |
51 | | strokeWidth | number | | 描边宽度 |
52 | | shadowOpacity | number | | 阴影透明度 |
53 |
54 | ### 图像类型字段
55 |
56 | type 为 image 时
57 |
58 | | 字段 | 类型 | 必填 | 含义 |
59 | | ------ | ----------- | ---- | -------- |
60 | | width | number | √ | 图像宽度 |
61 | | height | number | √ | 图像高度 |
62 | | value | string | √ | 图像链接 |
63 | | crop | `CropProps` | | 剪裁参数 |
64 | | skewX | number | |
65 | | skewY | number | |
66 |
67 | #### CropProps
68 |
69 | | 字段 | 类型 | 必填 | 含义 |
70 | | ------------ | ------ | ---- | ---------------- |
71 | | originWidth | number | √ | 原始图片宽度 |
72 | | originHeight | number | √ | 原始图片高度 |
73 | | width | number | √ | 剪裁宽度 |
74 | | height | number | √ | 剪裁高度 |
75 | | unit | `px` | √ | 单位,必须写"px" |
76 | | x | number | √ | 剪裁框水平定位 |
77 | | y | number | √ | 剪裁框水平定位 |
78 |
79 | ### 文字类型参数
80 |
81 | type 为 text 时
82 | | 字段 | 类型 | 必填 | 含义 |
83 | | ----------- | -------- | ------- | -------------------------------------------------------- | -------- |
84 | value|string|√ |文本
85 | color|string ||文本颜色,默认为`#000`
86 | fontSize|number||字体大小
87 | fill|string||字体颜色,这里 hexcode 必须为 8 位,如:"#f800004d",最后两位为透明度,具体转换规则见下
88 | | fontStyle | `'bold' | 'italic' |'bold italic' ` | | 加粗 "bold" 斜体"italic" 二者的任意排列组合 |
89 | | textDecoration |`'underline' | 'line-through' |'underline line-through' ` | | 下划线 "underline" 贯穿线"line-through" 二者的任意排列组合 |
90 | align|`'left' | 'right' | 'center'`||对齐方式,默认左对齐
91 |
92 | #### hexcode 转换规则
93 |
94 | alpha 为透明度,当 alpha 为 0 时彻底透明;
95 |
96 | ```
97 | hexcode最后两位 = alpha < 0.01 ? '00' : Math.round(255 * alpha).toString(16)
98 | ```
99 |
100 | ### reference
101 |
102 | 1. [text-shadow](https://www.w3.org/Style/Examples/007/text-shadow.zh_CN.html)
103 |
--------------------------------------------------------------------------------
/src/type.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 | import Konva from 'konva';
3 | export type itemType = 'image' | 'text' | 'shape' | 'stage' | 'group';
4 |
5 | // props
6 | export interface IaddItem {
7 | type: itemType;
8 | value: string;
9 | }
10 |
11 | export type LayerIdType = string | number;
12 |
13 | export interface IProps {
14 | width: number;
15 | height: number;
16 | backgroundColor?: string;
17 | addItem?: IaddItem;
18 | backgroundStyle?: CSSProperties;
19 | selectedItemChange?: any;
20 | maxStep?: number;
21 | setRedo?: (a: boolean) => void;
22 | setWithdraw?: (a: boolean) => void;
23 | onChangeSelected?: (a?: any) => void; // 监听当前元素改变
24 | bindRef?: (a: any) => void;
25 | stepInfo?: Iinfo[];
26 | onChangeStep?: (steps: any) => void;
27 | }
28 |
29 | // 内部的
30 | export interface IcommonInfo {
31 | id: LayerIdType;
32 | type: itemType;
33 | isSelected?: boolean;
34 | handleInfo: (a: any) => void;
35 | handleSelected?: (id: number) => void;
36 | setShowTransformer: (s: boolean) => void;
37 | stageRef?: any;
38 | myRef?: any;
39 | trRef?: any;
40 | onRef?: (a: any) => void;
41 | banDrag?: boolean;
42 | isNew?: boolean;
43 | x: number;
44 | y: number;
45 | // w: number;
46 | // h: number;
47 | scaleX?: number;
48 | scaleY?: number;
49 | stageScale: number;
50 | fontSize?: number;
51 | mType?: number;
52 | elementName?: string;
53 | label?: string; // 元素层名称
54 | name?: string; //psd解析出来的图层名称
55 | }
56 |
57 | export interface IimageInfo extends IcommonInfo {
58 | type: 'image';
59 | value: string;
60 | trRef: any;
61 | crop: any;
62 | width?: number;
63 | height?: number;
64 | _isAdaptStage?: number;
65 | _isProportionalScaling?: number;
66 | _isChangedCrop?: boolean;
67 | }
68 |
69 | interface IshapeCommon {
70 | id: LayerIdType;
71 | }
72 |
73 | interface RectProps extends Konva.RectConfig {}
74 | interface CircleProps extends Konva.CircleConfig {}
75 | interface ArcProps extends Konva.ArcConfig {}
76 | interface StarProps extends Konva.StarConfig {}
77 | interface ArrowProps extends Konva.ArrowConfig {}
78 | interface EllipseProps extends Konva.EllipseConfig {}
79 |
80 | export type ShapeType =
81 | | 'rect'
82 | | 'circle'
83 | | 'arc'
84 | | 'star'
85 | | 'arrow'
86 | | 'ellipse';
87 |
88 | type ShapePropsMap = {
89 | rect: RectProps;
90 | circle: CircleProps;
91 | arc: ArcProps;
92 | star: StarProps;
93 | arrow: ArrowProps;
94 | ellipse: EllipseProps;
95 | };
96 | export type IShapeInfo = ShapePropsMap[ShapeType];
97 |
98 | // interface Shape extends ShapeProps, Konva.ShapeConfig {
99 | // type: ShapeType;
100 | // }
101 |
102 | type Shape = ShapePropsMap[T] &
103 | Konva.ShapeConfig & { type: T };
104 |
105 | // export interface IShapeInfo extends IcommonInfo {
106 | // type: 'shape';
107 | // value: ShapeType;
108 | // fill?: string;
109 | // width?: number;
110 | // height?: number;
111 | // stroke?: string; // 描边颜色
112 | // strokeWidth?: number; // 描边宽度
113 |
114 | // // 以下为Rect专属
115 | // cornerRadius?: number | Array;
116 |
117 | // // 以下为Circle专属props
118 | // radius?: number;
119 |
120 | // // 以下为arc专属字段
121 | // innerRadius?: number; // 内径
122 | // outerRadius?: number; // 外径
123 | // angle?: number; // 弧形圆角
124 |
125 | // // 以下为star专属
126 | // numPoints?: number;
127 |
128 | // // 以下为arrow专属
129 | // points?: Array;
130 |
131 | // // 以下为ellipse专属
132 | // ellipseRadius?: { radiusX: number; radiusY: number };
133 | // // pointerLength?: number;
134 | // // pointerWidth?: number;
135 | // }
136 |
137 | export interface IgroupInfo extends IcommonInfo {
138 | type: 'group';
139 | elements: Array;
140 | }
141 |
142 | export interface ItextInfo extends IcommonInfo {
143 | type: 'text';
144 | value: string;
145 | color?: 'string';
146 | width?: number;
147 | height?: number;
148 | }
149 |
150 | export interface IFunc {
151 | exportToImage: (
152 | a: string,
153 | opt?: { scale?: number; quality?: number; fileType?: string }
154 | ) => void;
155 | exportToBASE64: () => Promise;
156 | exportToFile: (format: string, filename: string) => File | undefined;
157 | withdraw: () => void;
158 | redo: () => void;
159 | canvasScale: (a: number) => void;
160 | deleteItem: () => void;
161 | copyItem: () => void;
162 | getInfo: () => any;
163 | moveLayerLevel: (i: number) => void;
164 | moveLayer: (direction: string, delta: number) => void;
165 | clearSelected: () => void;
166 | setSelectedIndex: (id: LayerIdType) => void;
167 | toogleLock: (id: LayerIdType) => void;
168 | toggleMultiSelected: (state: boolean) => void;
169 | madeGroup: (layers: any) => void;
170 | divideGroup: (groupId: string) => void;
171 | changeLayerInfoById: (id: LayerIdType, item: object) => void;
172 | // getSelectedInfo: () => Iinfo | Array;
173 | }
174 |
175 | export type Iinfo = IimageInfo | ItextInfo | IShapeInfo | IgroupInfo;
176 |
177 |
--------------------------------------------------------------------------------
/src/utils/circularQueue.ts:
--------------------------------------------------------------------------------
1 | class circularQueue {
2 | private list: any[] = [];
3 | private front: number = 0;
4 | private tail: number = 0;
5 | public length: number = 0;
6 | public current: number = 0;
7 |
8 | constructor(size: number, defaultElement = []) {
9 | this.length = size;
10 | this.front = 0;
11 | this.current = 0;
12 | this.list = new Array(size);
13 | this.list[0] = defaultElement;
14 | this.tail = 1;
15 | }
16 |
17 | get canMoveForward() {
18 | return !this.isEmpty() && (this.current + 1) % this.length !== this.tail;
19 | }
20 | get canMoveBack() {
21 | return this.current !== this.front;
22 | }
23 |
24 | // 清空current之后的元素
25 | clearAfterCurrent = () => {
26 | let i = this.current;
27 | const length = this.length;
28 |
29 | while ((i + 1) % length !== this.tail) {
30 | const clearIndex = (i + 1) % length;
31 | this.list[clearIndex] = undefined;
32 | i = clearIndex;
33 | }
34 | this.tail = (this.current + 1) % this.length;
35 | };
36 |
37 | // 入队
38 | enqueue = (item: any) => {
39 | // 当入队时current不是处于队尾指针的前驱时,需要清空current到队尾之间的所有元素,并重置尾指针
40 | if (this.isFull() && (this.current + 1) % this.length !== this.tail) {
41 | this.clearAfterCurrent();
42 | }
43 |
44 | if (this.isFull()) {
45 | this.tail = (this.current + 1) % this.length;
46 | // 满了移动头指针
47 | this.front = (this.front + 1) % this.length;
48 | }
49 | // const index = this.tail % this.length;
50 | this.list[this.tail] = item;
51 | this.current = this.tail;
52 | this.tail = (this.tail + 1) % this.length;
53 | };
54 |
55 | // 不涉及
56 | dequeue() {}
57 |
58 | isEmpty = () => {
59 | return typeof this.list[this.front] === 'undefined';
60 | };
61 |
62 | isFull = () => {
63 | return (
64 | this.front === this.tail && typeof this.list[this.front] !== 'undefined'
65 | );
66 | };
67 |
68 | getCurrent = () => {
69 | return this.list[this.current];
70 | };
71 |
72 | // 改变当前current指向的
73 | // changeCurrent() {
74 |
75 | // }
76 |
77 | // 往右移一步 (尾指针方向)
78 | moveForward = () => {
79 | if (this.canMoveForward) {
80 | this.current = this.isFull()
81 | ? (this.current + 1 + this.length) % this.length
82 | : this.current + 1;
83 | }
84 | };
85 | // 往左移一步 (头指针方向)
86 | moveBack = () => {
87 | if (this.canMoveBack) {
88 | this.current = this.isFull()
89 | ? (this.current - 1 + this.length) % this.length
90 | : this.current - 1;
91 | }
92 | };
93 |
94 | print = () => {
95 | let i = 0;
96 | let p = this.front;
97 | while (i < this.length) {
98 | p = (p + 1) % this.length;
99 | i++;
100 | }
101 | };
102 |
103 | // 清空当前队列中所有内容
104 | clear = () => {
105 | this.length = 0;
106 | this.front = 0;
107 | this.current = 0;
108 | this.list = [];
109 | this.tail = 0;
110 | };
111 | }
112 |
113 | export default circularQueue;
114 |
--------------------------------------------------------------------------------
/src/utils/debounce.ts:
--------------------------------------------------------------------------------
1 | const debounce = (callback = (a?: any, b?: any) => {}, time = 100) => {
2 | let timer: any = null;
3 | // @ts-ignore
4 | return (...params) => {
5 | clearTimeout(timer);
6 | timer = setTimeout(() => {
7 | callback(...params);
8 | }, time);
9 | };
10 | };
11 |
12 | export default debounce;
13 |
--------------------------------------------------------------------------------
/src/utils/handleKonvaItem.ts:
--------------------------------------------------------------------------------
1 | const handleKonvaItem = (konvaNode: any) => {
2 | // const { attrs, textWidth, textHeight } = konvaNode;
3 | const { attrs } = konvaNode;
4 | const {
5 | scaleX = 1,
6 | scaleY = 1,
7 | rotation,
8 | skewX,
9 | skewY,
10 | x = 0,
11 | y = 0,
12 | type,
13 | } = attrs;
14 | const otherProperty: any = {};
15 | if (type === 'text') {
16 | otherProperty.x = Math.round(x);
17 | otherProperty.y = Math.round(y);
18 | // otherProperty.w = Math.round(textWidth * scaleX);
19 | // otherProperty.h = Math.round(textHeight * scaleY);
20 | }
21 |
22 | return {
23 | scaleX,
24 | scaleY,
25 | rotation,
26 | skewX,
27 | skewY,
28 | x,
29 | y,
30 | ...otherProperty,
31 | };
32 | };
33 |
34 | export default handleKonvaItem;
35 |
--------------------------------------------------------------------------------
/src/utils/handleSize.ts:
--------------------------------------------------------------------------------
1 | export const getRealSize = (ref: any) => {
2 | if (ref?.current) {
3 | console.log('ref', ref);
4 |
5 | const textNode = ref.current;
6 | const width = textNode.getWidth();
7 | console.log('width', width);
8 |
9 | const height = textNode.getHeight();
10 | console.log('height', height);
11 |
12 | return { width, height };
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/src/utils/imageAdapt.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-len */
2 | type resType = { width?: number; height?: number; x?: number; y?: number };
3 |
4 | const _coreAdaption = (
5 | substract: number,
6 | ratio: number,
7 | containerW: number,
8 | containerH: number
9 | ) => {
10 | const res: resType = {};
11 |
12 | switch (true) {
13 | case substract > 0: {
14 | res.width = Math.round(containerH * ratio);
15 | res.height = containerH;
16 | break;
17 | }
18 | case substract < 0: {
19 | res.width = containerW;
20 | res.height = Math.round(containerW / ratio);
21 | break;
22 | }
23 | default: {
24 | res.height = containerH;
25 | res.width = containerW;
26 | }
27 | }
28 |
29 | return res;
30 | };
31 |
32 | /** generate the coordinate of the image placed in the center of the stage.
33 | */
34 | const _computeCenterInStage = (
35 | imgW: number,
36 | imgH: number,
37 | stageW: number,
38 | stageH: number
39 | ) => {
40 | const x = Math.round((stageW - imgW) / 2);
41 | const y = Math.round((stageH - imgH) / 2);
42 | return { x, y, width: imgW, height: imgH };
43 | };
44 |
45 | /** generate properties of konvaImage for image-replacement.
46 | * The rules of image-replacement:
47 | 1. Compute the original ratio:original_ratio=oldImage.width/oldImage.height
48 | 2. Compute the current ratio:current_ratio=cur.width/cur.height
49 | 3. if the original_ratio is greater than current_ratio, the current image is visually vertical,
50 | change the height of current into the old one.
51 | 4. if the original_ratio is less than current_ratio, the current image is visually horizontal,
52 | change the width of current into the old one.
53 | 5. if both of the ratio are equal,just scaling the current one to the same ratio as the old one.
54 | */
55 | const adaptReplaceImage = (
56 | image: any,
57 | oldSize: { width: number; height: number }
58 | ) => {
59 | if (image && oldSize) {
60 | const imgW = image.width;
61 | const imgH = image.height;
62 | const curRatio = imgW / imgH;
63 |
64 | const oldW = oldSize.width;
65 | const oldH = oldSize.height;
66 | const oldRatio = oldW / oldH;
67 | const substract = oldRatio - curRatio;
68 | const res: resType = _coreAdaption(substract, curRatio, oldW, oldH);
69 | return res;
70 | }
71 | };
72 |
73 | /** generate properties of konvaImage for coming-image in stage.
74 | * The rules of coming-image rendering:
75 | * 1. compute the size of stage and image.
76 | * if both of the width and height of the image is less than those of the stage, just return.
77 | * 2. compute the ratio of stage: ratio=stage.width/stage.height.if the ratio is much than 1,the stage is visually horizontal.
78 | * The height of the coming-image must be changed into the stage's height.
79 | * 3. if the ratio is less than 1,the stage is visually vertical.
80 | * The width of the coming-image must be changed into the stage's width.
81 | */
82 | const adaptNewImage = (image: any, stage: any) => {
83 | if (!stage || !image) return;
84 |
85 | const imgW = image.width;
86 | const imgH = image.height;
87 |
88 | const stageW = stage.width();
89 | const stageH = stage.height();
90 |
91 | if (imgW < stageW && imgH < stageH) {
92 | return _computeCenterInStage(imgW, imgH, stageW, stageH);
93 | }
94 |
95 | const stageRatio = stageW / stageH;
96 | const curRatio = imgW / imgH;
97 | const substract = stageRatio - curRatio;
98 |
99 | const size: resType = _coreAdaption(substract, curRatio, stageW, stageH);
100 | if (size.width && size.height) {
101 | const coordinate = _computeCenterInStage(
102 | size.width,
103 | size.height,
104 | stageW,
105 | stageH
106 | );
107 | return { ...coordinate, ...size };
108 | } else {
109 | return size;
110 | }
111 | };
112 |
113 | /**
114 | * 根据画布尺寸,适配相应的缩放比例
115 | */
116 | const stageScaleAdapt = (width: number, height: number) => {
117 | const max = Math.max(width, height);
118 | switch (true) {
119 | case max <= 960:
120 | return 0.7;
121 | case max <= 1200:
122 | return 0.6;
123 | case max <= 1400:
124 | return 0.5;
125 | case max <= 1700:
126 | return 0.4;
127 | default:
128 | return 0.3;
129 | }
130 | };
131 |
132 | /**
133 | * 根据舞台宽高和画布宽高,自动计算缩放比例
134 | */
135 | const stageScaleAutoAdapt = (
136 | stageW: number,
137 | stageH: number,
138 | imgW: number,
139 | imgH: number
140 | ) => {
141 | if (imgW < stageW && imgH < stageH) {
142 | return 1; // 画布比舞台小的不缩放
143 | } else {
144 | const xScale = imgW / stageW;
145 | const yScale = imgH / stageH;
146 | return Math.floor((1 / Math.max(xScale, yScale)) * 100) / 100;
147 | }
148 | };
149 |
150 | const cropImageAdaptStage = (
151 | crop: any,
152 | changedWidth: number,
153 | changedHeight: number
154 | ) => {
155 | const { x, y, width, height } = crop;
156 | const kx = width / changedWidth;
157 | const ky = height / changedHeight;
158 | return { x: x / kx, y: y / ky };
159 | // return { x: x, y: y };
160 | };
161 |
162 | export {
163 | adaptReplaceImage,
164 | adaptNewImage,
165 | cropImageAdaptStage,
166 | stageScaleAdapt,
167 | stageScaleAutoAdapt,
168 | };
169 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import * as AdaptStrategy from './imageAdapt';
2 |
3 | export { AdaptStrategy };
4 |
--------------------------------------------------------------------------------
/src/utils/textHandler.ts:
--------------------------------------------------------------------------------
1 | class SelectChangeListener {
2 | target: HTMLElement;
3 | originalText: string;
4 | handleAnchor: (
5 | allWidth: number,
6 | start: number,
7 | end: number,
8 | total: number
9 | ) => void;
10 | allWidth: number;
11 | constructor(
12 | target: HTMLElement,
13 | originalText: string,
14 | handleAnchor: (
15 | allWidth: number,
16 | start: number,
17 | end: number,
18 | total: number
19 | ) => void,
20 | allWidth: number
21 | ) {
22 | this.target = target;
23 | this.originalText = originalText;
24 | this.handleAnchor = handleAnchor;
25 | this.allWidth = allWidth;
26 | }
27 |
28 | public handler = (e: any) => {
29 | const content = e.target.value;
30 | console.log('content', content);
31 | const selection = document.all
32 | ? // @ts-ignore
33 | document.selection.createRange().text
34 | : document.getSelection();
35 |
36 | const text = selection.toString();
37 | const startIndex = this.originalText.indexOf(text);
38 | console.log('text', text);
39 | if (startIndex > -1 && content) {
40 | this.handleAnchor(
41 | this.allWidth,
42 | startIndex,
43 | startIndex + text.length,
44 | content.length
45 | );
46 | }
47 | };
48 |
49 | listen = () => {
50 | document.addEventListener('mouseup', this.handler);
51 | };
52 |
53 | destory = () => {
54 | document.removeEventListener('mouseup', this.handler);
55 | };
56 | }
57 |
58 | const getRealBoxSize = (trRef: any, stageScale: number, textNode: any) => {
59 | const transformerBoxAttr = trRef.current.children?.[0].attrs;
60 |
61 | const size: any = {};
62 |
63 | size.width =
64 | transformerBoxAttr.width * stageScale - textNode.padding() * 2 + 'px';
65 | size.height =
66 | transformerBoxAttr.height * stageScale - textNode.padding() * 2 + 'px';
67 |
68 | return size;
69 | };
70 |
71 | export { SelectChangeListener, getRealBoxSize };
72 |
--------------------------------------------------------------------------------
/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import _, { isObject } from 'lodash';
2 | import { Iinfo, IcommonInfo, LayerIdType } from '../type';
3 |
4 | const randomId = () => {
5 | const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
6 | const length = 10; // 生成10位的随机字符串
7 | const randomChars = _.sampleSize(chars, length);
8 | return randomChars.join('');
9 | };
10 |
11 | /**
12 | * canvasInfo中有重复的id,对其进行替换并输出
13 | * TODO: 未进行嵌套组结构适配
14 | */
15 | const handleDuplicateId = (canvasInfo: IcommonInfo[] = []) => {
16 | const idMap: any = {};
17 |
18 | /**
19 | * 产生独一的key
20 | * @param key label或原本的id
21 | * @returns keystring
22 | */
23 | const handleKey = (key: string | number, label?: string) => {
24 | const isExisit = !!idMap[key];
25 | if (!isExisit) {
26 | idMap[key] = 1;
27 | return key;
28 | } else {
29 | if (label) {
30 | return idMap[label] ? randomId() : label;
31 | }
32 | return randomId();
33 | }
34 | };
35 |
36 | return canvasInfo.map((info: IcommonInfo) => {
37 | if (info) {
38 | // @ts-ignore
39 | const { id, label, value, elementName, name } = info;
40 | return {
41 | ...info,
42 | label: label || name || value || elementName,
43 | id: handleKey(id, label),
44 | };
45 | }
46 | return info;
47 | });
48 | };
49 |
50 | const downloadURI = (uri: string, name: string) => {
51 | const link = document.createElement('a');
52 | link.download = name;
53 | link.href = uri;
54 | document.body.appendChild(link);
55 | link.click();
56 | document.body.removeChild(link);
57 | };
58 |
59 | const isSelectedId = (id: LayerIdType, layerId: number) => {
60 | if (Array.isArray(id)) {
61 | return id.includes(layerId);
62 | } else {
63 | return id === layerId;
64 | }
65 | };
66 |
67 | // 多选元素更新patch
68 | const updateMultiPatch = (patch: any, layers: Array) => {
69 | const newLayers = [...layers];
70 | if (isObject(patch)) {
71 | const ids = Object.keys(patch);
72 | ids.forEach((id: string) => {
73 | const index = newLayers.findIndex((layer: any) => layer?.id === id);
74 | // @ts-ignore
75 | if (index > -1 && isObject(patch[id])) {
76 | //@ts-ignore
77 | newLayers[index] = { ...newLayers[index], ...patch[id] };
78 | }
79 | });
80 | }
81 | console.error('多选patch格式错误');
82 | };
83 |
84 | export { handleDuplicateId, downloadURI, isSelectedId, updateMultiPatch };
85 |
--------------------------------------------------------------------------------
/src/字段说明.md:
--------------------------------------------------------------------------------
1 | ### 通用字段
2 |
3 | | 字段 | 类型 | 必填 | 含义 |
4 | | ------------- | ------------- | ------- | ---------------------------------------------------- | -------- |
5 | | id | String | √ | 唯一标识符 |
6 | | type | `"image" | "text"` | √ | 图层类型 |
7 | | elementName | string | √ | 图层名称 | |
8 | | x | number | | 水平位置定位(以画布左上角为原点)默认为 0 |
9 | | y | number | | 垂直位置定位(以画布左上角为原点)默认为 0 |
10 | | opacity | number [0,1] | | 图层透明度,默认为 1 |
11 | | scaleX | number | | 水平方向缩放倍率,默认:1;负数时向 x 轴的负方向缩放 |
12 | | scaleY | number | | 垂直方向缩放倍率,默认:1;负数时向 y 轴的负方向缩放 |
13 | | rotation | number | | 顺时针旋转角度 |
14 | | shadowOffsetX | number | | 阴影水平偏移 |
15 | | shadowOffsetY | number | | 阴影垂直偏移 |
16 | | shadowColor | string | | 阴影颜色 |
17 | | shadowBlur | number [0,40] | | 投影模糊扩散 |
18 | | shadowOpacity | number [0,1] | | 投影透明度度 |
19 | | stroke | string | | 描边颜色 |
20 | | strokeWidth | number | | 描边宽度 |
21 | | shadowOpacity | number | | 阴影透明度 |
22 |
23 | ### 图像类型字段
24 |
25 | type 为 image 时
26 |
27 | | 字段 | 类型 | 必填 | 含义 |
28 | | ------ | ----------- | ---- | -------- |
29 | | width | number | √ | 图像宽度 |
30 | | height | number | √ | 图像高度 |
31 | | value | string | √ | 图像链接 |
32 | | crop | `CropProps` | | 剪裁参数 |
33 | | skewX | number | |
34 | | skewY | number | |
35 |
36 | #### CropProps
37 |
38 | | 字段 | 类型 | 必填 | 含义 |
39 | | ------------ | ------ | ---- | ---------------- |
40 | | originWidth | number | √ | 原始图片宽度 |
41 | | originHeight | number | √ | 原始图片高度 |
42 | | width | number | √ | 剪裁宽度 |
43 | | height | number | √ | 剪裁高度 |
44 | | unit | `px` | √ | 单位,必须写"px" |
45 | | x | number | √ | 剪裁框水平定位 |
46 | | y | number | √ | 剪裁框水平定位 |
47 |
48 | ### 文字类型参数
49 |
50 | type 为 text 时
51 | | 字段 | 类型 | 必填 | 含义 |
52 | | ----------- | -------- | ------- | -------------------------------------------------------- | -------- |
53 | value|string|√ |文本
54 | color|string ||文本颜色,默认为`#000`
55 | fontSize|number||字体大小
56 | fill|string||字体颜色,这里 hexcode 必须为 8 位,如:"#f800004d",最后两位为透明度,具体转换规则见下
57 | | fontStyle | `'bold' | 'italic' |'bold italic' ` | | 加粗 "bold" 斜体"italic" 二者的任意排列组合 |
58 | | textDecoration |`'underline' | 'line-through' |'underline line-through' ` | | 下划线 "underline" 贯穿线"line-through" 二者的任意排列组合 |
59 | align|`'left' | 'right' | 'center'`||对齐方式,默认左对齐
60 |
61 | #### hexcode 转换规则
62 |
63 | alpha 为透明度,当 alpha 为 0 时彻底透明;
64 |
65 | ```
66 | hexcode最后两位 = alpha < 0.01 ? '00' : Math.round(255 * alpha).toString(16)
67 | ```
68 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "target": "es2015",
5 | "allowJs": true,
6 | "lib": ["DOM"],
7 | "declaration": true,
8 | "emitDeclarationOnly": false,
9 | "resolveJsonModule":true,
10 | "esModuleInterop": true,
11 | "baseUrl": "./",
12 | "paths": {
13 | "src/*": ["src/*.js"]
14 | },
15 | "moduleResolution": "node",
16 | "isolatedModules": true,
17 | "jsx": "react-jsx",
18 | "noImplicitAny": false,
19 | "skipLibCheck": true,
20 | "allowSyntheticDefaultImports": true,
21 | "removeComments": true
22 | },
23 | "include": ["./index.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/unpublish.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | echo "执行的文件名:$0";
3 | echo "第一个参数为:$1";
4 | echo "第二个参数为:$2";
5 | echo "第三个参数为:$3";
6 |
7 | DEV_ENV="-dev"
8 | PROD_ENV="-prod"
9 |
10 |
11 | if test $1 = $DEV_ENV
12 | then
13 | REPO="http://localhost:4873/"
14 | elif test $1 = $PROD_ENV
15 | then
16 | token=$(cat ./.npm_token)
17 | echo "token=$token"
18 | REPO="https://registry.npmjs.org/"
19 | else
20 | echo "enviroment invalid"
21 | exit 8
22 | fi
23 |
24 | echo "REPO=$REPO"
25 | version=$(jq -r '.version' package.json)
26 |
27 | npm unpublish react-konva-editor@$version --force --registry $REPO|| echo "【no need to unpublish】"
28 | echo "【unpublish!!】"
29 |
30 | echo $n press any key to exit: $c
31 | read name
32 | echo "$name"
33 |
34 | # 删除老版本
35 | # 发布新版本
--------------------------------------------------------------------------------