@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,339 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { hexToArgb, rgbaToArgb, getFontSize } from "@versa_ai/vmml-utils";
3
+ class VmmlConverter {
4
+ private vmml: any;
5
+ private canvasSize: any;
6
+ private tracks: any;
7
+ private heightScale: any;
8
+ private widthScale: any;
9
+ private fontSize: number;
10
+
11
+ /**
12
+ * VmmlConverter 构造函数
13
+ * @param vmml - VMML 模板数据
14
+ * @param canvasSize - 画布尺寸
15
+ */
16
+ constructor({ vmml, canvasSize }: { vmml: any; canvasSize: any }) {
17
+ const { width, height } = vmml.template.dimension;
18
+ this.vmml = vmml;
19
+ this.canvasSize = canvasSize;
20
+ this.tracks = vmml.template.tracks;
21
+ this.fontSize = getFontSize(width, height);
22
+
23
+ // 计算宽高比
24
+ this.heightScale = height / canvasSize.height;
25
+ this.widthScale = width / canvasSize.width;
26
+ }
27
+
28
+ //更新位置
29
+ private setPosParam(fObj: any): object {
30
+ console.log("setPosParam fObj",fObj)
31
+ const {
32
+ clipData: { type },
33
+ centerPoint,
34
+ scaleX,
35
+ scaleY,
36
+ angle,
37
+ width,
38
+ height,
39
+ } = fObj;
40
+ let _scaleX, _scaleY, _centerX, _centerY;
41
+
42
+ if (type === "文字") {
43
+ // const [rect, textbox] = fObj.objects;
44
+ _scaleX = (22 * scaleX * this.widthScale) / this.fontSize;
45
+ _scaleY = (22 * scaleY * this.heightScale) / this.fontSize;
46
+ _centerX = centerPoint.x / this.canvasSize.width;
47
+ _centerY = centerPoint.y / this.canvasSize.height;
48
+ // fObj.clipData.lineSpacing = 22 * textbox.lineHeight * scaleY * this.heightScale;
49
+ } else if (type === "表情包") {
50
+ _scaleX = (width * scaleX * this.widthScale) / width;
51
+ _scaleY = (height * scaleY * this.heightScale) / height;
52
+ _centerX = centerPoint.x / this.canvasSize.width;
53
+ _centerY = centerPoint.y / this.canvasSize.height;
54
+ }
55
+
56
+ return {
57
+ scaleX: _scaleX,
58
+ scaleY: _scaleY,
59
+ centerX: _centerX,
60
+ centerY: _centerY,
61
+ rotationZ: angle,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * 转换 TextClip
67
+ * @param fObj - 画布fObj
68
+ */
69
+ public addTextClip(fObj: any): void {
70
+ console.log("addTextClip fObj", fObj);
71
+ const posParam = this.setPosParam(fObj);
72
+
73
+ // const [rect, textbox] = fObj.objects;
74
+ // const { clipData: { id, inPoint, lineSpacing }} = fObj;
75
+ const { clipData: { id, inPoint, text, textColor, bgColor}} = fObj;
76
+ const {template:{duration}} = this.vmml;
77
+ const clips = [];
78
+ const editorTrack = this.tracks.find((track: any) => track.editorType === "文字");
79
+
80
+ const tClipData = {
81
+ duration,
82
+ originDuration: duration,
83
+ start: 0,
84
+ end: duration,
85
+ inPoint,
86
+ id,
87
+ type: 202,
88
+ fObj,
89
+ textClip: {
90
+ lineSpacing: 0,
91
+ alignType: 0,
92
+ fontCode: '',
93
+ bold: false,
94
+ dimension: {
95
+ height: 282,
96
+ width: 825,
97
+ },
98
+ posParam,
99
+ backgroundColor: this.toARGB(bgColor),
100
+ textContent: text,
101
+ textColor: this.toARGB(textColor),
102
+ // fontFamily:'unset'
103
+ },
104
+ };
105
+ if (!editorTrack) {
106
+ clips.push(tClipData);
107
+ this.tracks.push({
108
+ clips,
109
+ description: "editorText",
110
+ id: uuidv4(),
111
+ type: 2,
112
+ editorType: "文字",
113
+ });
114
+ } else {
115
+ editorTrack.clips.push(tClipData);
116
+ }
117
+
118
+ console.log("addTextClip 最终vmml", this.vmml);
119
+ }
120
+
121
+ /**
122
+ * 转换 VideoClip
123
+ * @param fObj - 画布fObj
124
+ */
125
+ public addVideoClip(fObj: any): void {
126
+ console.log("addVideoClip fObj", fObj);
127
+ const clips = [];
128
+ const editorTrack = this.tracks.find((track: any) => track.editorType === "表情包");
129
+ const clipData = this.loadClipData(fObj);
130
+
131
+ if (!editorTrack) {
132
+ const index = this.tracks.findIndex((track: any) => track.editorType === "文字");
133
+ for (const item of clipData) {
134
+ if (item !== null) {
135
+ clips.push(item);
136
+ }
137
+ }
138
+
139
+ const data = {
140
+ clips,
141
+ description: "editorEmoji",
142
+ id: uuidv4(),
143
+ type: 1,
144
+ editorType: "表情包",
145
+ };
146
+ if (index !== -1) {
147
+ this.tracks.splice(index, 0, data);
148
+ } else {
149
+ this.tracks.push(data);
150
+ }
151
+ } else {
152
+ for (const item of clipData) {
153
+ if (item !== null) {
154
+ editorTrack.clips.push(item);
155
+ }
156
+ }
157
+ }
158
+
159
+ console.log("addVideoClip 最终vmml", this.vmml);
160
+ }
161
+
162
+ /**
163
+ * 更新 clip
164
+ * @param fObj - 画布fObj
165
+ */
166
+ public updateClip(fObj: any) {
167
+ console.log("updateClip fObj", fObj);
168
+ const posParam = this.setPosParam(fObj);
169
+ const {
170
+ // clipData: { id, type, lineSpacing },
171
+ clipData: { id, type, lineSpacing, originClip },
172
+ } = fObj;
173
+ let existClip = null;
174
+ // 模板内可编辑的clip
175
+ if (originClip) {
176
+ existClip = originClip;
177
+ } else {
178
+ const editorTrack = this.tracks.find((track: any) => track.editorType === type);
179
+ existClip = (editorTrack?.clips || []).find((clip: any) => clip.id === id);
180
+ }
181
+
182
+ if (existClip) {
183
+ //修改现有clip的代码
184
+ !originClip && (existClip.fObj = fObj);
185
+ if (type === "表情包") {
186
+ existClip.videoClip.posParam = posParam;
187
+ } else if (type === "文字") {
188
+ const { clipData: { text, textColor, bgColor}} = fObj;
189
+ const scale = this.fontSize / 22;
190
+ existClip.textClip = {
191
+ ...existClip.textClip,
192
+ posParam,
193
+ backgroundColor: this.toARGB(bgColor),
194
+ textContent: text,
195
+ textColor: this.toARGB(textColor),
196
+ dimension: {
197
+ width: Math.floor(fObj.width * scale),
198
+ height: Math.floor(fObj.height * scale),
199
+ }
200
+ };
201
+ }
202
+ }
203
+
204
+ console.log("updateClip 最终vmml", this.vmml);
205
+ }
206
+
207
+ /**
208
+ * 删除 Clip
209
+ * @param id - 实例 id
210
+ * @param type - 实例 类型
211
+ */
212
+ public deleteClip({ id, type, originClip }: { id: string; type: string, originClip: any }): void {
213
+ // 模板内的可编辑clip
214
+ if (originClip) {
215
+ originClip.duration = 0;
216
+ } else {
217
+ const editorTrack = this.tracks.find((track: any) => track.editorType === type);
218
+ const index = editorTrack.clips.findIndex((item: any) => item.id === id);
219
+
220
+ if (index !== -1) {
221
+ if (editorTrack.clips[index + 1] && editorTrack.clips[index + 1].audioClip) {
222
+ editorTrack.clips.splice(index, 2); // 删除当前元素及下一个 audio 元素
223
+ } else {
224
+ editorTrack.clips.splice(index, 1); // 只删除当前元素
225
+ }
226
+ }
227
+
228
+ if (editorTrack.clips.length === 0) {
229
+ const trackIndex = this.tracks.indexOf(editorTrack);
230
+ if (trackIndex !== -1) {
231
+ this.tracks.splice(trackIndex, 1);
232
+ }
233
+ }
234
+ }
235
+
236
+ console.log("deleteClip 最终Vmml", this.vmml);
237
+ }
238
+
239
+ //切换静音 视频/音频
240
+ public changeMute({ id, isMute }: { id: string; isMute: boolean }): void {
241
+ const editorTrack = this.tracks.find((track: any) => track.editorType === "表情包");
242
+ const index = editorTrack.clips.findIndex((item: any) => item.id === id);
243
+ if (index !== -1) {
244
+ const volume = isMute ? 0 : 1;
245
+
246
+ // 设置当前元素的 volume 为 0
247
+ editorTrack.clips[index].videoClip.volume = volume;
248
+ // 检查下一个元素是否存在且类型为 audio
249
+ if (editorTrack.clips[index + 1] && editorTrack.clips[index + 1].audioClip) {
250
+ editorTrack.clips[index + 1].audioClip.volume = volume;
251
+ }
252
+ }
253
+
254
+ console.log("changeMute 最终Vmml", this.vmml);
255
+ }
256
+
257
+ //加载clip数据
258
+ private loadClipData(fObj: any): any {
259
+ const posParam = this.setPosParam(fObj);
260
+ const {
261
+ clipData: { id, inPoint, fileUrl },
262
+ width,
263
+ height,
264
+ } = fObj;
265
+ let aClipData = null;
266
+
267
+ // 计算表情包可用的最大时长
268
+ const availableDuration = this.vmml.template.duration - inPoint;
269
+ const _duration = fileUrl.duration > availableDuration ? availableDuration : fileUrl.duration;
270
+
271
+ const vClipData = {
272
+ duration: _duration,
273
+ originDuration: fileUrl.duration,
274
+ start: 0,
275
+ end: _duration,
276
+ inPoint,
277
+ type: 102,
278
+ id,
279
+ fObj,
280
+ videoClip: {
281
+ grayMaskUrl: fileUrl.grayMaskUrl,
282
+ webmUrl: fileUrl.webmUrl,
283
+ movUrl: fileUrl.movUrl,
284
+ gifUrl: fileUrl.visibleUrl,
285
+ dimension: {
286
+ height,
287
+ width,
288
+ },
289
+ posParam,
290
+ constantSpeed: 1,
291
+ volume: 1,
292
+ sourceUrl: fileUrl.visibleUrl,
293
+ thumbnailSourceUrl: fileUrl.thumbnailUrl,
294
+ sourceCode: "",
295
+ mimeType: fileUrl.visiableMimeType,
296
+ },
297
+ };
298
+ //png/mp4 + mp3音频
299
+ if (fileUrl.hasAudio === "true") {
300
+ aClipData = {
301
+ duration: _duration,
302
+ originDuration: fileUrl.duration,
303
+ start: 0,
304
+ end: _duration,
305
+ inPoint,
306
+ type: 304,
307
+ id: uuidv4(),
308
+ audioClip: {
309
+ sourceUrl: fileUrl.soundUrl,
310
+ volume: 1,
311
+ constantSpeed: 1,
312
+ },
313
+ };
314
+ }
315
+
316
+ return [vClipData, aClipData];
317
+ }
318
+
319
+ /**
320
+ * 将颜色转换为 ARGB 格式
321
+ * @param color - 颜色字符串
322
+ * @returns ARGB 颜色字符串
323
+ */
324
+ private toARGB(color: string) {
325
+ if (color === "transparent") {
326
+ return "#00000000";
327
+ }
328
+
329
+ if (color.includes("rgb")) {
330
+ return rgbaToArgb(color);
331
+ }
332
+
333
+ if (color.includes("#")) {
334
+ return hexToArgb(color);
335
+ }
336
+ }
337
+ }
338
+
339
+ export default VmmlConverter;
@@ -0,0 +1,10 @@
1
+ export const backIcon = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/9f7e2aae-06a3-4f46-8601-a53dd2b70b7b.png";
2
+ export const closeIcon = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/c5dc68e4-2db5-4ea6-ada6-191bb9128a4c.png";
3
+ export const nextIcon = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/ba4e4ece-ec5e-43a2-9a21-08a9a648a5f2.png";
4
+ export const wordIcon = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/6ba37577-5fef-4722-9a61-f7ab5e5790ad.png";
5
+ export const emotionIcon = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/a6bb2684-4f26-481a-81dd-90500c1f7544.png";
6
+ export const playIcon = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/9c6d947f-071a-4875-9546-fd0dce371928.png";
7
+ export const pauseIcon = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/65076da8-c1e4-42a9-90af-839a2e42ace7.png";
8
+ export const iconText2 = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/f50a1be1-512b-49f5-8be5-61b85c7cbb66.png";
9
+ export const iconText1 = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/f9071de5-48d0-42ca-8b9a-13ae19ed48e3.png";
10
+ export const signIcon = "https://mass.alipay.com/finmedia_versaassets/uri/file/as/bcbd9a71-ec40-4c4d-ba03-29c2bec2bf89.png";
@@ -0,0 +1,199 @@
1
+ import { isIOS } from "@versa_ai/vmml-utils";
2
+ const defaultBottom = "20px";
3
+ //判断安卓版本
4
+ export const checkIfAndroidAndVersion = () => {
5
+ const userAgent = navigator.userAgent;
6
+ const androidPattern = /Android\s([0-9\.]*)/;
7
+
8
+ const match = userAgent.match(androidPattern);
9
+
10
+ if (match) {
11
+ const version = match[1];
12
+ console.log(`This is an Android device with version ${version}`);
13
+ return version;
14
+ }
15
+ };
16
+ const checkIPhoneSeries = () => {
17
+ const screenWidth = window.screen.width;
18
+ const screenHeight = window.screen.height;
19
+ // console.info('screen size',`screenWidth:${screenWidth},screenHeight:${screenHeight}`)
20
+ if (screenWidth === 320 && screenHeight === 480) {
21
+ return "iPhone 4 or 4S";
22
+ } else if (screenWidth === 375 && screenHeight === 667) {
23
+ return "iPhone 6/7/8";
24
+ } else if (screenWidth === 414 && screenHeight === 736) {
25
+ return "iPhone 6/7/8 Plus";
26
+ } else if (screenWidth === 375 && screenHeight === 812) {
27
+ return "iPhone X/XS";
28
+ } else if (screenWidth === 414 && screenHeight === 896) {
29
+ return "iPhone XR/11";
30
+ } else if (screenWidth === 414 && screenHeight === 844) {
31
+ return "iPhone 12 mini";
32
+ } else if (screenWidth === 414 && screenHeight === 915) {
33
+ return "iPhone 13 Pro Max/12 Pro Max";
34
+ } else if (screenWidth === 390 && screenHeight === 844) {
35
+ return "iPhone 13 mini";
36
+ } else {
37
+ return "Unknown iPhone model";
38
+ }
39
+ };
40
+ export const getIOSVersion = () => {
41
+ if (isIOS()) {
42
+ const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/);
43
+ if (match) {
44
+ const version = `${match[1]}.${match[2]}`;
45
+ return version;
46
+ }
47
+ }
48
+ return null;
49
+ };
50
+ const iPhoneAdapter = (series: string) => {
51
+ const seriesArr = ["iPhone 6/7/8 Plus", "iPhone 6/7/8"];
52
+ return seriesArr.includes(series);
53
+ };
54
+ //判断当前版本是否需要做适配
55
+ export const canAdapter = () => {
56
+ const versionArr = ["12"];
57
+ const version = checkIfAndroidAndVersion();
58
+ if (version !== undefined) {
59
+ return versionArr.includes(version);
60
+ }
61
+ };
62
+ // ios和Android键盘兼容
63
+ // 目前测试设备中键盘唤起不会触发resize事件,所以需要监听focusin事件,所以watchKeyBoard不存在系统区分
64
+ export const watchKeyBoard = (callback: (isShow: boolean) => void) => {
65
+ const focusInHandler = (e: FocusEvent) => {
66
+ callback(true);
67
+ stopMove();
68
+ };
69
+
70
+ const focusOutHandler = (e: FocusEvent) => {
71
+ callback(false);
72
+ };
73
+
74
+ document.body.addEventListener("focusin", focusInHandler);
75
+ document.body.addEventListener("focusout", focusOutHandler);
76
+
77
+ // 返回一个函数,用于移除事件监听器
78
+ return () => {
79
+ document.body.removeEventListener("focusin", focusInHandler);
80
+ document.body.removeEventListener("focusout", focusOutHandler);
81
+ };
82
+ };
83
+ export const stopMove = () => {
84
+ const dom = document.getElementsByClassName("overlay")[0];
85
+ const element = document.querySelector(".mappingarea") as HTMLElement;
86
+
87
+ const stopScroll = (e: any) => {
88
+ const lastTouchPositionX = 0;
89
+ const lastTouchPositionY = 0;
90
+ // 排除可以滚动的区域
91
+ const currentTouchPositionX = e.touches[0].clientX;
92
+ const currentTouchPositionY = e.touches[0].clientY;
93
+
94
+ const deltaX = currentTouchPositionX - lastTouchPositionX;
95
+ const deltaY = currentTouchPositionY - lastTouchPositionY;
96
+ if (["text-header", "overlay", "text_bg_change"].includes(e.target?.className)) {
97
+ e.preventDefault(); // 阻止纵向滚动
98
+ // if (Math.abs(deltaY) > Math.abs(deltaX)) {
99
+ // e.preventDefault(); // 阻止纵向滚动
100
+ // }
101
+ }
102
+ if (element) {
103
+ const willOverflow = element.scrollHeight > element.clientHeight;
104
+ if (willOverflow) {
105
+ return;
106
+ } else if (e.target?.className.split(" ").includes("color-item") || e.target?.className === "text_color_change") {
107
+ if (deltaX < 0 || deltaX > 0) {
108
+ // 允许横向滚动
109
+ return;
110
+ } else {
111
+ e.preventDefault(); // 阻止非横向滚动
112
+ }
113
+ } else {
114
+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
115
+ e.preventDefault(); // 阻止其他元素的纵向滚动
116
+ }
117
+ }
118
+ }
119
+ e.preventDefault();
120
+ };
121
+ if (dom) {
122
+ dom.addEventListener("touchmove", stopScroll);
123
+ }
124
+ };
125
+ export const scrollToTop = () => {
126
+ window.scrollTo(0, 0);
127
+ };
128
+
129
+ export const onKeyBoardAction = (
130
+ status: boolean,
131
+ headerRef: HTMLDivElement | null,
132
+ textareaRef: HTMLTextAreaElement | null,
133
+ coverRef: HTMLDivElement | null,
134
+ mappingareaRef: HTMLDivElement | null,
135
+ aimTop?: number,
136
+ ) => {
137
+ //IOS适配
138
+ if (isIOS()) {
139
+ forIos(status, headerRef, textareaRef, coverRef, mappingareaRef);
140
+ }
141
+ //Android适配
142
+ else {
143
+ // forAndroid(currentScrollY, status, headerRef, textareaRef, coverRef, mappingareaRef);
144
+ }
145
+ };
146
+
147
+ //方案一
148
+ const forIos = (
149
+ status: boolean,
150
+ headerRef: HTMLDivElement | null,
151
+ textareaRef: HTMLTextAreaElement | null,
152
+ coverRef: HTMLDivElement | null,
153
+ mappingareaRef: HTMLDivElement | null,
154
+ ) => {
155
+ const series = checkIPhoneSeries();
156
+ const isIPhoneAdapt = iPhoneAdapter(series);
157
+ //键盘展开
158
+ if (status) {
159
+ const totalHeight = document.documentElement.clientHeight || document.body.clientHeight;
160
+ //ios键盘高度约为屏幕的25%
161
+ const keyBoardHeight = totalHeight * 0.25;
162
+ if (coverRef && textareaRef) {
163
+ const textareaRefPos = textareaRef.getBoundingClientRect()
164
+ const { height } = textareaRefPos
165
+ if (isIPhoneAdapt) {
166
+ coverRef.style.bottom = `${keyBoardHeight + 50}px`
167
+ } else {
168
+ coverRef.style.transform = `translateY(${-(height + 10)}px)`
169
+ }
170
+ }
171
+ } else {
172
+ if (coverRef) {
173
+ if (isIPhoneAdapt) {
174
+ coverRef.style.bottom = `8vw`
175
+ } else {
176
+ coverRef.style.transform = `translateY(0px)`
177
+ }
178
+
179
+ }
180
+ }
181
+ };
182
+ const forAndroid = (
183
+ currentScrollY: number,
184
+ status: boolean,
185
+ headerRef: HTMLDivElement | null,
186
+ textareaRef: HTMLTextAreaElement | null,
187
+ coverRef: HTMLDivElement | null,
188
+ mappingareaRef: HTMLDivElement | null,
189
+ ) => {
190
+ const updatePosition = () => {
191
+ if (mappingareaRef && textareaRef) {
192
+ const rect = textareaRef.getBoundingClientRect();
193
+ mappingareaRef.style.top = `${rect.top}px`;
194
+ mappingareaRef.style.left = `${rect.left}px`;
195
+ }
196
+ }
197
+ window.addEventListener('scroll', updatePosition);
198
+ window.addEventListener('resize', updatePosition);
199
+ };