@tmagic/stage 1.4.8 → 1.4.9

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,337 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making TMagicEditor available.
3
+ *
4
+ * Copyright (C) 2023 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
+ import { createDiv, getDocument, injectStyle } from '@tmagic/utils';
20
+
21
+ import { Mode, ZIndex } from './const';
22
+ import Rule from './Rule';
23
+ import type { MaskEvents, RuleOptions } from './types';
24
+ import { getScrollParent, isFixedParent } from './util';
25
+
26
+ const wrapperClassName = 'editor-mask-wrapper';
27
+
28
+ const hideScrollbar = () => {
29
+ injectStyle(getDocument(), `.${wrapperClassName}::-webkit-scrollbar { width: 0 !important; display: none }`);
30
+ };
31
+
32
+ const createContent = (): HTMLDivElement =>
33
+ createDiv({
34
+ className: 'editor-mask',
35
+ cssText: `
36
+ position: absolute;
37
+ top: 0;
38
+ left: 0;
39
+ transform: translate3d(0, 0, 0);
40
+ `,
41
+ });
42
+
43
+ const createWrapper = (): HTMLDivElement => {
44
+ const el = createDiv({
45
+ className: wrapperClassName,
46
+ cssText: `
47
+ position: absolute;
48
+ top: 0;
49
+ left: 0;
50
+ height: 100%;
51
+ width: 100%;
52
+ overflow: hidden;
53
+ z-index: ${ZIndex.MASK};
54
+ `,
55
+ });
56
+
57
+ hideScrollbar();
58
+
59
+ return el;
60
+ };
61
+
62
+ /**
63
+ * 蒙层
64
+ * @description 用于拦截页面的点击动作,避免点击时触发组件自身动作;在编辑器中点击组件应当是选中组件;
65
+ */
66
+ export default class StageMask extends Rule {
67
+ public content: HTMLDivElement = createContent();
68
+ public wrapper: HTMLDivElement;
69
+ public page: HTMLElement | null = null;
70
+ public scrollTop = 0;
71
+ public scrollLeft = 0;
72
+ public width = 0;
73
+ public height = 0;
74
+ public wrapperHeight = 0;
75
+ public wrapperWidth = 0;
76
+ public maxScrollTop = 0;
77
+ public maxScrollLeft = 0;
78
+
79
+ private mode: Mode = Mode.ABSOLUTE;
80
+ private pageScrollParent: HTMLElement | null = null;
81
+ private intersectionObserver: IntersectionObserver | null = null;
82
+ private wrapperResizeObserver: ResizeObserver | null = null;
83
+
84
+ constructor(options?: RuleOptions) {
85
+ const wrapper = createWrapper();
86
+ super(wrapper, options);
87
+
88
+ this.wrapper = wrapper;
89
+
90
+ this.content.addEventListener('wheel', this.mouseWheelHandler);
91
+ this.wrapper.appendChild(this.content);
92
+ }
93
+
94
+ public setMode(mode: Mode) {
95
+ this.mode = mode;
96
+ this.scroll();
97
+
98
+ this.content.dataset.mode = mode;
99
+
100
+ if (mode === Mode.FIXED) {
101
+ this.content.style.width = `${this.wrapperWidth}px`;
102
+ this.content.style.height = `${this.wrapperHeight}px`;
103
+ } else {
104
+ this.content.style.width = `${this.width}px`;
105
+ this.content.style.height = `${this.height}px`;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * 初始化视窗和蒙层监听,监听元素是否在视窗区域、监听mask蒙层所在的wrapper大小变化
111
+ * @description 初始化视窗和蒙层监听
112
+ * @param page 页面Dom节点
113
+ */
114
+ public observe(page: HTMLElement): void {
115
+ if (!page) return;
116
+
117
+ this.page = page;
118
+ this.initObserverIntersection();
119
+ this.initObserverWrapper();
120
+ }
121
+
122
+ /**
123
+ * 处理页面大小变更,同步页面和mask大小
124
+ * @param entries ResizeObserverEntry,获取页面最新大小
125
+ */
126
+ public pageResize(entries: ResizeObserverEntry[]): void {
127
+ const [entry] = entries;
128
+ const { clientHeight, clientWidth } = entry.target;
129
+ this.setHeight(clientHeight);
130
+ this.setWidth(clientWidth);
131
+
132
+ this.scroll();
133
+ }
134
+
135
+ /**
136
+ * 监听一个组件是否在画布可视区域内
137
+ * @param el 被选中的组件,可能是左侧目录树中选中的
138
+ */
139
+ public observerIntersection(el: HTMLElement): void {
140
+ this.intersectionObserver?.observe(el);
141
+ }
142
+
143
+ /**
144
+ * 挂载Dom节点
145
+ * @param el 将蒙层挂载到该Dom节点上
146
+ */
147
+ public mount(el: HTMLDivElement): void {
148
+ if (!this.content) throw new Error('content 不存在');
149
+
150
+ el.appendChild(this.wrapper);
151
+ }
152
+
153
+ public setLayout(el: HTMLElement): void {
154
+ this.setMode(isFixedParent(el) ? Mode.FIXED : Mode.ABSOLUTE);
155
+ }
156
+
157
+ public scrollIntoView(el: Element): void {
158
+ // 不可以有横向滚动
159
+ if (!this.page || el.getBoundingClientRect().left >= this.page.scrollWidth) return;
160
+
161
+ el.scrollIntoView();
162
+ if (!this.pageScrollParent) return;
163
+ this.scrollLeft = this.pageScrollParent.scrollLeft;
164
+ this.scrollTop = this.pageScrollParent.scrollTop;
165
+
166
+ this.scroll();
167
+ }
168
+
169
+ /**
170
+ * 销毁实例
171
+ */
172
+ public destroy(): void {
173
+ this.content?.remove();
174
+ this.page = null;
175
+ this.pageScrollParent = null;
176
+ this.wrapperResizeObserver?.disconnect();
177
+
178
+ super.destroy();
179
+ }
180
+
181
+ public on<Name extends keyof MaskEvents, Param extends MaskEvents[Name]>(
182
+ eventName: Name,
183
+ listener: (...args: Param) => void | Promise<void>,
184
+ ) {
185
+ return super.on(eventName, listener as any);
186
+ }
187
+
188
+ public emit<Name extends keyof MaskEvents, Param extends MaskEvents[Name]>(eventName: Name, ...args: Param) {
189
+ return super.emit(eventName, ...args);
190
+ }
191
+
192
+ /**
193
+ * 监听选中元素是否在画布可视区域内,如果目标元素不在可视区域内,通过滚动使该元素出现在可视区域
194
+ */
195
+ private initObserverIntersection(): void {
196
+ this.pageScrollParent = getScrollParent(this.page as HTMLElement) || null;
197
+ this.intersectionObserver?.disconnect();
198
+
199
+ if (typeof IntersectionObserver !== 'undefined') {
200
+ this.intersectionObserver = new IntersectionObserver(
201
+ (entries) => {
202
+ entries.forEach((entry) => {
203
+ const { target, intersectionRatio } = entry;
204
+ if (intersectionRatio <= 0) {
205
+ this.scrollIntoView(target);
206
+ }
207
+ this.intersectionObserver?.unobserve(target);
208
+ });
209
+ },
210
+ {
211
+ root: this.pageScrollParent,
212
+ rootMargin: '0px',
213
+ threshold: 1.0,
214
+ },
215
+ );
216
+ }
217
+ }
218
+
219
+ /**
220
+ * 监听mask的容器大小变化
221
+ */
222
+ private initObserverWrapper(): void {
223
+ this.wrapperResizeObserver?.disconnect();
224
+ if (typeof ResizeObserver !== 'undefined') {
225
+ this.wrapperResizeObserver = new ResizeObserver((entries) => {
226
+ const [entry] = entries;
227
+ const { clientHeight, clientWidth } = entry.target;
228
+ this.wrapperHeight = clientHeight;
229
+ this.wrapperWidth = clientWidth;
230
+ this.setMaxScrollLeft();
231
+ this.setMaxScrollTop();
232
+ });
233
+ this.wrapperResizeObserver.observe(this.wrapper);
234
+ }
235
+ }
236
+
237
+ private scroll() {
238
+ this.fixScrollValue();
239
+
240
+ let { scrollLeft, scrollTop } = this;
241
+
242
+ if (this.pageScrollParent) {
243
+ this.pageScrollParent.scrollTo({
244
+ top: scrollTop,
245
+ left: scrollLeft,
246
+ });
247
+ }
248
+
249
+ if (this.mode === Mode.FIXED) {
250
+ scrollLeft = 0;
251
+ scrollTop = 0;
252
+ }
253
+
254
+ this.scrollRule(scrollTop);
255
+ this.scrollTo(scrollLeft, scrollTop);
256
+ }
257
+
258
+ private scrollTo(scrollLeft: number, scrollTop: number): void {
259
+ this.content.style.transform = `translate3d(${-scrollLeft}px, ${-scrollTop}px, 0)`;
260
+
261
+ const event = new CustomEvent<{
262
+ scrollLeft: number;
263
+ scrollTop: number;
264
+ }>('customScroll', {
265
+ detail: {
266
+ scrollLeft: this.scrollLeft,
267
+ scrollTop: this.scrollTop,
268
+ },
269
+ });
270
+ this.content.dispatchEvent(event);
271
+ }
272
+
273
+ /**
274
+ * 设置蒙层高度
275
+ * @param height 高度
276
+ */
277
+ private setHeight(height: number): void {
278
+ this.height = height;
279
+ this.setMaxScrollTop();
280
+ this.content.style.height = `${height}px`;
281
+ }
282
+
283
+ /**
284
+ * 设置蒙层宽度
285
+ * @param width 宽度
286
+ */
287
+ private setWidth(width: number): void {
288
+ this.width = width;
289
+ this.setMaxScrollLeft();
290
+ this.content.style.width = `${width}px`;
291
+ }
292
+
293
+ /**
294
+ * 计算并设置最大滚动宽度
295
+ */
296
+ private setMaxScrollLeft(): void {
297
+ this.maxScrollLeft = Math.max(this.width - this.wrapperWidth, 0);
298
+ }
299
+
300
+ /**
301
+ * 计算并设置最大滚动高度
302
+ */
303
+ private setMaxScrollTop(): void {
304
+ this.maxScrollTop = Math.max(this.height - this.wrapperHeight, 0);
305
+ }
306
+
307
+ /**
308
+ * 修复滚动距离
309
+ * 由于滚动容器变化等因素,会导致当前滚动的距离不正确
310
+ */
311
+ private fixScrollValue(): void {
312
+ if (this.scrollTop < 0) this.scrollTop = 0;
313
+ if (this.scrollLeft < 0) this.scrollLeft = 0;
314
+ if (this.maxScrollTop < this.scrollTop) this.scrollTop = this.maxScrollTop;
315
+ if (this.maxScrollLeft < this.scrollLeft) this.scrollLeft = this.maxScrollLeft;
316
+ }
317
+
318
+ private mouseWheelHandler = (event: WheelEvent) => {
319
+ if (!this.page) throw new Error('page 未初始化');
320
+
321
+ const { deltaY, deltaX } = event;
322
+
323
+ if (this.page.clientHeight < this.wrapperHeight && deltaY) return;
324
+ if (this.page.clientWidth < this.wrapperWidth && deltaX) return;
325
+
326
+ if (this.maxScrollTop > 0) {
327
+ this.scrollTop = this.scrollTop + deltaY;
328
+ }
329
+
330
+ if (this.maxScrollLeft > 0) {
331
+ this.scrollLeft = this.scrollLeft + deltaX;
332
+ }
333
+
334
+ this.scroll();
335
+ this.emit('scroll', event);
336
+ };
337
+ }
@@ -0,0 +1,229 @@
1
+ /*
2
+ * Tencent is pleased to support the open source community by making TMagicEditor available.
3
+ *
4
+ * Copyright (C) 2023 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
+ import Moveable from 'moveable';
20
+
21
+ import { DRAG_EL_ID_PREFIX, Mode, StageDragStatus } from './const';
22
+ import DragResizeHelper from './DragResizeHelper';
23
+ import MoveableOptionsManager from './MoveableOptionsManager';
24
+ import {
25
+ DelayedMarkContainer,
26
+ GetRenderDocument,
27
+ MarkContainerEnd,
28
+ MoveableOptionsManagerConfig,
29
+ MultiDrEvents,
30
+ StageMultiDragResizeConfig,
31
+ } from './types';
32
+ import { getMode } from './util';
33
+
34
+ export default class StageMultiDragResize extends MoveableOptionsManager {
35
+ /** 画布容器 */
36
+ public container: HTMLElement;
37
+ /** 多选:目标节点组 */
38
+ public targetList: HTMLElement[] = [];
39
+ /** Moveable多选拖拽类实例 */
40
+ public moveableForMulti?: Moveable;
41
+ public dragStatus: StageDragStatus = StageDragStatus.END;
42
+ private dragResizeHelper: DragResizeHelper;
43
+ private getRenderDocument: GetRenderDocument;
44
+ private delayedMarkContainer: DelayedMarkContainer;
45
+ private markContainerEnd: MarkContainerEnd;
46
+
47
+ constructor(config: StageMultiDragResizeConfig) {
48
+ const moveableOptionsManagerConfig: MoveableOptionsManagerConfig = {
49
+ container: config.container,
50
+ moveableOptions: config.moveableOptions,
51
+ getRootContainer: config.getRootContainer,
52
+ };
53
+ super(moveableOptionsManagerConfig);
54
+
55
+ this.delayedMarkContainer = config.delayedMarkContainer;
56
+ this.markContainerEnd = config.markContainerEnd;
57
+ this.container = config.container;
58
+ this.getRenderDocument = config.getRenderDocument;
59
+
60
+ this.dragResizeHelper = config.dragResizeHelper;
61
+
62
+ this.on('update-moveable', () => {
63
+ if (this.moveableForMulti) {
64
+ this.updateMoveable();
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * 多选
71
+ * @param els
72
+ */
73
+ public multiSelect(els: HTMLElement[]): void {
74
+ if (els.length === 0) {
75
+ return;
76
+ }
77
+ this.mode = getMode(els[0]);
78
+ this.targetList = els;
79
+
80
+ this.dragResizeHelper.updateGroup(els);
81
+
82
+ this.setElementGuidelines(this.targetList);
83
+
84
+ this.moveableForMulti?.destroy();
85
+ this.dragResizeHelper.clear();
86
+ this.moveableForMulti = new Moveable(
87
+ this.container,
88
+ this.getOptions(true, {
89
+ target: this.dragResizeHelper.getShadowEls(),
90
+ }),
91
+ );
92
+
93
+ let timeout: NodeJS.Timeout | undefined;
94
+
95
+ this.moveableForMulti
96
+ .on('resizeGroupStart', (e) => {
97
+ this.dragResizeHelper.onResizeGroupStart(e);
98
+ this.dragStatus = StageDragStatus.START;
99
+ })
100
+ .on('resizeGroup', (e) => {
101
+ this.dragResizeHelper.onResizeGroup(e);
102
+ this.dragStatus = StageDragStatus.ING;
103
+ })
104
+ .on('resizeGroupEnd', () => {
105
+ this.update(true);
106
+ this.dragStatus = StageDragStatus.END;
107
+ })
108
+ .on('dragGroupStart', (e) => {
109
+ this.dragResizeHelper.onDragGroupStart(e);
110
+ this.dragStatus = StageDragStatus.START;
111
+ })
112
+ .on('dragGroup', (e) => {
113
+ if (timeout) {
114
+ globalThis.clearTimeout(timeout);
115
+ timeout = undefined;
116
+ }
117
+ timeout = this.delayedMarkContainer(e.inputEvent, this.targetList);
118
+
119
+ this.dragResizeHelper.onDragGroup(e);
120
+ this.dragStatus = StageDragStatus.ING;
121
+ })
122
+ .on('dragGroupEnd', () => {
123
+ if (timeout) {
124
+ globalThis.clearTimeout(timeout);
125
+ timeout = undefined;
126
+ }
127
+ const parentEl = this.markContainerEnd();
128
+ this.update(false, parentEl);
129
+ this.dragStatus = StageDragStatus.END;
130
+ })
131
+ .on('clickGroup', (e) => {
132
+ const { inputTarget, targets } = e;
133
+ // 如果有多个元素被选中,同时点击的元素在选中元素中的其中一项,可能是多选态切换为该元素的单选态,抛事件给上一层继续判断是否切换
134
+ if (targets.length > 1 && targets.includes(inputTarget)) {
135
+ this.emit('change-to-select', inputTarget.id.replace(DRAG_EL_ID_PREFIX, ''), e.inputEvent);
136
+ }
137
+ });
138
+ }
139
+
140
+ public canSelect(el: HTMLElement, selectedEl: HTMLElement | null): boolean {
141
+ const currentTargetMode = getMode(el);
142
+ let selectedElMode = '';
143
+
144
+ // 流式布局不支持多选
145
+ if (currentTargetMode === Mode.SORTABLE) {
146
+ return false;
147
+ }
148
+
149
+ if (this.targetList.length === 0 && selectedEl) {
150
+ // 单选后添加到多选的情况
151
+ selectedElMode = getMode(selectedEl);
152
+ } else if (this.targetList.length > 0) {
153
+ // 已加入多选列表的布局模式是一样的,取第一个判断
154
+ selectedElMode = getMode(this.targetList[0]);
155
+ }
156
+ // 定位模式不同,不可混选
157
+ if (currentTargetMode !== selectedElMode) {
158
+ return false;
159
+ }
160
+ return true;
161
+ }
162
+
163
+ public updateMoveable(eleList = this.targetList) {
164
+ if (!this.moveableForMulti) return;
165
+ if (!eleList) throw new Error('未选中任何节点');
166
+
167
+ this.targetList = eleList;
168
+ this.dragResizeHelper.setTargetList(eleList);
169
+
170
+ const options = this.getOptions(true, {
171
+ target: this.dragResizeHelper.getShadowEls(),
172
+ });
173
+
174
+ Object.entries(options).forEach(([key, value]) => {
175
+ (this.moveableForMulti as any)[key] = value;
176
+ });
177
+ this.moveableForMulti.updateRect();
178
+ }
179
+
180
+ /**
181
+ * 清除多选状态
182
+ */
183
+ public clearSelectStatus(): void {
184
+ if (!this.moveableForMulti) return;
185
+ this.dragResizeHelper.clearMultiSelectStatus();
186
+ this.moveableForMulti.target = null;
187
+ this.moveableForMulti.updateTarget();
188
+ this.targetList = [];
189
+ }
190
+
191
+ /**
192
+ * 销毁实例
193
+ */
194
+ public destroy(): void {
195
+ this.moveableForMulti?.destroy();
196
+ this.dragResizeHelper.destroy();
197
+ }
198
+
199
+ public on<Name extends keyof MultiDrEvents, Param extends MultiDrEvents[Name]>(
200
+ eventName: Name,
201
+ listener: (...args: Param) => void | Promise<void>,
202
+ ) {
203
+ return super.on(eventName, listener as any);
204
+ }
205
+
206
+ public emit<Name extends keyof MultiDrEvents, Param extends MultiDrEvents[Name]>(eventName: Name, ...args: Param) {
207
+ return super.emit(eventName, ...args);
208
+ }
209
+
210
+ /**
211
+ * 拖拽完成后将更新的位置信息暴露给上层业务方,业务方可以接收事件进行保存
212
+ * @param isResize 是否进行大小缩放
213
+ */
214
+ private update(isResize = false, parentEl: HTMLElement | null = null): void {
215
+ if (this.targetList.length === 0) return;
216
+
217
+ const doc = this.getRenderDocument();
218
+ if (!doc) return;
219
+
220
+ const data = this.targetList.map((targetItem) => {
221
+ const rect = this.dragResizeHelper.getUpdatedElRect(targetItem, parentEl, doc);
222
+ return {
223
+ el: targetItem,
224
+ style: isResize ? rect : { left: rect.left, top: rect.top },
225
+ };
226
+ });
227
+ this.emit('update', { data, parentEl });
228
+ }
229
+ }