@tmagic/editor 1.8.0-beta.19 → 1.8.0-beta.20

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-beta.19",
2
+ "version": "1.8.0-beta.20",
3
3
  "name": "@tmagic/editor",
4
4
  "type": "module",
5
5
  "sideEffects": [
@@ -58,11 +58,11 @@
58
58
  "moveable": "^0.53.0",
59
59
  "serialize-javascript": "^7.0.0",
60
60
  "sortablejs": "^1.15.6",
61
- "@tmagic/design": "1.8.0-beta.19",
62
- "@tmagic/table": "1.8.0-beta.19",
63
- "@tmagic/stage": "1.8.0-beta.19",
64
- "@tmagic/utils": "1.8.0-beta.19",
65
- "@tmagic/form": "1.8.0-beta.19"
61
+ "@tmagic/design": "1.8.0-beta.20",
62
+ "@tmagic/form": "1.8.0-beta.20",
63
+ "@tmagic/stage": "1.8.0-beta.20",
64
+ "@tmagic/table": "1.8.0-beta.20",
65
+ "@tmagic/utils": "1.8.0-beta.20"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/events": "^3.0.3",
@@ -76,7 +76,7 @@
76
76
  "type-fest": "^5.2.0",
77
77
  "typescript": "^6.0.3",
78
78
  "vue": "^3.5.40",
79
- "@tmagic/core": "1.8.0-beta.19"
79
+ "@tmagic/core": "1.8.0-beta.20"
80
80
  },
