@tmagic/stage 1.8.0-manmanyu.9 → 1.8.0

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.
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.8.0-manmanyu.9",
2
+ "version": "1.8.0",
3
3
  "name": "@tmagic/stage",
4
4
  "type": "module",
5
5
  "sideEffects": false,
@@ -30,21 +30,21 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@scena/guides": "^0.29.2",
33
+ "@zumer/snapdom": "^2.23.2",
33
34
  "events": "^3.3.0",
34
- "@zumer/snapdom": "^2.8.0",
35
35
  "keycon": "^1.4.0",
36
- "lodash-es": "^4.17.21",
36
+ "lodash-es": "^4.18.1",
37
37
  "moveable": "^0.53.0",
38
38
  "moveable-helper": "^0.4.0",
39
39
  "scenejs": "^1.10.3"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/events": "^3.0.3",
43
- "@types/lodash-es": "^4.17.4"
43
+ "@types/lodash-es": "^4.17.12"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "typescript": "^6.0.3",
47
- "@tmagic/core": "1.8.0-manmanyu.9"
47
+ "@tmagic/core": "1.8.0"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "typescript": {
@@ -84,6 +84,8 @@ export default class ActionManager extends EventEmitter {
84
84
  private containerHighlightDuration: number;
85
85
  /** 将组件加入容器的操作方式 */
86
86
  private containerHighlightType?: ContainerHighlightType;
87
+ /** 是否仅在新增组件时才启用将组件加入容器 */
88
+ private containerHighlightAddOnly = false;
87
89
  private isAltKeydown = false;
88
90
  private getTargetElement: GetTargetElement;
89
91
  private getElementsFromPoint: GetElementsFromPoint;
@@ -124,6 +126,7 @@ export default class ActionManager extends EventEmitter {
124
126
  this.containerHighlightClassName = config.containerHighlightClassName || CONTAINER_HIGHLIGHT_CLASS_NAME;
125
127
  this.containerHighlightDuration = config.containerHighlightDuration || defaultContainerHighlightDuration;
126
128
  this.containerHighlightType = config.containerHighlightType;
129
+ this.containerHighlightAddOnly = config.containerHighlightAddOnly ?? false;
127
130
  this.disabledMultiSelect = config.disabledMultiSelect ?? false;
128
131
  this.alwaysMultiSelect = config.alwaysMultiSelect ?? false;
129
132
  this.getTargetElement = config.getTargetElement;
@@ -428,10 +431,15 @@ export default class ActionManager extends EventEmitter {
428
431
  * 标记的作用:1、高亮容器,给用户一个加入容器的交互感知;2、释放鼠标后,通过标记的标志找到要加入的容器
429
432
  * @param event 鼠标事件
430
433
  * @param excludeElList 计算鼠标所在容器时要排除的元素列表
434
+ * @param isAdd 当前操作是否为新增组件(从组件列表拖入),开启 containerHighlightAddOnly 时只有新增才会标记
431
435
  * @returns timeoutId,调用方在鼠标移走时要取消该timeout,阻止标记
432
436
  */
433
- public delayedMarkContainer(event: MouseEvent, excludeElList: Element[] = []): NodeJS.Timeout | undefined {
434
- if (this.canAddToContainer()) {
437
+ public delayedMarkContainer(
438
+ event: MouseEvent,
439
+ excludeElList: Element[] = [],
440
+ isAdd = false,
441
+ ): NodeJS.Timeout | undefined {
442
+ if (this.canAddToContainer(isAdd)) {
435
443
  return globalThis.setTimeout(() => {
436
444
  this.addContainerHighlightClassName(event, excludeElList);
437
445
  }, this.containerHighlightDuration);
@@ -609,9 +617,13 @@ export default class ActionManager extends EventEmitter {
609
617
  }
610
618
 
611
619
  /**
612
- * 当前状态下能否将组件加入容器,默认是鼠标悬停一段时间加入,alt模式则是按住alt+鼠标悬停一段时间加入
620
+ * 当前状态下能否将组件加入容器,默认是鼠标悬停一段时间加入,alt模式则是按住alt+鼠标悬停一段时间加入;
621
+ * 开启containerHighlightAddOnly时,画布中拖动已有组件不能加入容器
622
+ * @param isAdd 当前操作是否为新增组件
613
623
  */
614
- private canAddToContainer(): boolean {
624
+ private canAddToContainer(isAdd = false): boolean {
625
+ if (this.containerHighlightAddOnly && !isAdd) return false;
626
+
615
627
  return (
616
628
  this.containerHighlightType === ContainerHighlightType.DEFAULT ||
617
629
  (this.containerHighlightType === ContainerHighlightType.ALT && this.isAltKeydown)
@@ -624,10 +636,10 @@ export default class ActionManager extends EventEmitter {
624
636
  */
625
637
  private markContainerEnd(): HTMLElement | null {
626
638
  const doc = this.getRenderDocument();
627
- if (doc && this.canAddToContainer()) {
628
- return removeClassNameByClassName(doc, this.containerHighlightClassName);
629
- }
630
- return null;
639
+ if (!doc) return null;
640
+
641
+ const markedContainer = removeClassNameByClassName(doc, this.containerHighlightClassName);
642
+ return this.canAddToContainer() ? markedContainer : null;
631
643
  }
632
644
 
633
645
  private initMouseEvent(): void {
package/src/StageCore.ts CHANGED
@@ -263,10 +263,15 @@ export default class StageCore extends EventEmitter {
263
263
  * 标记的作用:1、高亮容器,给用户一个加入容器的交互感知;2、释放鼠标后,通过标记的标志找到要加入的容器
264
264
  * @param event 鼠标事件
265
265
  * @param excludeElList 计算鼠标所在容器时要排除的元素列表
266
+ * @param isAdd 当前操作是否为新增组件(从组件列表拖入),开启 containerHighlightAddOnly 时只有新增才会标记
266
267
  * @returns timeoutId,调用方在鼠标移走时要取消该timeout,阻止标记
267
268
  */
268
- public delayedMarkContainer(event: MouseEvent, excludeElList: Element[] = []): NodeJS.Timeout | undefined {
269
- return this.actionManager?.delayedMarkContainer(event, excludeElList);
269
+ public delayedMarkContainer(
270
+ event: MouseEvent,
271
+ excludeElList: Element[] = [],
272
+ isAdd = false,
273
+ ): NodeJS.Timeout | undefined {
274
+ return this.actionManager?.delayedMarkContainer(event, excludeElList, isAdd);
270
275
  }
271
276
 
272
277
  public getMoveableOption<K extends keyof MoveableOptions>(key: K): MoveableOptions[K] | undefined {
@@ -363,11 +368,23 @@ export default class StageCore extends EventEmitter {
363
368
  }
364
369
  }
365
370
 
371
+ /**
372
+ * page-el-update 后,若当前选中节点仍在新页面文档中且需要自动滚动,则重新触发 scrollIntoView
373
+ */
374
+ private scrollSelectedIntoViewAfterPageUpdate(): void {
375
+ const selected = this.actionManager?.getSelectedEl();
376
+ if (!selected?.isConnected) return;
377
+ if (!(this.autoScrollIntoView || selected.dataset.autoScrollIntoView)) return;
378
+
379
+ this.mask?.observerIntersection(selected);
380
+ }
381
+
366
382
  private getActionManagerConfig(config: StageCoreConfig): ActionManagerConfig {
367
383
  const actionManagerConfig: ActionManagerConfig = {
368
384
  containerHighlightClassName: config.containerHighlightClassName,
369
385
  containerHighlightDuration: config.containerHighlightDuration,
370
386
  containerHighlightType: config.containerHighlightType,
387
+ containerHighlightAddOnly: config.containerHighlightAddOnly,
371
388
  moveableOptions: config.moveableOptions,
372
389
  container: this.mask!.content,
373
390
  disabledDragStart: config.disabledDragStart,
@@ -394,6 +411,10 @@ export default class StageCore extends EventEmitter {
394
411
  this.mask?.observe(el);
395
412
  this.observePageResize(el);
396
413
 
414
+ // observe 切页时会重置滚动;若 select + scrollIntoView 已先完成,这里对仍挂载的选中节点
415
+ // 重新触发一次,避免滚动被打回顶部
416
+ this.scrollSelectedIntoViewAfterPageUpdate();
417
+
397
418
  this.emit('page-el-update', el);
398
419
  });
399
420
  }
package/src/StageMask.ts CHANGED
@@ -21,7 +21,7 @@ import { createDiv, getDocument, injectStyle } from '@tmagic/core';
21
21
  import { Mode, ZIndex } from './const';
22
22
  import Rule from './Rule';
23
23
  import type { MaskEvents, RuleOptions } from './types';
24
- import { getScrollParent, isFixedParent } from './util';
24
+ import { getScrollParent, isFixedParent, scrollElementIntoView } from './util';
25
25
 
26
26
  const wrapperClassName = 'editor-mask-wrapper';
27
27
 
@@ -108,28 +108,50 @@ export default class StageMask extends Rule {
108
108
 
109
109
  /**
110
110
  * 初始化视窗和蒙层监听,监听元素是否在视窗区域、监听mask蒙层所在的wrapper大小变化
111
- * @description 初始化视窗和蒙层监听
111
+ * @description 初始化视窗和蒙层监听;切换到新页面 DOM 时重置滚动
112
112
  * @param page 页面Dom节点
113
113
  */
114
114
  public observe(page: HTMLElement): void {
115
115
  if (!page) return;
116
116
 
117
+ const pageChanged = this.page !== page;
118
+
117
119
  this.page = page;
118
120
  this.initObserverIntersection();
119
121
  this.initObserverWrapper();
122
+
123
+ if (pageChanged) {
124
+ // runtime 按 pageId 重挂载后,滚动容器(多为 documentElement)可能仍残留上一页偏移;
125
+ // 新页默认从顶部展示:重置页面与 mask 滚动,并立刻 scroll() 刷新 transform/标尺。
126
+ // syncPageSize 在宽高为 0 时会提前返回,不能依赖它刷新视觉状态。
127
+ // 若 select 已先 scrollIntoView,由 StageCore 在 page-el-update 后重新触发一次补回。
128
+ this.pageScrollParent?.scrollTo({ top: 0, left: 0 });
129
+ this.scrollTop = 0;
130
+ this.scrollLeft = 0;
131
+ this.scroll();
132
+ }
133
+
134
+ // 页面切换后立即同步一次;ResizeObserver 首次回调可能仍上报旧页/0 尺寸
135
+ this.syncPageSize();
120
136
  }
121
137
 
122
138
  /**
123
- * 处理页面大小变更,同步页面和mask大小
139
+ * 处理页面大小变更,同步页面和 mask 大小
140
+ * @description 仅处理当前 page 且仍挂载在文档中的 entry;非当前页、已卸载或宽高为 0 的回调会被忽略
124
141
  * @param entries ResizeObserverEntry,获取页面最新大小
125
142
  */
126
143
  public pageResize(entries: ResizeObserverEntry[]): void {
127
- const [entry] = entries;
128
- const { clientHeight, clientWidth } = entry.target;
129
- this.setHeight(clientHeight);
130
- this.setWidth(clientWidth);
144
+ if (!this.page || !entries.length) return;
131
145
 
132
- this.scroll();
146
+ // 取当前 page 对应 entry,避免 batch 中首项是旧页时整批被丢弃
147
+ const entry = entries.find((item) => item.target === this.page);
148
+ if (!entry) return;
149
+
150
+ const { target } = entry;
151
+ // 旧页卸载后仍可能回调,已断开连接的节点不再同步尺寸
152
+ if (!target.isConnected) return;
153
+
154
+ this.applyPageSize(target.clientWidth, target.clientHeight);
133
155
  }
134
156
 
135
157
  /**
@@ -165,10 +187,12 @@ export default class StageMask extends Rule {
165
187
  return;
166
188
  }
167
189
 
168
- el.scrollIntoView();
169
-
170
190
  if (!this.pageScrollParent) return;
171
191
 
192
+ // 不使用原生 scrollIntoView,避免编辑器画布外层滚动容器被浏览器连带滚动导致整个 stage 位移,
193
+ // 只滚动页面所在的滚动容器
194
+ scrollElementIntoView(el, this.pageScrollParent);
195
+
172
196
  this.scrollLeft = this.pageScrollParent.scrollLeft;
173
197
  this.scrollTop = this.pageScrollParent.scrollTop;
174
198
 
@@ -184,6 +208,7 @@ export default class StageMask extends Rule {
184
208
  this.content?.remove();
185
209
  this.page = null;
186
210
  this.pageScrollParent = null;
211
+ this.intersectionObserver?.disconnect();
187
212
  this.wrapperResizeObserver?.disconnect();
188
213
  }
189
214
 
@@ -285,6 +310,26 @@ export default class StageMask extends Rule {
285
310
  this.content.dispatchEvent(event);
286
311
  }
287
312
 
313
+ /**
314
+ * 从当前页面同步蒙层宽高,并修正滚动偏移
315
+ */
316
+ private syncPageSize(): void {
317
+ if (!this.page?.isConnected) return;
318
+
319
+ this.applyPageSize(this.page.clientWidth, this.page.clientHeight);
320
+ }
321
+
322
+ /**
323
+ * 应用页面尺寸到蒙层;宽高为 0 时忽略,避免切换/布局过程中把 editor-mask 置空
324
+ */
325
+ private applyPageSize(clientWidth: number, clientHeight: number): void {
326
+ if (clientWidth <= 0 || clientHeight <= 0) return;
327
+
328
+ this.setHeight(clientHeight);
329
+ this.setWidth(clientWidth);
330
+ this.scroll();
331
+ }
332
+
288
333
  /**
289
334
  * 设置蒙层高度
290
335
  * @param height 高度
package/src/types.ts CHANGED
@@ -53,7 +53,11 @@ export type GetTargetElement = (id: Id) => HTMLElement | null;
53
53
  /** render提供的接口,通过坐标获得坐标下所有HTML元素数组 */
54
54
  export type GetElementsFromPoint = (point: Point) => HTMLElement[];
55
55
  export type GetRenderDocument = () => Document | undefined;
56
- export type DelayedMarkContainer = (event: MouseEvent, exclude: Element[]) => NodeJS.Timeout | undefined;
56
+ export type DelayedMarkContainer = (
57
+ event: MouseEvent,
58
+ exclude?: Element[],
59
+ isAdd?: boolean,
60
+ ) => NodeJS.Timeout | undefined;
57
61
  export type MarkContainerEnd = () => HTMLElement | null;
58
62
  export type GetRootContainer = () => HTMLDivElement | undefined;
59
63
 
@@ -74,6 +78,11 @@ export interface StageCoreConfig {
74
78
  containerHighlightClassName?: string;
75
79
  containerHighlightDuration?: number;
76
80
  containerHighlightType?: ContainerHighlightType;
81
+ /**
82
+ * 是否仅在新增组件(从组件列表拖入新组件)时才启用将组件拖入容器,
83
+ * 开启后在画布中拖动已有组件不会识别容器,默认 false
84
+ */
85
+ containerHighlightAddOnly?: boolean;
77
86
  moveableOptions?: CustomizeMoveableOptions;
78
87
  /** runtime 的HTML地址,可以是一个HTTP地址,如果和编辑器不同域,需要设置跨域,也可以是一个相对或绝对路径 */
79
88
  runtimeUrl?: string;
@@ -102,6 +111,8 @@ export interface ActionManagerConfig {
102
111
  containerHighlightClassName?: string;
103
112
  containerHighlightDuration?: number;
104
113
  containerHighlightType?: ContainerHighlightType;
114
+ /** 见 StageCoreConfig.containerHighlightAddOnly */
115
+ containerHighlightAddOnly?: boolean;
105
116
  moveableOptions?: CustomizeMoveableOptions;
106
117
  disabledDragStart?: boolean;
107
118
  disabledMultiSelect?: boolean;
@@ -224,6 +235,8 @@ export interface UpdateData {
224
235
  parent?: MContainer;
225
236
  parentId?: Id;
226
237
  root: MApp;
238
+ /** 插入到父节点 items 中的目标下标;runtime 优先按此同步,避免依赖选中态推算顺序 */
239
+ index?: number;
227
240
  }
228
241
 
229
242
  export interface RemoveData {
package/src/util.ts CHANGED
@@ -126,7 +126,7 @@ export const getScrollParent = (element: HTMLElement, includeHidden = false): HT
126
126
 
127
127
  if (isFixed(style)) return null;
128
128
 
129
- for (let parent = element; parent.parentElement; ) {
129
+ for (let parent = element; parent.parentElement;) {
130
130
  parent = parent.parentElement;
131
131
 
132
132
  if (parent.tagName === 'HTML') return parent;
@@ -141,6 +141,48 @@ export const getScrollParent = (element: HTMLElement, includeHidden = false): HT
141
141
  return null;
142
142
  };
143
143
 
144
+ /**
145
+ * 将元素滚动到指定滚动容器的可视区域内(仅垂直方向,滚动最小距离)
146
+ * @description 与原生 scrollIntoView 不同,只滚动指定的容器自身,不会连带滚动外层祖先滚动容器,
147
+ * 避免编辑器画布外层容器(如 stage 外层使用 transform 模拟滚动的容器)被浏览器自动滚动导致整个画布位移
148
+ * @param el 目标元素
149
+ * @param container 滚动容器
150
+ */
151
+ export const scrollElementIntoView = (el: Element, container: HTMLElement): void => {
152
+ const { ownerDocument } = container;
153
+ const win = ownerDocument?.defaultView;
154
+ if (!ownerDocument || !win) return;
155
+
156
+ const elRect = el.getBoundingClientRect();
157
+
158
+ let viewTop = 0;
159
+ let viewHeight = 0;
160
+
161
+ if (container === ownerDocument.documentElement || container === ownerDocument.body) {
162
+ // 文档滚动元素的可视区域为窗口视口
163
+ viewTop = 0;
164
+ viewHeight = win.innerHeight;
165
+ } else {
166
+ const containerRect = container.getBoundingClientRect();
167
+ viewTop = containerRect.top + container.clientTop;
168
+ viewHeight = container.clientHeight;
169
+ }
170
+
171
+ const viewBottom = viewTop + viewHeight;
172
+
173
+ let delta = 0;
174
+ if (elRect.top < viewTop) {
175
+ delta = elRect.top - viewTop;
176
+ } else if (elRect.bottom > viewBottom) {
177
+ // 元素高度超过可视区域高度时,优先保证元素顶部可见
178
+ delta = Math.min(elRect.bottom - viewBottom, elRect.top - viewTop);
179
+ }
180
+
181
+ if (delta !== 0) {
182
+ container.scrollTop += delta;
183
+ }
184
+ };
185
+
144
186
  export const removeSelectedClassName = (doc: Document) => {
145
187
  const oldEl = doc.querySelector(`.${SELECTED_CLASS}`);
146
188