@tmagic/stage 1.8.0-manmanyu.9 → 1.8.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.
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.8.0-manmanyu.9",
2
+ "version": "1.8.1",
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.1"
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, isDocumentVisible, isElementVisible, 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
  /**
@@ -154,7 +176,14 @@ export default class StageMask extends Rule {
154
176
  this.setMode(isFixedParent(el) ? Mode.FIXED : Mode.ABSOLUTE);
155
177
  }
156
178
 
179
+ /**
180
+ * 将元素滚动到画布可视区域内
181
+ * @description 页面不可见时不做滚动:此时滚动既无视觉效果,又会记录错误的滚动偏移
182
+ * @param el 目标元素
183
+ */
157
184
  public scrollIntoView(el: Element): void {
185
+ if (!this.isPageVisible()) return;
186
+
158
187
  // 不可以有横向滚动
159
188
  if (!this.page || el.getBoundingClientRect().left >= this.page.scrollWidth) return;
160
189
 
@@ -165,10 +194,12 @@ export default class StageMask extends Rule {
165
194
  return;
166
195
  }
167
196
 
168
- el.scrollIntoView();
169
-
170
197
  if (!this.pageScrollParent) return;
171
198
 
199
+ // 不使用原生 scrollIntoView,避免编辑器画布外层滚动容器被浏览器连带滚动导致整个 stage 位移,
200
+ // 只滚动页面所在的滚动容器
201
+ scrollElementIntoView(el, this.pageScrollParent);
202
+
172
203
  this.scrollLeft = this.pageScrollParent.scrollLeft;
173
204
  this.scrollTop = this.pageScrollParent.scrollTop;
174
205
 
@@ -184,6 +215,7 @@ export default class StageMask extends Rule {
184
215
  this.content?.remove();
185
216
  this.page = null;
186
217
  this.pageScrollParent = null;
218
+ this.intersectionObserver?.disconnect();
187
219
  this.wrapperResizeObserver?.disconnect();
188
220
  }
189
221
 
@@ -210,6 +242,10 @@ export default class StageMask extends Rule {
210
242
  (entries) => {
211
243
  entries.forEach((entry) => {
212
244
  const { target, intersectionRatio } = entry;
245
+
246
+ // 页面不可见时不滚动,同时保留监听,等页面恢复可见后由 IntersectionObserver 重新回调
247
+ if (!this.isPageVisible()) return;
248
+
213
249
  if (intersectionRatio <= 0) {
214
250
  this.scrollIntoView(target);
215
251
  }
@@ -285,6 +321,40 @@ export default class StageMask extends Rule {
285
321
  this.content.dispatchEvent(event);
286
322
  }
287
323
 
324
+ /**
325
+ * 页面是否可见
326
+ * @description 页面未挂载、或画布容器被隐藏(如编辑器切到其他面板)时页面不可见;
327
+ * 蒙层与页面所在的 iframe 挂载在同一容器中,容器隐藏时页面内部元素仍可能是"可见"的,因此额外判断蒙层;
328
+ * 浏览器 tab 不可见(切到其他标签页、窗口最小化)时同样视为不可见
329
+ */
330
+ private isPageVisible(): boolean {
331
+ if (!isDocumentVisible(this.page?.ownerDocument) || !isDocumentVisible(this.wrapper.ownerDocument)) return false;
332
+
333
+ if (!isElementVisible(this.page)) return false;
334
+
335
+ return !this.wrapper.isConnected || isElementVisible(this.wrapper);
336
+ }
337
+
338
+ /**
339
+ * 从当前页面同步蒙层宽高,并修正滚动偏移
340
+ */
341
+ private syncPageSize(): void {
342
+ if (!this.page?.isConnected) return;
343
+
344
+ this.applyPageSize(this.page.clientWidth, this.page.clientHeight);
345
+ }
346
+
347
+ /**
348
+ * 应用页面尺寸到蒙层;宽高为 0 时忽略,避免切换/布局过程中把 editor-mask 置空
349
+ */
350
+ private applyPageSize(clientWidth: number, clientHeight: number): void {
351
+ if (clientWidth <= 0 || clientHeight <= 0) return;
352
+
353
+ this.setHeight(clientHeight);
354
+ this.setWidth(clientWidth);
355
+ this.scroll();
356
+ }
357
+
288
358
  /**
289
359
  * 设置蒙层高度
290
360
  * @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,77 @@ export const getScrollParent = (element: HTMLElement, includeHidden = false): HT
141
141
  return null;
142
142
  };
143
143
 
144
+ /**
145
+ * 判断文档是否可见
146
+ * @description 浏览器 tab 切到后台、窗口最小化时文档 visibilityState 为 hidden;
147
+ * 只将 hidden 视为不可见,避免 prerender 等中间态被误判
148
+ * @param doc 目标文档
149
+ */
150
+ export const isDocumentVisible = (doc: Document | null | undefined): boolean => doc?.visibilityState !== 'hidden';
151
+
152
+ /**
153
+ * 判断元素是否可见
154
+ * @description 元素未挂载到文档、或其自身/祖先被 display:none、visibility:hidden 隐藏时视为不可见;
155
+ * 优先使用浏览器提供的 checkVisibility,不支持时退化为沿祖先链检查计算样式
156
+ * @param el 目标元素
157
+ */
158
+ export const isElementVisible = (el: Element | null | undefined): boolean => {
159
+ if (!el?.isConnected) return false;
160
+
161
+ if (typeof el.checkVisibility === 'function') {
162
+ return el.checkVisibility({ checkVisibilityCSS: true });
163
+ }
164
+
165
+ for (let node: Element | null = el; node; node = node.parentElement) {
166
+ const { display, visibility } = getComputedStyle(node);
167
+ if (display === 'none' || visibility === 'hidden') return false;
168
+ }
169
+
170
+ return true;
171
+ };
172
+
173
+ /**
174
+ * 将元素滚动到指定滚动容器的可视区域内(仅垂直方向,滚动最小距离)
175
+ * @description 与原生 scrollIntoView 不同,只滚动指定的容器自身,不会连带滚动外层祖先滚动容器,
176
+ * 避免编辑器画布外层容器(如 stage 外层使用 transform 模拟滚动的容器)被浏览器自动滚动导致整个画布位移
177
+ * @param el 目标元素
178
+ * @param container 滚动容器
179
+ */
180
+ export const scrollElementIntoView = (el: Element, container: HTMLElement): void => {
181
+ const { ownerDocument } = container;
182
+ const win = ownerDocument?.defaultView;
183
+ if (!ownerDocument || !win) return;
184
+
185
+ const elRect = el.getBoundingClientRect();
186
+
187
+ let viewTop = 0;
188
+ let viewHeight = 0;
189
+
190
+ if (container === ownerDocument.documentElement || container === ownerDocument.body) {
191
+ // 文档滚动元素的可视区域为窗口视口
192
+ viewTop = 0;
193
+ viewHeight = win.innerHeight;
194
+ } else {
195
+ const containerRect = container.getBoundingClientRect();
196
+ viewTop = containerRect.top + container.clientTop;
197
+ viewHeight = container.clientHeight;
198
+ }
199
+
200
+ const viewBottom = viewTop + viewHeight;
201
+
202
+ let delta = 0;
203
+ if (elRect.top < viewTop) {
204
+ delta = elRect.top - viewTop;
205
+ } else if (elRect.bottom > viewBottom) {
206
+ // 元素高度超过可视区域高度时,优先保证元素顶部可见
207
+ delta = Math.min(elRect.bottom - viewBottom, elRect.top - viewTop);
208
+ }
209
+
210
+ if (delta !== 0) {
211
+ container.scrollTop += delta;
212
+ }
213
+ };
214
+
144
215
  export const removeSelectedClassName = (doc: Document) => {
145
216
  const oldEl = doc.querySelector(`.${SELECTED_CLASS}`);
146
217