@versa_ai/vmml-editor 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +335 -0
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +1 -0
  4. package/biome.json +7 -0
  5. package/dist/index.d.mts +5 -0
  6. package/dist/index.d.ts +5 -0
  7. package/dist/index.js +2675 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/index.mjs +2673 -0
  10. package/dist/index.mjs.map +1 -0
  11. package/package.json +48 -0
  12. package/postcss.config.js +3 -0
  13. package/src/assets/css/closeLayer.scss +50 -0
  14. package/src/assets/css/colorSelector.scss +59 -0
  15. package/src/assets/css/editorTextMenu.less +130 -0
  16. package/src/assets/css/editorTextMenu.scss +149 -0
  17. package/src/assets/css/index.scss +252 -0
  18. package/src/assets/css/loading.scss +31 -0
  19. package/src/assets/css/maxTextLayer.scss +31 -0
  20. package/src/assets/img/icon_Brush.png +0 -0
  21. package/src/assets/img/icon_Change.png +0 -0
  22. package/src/assets/img/icon_Cut.png +0 -0
  23. package/src/assets/img/icon_Face.png +0 -0
  24. package/src/assets/img/icon_Graffiti.png +0 -0
  25. package/src/assets/img/icon_Mute.png +0 -0
  26. package/src/assets/img/icon_Refresh.png +0 -0
  27. package/src/assets/img/icon_Text1.png +0 -0
  28. package/src/assets/img/icon_Text2.png +0 -0
  29. package/src/assets/img/icon_Volume.png +0 -0
  30. package/src/assets/img/icon_Word.png +0 -0
  31. package/src/components/CloseLayer.tsx +25 -0
  32. package/src/components/ColorSelector.tsx +90 -0
  33. package/src/components/Controls.tsx +32 -0
  34. package/src/components/EditorCanvas.tsx +566 -0
  35. package/src/components/Loading.tsx +16 -0
  36. package/src/components/MaxTextLayer.tsx +27 -0
  37. package/src/components/SeekBar.tsx +126 -0
  38. package/src/components/TextMenu.tsx +332 -0
  39. package/src/components/VideoMenu.tsx +49 -0
  40. package/src/index.tsx +551 -0
  41. package/src/utils/HistoryClass.ts +131 -0
  42. package/src/utils/VmmlConverter.ts +339 -0
  43. package/src/utils/const.ts +10 -0
  44. package/src/utils/keyBoardUtils.ts +199 -0
  45. package/src/utils/usePeekControl.ts +242 -0
  46. package/tsconfig.json +5 -0
  47. package/tsup.config.ts +14 -0
