@tmagic/stage 1.0.0-beta.1

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.
@@ -0,0 +1,471 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making TMagicEditor available.
3
+ *
4
+ * Copyright (C) 2021 THL A29 Limited, a Tencent company. All rights reserved.
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+
19
+ /* eslint-disable no-param-reassign */
20
+ import { EventEmitter } from 'events';
21
+
22
+ import type { MoveableOptions } from 'moveable';
23
+ import Moveable from 'moveable';
24
+
25
+ import { GHOST_EL_ID_PREFIX } from './const';
26
+ import StageCore from './StageCore';
27
+ import type { SortEventData, StageDragResizeConfig } from './types';
28
+ import { getAbsolutePosition, getMode, getOffset, Mode } from './util';
29
+
30
+ enum ActionStatus {
31
+ START = 'start',
32
+ ING = 'ing',
33
+ END = 'end',
34
+ }
35
+
36
+ /**
37
+ * 选中框
38
+ */
39
+ export default class StageDragResize extends EventEmitter {
40
+ public core: StageCore;
41
+ public container: HTMLElement;
42
+ public target?: HTMLElement;
43
+ public moveable?: Moveable;
44
+
45
+ private dragStatus: ActionStatus = ActionStatus.END;
46
+ private elObserver?: ResizeObserver;
47
+ private ghostEl: HTMLElement | undefined;
48
+ private horizontalGuidelines: number[] = [];
49
+ private verticalGuidelines: number[] = [];
50
+ private mode: Mode = Mode.ABSOLUTE;
51
+
52
+ constructor(config: StageDragResizeConfig) {
53
+ super();
54
+
55
+ this.core = config.core;
56
+ this.container = config.container;
57
+ this.initObserver();
58
+ }
59
+
60
+ /**
61
+ * 将选中框渲染并覆盖到选中的组件Dom节点上方
62
+ * @param el 选中组件的Dom节点元素
63
+ */
64
+ public async select(el: HTMLElement): Promise<void> {
65
+ if (this.target === el) {
66
+ this.refresh();
67
+ return;
68
+ }
69
+
70
+ this.moveable?.destroy();
71
+
72
+ this.target = el;
73
+ this.mode = getMode(el);
74
+
75
+ const options = await this.getOptions();
76
+
77
+ this.moveable = new Moveable(this.container, options);
78
+ this.bindResizeEvent();
79
+ this.bindDragEvent();
80
+
81
+ this.syncRect(el);
82
+ }
83
+
84
+ /**
85
+ * 初始化选中框并渲染出来
86
+ * @param param0
87
+ */
88
+
89
+ public async refresh() {
90
+ const options = await this.getOptions();
91
+ Object.entries(options).forEach(([key, value]) => {
92
+ (this.moveable as any)[key] = value;
93
+ });
94
+ this.updateMoveableTarget();
95
+ }
96
+
97
+ public async setVGuidelines(verticalGuidelines: number[]): Promise<void> {
98
+ this.verticalGuidelines = verticalGuidelines;
99
+ this.target && (await this.select(this.target));
100
+ }
101
+
102
+ public async setHGuidelines(horizontalGuidelines: number[]): Promise<void> {
103
+ this.horizontalGuidelines = horizontalGuidelines;
104
+ this.target && (await this.select(this.target));
105
+ }
106
+
107
+ public updateMoveableTarget(target?: HTMLElement): void {
108
+ if (!this.moveable) throw new Error('为初始化目标');
109
+
110
+ if (target) {
111
+ this.moveable.target = target;
112
+ }
113
+
114
+ if (this.target) {
115
+ this.mode = getMode(this.target);
116
+ }
117
+
118
+ this.moveable.updateTarget();
119
+ }
120
+
121
+ /**
122
+ * 销毁实例
123
+ */
124
+ public destroy(): void {
125
+ this.destroyGhostEl();
126
+ this.moveable?.destroy();
127
+ this.dragStatus = ActionStatus.END;
128
+ this.elObserver?.disconnect();
129
+ this.removeAllListeners();
130
+ }
131
+
132
+ private bindResizeEvent(): void {
133
+ if (!this.moveable) throw new Error('moveable 为初始化');
134
+
135
+ const frame = {
136
+ translate: [0, 0],
137
+ };
138
+
139
+ this.moveable
140
+ .on('resizeStart', (e) => {
141
+ if (e.dragStart) {
142
+ const rect = this.moveable!.getRect();
143
+ const offset = getAbsolutePosition(e.target as HTMLElement, rect);
144
+ e.dragStart.set([offset.left, offset.top]);
145
+
146
+ if (this.ghostEl) {
147
+ this.destroyGhostEl();
148
+ this.updateMoveableTarget(this.target);
149
+ }
150
+ }
151
+ })
152
+ .on('resize', ({ target, width, height, drag }) => {
153
+ if (!this.moveable) return;
154
+ if (!this.target) return;
155
+ const { beforeTranslate } = drag;
156
+ frame.translate = beforeTranslate;
157
+ this.dragStatus = ActionStatus.ING;
158
+
159
+ target.style.width = `${width}px`;
160
+ target.style.height = `${height}px`;
161
+
162
+ if ([Mode.ABSOLUTE, Mode.FIXED].includes(this.mode)) {
163
+ target.style.left = `${beforeTranslate[0]}px`;
164
+ target.style.top = `${beforeTranslate[1]}px`;
165
+ }
166
+ })
167
+ .on('resizeEnd', ({ target }) => {
168
+ this.dragStatus = ActionStatus.END;
169
+
170
+ const rect = this.moveable!.getRect();
171
+ const offset = getAbsolutePosition(target as HTMLElement, rect);
172
+
173
+ this.updateMoveableTarget(this.target);
174
+
175
+ this.emit('update', {
176
+ el: this.target,
177
+ style: {
178
+ width: this.calcValueByFontsize(rect.width),
179
+ height: this.calcValueByFontsize(rect.height),
180
+ position: this.target?.style.position,
181
+ ...(this.mode === Mode.SORTABLE
182
+ ? {}
183
+ : {
184
+ left: this.calcValueByFontsize(offset.left),
185
+ top: this.calcValueByFontsize(offset.top),
186
+ }),
187
+ },
188
+ });
189
+ });
190
+ }
191
+
192
+ private bindDragEvent(): void {
193
+ if (!this.moveable) throw new Error('moveable 为初始化');
194
+
195
+ this.moveable
196
+ .on('dragStart', () => {
197
+ if (!this.target) throw new Error('未选中组件');
198
+
199
+ this.dragStatus = ActionStatus.START;
200
+
201
+ if (this.mode === Mode.SORTABLE) {
202
+ this.ghostEl = this.generateGhostEl(this.target);
203
+ this.updateMoveableTarget(this.ghostEl);
204
+ }
205
+ })
206
+ .on('drag', ({ target, left, top }) => {
207
+ this.dragStatus = ActionStatus.ING;
208
+ if (this.mode === Mode.SORTABLE && (!this.ghostEl || target !== this.ghostEl)) {
209
+ return;
210
+ }
211
+
212
+ if ([Mode.ABSOLUTE, Mode.FIXED].includes(this.mode)) {
213
+ target.style.left = `${left}px`;
214
+ target.style.top = `${top}px`;
215
+ } else if (this.target) {
216
+ const offset = getAbsolutePosition(this.target, getOffset(this.target));
217
+ target.style.top = `${offset.top + top}px`;
218
+ }
219
+ })
220
+ .on('dragEnd', () => {
221
+ // 点击不拖动时会触发dragStart和dragEnd,但是不会有drag事件
222
+ if (this.dragStatus !== ActionStatus.ING) {
223
+ return;
224
+ }
225
+
226
+ if (!this.target) return;
227
+
228
+ this.dragStatus = ActionStatus.END;
229
+ this.updateMoveableTarget(this.target);
230
+
231
+ switch (this.mode) {
232
+ case Mode.SORTABLE:
233
+ this.sort();
234
+ break;
235
+ default:
236
+ this.drag();
237
+ }
238
+
239
+ this.destroyGhostEl();
240
+ });
241
+ }
242
+
243
+ private async getSnapElements(el: HTMLElement): Promise<HTMLElement[]> {
244
+ const { renderer } = this.core;
245
+ const getSnapElements =
246
+ (await renderer.getRuntime())?.getSnapElements ||
247
+ (() => {
248
+ const doc = renderer.contentWindow?.document;
249
+ const elementGuidelines = (doc ? Array.from(doc.querySelectorAll('[id]')) : [])
250
+ // 排除掉当前组件本身
251
+ .filter((element) => element !== this.target && !this.target?.contains(element));
252
+ return elementGuidelines as HTMLElement[];
253
+ });
254
+ return getSnapElements(el);
255
+ }
256
+
257
+ private sort(): void {
258
+ if (!this.target || !this.ghostEl) throw new Error('未知错误');
259
+ const { top } = this.ghostEl.getBoundingClientRect();
260
+ const { top: oriTop } = this.target.getBoundingClientRect();
261
+ const deltaTop = top - oriTop;
262
+ if (Math.abs(deltaTop) >= this.target.clientHeight / 2) {
263
+ if (deltaTop > 0) {
264
+ this.emit('sort', down(deltaTop, this.target));
265
+ } else {
266
+ this.emit('sort', up(deltaTop, this.target));
267
+ }
268
+ } else {
269
+ this.emit('sort', {
270
+ src: this.target.id,
271
+ dist: this.target.id,
272
+ });
273
+ }
274
+ }
275
+
276
+ private drag(): void {
277
+ const rect = this.moveable!.getRect();
278
+ const offset = getAbsolutePosition(this.target as HTMLElement, rect);
279
+
280
+ this.emit('update', {
281
+ el: this.target,
282
+ style: {
283
+ left: this.calcValueByFontsize(this.mode === Mode.FIXED ? rect.left : offset.left),
284
+ top: this.calcValueByFontsize(this.mode === Mode.FIXED ? rect.top : offset.top),
285
+ width: this.calcValueByFontsize(rect.width),
286
+ height: this.calcValueByFontsize(rect.height),
287
+ },
288
+ });
289
+ }
290
+
291
+ private generateGhostEl(el: HTMLElement): HTMLElement {
292
+ if (this.ghostEl) {
293
+ this.destroyGhostEl();
294
+ }
295
+
296
+ const ghostEl = el.cloneNode(true) as HTMLElement;
297
+ const { top, left } = getAbsolutePosition(el, getOffset(el));
298
+ ghostEl.id = `${GHOST_EL_ID_PREFIX}${ghostEl.id}`;
299
+ ghostEl.style.zIndex = '5';
300
+ ghostEl.style.opacity = '.5';
301
+ ghostEl.style.position = 'absolute';
302
+ ghostEl.style.left = `${left}px`;
303
+ ghostEl.style.top = `${top}px`;
304
+ el.after(ghostEl);
305
+ return ghostEl;
306
+ }
307
+
308
+ private destroyGhostEl(): void {
309
+ this.ghostEl?.remove();
310
+ this.ghostEl = undefined;
311
+ }
312
+
313
+ private async getOptions(options: MoveableOptions = {}): Promise<MoveableOptions> {
314
+ if (!this.target) return {};
315
+
316
+ const isSortable = this.mode === Mode.SORTABLE;
317
+ const { config, renderer, mask } = this.core;
318
+ const { iframe } = renderer;
319
+
320
+ let { moveableOptions = {} } = config;
321
+
322
+ if (typeof moveableOptions === 'function') {
323
+ moveableOptions = moveableOptions(this.core);
324
+ }
325
+
326
+ const boundsOptions = {
327
+ top: 0,
328
+ left: 0,
329
+ right: iframe?.clientWidth,
330
+ bottom: this.mode === Mode.FIXED ? iframe?.clientHeight : mask.page?.clientHeight,
331
+ ...(moveableOptions.bounds || {}),
332
+ };
333
+
334
+ return {
335
+ target: this.target,
336
+ scrollable: true,
337
+ origin: true,
338
+ zoom: 1,
339
+ dragArea: true,
340
+ draggable: true,
341
+ resizable: true,
342
+ snappable: !isSortable,
343
+ snapGap: !isSortable,
344
+ snapElement: !isSortable,
345
+ snapVertical: !isSortable,
346
+ snapHorizontal: !isSortable,
347
+ snapCenter: !isSortable,
348
+ container: renderer.contentWindow?.document.body,
349
+
350
+ elementGuidelines: isSortable ? [] : await this.getSnapElements(this.target),
351
+ horizontalGuidelines: this.horizontalGuidelines,
352
+ verticalGuidelines: this.verticalGuidelines,
353
+
354
+ bounds: boundsOptions,
355
+ ...options,
356
+ ...moveableOptions,
357
+ };
358
+ }
359
+
360
+ private initObserver(): void {
361
+ if (typeof ResizeObserver === 'undefined') {
362
+ return;
363
+ }
364
+
365
+ this.elObserver = new ResizeObserver(() => {
366
+ const doc = this.core.renderer.contentWindow?.document;
367
+ if (!doc || !this.target || !this.moveable) return;
368
+
369
+ /** 组件可能已经重新渲染了,所以需要重新获取新的dom */
370
+ const target = doc.getElementById(this.target.id);
371
+
372
+ if (this.ghostEl) {
373
+ this.destroyGhostEl();
374
+ }
375
+
376
+ if (target && target !== this.target) {
377
+ this.syncRect(target);
378
+ this.target = target;
379
+ }
380
+
381
+ this.updateMoveableTarget(this.target);
382
+ });
383
+ }
384
+
385
+ private syncRect(el: HTMLElement): void {
386
+ this.elObserver?.disconnect();
387
+ this.elObserver?.observe(el);
388
+ }
389
+
390
+ private calcValueByFontsize(value: number) {
391
+ const { contentWindow } = this.core.renderer;
392
+ const fontSize = contentWindow?.document.documentElement.style.fontSize;
393
+
394
+ if (fontSize) {
395
+ const times = globalThis.parseFloat(fontSize) / 100;
396
+ return value / times;
397
+ }
398
+
399
+ return value;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * 下移组件位置
405
+ * @param {number} deltaTop 偏移量
406
+ * @param {Object} detail 当前选中的组件配置
407
+ */
408
+ export const down = (deltaTop: number, target: HTMLElement | SVGElement): SortEventData | void => {
409
+ let swapIndex = 0;
410
+ let addUpH = target.clientHeight;
411
+ const brothers = Array.from(target.parentNode?.children || []).filter(
412
+ (node) => !node.id.startsWith(GHOST_EL_ID_PREFIX),
413
+ );
414
+ const index = brothers.indexOf(target);
415
+ // 往下移动
416
+ const downEls = brothers.slice(index + 1) as HTMLElement[];
417
+
418
+ for (let i = 0; i < downEls.length; i++) {
419
+ const ele = downEls[i];
420
+ // 是 fixed 不做处理
421
+ if (ele.style?.position === 'fixed') {
422
+ continue;
423
+ }
424
+ addUpH += ele.clientHeight / 2;
425
+ if (deltaTop <= addUpH) {
426
+ break;
427
+ }
428
+ addUpH += ele.clientHeight / 2;
429
+ swapIndex = i;
430
+ }
431
+ return {
432
+ src: target.id,
433
+ dist: downEls.length && swapIndex > -1 ? downEls[swapIndex].id : target.id,
434
+ };
435
+ };
436
+
437
+ /**
438
+ * 上移组件位置
439
+ * @param {Array} brothers 处于同一容器下的所有子组件配置
440
+ * @param {number} index 当前组件所处的位置
441
+ * @param {number} deltaTop 偏移量
442
+ * @param {Object} detail 当前选中的组件配置
443
+ */
444
+ export const up = (deltaTop: number, target: HTMLElement | SVGElement): SortEventData | void => {
445
+ const brothers = Array.from(target.parentNode?.children || []).filter(
446
+ (node) => !node.id.startsWith(GHOST_EL_ID_PREFIX),
447
+ );
448
+ const index = brothers.indexOf(target);
449
+ // 往上移动
450
+ const upEls = brothers.slice(0, index) as HTMLElement[];
451
+
452
+ let addUpH = target.clientHeight;
453
+ let swapIndex = upEls.length - 1;
454
+
455
+ for (let i = upEls.length - 1; i >= 0; i--) {
456
+ const ele = upEls[i];
457
+ if (!ele) continue;
458
+ // 是 fixed 不做处理
459
+ if (ele.style.position === 'fixed') continue;
460
+
461
+ addUpH += ele.clientHeight / 2;
462
+ if (-deltaTop <= addUpH) break;
463
+ addUpH += ele.clientHeight / 2;
464
+
465
+ swapIndex = i;
466
+ }
467
+ return {
468
+ src: target.id,
469
+ dist: upEls.length && swapIndex > -1 ? upEls[swapIndex].id : target.id,
470
+ };
471
+ };