81
81
  "peerDependenciesMeta": {
82
82
  "typescript": {
@@ -6,7 +6,7 @@
6
6
  import { computed, markRaw, useTemplateRef } from 'vue';
7
7
  import { Files, Plus } from '@element-plus/icons-vue';
8
8
 
9
- import { isPage, isPageFragment } from '@tmagic/utils';
9
+ import { isPageOrFragment } from '@tmagic/utils';
10
10
 
11
11
  import ContentMenu from '@editor/components/ContentMenu.vue';
12
12
  import { useServices } from '@editor/hooks/use-services';
@@ -101,7 +101,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
101
101
  type: 'button',
102
102
  text: '全部折叠',
103
103
  icon: FolderMinusIcon,
104
- display: () => isPage(node.value) || isPageFragment(node.value),
104
+ display: () => isPageOrFragment(node.value),
105
105
  handler: () => {
106
106
  emit('collapse-all');
107
107
  },
@@ -2,7 +2,7 @@ import { computed, type ComputedRef, nextTick, type Ref, type ShallowRef } from
2
2
  import { throttle } from 'lodash-es';
3
3
 
4
4
  import { Id, MNode } from '@tmagic/core';
5
- import { getElById, isPage, isPageFragment } from '@tmagic/utils';
5
+ import { getElById, isPageOrFragment } from '@tmagic/utils';
6
6
 
7
7
  import type { LayerNodeStatus, Services, TreeNodeData } from '@editor/type';
8
8
  import { UI_SELECT_MODE_EVENT_NAME } from '@editor/utils/const';
@@ -36,7 +36,7 @@ export const useClick = (
36
36
  };
37
37
 
38
38
  const multiSelect = async (data: MNode) => {
39
- if (isPage(data) || isPageFragment(data)) {
39
+ if (isPageOrFragment(data)) {
40
40
  return;
41
41
  }
42
42
 
@@ -50,7 +50,7 @@ export const useClick = (
50
50
  return;
51
51
  }
52
52
 
53
- if (isPage(node) || isPageFragment(node)) {
53
+ if (isPageOrFragment(node)) {
54
54
  return;
55
55
  }
56
56
 
@@ -1,7 +1,7 @@
1
1
  import { computed, onBeforeUnmount, ref, watch } from 'vue';
2
2
 
3
3
  import type { Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
4
- import { getNodePath, isPage, isPageFragment, traverseNode } from '@tmagic/utils';
4
+ import { getNodePath, isPageOrFragment, traverseNode } from '@tmagic/utils';
5
5
 
6
6
  import type { LayerNodeStatus, Services } from '@editor/type';
7
7
  import { updateStatus } from '@editor/utils/tree';
@@ -136,7 +136,7 @@ export const useNodeStatus = ({ editorService }: Services) => {
136
136
 
137
137
  const addHandler = (newNodes: MNode[]) => {
138
138
  newNodes.forEach((node) => {
139
- if (isPage(node) || isPageFragment(node)) return;
139
+ if (isPageOrFragment(node)) return;
140
140
 
141
141
  traverseNode(node, (node: MNode) => {
142
142
  nodeStatusMap.value?.set(node.id, {
@@ -7,7 +7,7 @@ import { computed, markRaw, ref, useTemplateRef, watch } from 'vue';
7
7
  import { Bottom, Top } from '@element-plus/icons-vue';
8
8
 
9
9
  import { NodeType } from '@tmagic/core';
10
- import { isPage, isPageFragment } from '@tmagic/utils';
10
+ import { isPageOrFragment } from '@tmagic/utils';
11
11
 
12
12
  import ContentMenu from '@editor/components/ContentMenu.vue';
13
13
  import { useServices } from '@editor/hooks/use-services';
@@ -59,14 +59,14 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
59
59
  direction: 'horizontal',
60
60
  display: () => {
61
61
  if (!node.value) return false;
62
- return !isPage(node.value) && !isPageFragment(node.value);
62
+ return !isPageOrFragment(node.value);
63
63
  },
64
64
  },
65
65
  {
66
66
  type: 'button',
67
67
  text: '上移一层',
68
68
  icon: markRaw(Top),
69
- display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
69
+ display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
70
70
  handler: () => {
71
71
  editorService.moveLayer(1, { historySource: 'stage-contextmenu' });
72
72
  },
@@ -75,7 +75,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
75
75
  type: 'button',
76
76
  text: '下移一层',
77
77
  icon: markRaw(Bottom),
78
- display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
78
+ display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
79
79
  handler: () => {
80
80
  editorService.moveLayer(-1, { historySource: 'stage-contextmenu' });
81
81
  },
@@ -84,7 +84,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
84
84
  type: 'button',
85
85
  text: '置顶',
86
86
  icon: markRaw(Top),
87
- display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
87
+ display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
88
88
  handler: () => {
89
89
  editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' });
90
90
  },
@@ -93,7 +93,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
93
93
  type: 'button',
94
94
  text: '置底',
95
95
  icon: markRaw(Bottom),
96
- display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
96
+ display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
97
97
  handler: () => {
98
98
  editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' });
99
99
  },
@@ -102,7 +102,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
102
102
  {
103
103
  type: 'divider',
104
104
  direction: 'horizontal',
105
- display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
105
+ display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
106
106
  },
107
107
  useDeleteMenu('stage-contextmenu'),
108
108
  {
@@ -17,14 +17,14 @@
17
17
  */
18
18
  import { reactive, shallowReactive } from 'vue';
19
19
  import { throttle } from 'lodash-es';
20
- import serialize from 'serialize-javascript';
21
20
 
22
- import type { DepData, DepExtendedData, Id, MApp, MNode, Target, TargetNode } from '@tmagic/core';
21
+ import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
23
22
  import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
24
23
  import { isPage } from '@tmagic/utils';
25
24
 
25
+ import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
26
26
  import { IdleTask } from '@editor/utils/dep/idle-task';
27
- import Work from '@editor/utils/dep/worker.ts?worker&inline';
27
+ import type { DepsData } from '@editor/utils/dep/worker';
28
28
 
29
29
  import BaseService from './BaseService';
30
30
 
@@ -54,6 +54,7 @@ interface CollectBatch {
54
54
  dsPending: number;
55
55
  collectedEmitted: boolean;
56
56
  dsSettled: boolean;
57
+ aborted: boolean;
57
58
  resolve: (completed: boolean) => void;
58
59
  }
59
60
 
@@ -63,7 +64,9 @@ class Dep extends BaseService {
63
64
  taskLength: 0,
64
65
  });
65
66
 
66
- private idleTask = new IdleTask<{ node: TargetNode; deep: boolean; target: Target }>();
67
+ private idleTask = new IdleTask<void>();
68
+
69
+ private collectWorker = new CollectWorkerClient();
67
70
 
68
71
  private watcher = new Watcher({ initialTargets: reactive({}) });
69
72
 
@@ -153,6 +156,14 @@ class Dep extends BaseService {
153
156
  this.emit('ds-collected', nodes, deep);
154
157
  }
155
158
 
159
+ /**
160
+ * 空闲收集依赖
161
+ *
162
+ * 遍历匹配是整个收集过程中最耗时的部分(要深度遍历节点树并对每个属性做 target 匹配),
163
+ * 节点/数据源多时在主线程执行会长时间卡死页面,因此优先交给 worker 执行,主线程只负责把结果写回 target。
164
+ * 自定义 target 的 isTarget 是无法跨线程传递的闭包函数(没有 descriptor),仍走主线程空闲队列;
165
+ * worker 不可用(如 SSR)或执行失败时也会回退到主线程空闲队列。
166
+ */
156
167
  public async collectIdle(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) {
157
168
  if (this.waitingWorker) {
158
169
  await this.waitingWorker;
@@ -165,12 +176,23 @@ class Dep extends BaseService {
165
176
  dsPending: 0,
166
177
  collectedEmitted: false,
167
178
  dsSettled: false,
179
+ aborted: false,
168
180
  resolve: () => {},
169
181
  };
170
182
 
171
- this.watcher.collectByCallback(nodes, type, ({ node, target }) => {
172
- this.enqueueTask(node, target, depExtendedData, deep, batch);
173
- });
183
+ const workerTargets: Target[] = [];
184
+
185
+ for (const target of this.watcher.getCollectableTargets(type)) {
186
+ if (target.descriptor && this.collectWorker.isSupported) {
187
+ workerTargets.push(target);
188
+ } else {
189
+ this.enqueueTasks(nodes, target, depExtendedData, deep, batch);
190
+ }
191
+ }
192
+
193
+ if (workerTargets.length && nodes.length) {
194
+ this.collectByCollectWorker(nodes, workerTargets, depExtendedData, deep, batch);
195
+ }
174
196
 
175
197
  // 没有命中任何 target,无需收集,直接完成
176
198
  if (batch.pending === 0) {
@@ -197,18 +219,9 @@ class Dep extends BaseService {
197
219
  this.waitingWorker = promise;
198
220
  this.resolveWaitingWorker = waitingResolve;
199
221
 
200
- return new Promise<Record<string, Record<string, DepData>>>((resolve) => {
201
- const worker = new Work();
202
- worker.postMessage({
203
- dsl: serialize(dsl),
204
- });
205
- worker.onmessage = (e) => {
206
- resolve(e.data);
207
- };
208
- worker.onerror = () => {
209
- resolve({});
210
- };
211
- }).then((depsData) => {
222
+ return this.collectWorker.collectDsl(dsl).then((deps) => {
223
+ const depsData: DepsData = deps || {};
224
+
212
225
  if (generation !== this.workerGeneration) {
213
226
  waitingResolve();
214
227
  return depsData;
@@ -245,8 +258,12 @@ class Dep extends BaseService {
245
258
  // 先删除原有依赖,重新收集
246
259
  if (isPage(node)) {
247
260
  this.removePageDep(target, depExtendedData);
248
- } else {
261
+ } else if (deep) {
262
+ // deep 收集会覆盖整棵子树,删除也要同步清掉子孙旧依赖
249
263
  this.watcher.removeTargetDep(target, node);
264
+ } else {
265
+ // 非 deep 只动当前节点,避免误清子节点上其他尚未重收的依赖
266
+ target.removeDep(node.id);
250
267
  }
251
268
 
252
269
  this.watcher.collectItem(node, target, depExtendedData, deep);
@@ -271,6 +288,8 @@ class Dep extends BaseService {
271
288
  public clearIdleTasks() {
272
289
  this.abortActiveBatches();
273
290
  this.idleTask.clearTasks();
291
+ // 丢掉 worker 在途任务,避免 abort 后的长任务继续占着常驻 worker
292
+ this.collectWorker.abort();
274
293
  }
275
294
 
276
295
  public on<Name extends keyof DepEvents, Param extends DepEvents[Name]>(
@@ -294,6 +313,7 @@ class Dep extends BaseService {
294
313
  this.resolveWaitingWorker = undefined;
295
314
  this.waitingWorker = undefined;
296
315
  this.idleTask.clearTasks();
316
+ this.collectWorker.abort();
297
317
 
298
318
  for (const type of Object.keys(this.watcher.getTargetsList())) {
299
319
  this.removeTargets(type);
@@ -309,6 +329,7 @@ class Dep extends BaseService {
309
329
  this.reset();
310
330
  this.removeAllPlugins();
311
331
  this.idleTask.removeAllListeners();
332
+ this.collectWorker.terminate();
312
333
  }
313
334
 
314
335
  public emit<Name extends keyof DepEvents, Param extends DepEvents[Name]>(eventName: Name, ...args: Param) {
@@ -328,6 +349,110 @@ class Dep extends BaseService {
328
349
  }
329
350
  }
330
351
 
352
+ /**
353
+ * 把遍历匹配交给 worker,结果回来后按 target 分片写回主线程
354
+ */
355
+ private collectByCollectWorker(
356
+ nodes: MNode[],
357
+ targets: Target[],
358
+ depExtendedData: DepExtendedData,
359
+ deep: boolean,
360
+ batch: CollectBatch,
361
+ ) {
362
+ const hasDataSource = targets.some((target) => target.type === DepTargetType.DATA_SOURCE);
363
+
364
+ // 先占位,避免 worker 返回前批次被误判为已完成(提前 resolve、collecting 提前复位)
365
+ this.beginBatchTask(batch, hasDataSource);
366
+
367
+ this.collectWorker
368
+ .collect({
369
+ nodes,
370
+ targets: targets.map((target) => target.descriptor!),
371
+ depExtendedData,
372
+ deep,
373
+ })
374
+ .then((result) => {
375
+ if (batch.aborted) return;
376
+
377
+ for (const target of targets) {
378
+ if (result) {
379
+ // 写回是纯字典操作,但数据量大时同样会占用主线程,因此按 target 拆成空闲任务
380
+ this.enqueueApplyTask(target, result.deps[target.type]?.[target.id] || {}, result.nodeIds, batch, {
381
+ nodes,
382
+ depExtendedData,
383
+ deep,
384
+ });
385
+ } else {
386
+ this.enqueueTasks(nodes, target, depExtendedData, deep, batch);
387
+ }
388
+ }
389
+ })
390
+ .finally(() => {
391
+ // 释放占位:必须在新任务入队之后,否则批次会在中途被判定为完成
392
+ this.onBatchTaskDone(batch, hasDataSource);
393
+ });
394
+ }
395
+
396
+ /**
397
+ * 用 worker 的收集结果替换 target 上这批节点的依赖
398
+ */
399
+ private enqueueApplyTask(
400
+ target: Target,
401
+ deps: DepData,
402
+ nodeIds: Id[],
403
+ batch: CollectBatch,
404
+ { nodes, depExtendedData, deep }: { nodes: MNode[]; depExtendedData: DepExtendedData; deep: boolean },
405
+ ) {
406
+ const isDataSource = target.type === DepTargetType.DATA_SOURCE;
407
+
408
+ this.beginBatchTask(batch, isDataSource);
409
+
410
+ this.idleTask.enqueueTask(
411
+ () => {
412
+ try {
413
+ // target 可能在 worker 收集期间被移除或重建(如修改数据源字段)
414
+ const current = this.watcher.getTarget(target.id, target.type);
415
+ if (current !== target) {
416
+ // 已被同 id 的新 target 替换时,用当前 target 在主线程补收,避免依赖空窗
417
+ if (current) {
418
+ this.enqueueTasks(nodes, current, depExtendedData, deep, batch);
419
+ }
420
+ return;
421
+ }
422
+
423
+ // 页面按 pageId 匹配删除,可清掉页面内已被删除节点的残留依赖
424
+ if (nodes.some((node) => isPage(node))) {
425
+ this.removePageDep(target, depExtendedData);
426
+ }
427
+
428
+ for (const id of nodeIds) {
429
+ target.removeDep(id);
430
+ }
431
+
432
+ for (const [id, dep] of Object.entries(deps)) {
433
+ target.deps[id] = dep;
434
+ }
435
+ } finally {
436
+ this.onBatchTaskDone(batch, isDataSource);
437
+ }
438
+ },
439
+ undefined,
440
+ isDataSource,
441
+ );
442
+ }
443
+
444
+ private enqueueTasks(
445
+ nodes: MNode[],
446
+ target: Target,
447
+ depExtendedData: DepExtendedData,
448
+ deep: boolean,
449
+ batch: CollectBatch,
450
+ ) {
451
+ for (const node of nodes) {
452
+ this.enqueueTask(node, target, depExtendedData, deep, batch);
453
+ }
454
+ }
455
+
331
456
  private enqueueTask(
332
457
  node: MNode,
333
458
  target: Target,
@@ -337,24 +462,17 @@ class Dep extends BaseService {
337
462
  ) {
338
463
  const isDataSource = target.type === DepTargetType.DATA_SOURCE;
339
464
 
340
- batch.pending += 1;
341
- if (isDataSource) {
342
- batch.dsPending += 1;
343
- }
465
+ this.beginBatchTask(batch, isDataSource);
344
466
 
345
467
  this.idleTask.enqueueTask(
346
- ({ node, deep, target }) => {
468
+ () => {
347
469
  try {
348
- this.collectNode(node, target, depExtendedData, deep);
470
+ this.collectNode(node, target, depExtendedData);
349
471
  } finally {
350
472
  this.onBatchTaskDone(batch, isDataSource);
351
473
  }
352
474
  },
353
- {
354
- node,
355
- deep: false,
356
- target,
357
- },
475
+ undefined,
358
476
  isDataSource,
359
477
  );
360
478
 
@@ -365,7 +483,16 @@ class Dep extends BaseService {
365
483
  }
366
484
  }
367
485
 
486
+ private beginBatchTask(batch: CollectBatch, isDataSource: boolean) {
487
+ batch.pending += 1;
488
+ if (isDataSource) {
489
+ batch.dsPending += 1;
490
+ }
491
+ }
492
+
368
493
  private onBatchTaskDone(batch: CollectBatch, isDataSource: boolean) {
494
+ if (batch.aborted) return;
495
+
369
496
  if (isDataSource) {
370
497
  batch.dsPending -= 1;
371
498
  // 数据源依赖收集完先 resolve,让 stage 尽快更新,其余依赖继续在后台收集
@@ -412,6 +539,8 @@ class Dep extends BaseService {
412
539
  this.activeBatches.clear();
413
540
 
414
541
  for (const batch of batches) {
542
+ // 标记中断:worker 结果可能在之后才返回,此时不能再写回依赖,也不能再结算批次
543
+ batch.aborted = true;
415
544
  batch.resolve(false);
416
545
  }
417
546
 
@@ -16,7 +16,7 @@
16
16
  * limitations under the License.
17
17
  */
18
18
 
19
- import { reactive, toRaw } from 'vue';
19
+ import { nextTick, reactive, toRaw } from 'vue';
20
20
  import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
21
21
 
22
22
  import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
@@ -30,6 +30,7 @@ import {
30
30
  guid,
31
31
  isPage,
32
32
  isPageFragment,
33
+ isPageOrFragment,
33
34
  setValueByKeyPath,
34
35
  traverseNode,
35
36
  } from '@tmagic/utils';
@@ -272,7 +273,7 @@ class Editor extends BaseService {
272
273
  public isOnDifferentPage(node: MNode): boolean {
273
274
  const currentPageId = this.get('page')?.id;
274
275
  if (currentPageId === undefined || currentPageId === null) return false;
275
- if (isPage(node) || isPageFragment(node)) {
276
+ if (isPageOrFragment(node)) {
276
277
  return `${node.id}` !== `${currentPageId}`;
277
278
  }
278
279
  const nodePage = this.getNodeInfo(node.id, false).page;
@@ -413,11 +414,11 @@ class Editor extends BaseService {
413
414
 
414
415
  if (!curNode) throw new Error('当前选中节点为空');
415
416
 
416
- if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !(isPage(node) || isPageFragment(node))) {
417
+ if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !isPageOrFragment(node)) {
417
418
  throw new Error('app下不能添加组件');
418
419
  }
419
420
 
420
- if (parent.id !== curNode.id && !(isPage(node) || isPageFragment(node))) {
421
+ if (parent.id !== curNode.id && !isPageOrFragment(node)) {
421
422
  const index = parent.items.indexOf(curNode);
422
423
  parent.items?.splice(index + 1, 0, node);
423
424
  } else {
@@ -487,7 +488,7 @@ class Editor extends BaseService {
487
488
  const newNodes = await Promise.all(
488
489
  addNodes.map((node) => {
489
490
  const root = this.get('root');
490
- if ((isPage(node) || isPageFragment(node)) && root) {
491
+ if (isPageOrFragment(node) && root) {
491
492
  return this.doAdd(node, root);
492
493
  }
493
494
  const parentNode = parent ?? getAddParent(node);
@@ -523,7 +524,7 @@ class Editor extends BaseService {
523
524
  }
524
525
  }
525
526
 
526
- if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
527
+ if (!isPageOrFragment(newNodes[0])) {
527
528
  const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
528
529
  if (!doNotPushHistory) {
529
530
  const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
@@ -554,8 +555,8 @@ class Editor extends BaseService {
554
555
  doNotPushHistory,
555
556
  });
556
557
 
557
- // 页面 / 页面片新增不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知
558
- const addedPages = newNodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[];
558
+ // 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
559
+ const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
559
560
  if (addedPages.length) {
560
561
  historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
561
562
  }
@@ -578,12 +579,14 @@ class Editor extends BaseService {
578
579
 
579
580
  if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点');
580
581
 
581
- parent.items?.splice(index, 1);
582
582
  const stage = this.get('stage');
583
- stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
583
+ const currentPage = this.get('page');
584
+ const isDeletingCurrentPage = isPageOrFragment(node) && !!currentPage && `${currentPage.id}` === `${node.id}`;
585
+
586
+ parent.items?.splice(index, 1);
584
587
 
585
588
  // 始终清理已删除节点在 state 中的残留引用:
586
- // - 即使后续会调用 selectDefault / select(parent) 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点
589
+ // - 即使后续会调用 select 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点
587
590
  const selectedNodes = this.get('nodes');
588
591
  const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
589
592
  if (removedSelectedIndex !== -1) {
@@ -591,38 +594,44 @@ class Editor extends BaseService {
591
594
  nextSelected.splice(removedSelectedIndex, 1);
592
595
  this.set('nodes', nextSelected);
593
596
  }
594
- if (isPage(node) || isPageFragment(node)) {
595
- const currentPage = this.get('page');
596
- if (currentPage && `${currentPage.id}` === `${node.id}`) {
597
- this.set('page', null);
598
- }
599
- }
600
-
601
- const selectDefault = async (pages: MNode[]) => {
602
- if (pages[0]) {
603
- await this.select(pages[0]);
604
- stage?.select(pages[0].id);
605
- } else {
606
- this.selectRoot();
607
- }
608
- };
609
597
 
598
+ const removeData = { id: node.id, parentId: parent.id, root: cloneDeep(root) };
610
599
  const rootItems = root.items || [];
611
-
612
- if (isPage(node)) {
613
- this.state.pageLength -= 1;
614
-
615
- // 删除页面后默认会切到首个剩余页面(selectDefault),doNotSwitchPage 时跳过这次自动切换
616
- if (!doNotSelect && !doNotSwitchPage) {
617
- await selectDefault(rootItems);
600
+ // 删非当前页时画布不应该变动,所以只有删的是当前页才重新选中
601
+ const shouldReselect = isDeletingCurrentPage && !doNotSelect && !doNotSwitchPage;
602
+ // 还有剩余页面才能切过去,否则退回选中 root
603
+ const shouldSwitchPage = shouldReselect && rootItems.length > 0;
604
+
605
+ if (isPageOrFragment(node)) {
606
+ if (isPage(node)) {
607
+ this.state.pageLength -= 1;
608
+ } else {
609
+ this.state.pageFragmentLength -= 1;
618
610
  }
619
- } else if (isPageFragment(node)) {
620
- this.state.pageFragmentLength -= 1;
621
611
 
622
- if (!doNotSelect && !doNotSwitchPage) {
623
- await selectDefault(rootItems);
612
+ if (shouldSwitchPage) {
613
+ // runtime 收到页面删除时会销毁当前渲染的 page 实例,必须先把画布切到剩余页面再通知删除,
614
+ // 否则画布会被清空,且要等下一次 updatePageId 才能恢复。
615
+ // nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
616
+ await this.select(rootItems[0]);
617
+ stage?.select(rootItems[0].id);
618
+ await nextTick();
619
+ stage?.remove(removeData);
620
+ } else {
621
+ // page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出,
622
+ // 且不能 await:runtime 未 ready 时 getRuntime 的监听会随 destroy 一起被移除,永远不会 resolve
623
+ stage?.remove(removeData);
624
+
625
+ if (shouldReselect) {
626
+ // 页面已全部删完
627
+ this.selectRoot();
628
+ } else if (isDeletingCurrentPage) {
629
+ this.set('page', null);
630
+ }
624
631
  }
625
632
  } else {
633
+ stage?.remove(removeData);
634
+
626
635
  if (!doNotSelect) {
627
636
  await this.select(parent);
628
637
  stage?.select(parent.id);
@@ -664,7 +673,7 @@ class Editor extends BaseService {
664
673
 
665
674
  const removedItems: StepDiffItem<MNode>[] = [];
666
675
  let pageForOp: { name: string; id: Id } | null = null;
667
- if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
676
+ if (!isPageOrFragment(nodes[0])) {
668
677
  for (const n of nodes) {
669
678
  const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
670
679
  if (parent && curNode) {
@@ -702,8 +711,8 @@ class Editor extends BaseService {
702
711
  this.emit('remove', nodes);
703
712
  this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
704
713
 
705
- // 页面 / 页面片删除不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知
706
- const removedPages = nodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[];
714
+ // 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
715
+ const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
707
716
  if (removedPages.length) {
708
717
  historyService.notifyPageStructureChange({ add: [], remove: removedPages });
709
718
  }
@@ -764,7 +773,7 @@ class Editor extends BaseService {
764
773
  }
765
774
 
766
775
  // 只有被更新节点正好是当前选中页面时才同步 state.page,避免「更新非当前页」误将编辑器切到该页
767
- if (isPage(newConfig) || isPageFragment(newConfig)) {
776
+ if (isPageOrFragment(newConfig)) {
768
777
  const currentPage = this.get('page');
769
778
  if (currentPage && `${currentPage.id}` === `${newConfig.id}`) {
770
779
  this.set('page', newConfig as MPage | MPageFragment);
@@ -1139,7 +1148,7 @@ class Editor extends BaseService {
1139
1148
  }: DslOpOptions = {},
1140
1149
  ): Promise<MNode | MNode[]> {
1141
1150
  const isBatch = Array.isArray(config);
1142
- const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
1151
+ const configs = (isBatch ? config : [config]).filter((item) => !isPageOrFragment(item));
1143
1152
 
1144
1153
  if (configs.length === 0) {
1145
1154
  throw new Error('没有可移动的节点');
@@ -1760,7 +1769,7 @@ class Editor extends BaseService {
1760
1769
  private getPageOfNode(id: Id): MPage | MPageFragment | null {
1761
1770
  const { node, page } = this.getNodeInfo(id, false);
1762
1771
  if (page) return page;
1763
- if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment;
1772
+ if (node && isPageOrFragment(node)) return node as MPage | MPageFragment;
1764
1773
  return null;
1765
1774
  }
1766
1775
 
@@ -357,7 +357,7 @@ class History extends BaseService {
357
357
  * 派发「页面 / 页面片结构变更」事件(`page-structure-change`)。
358
358
  *
359
359
  * 常规 `editorService.add` / `remove` 页面节点不会写入 `page` 历史栈(见 editor.add / remove 中
360
- * 对 isPage / isPageFragment 的分支),因此不会产生任何 historyService 事件。该方法用于在这些
360
+ * 对 isPageOrFragment 的分支),因此不会产生任何 historyService 事件。该方法用于在这些
361
361
  * 场景(以及 setRoot 整体替换 DSL 增删页面)下,向外统一通知页面结构的增删变化,供业务方感知。
362
362
  *
363
363
  * 一次操作涉及多个页面时,调用方应把本次增删的页面**合并为一个 change 一次性传入**,