@@ -0,0 +1,126 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import { interpolate } from "remotion"
3
+ import { signIcon } from "../utils/const"
4
+
5
+ const getFrameFromX = (
6
+ clientX: number,
7
+ durationInFrames: number,
8
+ width: number,
9
+ ) => {
10
+ const pos = clientX;
11
+ const frame = Math.round(
12
+ interpolate(pos, [0, width], [0, durationInFrames - 1], {
13
+ extrapolateLeft: 'clamp',
14
+ extrapolateRight: 'clamp',
15
+ }),
16
+ );
17
+ return frame;
18
+ };
19
+ console.log(2222)
20
+ const SeekBar = ({ player, vmmlRef, frame, durationInFrames, intoEdit, setDragState, signList }: any ) => {
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+ const [dragging, setDragging] = useState<any>({
23
+ dragging: false,
24
+ })
25
+
26
+ const fillStyle = useMemo(() => {
27
+ return {
28
+ width: `${(frame / (durationInFrames - 1)) * 100}%`,
29
+ }
30
+ }, [frame, durationInFrames]);
31
+
32
+ const cirleStyle = useMemo(() => {
33
+ return {
34
+ left: `calc(${(frame / (durationInFrames - 1)) * 100}% - 1.6vw)`,
35
+ }
36
+ }, [frame, durationInFrames])
37
+
38
+ const onPointerDown = useCallback((e: React.PointerEvent) => {
39
+ if (e.button !== 0) {
40
+ return;
41
+ }
42
+ const { left, width } = containerRef.current?.getBoundingClientRect() as any;
43
+ const _frame = getFrameFromX(
44
+ e.clientX - left,
45
+ durationInFrames,
46
+ width,
47
+ );
48
+ setDragState(1);
49
+ setDragging({
50
+ dragging: true,
51
+ wasPlaying: player.isPlaying(),
52
+ });
53
+ player.pause();
54
+ player.seekTo(_frame);
55
+ vmmlRef.current && vmmlRef.current.onSeekStart();
56
+ }, [player, durationInFrames]);
57
+
58
+ const onPointerMove = useCallback((e: PointerEvent) => {
59
+ if (!dragging.dragging) {
60
+ return;
61
+ }
62
+ const { left, width } = containerRef.current?.getBoundingClientRect() as any;
63
+ const _frame = getFrameFromX(
64
+ e.clientX - left,
65
+ durationInFrames,
66
+ width,
67
+ );
68
+ setDragState(2);
69
+ player.seekTo(_frame);
70
+ }, [player, dragging.dragging, durationInFrames]);
71
+
72
+ const onPointerUp = (e: any) => {
73
+ setDragging({
74
+ dragging: false,
75
+ });
76
+ if (!dragging.dragging) {
77
+ return;
78
+ }
79
+ setDragState(3);
80
+ intoEdit();
81
+ vmmlRef.current && vmmlRef.current.onSeekEnd();
82
+ };
83
+
84
+ const onClickSign = (sign: any) => {
85
+ player.pause();
86
+ player.seekTo(sign.inFrame);
87
+ vmmlRef.current && vmmlRef.current.onSeekStart();
88
+ setTimeout(() => {
89
+ setDragState(4);
90
+ intoEdit(sign.inFrame);
91
+ vmmlRef.current && vmmlRef.current.onSeekEnd();
92
+ }, 100)
93
+ }
94
+
95
+ useEffect(() => {
96
+ if (!dragging.dragging) {
97
+ return;
98
+ }
99
+ const body = document.querySelector("body");
100
+ if (body) {
101
+ body.addEventListener('pointermove', onPointerMove);
102
+ body.addEventListener('pointerup', onPointerUp);
103
+ return () => {
104
+ body.removeEventListener('pointermove', onPointerMove);
105
+ body.removeEventListener('pointerup', onPointerUp);
106
+ };
107
+ }
108
+ }, [dragging.dragging, onPointerMove, onPointerUp]);
109
+
110
+ return (
111
+ <div ref={containerRef} className="seekbar-container">
112
+ <div className="seekbar-background">
113
+ <div className="seekbar-fill" onPointerDown={onPointerDown}></div>
114
+ <div className="seekbar-line" style={fillStyle}></div>
115
+ <div className="seekbar-signs">
116
+ {signList.map((item: any, index: number) => (
117
+ <img key={index} onClick={() => onClickSign(item)} style={{ left: `calc(${item.inFrame / durationInFrames * 100}% - 6px)` }} className="seekbar-sign" src={signIcon} alt="" />
118
+ ))}
119
+ </div>
120
+ <div className="seekbar-cirle" style={cirleStyle} onPointerDown={onPointerDown} />
121
+ </div>
122
+ </div>
123
+ )
124
+ }
125
+
126
+ export default SeekBar
@@ -0,0 +1,332 @@
1
+ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
2
+ import "../assets/css/editorTextMenu.scss";
3
+ import { closeIcon } from "../utils/const";
4
+ import { isIOS } from '@versa_ai/vmml-utils'
5
+ import { onKeyBoardAction, watchKeyBoard, canAdapter } from "../utils/keyBoardUtils";
6
+ import ColorSelector from "./ColorSelector";
7
+ // 处理文本内容,总共40个字
8
+ const formatText = (text: string, maxLengthBase = 40): string => {
9
+ // 正则表达式匹配所有类型的换行符
10
+ const newlineRegex = /(\r\n|\r|\n)/g;
11
+ const matches = text.match(newlineRegex)?.length || 0;
12
+ const maxLength = maxLengthBase + matches
13
+ let trimmedText = text.substring(0, maxLength)
14
+ if ((text.length - matches) === maxLengthBase) {
15
+ // 正则表达式去除尾部的换行符
16
+ trimmedText = trimmedText.replace(/(\r\n|\r|\n)+$/g, '');
17
+ }
18
+ return trimmedText;
19
+ };
20
+
21
+ const TextMenu = forwardRef(({ createText, textClose, textInfo, showTextButtons }: any, ref: any) => {
22
+ const formRef = useRef<HTMLFormElement>(null);
23
+ const headerRef = useRef<HTMLDivElement>(null);
24
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
25
+ const coverRef = useRef<HTMLDivElement>(null);
26
+ const mappingarea = useRef<HTMLDivElement>(null);
27
+ const [textContent, setTextContent] = useState("");
28
+ const [bgColor, setBgColor] = useState("transparent");
29
+ const [textColor, setTextColor] = useState("rgb(248,82,81)");
30
+ const [textPos, setTextPos] = useState<any>({ left: 0, top: 0, angle: 0, scaleX: 0.75, scaleY: 0.75, zoomX: 0, zoomY: 0 });
31
+ const [textBasicInfo, setTextBasicInfo] = useState<any>({
32
+ colorName: "red",
33
+ colorValue: "rgb(248,82,81)",
34
+ isBack: false,
35
+ textAlign: 'left'
36
+ });
37
+ const colorSelectorRef = useRef<any>(null);
38
+ const [textContentOnShow, setTextContentOnShow] = useState<any>([]);
39
+ const [scrollYValue, setScrollYValue] = useState(() => {
40
+ return Number.parseInt(localStorage.getItem('scrollYValue') as string) || null;
41
+ });
42
+ const scrollYValueRef = useRef(scrollYValue);
43
+ useEffect(() => {
44
+ const callback = (status: any) => {
45
+ if (scrollYValueRef.current === null) {
46
+ setTimeout(() => {
47
+ const newScrollYValue = window.scrollY;
48
+ setScrollYValue(newScrollYValue);
49
+ scrollYValueRef.current = newScrollYValue;
50
+ localStorage.setItem('scrollYValue', newScrollYValue.toString());
51
+ }, 150);
52
+ }
53
+ setTimeout(() => {
54
+ onKeyBoardAction(
55
+ status,
56
+ headerRef.current,
57
+ textareaRef.current,
58
+ coverRef.current,
59
+ mappingarea.current
60
+ );
61
+ }, 200);
62
+ };
63
+ const removeListeners = watchKeyBoard(callback);
64
+ // 清理函数,用于移除事件监听器
65
+ return () => {
66
+ removeListeners();
67
+ };
68
+ }, []);
69
+ useEffect(() => {
70
+ //设置同步滚动
71
+ const syncScroll = () => {
72
+ if (textareaRef.current && mappingarea.current) {
73
+ const scrollPosition = textareaRef.current.scrollTop;
74
+ mappingarea.current.scrollTop = scrollPosition;
75
+ }
76
+ };
77
+ if (textareaRef.current) {
78
+ // 添加滚动监听器到text-input
79
+ textareaRef.current.addEventListener("scroll", syncScroll);
80
+ // 添加输入监听器到textarea
81
+ textareaRef.current.addEventListener("input", syncScroll);
82
+ }
83
+
84
+ // 清理函数,移除监听器
85
+ return () => {
86
+ if (textareaRef.current) {
87
+ textareaRef.current.removeEventListener("scroll", syncScroll);
88
+ textareaRef.current.removeEventListener("input", syncScroll);
89
+ }
90
+ };
91
+ }, []);
92
+ useEffect(() => {
93
+ let lines = [];
94
+ //判断字符里是否存在\r
95
+ if (textContent.includes("\r")) {
96
+ lines = textContent.split("\r");
97
+ } else {
98
+ lines = textContent.split("\n");
99
+ }
100
+ setTextContentOnShow(lines);
101
+ }, [textContent]);
102
+
103
+ useEffect(() => {
104
+ if (Object.keys(textInfo).length > 0) {
105
+ setText(textInfo);
106
+ } else {
107
+ resetInfo();
108
+ }
109
+ }, [textInfo]);
110
+
111
+ useEffect(() => {
112
+ if (!isIOS()) {
113
+ if (mappingarea.current && textareaRef.current) {
114
+ textareaRef.current.style.position = 'static'
115
+ mappingarea.current.style.maxHeight = textareaRef.current.style.height = '50vh'
116
+ mappingarea.current.style.maxWidth = getComputedStyle(textareaRef.current).width;
117
+ textareaRef.current.focus();
118
+ }
119
+ } else {
120
+ if (textareaRef.current) {
121
+ textareaRef.current.focus();
122
+ }
123
+ }
124
+ }, [])
125
+ const handleOnChange = (e: any) => {
126
+ const inputValue = e.target.value;
127
+ setTextContent(formatText(inputValue));
128
+ };
129
+
130
+ const handleSubmit = (e: any) => {
131
+ if (showTextButtons) {
132
+ e.preventDefault();
133
+ }
134
+ if (formRef.current) {
135
+ const formData = new FormData(formRef.current);
136
+ // 也可以使用普通的对象:
137
+ const formJson = Object.fromEntries((formData as any).entries());
138
+ const { input_content } = formJson;
139
+ renderText(input_content);
140
+ }
141
+ };
142
+ const handleChangeColor = (item: any) => {
143
+ setColor(item);
144
+ };
145
+ const onBackGroundChange = (currentColorItem: string) => {
146
+ setColor(currentColorItem);
147
+ };
148
+ const setColor = (colorItem: any) => {
149
+ if (textareaRef.current) {
150
+ textareaRef.current.focus();
151
+ }
152
+ const { current } = colorSelectorRef;
153
+ const { colorName, colorValue } = colorItem;
154
+ const isBack = current ? current.getBackState() : false;
155
+ const textBasic = {
156
+ colorName,
157
+ colorValue,
158
+ isBack,
159
+ textAlign: textBasicInfo.textAlign
160
+ };
161
+ setTextBasicInfo(textBasic);
162
+ //给文字设置颜色
163
+ if (!isBack) {
164
+ setBgColor("transparent");
165
+ setTextColor(colorValue);
166
+ } else {
167
+ //给背景设置颜色
168
+ //点击切换颜色「非白色背景」,字都统一白色,背景变化对应颜色,若选择「白色背景」字变成黑色
169
+ setBgColor(colorValue);
170
+ colorName === "white" || colorValue === 'rgba(255, 255, 255, 1)' ? setTextColor("#000") : setTextColor("#fff");
171
+ }
172
+ };
173
+ const setText = (textInfo: any) => {
174
+ const { bgColor, text, textColor, left, top, angle, scaleX, scaleY, zoomX, zoomY, textBasicInfo, width, height } = textInfo;
175
+ const { isBack, colorName, colorValue } = textBasicInfo;
176
+ const fillColor = text === '请输入文案' && textColor === 'rgba(0, 0, 0, 0)' ? 'rgba(255, 255, 255, 1)' : textColor;
177
+ setBgColor(bgColor);
178
+ setTextColor(fillColor);
179
+ setTextContent(text);
180
+ setTextBasicInfo(textBasicInfo);
181
+ setTextPos({ left, top, angle, scaleX, scaleY, zoomX, zoomY, width, height });
182
+ const { current } = colorSelectorRef;
183
+ if (!current) return;
184
+ current.setIsSetBack(isBack);
185
+ current.setCurrentColorItem({ colorName, colorValue });
186
+ };
187
+
188
+ const resetInfo = () => {
189
+ //文字编辑缺省设置
190
+ setBgColor("transparent");
191
+ setTextColor("rgb(248,82,81)");
192
+ setTextContent("");
193
+ setTextPos({ left: 0, top: 0, angle: 0, scaleX: 0.75, scaleY: 0.75, zoomX: 0, zoomY: 0 });
194
+ };
195
+ const close = () => {
196
+ textClose(textInfo.id);
197
+ };
198
+ const renderText = (input_content?: string) => {
199
+ const { current } = mappingarea;
200
+ if (current) {
201
+ //获取映射区域的宽高传入画布,作为fabric文字的宽高
202
+ //获取计算属性
203
+ //在close时需要使用传入的textpos
204
+ const computedStyle = window.getComputedStyle(current);
205
+ const { width, height } = computedStyle;
206
+ setTextPos(
207
+ Object.assign(textPos, { width: Number(width.split("px")[0]), height: Number(height.split("px")[0]) }),
208
+ );
209
+ }
210
+ //判断是否空字符串,并且不全是空格
211
+ if (input_content && input_content.trim() !== "") {
212
+ const { id, fontFamily, fontAssetUrl } = textInfo;
213
+ //传递给父组件文字属性
214
+ //input_content带有换行符,所以不能用textContent传到player
215
+ createText({ textContent: input_content, bgColor, textColor, position: textPos, textBasicInfo, id, fontFamily, fontAssetUrl });
216
+ } else {
217
+ textClose(textInfo.id);
218
+ }
219
+ };
220
+ useImperativeHandle(ref, () => ({
221
+ setText,
222
+ close,
223
+ handleSubmit
224
+ }));
225
+
226
+ return (
227
+ <form method="post" onSubmit={handleSubmit} ref={formRef}>
228
+ <div className="overlay">
229
+ {/* 顶部 */}
230
+ {
231
+ showTextButtons && (
232
+ <div className="text-header" ref={headerRef}>
233
+ <div onClick={() => close()} className="close">
234
+ <img crossOrigin="anonymous" src={closeIcon} alt="close" />
235
+ </div>
236
+ <button type="submit" className="next-button">
237
+ 完成
238
+ </button>
239
+ </div>
240
+ )
241
+ }
242
+ {
243
+ !isIOS() && (
244
+ <div className="edit_container" style={{
245
+ width: '100%', position: 'relative', paddingLeft: '4.4vw',
246
+ paddingRight: '3.2vw', display: 'flex'
247
+ }}>
248
+ {/* 编辑区 */}
249
+ <textarea
250
+ ref={textareaRef}
251
+ cols={20}
252
+ wrap="hard"
253
+ name="input_content"
254
+ className="text-input"
255
+ value={textContent}
256
+ onChange={handleOnChange}
257
+ style={{ color: textColor, width: '94%', fontFamily: textInfo.fontFamily }}
258
+ />
259
+ {/* 映射颜色区域 */}
260
+ <div
261
+ className="mappingarea"
262
+ ref={mappingarea}
263
+ contentEditable
264
+ suppressContentEditableWarning
265
+ style={{
266
+ background: bgColor, position: 'absolute',
267
+ top: '0',
268
+ zIndex: '-1',
269
+ left: '4.4vw',
270
+ }}
271
+ >
272
+ {/* 颜色适应每行方案 */}
273
+ {textContentOnShow.map((text: string, index: number) => {
274
+ return (
275
+ <span className="mappingarea_span" key={index} style={{ maxWidth: '100%' }}>
276
+ {text ? text : " "}
277
+ </span>
278
+ );
279
+ })}
280
+ </div>
281
+ </div>
282
+ )
283
+ }
284
+ {
285
+ isIOS() && (
286
+ <>
287
+ {/* 编辑区 */}
288
+ <textarea
289
+ ref={textareaRef}
290
+ cols={20}
291
+ wrap="hard"
292
+ name="input_content"
293
+ className="text-input"
294
+ value={textContent}
295
+ onChange={handleOnChange}
296
+ style={{ color: textColor, fontFamily: textInfo.fontFamily }}
297
+ />
298
+ {/* 映射颜色区域 */}
299
+ <div
300
+ className="mappingarea"
301
+ ref={mappingarea}
302
+ contentEditable
303
+ suppressContentEditableWarning
304
+ style={{ background: bgColor }}
305
+ >
306
+ {/* 颜色适应每行方案 */}
307
+ {textContentOnShow.map((text: string, index: number) => {
308
+ return (
309
+ <span className="mappingarea_span" key={index}>
310
+ {text ? text : " "}
311
+ </span>
312
+ );
313
+ })}
314
+ </div>
315
+ </>
316
+
317
+ )
318
+ }
319
+ {/* 颜色选择器 */}
320
+ <div ref={coverRef} className="cover">
321
+ <ColorSelector
322
+ ref={colorSelectorRef}
323
+ onColorChange={handleChangeColor}
324
+ onBackGroundChange={onBackGroundChange}
325
+ />
326
+ </div>
327
+ </div>
328
+ </form>
329
+ );
330
+ });
331
+
332
+ export default TextMenu;
@@ -0,0 +1,49 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ const VideoMenu = ({ createImage, videoMenus, menuState }: any) => {
4
+
5
+ const [active, setActive] = useState<number>();
6
+ const [menus, setMenus] = useState<any>([])
7
+ const [items, setItems] = useState<any>([]);
8
+
9
+ const onClickClass = ({ id, emojiList }: any) => {
10
+ setActive(id);
11
+ setItems(emojiList)
12
+ }
13
+
14
+ const onClickItem = ({ emojiId, fileUrl }: any) => {
15
+ if (menuState === "video") {
16
+ createImage(fileUrl, emojiId);
17
+ }
18
+ }
19
+
20
+ useEffect(() => {
21
+ setMenus(videoMenus);
22
+ if (videoMenus.length) {
23
+ onClickClass(videoMenus[0]);
24
+ }
25
+ }, [videoMenus]);
26
+
27
+ return (
28
+ <div className={`editor-video-menu ${menuState === "video" ? "active" : ""}`}>
29
+ <div className="editor-video-menu-header">
30
+ {
31
+ menus.map((item: any) => (
32
+ <div className={ active === item.id ? "active": "" } key={item.id} onClick={() => onClickClass(item)} >{item.classifyType}</div>
33
+ ))
34
+ }
35
+ </div>
36
+ <div className="editor-video-menu-itmes">
37
+ {
38
+ items.map((item: any) => (
39
+ <div key={item.emojiId} onClick={() => onClickItem(item)}>
40
+ <img loading="lazy" src={item.fileUrl.thumbnailUrl} alt="素材" />
41
+ </div>
42
+ ))
43
+ }
44
+ </div>
45
+ </div>
46
+ )
47
+ }
48
+
49
+ export default VideoMenu