@craftile/core 1.2.1 → 1.3.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@craftile/core",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Framework-agnostic block editor engine for Craftile",
5
5
  "keywords": [
6
6
  "craftile",
@@ -26,8 +26,8 @@
26
26
  "src"
27
27
  ],
28
28
  "dependencies": {
29
- "@craftile/event-bus": "1.2.1",
30
- "@craftile/types": "1.2.1"
29
+ "@craftile/event-bus": "1.3.0",
30
+ "@craftile/types": "1.3.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "vitest": "^3.2.4",
@@ -9,7 +9,7 @@ export interface DuplicateBlockOptions {
9
9
  }
10
10
 
11
11
  export class DuplicateBlockCommand implements Command {
12
- private page: Page;
12
+ private getPage: () => Page;
13
13
  private blockId: string;
14
14
 
15
15
  // State for reverting
@@ -20,25 +20,31 @@ export class DuplicateBlockCommand implements Command {
20
20
  private regionId?: string | null;
21
21
  private emit: EngineEmitFn;
22
22
 
23
- constructor(page: Page, options: DuplicateBlockOptions) {
24
- this.page = page;
23
+ constructor(getPage: () => Page, options: DuplicateBlockOptions) {
24
+ this.getPage = getPage;
25
25
  this.blockId = options.blockId;
26
26
  this.emit = options.emit;
27
27
  }
28
28
 
29
29
  apply(): void {
30
- const originalBlock = this.page.blocks[this.blockId];
30
+ const page = this.getPage();
31
+ const originalBlock = page.blocks[this.blockId];
31
32
  if (!originalBlock) {
32
33
  throw new Error(`Block not found: ${this.blockId}`);
33
34
  }
34
35
 
35
36
  this.duplicatedBlockId = generateId();
36
37
 
37
- this.duplicatedBlock = this.cloneBlockWithNewIds(originalBlock, this.duplicatedBlockId, originalBlock.parentId);
38
+ this.duplicatedBlock = this.cloneBlockWithNewIds(
39
+ page,
40
+ originalBlock,
41
+ this.duplicatedBlockId,
42
+ originalBlock.parentId
43
+ );
38
44
 
39
- this.determineInsertLocation();
45
+ this.determineInsertLocation(page);
40
46
 
41
- this.insertDuplicatedBlock();
47
+ this.insertDuplicatedBlock(page);
42
48
 
43
49
  this.emit('block:duplicate', {
44
50
  originalBlockId: this.blockId,
@@ -55,7 +61,7 @@ export class DuplicateBlockCommand implements Command {
55
61
  return;
56
62
  }
57
63
 
58
- this.removeDuplicatedBlock();
64
+ this.removeDuplicatedBlock(this.getPage());
59
65
  }
60
66
 
61
67
  getBlockId(): string {
@@ -82,7 +88,7 @@ export class DuplicateBlockCommand implements Command {
82
88
  return this.regionId;
83
89
  }
84
90
 
85
- private cloneBlockWithNewIds(block: Block, newId: string, newParentId?: string): Block {
91
+ private cloneBlockWithNewIds(page: Page, block: Block, newId: string, newParentId?: string): Block {
86
92
  const clonedBlock: Block = {
87
93
  ...structuredClone(block),
88
94
  id: newId,
@@ -92,27 +98,27 @@ export class DuplicateBlockCommand implements Command {
92
98
 
93
99
  if (block.children && block.children.length > 0) {
94
100
  for (const childId of block.children) {
95
- const childBlock = this.page.blocks[childId];
101
+ const childBlock = page.blocks[childId];
96
102
  if (childBlock) {
97
103
  const newChildId = generateId();
98
- this.cloneBlockWithNewIds(childBlock, newChildId, newId);
104
+ this.cloneBlockWithNewIds(page, childBlock, newChildId, newId);
99
105
  clonedBlock.children.push(newChildId);
100
106
  }
101
107
  }
102
108
  }
103
109
 
104
- this.page.blocks[newId] = clonedBlock;
110
+ page.blocks[newId] = clonedBlock;
105
111
 
106
112
  return clonedBlock;
107
113
  }
108
114
 
109
- private determineInsertLocation(): void {
110
- const originalBlock = this.page.blocks[this.blockId];
115
+ private determineInsertLocation(page: Page): void {
116
+ const originalBlock = page.blocks[this.blockId];
111
117
 
112
118
  this.parentId = originalBlock.parentId || null;
113
119
 
114
120
  if (this.parentId) {
115
- const parent = this.page.blocks[this.parentId];
121
+ const parent = page.blocks[this.parentId];
116
122
  if (parent) {
117
123
  const index = parent.children.indexOf(this.blockId);
118
124
  this.insertIndex = index !== -1 ? index + 1 : parent.children.length;
@@ -120,52 +126,52 @@ export class DuplicateBlockCommand implements Command {
120
126
  this.insertIndex = 0;
121
127
  }
122
128
  } else {
123
- const region = this.page.regions.find((r) => r.blocks.includes(this.blockId));
129
+ const region = page.regions.find((r) => r.blocks.includes(this.blockId));
124
130
  if (region) {
125
131
  const index = region.blocks.indexOf(this.blockId);
126
132
  this.regionId = getRegionId(region);
127
133
  this.insertIndex = index + 1;
128
134
  } else {
129
- this.regionId = getRegionId(this.page.regions[0]) || 'main';
130
- this.insertIndex = this.page.regions[0].blocks.length || 0;
135
+ this.regionId = getRegionId(page.regions[0]) || 'main';
136
+ this.insertIndex = page.regions[0].blocks.length || 0;
131
137
  }
132
138
  }
133
139
  }
134
140
 
135
- private insertDuplicatedBlock(): void {
141
+ private insertDuplicatedBlock(page: Page): void {
136
142
  if (!this.duplicatedBlock) {
137
143
  return;
138
144
  }
139
145
 
140
146
  if (this.parentId) {
141
- const targetParent = this.page.blocks[this.parentId];
147
+ const targetParent = page.blocks[this.parentId];
142
148
  if (!targetParent) {
143
149
  throw new Error(`Parent block not found: ${this.parentId}`);
144
150
  }
145
151
 
146
152
  targetParent.children.splice(this.insertIndex, 0, this.duplicatedBlockId);
147
153
  } else {
148
- let targetRegion = this.page.regions.find((r) => getRegionId(r) === this.regionId);
154
+ let targetRegion = page.regions.find((r) => getRegionId(r) === this.regionId);
149
155
 
150
156
  if (!targetRegion) {
151
157
  targetRegion = { name: this.regionId || 'main', blocks: [] };
152
- this.page.regions.push(targetRegion);
158
+ page.regions.push(targetRegion);
153
159
  }
154
160
 
155
161
  targetRegion.blocks.splice(this.insertIndex, 0, this.duplicatedBlockId);
156
162
  }
157
163
  }
158
164
 
159
- private removeDuplicatedBlock(): void {
165
+ private removeDuplicatedBlock(page: Page): void {
160
166
  if (!this.duplicatedBlockId) {
161
167
  return;
162
168
  }
163
169
 
164
- this.removeBlockAndChildren(this.duplicatedBlockId);
170
+ this.removeBlockAndChildren(page, this.duplicatedBlockId);
165
171
 
166
172
  if (this.parentId) {
167
173
  // Remove from parent's children array
168
- const targetParent = this.page.blocks[this.parentId];
174
+ const targetParent = page.blocks[this.parentId];
169
175
  if (targetParent) {
170
176
  const index = targetParent.children.indexOf(this.duplicatedBlockId);
171
177
  if (index !== -1) {
@@ -174,8 +180,8 @@ export class DuplicateBlockCommand implements Command {
174
180
  }
175
181
  } else {
176
182
  // Remove from region
177
- if (this.page.regions) {
178
- const targetRegion = this.page.regions.find((r) => getRegionId(r) === this.regionId);
183
+ if (page.regions) {
184
+ const targetRegion = page.regions.find((r) => getRegionId(r) === this.regionId);
179
185
  if (targetRegion) {
180
186
  const index = targetRegion.blocks.indexOf(this.duplicatedBlockId);
181
187
  if (index !== -1) {
@@ -186,16 +192,16 @@ export class DuplicateBlockCommand implements Command {
186
192
  }
187
193
  }
188
194
 
189
- private removeBlockAndChildren(blockId: string): void {
190
- const block = this.page.blocks[blockId];
195
+ private removeBlockAndChildren(page: Page, blockId: string): void {
196
+ const block = page.blocks[blockId];
191
197
  if (!block) {
192
198
  return;
193
199
  }
194
200
 
195
201
  for (const childId of block.children) {
196
- this.removeBlockAndChildren(childId);
202
+ this.removeBlockAndChildren(page, childId);
197
203
  }
198
204
 
199
- delete this.page.blocks[blockId];
205
+ delete page.blocks[blockId];
200
206
  }
201
207
  }
@@ -15,7 +15,7 @@ export interface InsertBlockFromPresetOptions {
15
15
  }
16
16
 
17
17
  export class InsertBlockFromPresetCommand implements Command {
18
- private page: Page;
18
+ private getPage: () => Page;
19
19
  private blockType: string;
20
20
  private presetIndex?: number;
21
21
  private presetData?: BlockStructure;
@@ -31,8 +31,8 @@ export class InsertBlockFromPresetCommand implements Command {
31
31
  private emit: EngineEmitFn;
32
32
  private createdBlockIds: string[] = [];
33
33
 
34
- constructor(page: Page, options: InsertBlockFromPresetOptions) {
35
- this.page = page;
34
+ constructor(getPage: () => Page, options: InsertBlockFromPresetOptions) {
35
+ this.getPage = getPage;
36
36
  this.blockType = options.blockType;
37
37
  this.presetIndex = options.presetIndex;
38
38
  this.presetData = options.presetData;
@@ -69,7 +69,8 @@ export class InsertBlockFromPresetCommand implements Command {
69
69
  }
70
70
 
71
71
  apply(): void {
72
- const target = resolveInsertTarget(this.page, this.parentId, this.regionId, this.index);
72
+ const page = this.getPage();
73
+ const target = resolveInsertTarget(page, this.parentId, this.regionId, this.index);
73
74
  this.resolvedRegionId = target.kind === 'region' ? target.regionId : undefined;
74
75
 
75
76
  const blockSchema = this.blocksManager.get(this.blockType);
@@ -86,11 +87,11 @@ export class InsertBlockFromPresetCommand implements Command {
86
87
  parentId: undefined,
87
88
  };
88
89
 
89
- this.page.blocks[this.blockId] = this.insertedBlock;
90
+ page.blocks[this.blockId] = this.insertedBlock;
90
91
  this.createdBlockIds.push(this.blockId);
91
92
 
92
93
  if (this.presetData.children && this.presetData.children.length > 0) {
93
- this.insertedBlock.children = this.createChildrenFromPreset(this.presetData.children, this.blockId);
94
+ this.insertedBlock.children = this.createChildrenFromPreset(page, this.presetData.children, this.blockId);
94
95
  }
95
96
  } else if (this.presetIndex !== undefined) {
96
97
  const preset = blockSchema?.presets?.[this.presetIndex];
@@ -107,11 +108,11 @@ export class InsertBlockFromPresetCommand implements Command {
107
108
  parentId: undefined,
108
109
  };
109
110
 
110
- this.page.blocks[this.blockId] = this.insertedBlock;
111
+ page.blocks[this.blockId] = this.insertedBlock;
111
112
  this.createdBlockIds.push(this.blockId);
112
113
 
113
114
  if (preset.children && preset.children.length > 0) {
114
- this.insertedBlock.children = this.createChildrenFromPreset(preset.children, this.blockId);
115
+ this.insertedBlock.children = this.createChildrenFromPreset(page, preset.children, this.blockId);
115
116
  }
116
117
  }
117
118
 
@@ -125,7 +126,7 @@ export class InsertBlockFromPresetCommand implements Command {
125
126
  this.insertedBlock.parentId = target.parent.id;
126
127
  target.parent.children.splice(target.index, 0, this.blockId);
127
128
  } else {
128
- const region = this.page.regions.find((r) => getRegionId(r) === target.regionId)!;
129
+ const region = page.regions.find((r) => getRegionId(r) === target.regionId)!;
129
130
  region.blocks.splice(target.index, 0, this.blockId);
130
131
  }
131
132
 
@@ -134,7 +135,7 @@ export class InsertBlockFromPresetCommand implements Command {
134
135
  block: this.insertedBlock,
135
136
  parentId: this.parentId,
136
137
  index: this.actualIndex,
137
- regionId: this.regionId || getRegionId(this.page.regions[0]),
138
+ regionId: this.regionId || getRegionId(page.regions[0]),
138
139
  });
139
140
  }
140
141
 
@@ -143,21 +144,24 @@ export class InsertBlockFromPresetCommand implements Command {
143
144
  return;
144
145
  }
145
146
 
147
+ const page = this.getPage();
148
+
146
149
  // Remove all created blocks
147
150
  for (const blockId of this.createdBlockIds) {
148
- delete this.page.blocks[blockId];
151
+ delete page.blocks[blockId];
149
152
  }
150
153
 
151
154
  if (this.parentId) {
152
- const parent = this.page.blocks[this.parentId];
153
- if (parent && this.actualIndex !== undefined) {
154
- parent.children.splice(this.actualIndex, 1);
155
+ const parent = page.blocks[this.parentId];
156
+ const index = parent ? parent.children.indexOf(this.blockId) : -1;
157
+ if (parent && index !== -1) {
158
+ parent.children.splice(index, 1);
155
159
  }
156
160
  } else if (this.resolvedRegionId) {
157
- const targetRegion = this.page.regions.find((r) => getRegionId(r) === this.resolvedRegionId);
158
-
159
- if (targetRegion && this.actualIndex !== undefined) {
160
- targetRegion.blocks.splice(this.actualIndex, 1);
161
+ const targetRegion = page.regions.find((r) => getRegionId(r) === this.resolvedRegionId);
162
+ const index = targetRegion ? targetRegion.blocks.indexOf(this.blockId) : -1;
163
+ if (targetRegion && index !== -1) {
164
+ targetRegion.blocks.splice(index, 1);
161
165
  }
162
166
  }
163
167
 
@@ -177,7 +181,7 @@ export class InsertBlockFromPresetCommand implements Command {
177
181
  return this.insertedBlock;
178
182
  }
179
183
 
180
- private createChildrenFromPreset(structures: BlockStructure[], parentId: string): string[] {
184
+ private createChildrenFromPreset(page: Page, structures: BlockStructure[], parentId: string): string[] {
181
185
  const childIds: string[] = [];
182
186
 
183
187
  for (const structure of structures) {
@@ -202,12 +206,12 @@ export class InsertBlockFromPresetCommand implements Command {
202
206
  parentId,
203
207
  };
204
208
 
205
- this.page.blocks[childId] = childBlock;
209
+ page.blocks[childId] = childBlock;
206
210
  this.createdBlockIds.push(childId);
207
211
 
208
212
  // Recursively create nested children
209
213
  if (structure.children && structure.children.length > 0) {
210
- childBlock.children = this.createChildrenFromPreset(structure.children, childId);
214
+ childBlock.children = this.createChildrenFromPreset(page, structure.children, childId);
211
215
  }
212
216
 
213
217
  childIds.push(childId);
@@ -13,7 +13,7 @@ export interface InsertBlockOptions {
13
13
  }
14
14
 
15
15
  export class InsertBlockCommand implements Command {
16
- private page: Page;
16
+ private getPage: () => Page;
17
17
  private blockType: string;
18
18
  private parentId?: string;
19
19
  private regionId?: string;
@@ -26,8 +26,8 @@ export class InsertBlockCommand implements Command {
26
26
  private resolvedRegionId?: string;
27
27
  private emit: EngineEmitFn;
28
28
 
29
- constructor(page: Page, options: InsertBlockOptions) {
30
- this.page = page;
29
+ constructor(getPage: () => Page, options: InsertBlockOptions) {
30
+ this.getPage = getPage;
31
31
  this.blockType = options.blockType;
32
32
  this.parentId = options.parentId;
33
33
  this.regionId = options.regionId;
@@ -40,7 +40,8 @@ export class InsertBlockCommand implements Command {
40
40
  }
41
41
 
42
42
  apply(): void {
43
- const target = resolveInsertTarget(this.page, this.parentId, this.regionId, this.index);
43
+ const page = this.getPage();
44
+ const target = resolveInsertTarget(page, this.parentId, this.regionId, this.index);
44
45
  this.resolvedRegionId = target.kind === 'region' ? target.regionId : undefined;
45
46
 
46
47
  const blockName = this.blockSchema?.meta?.name || this.blockType;
@@ -54,14 +55,14 @@ export class InsertBlockCommand implements Command {
54
55
  parentId: undefined,
55
56
  };
56
57
 
57
- this.page.blocks[this.blockId] = this.insertedBlock;
58
+ page.blocks[this.blockId] = this.insertedBlock;
58
59
  this.actualIndex = target.index;
59
60
 
60
61
  if (target.kind === 'parent') {
61
62
  this.insertedBlock.parentId = target.parent.id;
62
63
  target.parent.children.splice(target.index, 0, this.blockId);
63
64
  } else {
64
- const region = this.page.regions.find((r) => getRegionId(r) === target.regionId)!;
65
+ const region = page.regions.find((r) => getRegionId(r) === target.regionId)!;
65
66
  region.blocks.splice(target.index, 0, this.blockId);
66
67
  }
67
68
 
@@ -70,7 +71,7 @@ export class InsertBlockCommand implements Command {
70
71
  block: this.insertedBlock,
71
72
  parentId: this.parentId,
72
73
  index: this.actualIndex,
73
- regionId: this.regionId || getRegionId(this.page.regions[0]),
74
+ regionId: this.regionId || getRegionId(page.regions[0]),
74
75
  });
75
76
  }
76
77
 
@@ -79,18 +80,21 @@ export class InsertBlockCommand implements Command {
79
80
  return;
80
81
  }
81
82
 
82
- delete this.page.blocks[this.blockId];
83
+ const page = this.getPage();
84
+
85
+ delete page.blocks[this.blockId];
83
86
 
84
87
  if (this.parentId) {
85
- const parent = this.page.blocks[this.parentId];
86
- if (parent && this.actualIndex !== undefined) {
87
- parent.children.splice(this.actualIndex, 1);
88
+ const parent = page.blocks[this.parentId];
89
+ const index = parent ? parent.children.indexOf(this.blockId) : -1;
90
+ if (parent && index !== -1) {
91
+ parent.children.splice(index, 1);
88
92
  }
89
93
  } else if (this.resolvedRegionId) {
90
- const targetRegion = this.page.regions.find((r) => getRegionId(r) === this.resolvedRegionId);
91
-
92
- if (targetRegion && this.actualIndex !== undefined) {
93
- targetRegion.blocks.splice(this.actualIndex, 1);
94
+ const targetRegion = page.regions.find((r) => getRegionId(r) === this.resolvedRegionId);
95
+ const index = targetRegion ? targetRegion.blocks.indexOf(this.blockId) : -1;
96
+ if (targetRegion && index !== -1) {
97
+ targetRegion.blocks.splice(index, 1);
94
98
  }
95
99
  }
96
100
 
@@ -1,4 +1,4 @@
1
- import type { Block, Page } from '@craftile/types';
1
+ import type { Page } from '@craftile/types';
2
2
  import type { Command, EngineEmitFn } from '../types';
3
3
  import type { ResolvedTarget } from '../utils';
4
4
  import { canInsertDynamicChildAt, clampIndex, getRegionId, resolveRegionId } from '../utils';
@@ -12,22 +12,22 @@ export interface MoveBlockOptions {
12
12
  }
13
13
 
14
14
  export class MoveBlockCommand implements Command {
15
- private page: Page;
15
+ private getPage: () => Page;
16
16
  private blockId: string;
17
17
  private targetParentId?: string;
18
18
  private targetIndex?: number;
19
19
  private targetRegionId?: string;
20
20
 
21
21
  // State for reverting
22
- private blockToMove?: Block;
22
+ private moved = false;
23
23
  private originalParentId?: string;
24
24
  private originalIndex!: number;
25
25
  private originalRegionId?: string;
26
26
 
27
27
  private emit: EngineEmitFn;
28
28
 
29
- constructor(page: Page, options: MoveBlockOptions) {
30
- this.page = page;
29
+ constructor(getPage: () => Page, options: MoveBlockOptions) {
30
+ this.getPage = getPage;
31
31
  this.blockId = options.blockId;
32
32
  this.targetParentId = options.targetParentId;
33
33
  this.targetIndex = options.targetIndex;
@@ -36,29 +36,37 @@ export class MoveBlockCommand implements Command {
36
36
  }
37
37
 
38
38
  apply(): void {
39
- const target = this.validateAndResolveTarget();
39
+ const page = this.getPage();
40
+ const blockToMove = page.blocks[this.blockId];
41
+ if (!blockToMove) {
42
+ throw new Error(`Block not found: ${this.blockId}`);
43
+ }
44
+
45
+ const target = this.validateAndResolveTarget(page);
40
46
 
41
47
  if (this.originalParentId) {
42
- const sourceParent = this.page.blocks[this.originalParentId];
48
+ const sourceParent = page.blocks[this.originalParentId];
43
49
  if (sourceParent && this.originalIndex !== -1) {
44
50
  sourceParent.children.splice(this.originalIndex, 1);
45
51
  }
46
52
  } else if (this.originalRegionId) {
47
- const sourceRegion = this.page.regions.find((r) => getRegionId(r) === this.originalRegionId);
53
+ const sourceRegion = page.regions.find((r) => getRegionId(r) === this.originalRegionId);
48
54
  if (sourceRegion && this.originalIndex !== -1) {
49
55
  sourceRegion.blocks.splice(this.originalIndex, 1);
50
56
  }
51
57
  }
52
58
 
53
59
  if (target.kind === 'parent') {
54
- this.blockToMove!.parentId = target.parent.id;
60
+ blockToMove.parentId = target.parent.id;
55
61
  target.parent.children.splice(target.index, 0, this.blockId);
56
62
  } else {
57
- this.blockToMove!.parentId = undefined;
58
- const region = this.page.regions.find((r) => getRegionId(r) === target.regionId)!;
63
+ blockToMove.parentId = undefined;
64
+ const region = page.regions.find((r) => getRegionId(r) === target.regionId)!;
59
65
  region.blocks.splice(target.index, 0, this.blockId);
60
66
  }
61
67
 
68
+ this.moved = true;
69
+
62
70
  this.emit('block:move', {
63
71
  blockId: this.blockId,
64
72
  targetParentId: this.targetParentId,
@@ -76,18 +84,19 @@ export class MoveBlockCommand implements Command {
76
84
  * Captures the source position too so `revert()` can restore it. Pure read —
77
85
  * does not mutate `page`.
78
86
  */
79
- private validateAndResolveTarget(): ResolvedTarget {
80
- this.blockToMove = this.page.blocks[this.blockId];
81
- if (!this.blockToMove) {
87
+ private validateAndResolveTarget(page: Page): ResolvedTarget {
88
+ const blockToMove = page.blocks[this.blockId];
89
+ if (!blockToMove) {
82
90
  throw new Error(`Block not found: ${this.blockId}`);
83
91
  }
84
92
 
85
- this.originalParentId = this.blockToMove.parentId;
93
+ this.originalParentId = blockToMove.parentId;
94
+ this.originalRegionId = undefined;
86
95
  if (this.originalParentId) {
87
- const sourceParent = this.page.blocks[this.originalParentId];
96
+ const sourceParent = page.blocks[this.originalParentId];
88
97
  this.originalIndex = sourceParent ? sourceParent.children.indexOf(this.blockId) : -1;
89
98
  } else {
90
- const sourceRegion = this.page.regions.find((r) => r.blocks.includes(this.blockId));
99
+ const sourceRegion = page.regions.find((r) => r.blocks.includes(this.blockId));
91
100
  if (sourceRegion) {
92
101
  this.originalRegionId = getRegionId(sourceRegion);
93
102
  this.originalIndex = sourceRegion.blocks.indexOf(this.blockId);
@@ -97,7 +106,7 @@ export class MoveBlockCommand implements Command {
97
106
  }
98
107
 
99
108
  if (this.targetParentId) {
100
- const parent = this.page.blocks[this.targetParentId];
109
+ const parent = page.blocks[this.targetParentId];
101
110
  if (!parent) {
102
111
  throw new Error(`Target parent not found: ${this.targetParentId}`);
103
112
  }
@@ -106,7 +115,7 @@ export class MoveBlockCommand implements Command {
106
115
  const prospective = sourceIsTarget ? parent.children.filter((id) => id !== this.blockId) : parent.children;
107
116
  const index = clampIndex(prospective.length, this.targetIndex);
108
117
 
109
- if (!canInsertDynamicChildAt(prospective, this.page.blocks, index)) {
118
+ if (!canInsertDynamicChildAt(prospective, page.blocks, index)) {
110
119
  throw new Error(
111
120
  `Cannot place at index ${index} of ${this.targetParentId}: not a valid slot for a dynamic child`
112
121
  );
@@ -115,8 +124,8 @@ export class MoveBlockCommand implements Command {
115
124
  return { kind: 'parent', parent, index };
116
125
  }
117
126
 
118
- const regionId = resolveRegionId(this.page, this.targetRegionId);
119
- const existing = this.page.regions.find((r) => getRegionId(r) === regionId);
127
+ const regionId = resolveRegionId(page, this.targetRegionId);
128
+ const existing = page.regions.find((r) => getRegionId(r) === regionId);
120
129
  if (!existing) {
121
130
  throw new Error(`Region not found: ${regionId}`);
122
131
  }
@@ -128,12 +137,18 @@ export class MoveBlockCommand implements Command {
128
137
  }
129
138
 
130
139
  revert(): void {
131
- if (!this.blockToMove || this.originalIndex === undefined) {
140
+ if (!this.moved || this.originalIndex === undefined) {
141
+ return;
142
+ }
143
+
144
+ const page = this.getPage();
145
+ const blockToMove = page.blocks[this.blockId];
146
+ if (!blockToMove) {
132
147
  return;
133
148
  }
134
149
 
135
- if (this.blockToMove.parentId) {
136
- const currentParent = this.page.blocks[this.blockToMove.parentId];
150
+ if (blockToMove.parentId) {
151
+ const currentParent = page.blocks[blockToMove.parentId];
137
152
  if (currentParent) {
138
153
  const index = currentParent.children.indexOf(this.blockId);
139
154
  if (index !== -1) {
@@ -141,7 +156,7 @@ export class MoveBlockCommand implements Command {
141
156
  }
142
157
  }
143
158
  } else {
144
- const region = this.page.regions.find((r) => r.blocks.includes(this.blockId));
159
+ const region = page.regions.find((r) => r.blocks.includes(this.blockId));
145
160
  if (region) {
146
161
  const index = region.blocks.indexOf(this.blockId);
147
162
  if (index !== -1) {
@@ -150,15 +165,15 @@ export class MoveBlockCommand implements Command {
150
165
  }
151
166
  }
152
167
 
153
- this.blockToMove.parentId = this.originalParentId;
168
+ blockToMove.parentId = this.originalParentId;
154
169
 
155
170
  if (this.originalParentId) {
156
- const originalParent = this.page.blocks[this.originalParentId];
171
+ const originalParent = page.blocks[this.originalParentId];
157
172
  if (originalParent) {
158
173
  originalParent.children.splice(this.originalIndex, 0, this.blockId);
159
174
  }
160
175
  } else if (this.originalRegionId) {
161
- const region = this.page.regions.find((r) => getRegionId(r) === this.originalRegionId);
176
+ const region = page.regions.find((r) => getRegionId(r) === this.originalRegionId);
162
177
  if (region) {
163
178
  region.blocks.splice(this.originalIndex, 0, this.blockId);
164
179
  }
@@ -8,7 +8,7 @@ export interface RemoveBlockOptions {
8
8
  }
9
9
 
10
10
  export class RemoveBlockCommand implements Command {
11
- private page: Page;
11
+ private getPage: () => Page;
12
12
  private blockId: string;
13
13
  private removedBlock?: Block;
14
14
  private originalIndex?: number;
@@ -16,14 +16,15 @@ export class RemoveBlockCommand implements Command {
16
16
  private regionId?: string;
17
17
  private emit: EngineEmitFn;
18
18
 
19
- constructor(page: Page, options: RemoveBlockOptions) {
20
- this.page = page;
19
+ constructor(getPage: () => Page, options: RemoveBlockOptions) {
20
+ this.getPage = getPage;
21
21
  this.blockId = options.blockId;
22
22
  this.emit = options.emit;
23
23
  }
24
24
 
25
25
  apply(): void {
26
- this.removedBlock = this.page.blocks[this.blockId];
26
+ const page = this.getPage();
27
+ this.removedBlock = page.blocks[this.blockId];
27
28
 
28
29
  if (!this.removedBlock) {
29
30
  throw new Error(`Block not found: ${this.blockId}`);
@@ -32,7 +33,7 @@ export class RemoveBlockCommand implements Command {
32
33
  this.originalParentId = this.removedBlock.parentId;
33
34
 
34
35
  if (this.originalParentId) {
35
- const parent = this.page.blocks[this.originalParentId];
36
+ const parent = page.blocks[this.originalParentId];
36
37
  if (parent) {
37
38
  this.originalIndex = parent.children.indexOf(this.blockId);
38
39
  if (this.originalIndex !== -1) {
@@ -40,7 +41,7 @@ export class RemoveBlockCommand implements Command {
40
41
  }
41
42
  }
42
43
  } else {
43
- const region = this.page.regions.find((r) => r.blocks.includes(this.blockId));
44
+ const region = page.regions.find((r) => r.blocks.includes(this.blockId));
44
45
  if (region) {
45
46
  this.originalIndex = region.blocks.indexOf(this.blockId);
46
47
  this.regionId = getRegionId(region);
@@ -48,7 +49,7 @@ export class RemoveBlockCommand implements Command {
48
49
  }
49
50
  }
50
51
 
51
- delete this.page.blocks[this.blockId];
52
+ delete page.blocks[this.blockId];
52
53
 
53
54
  this.emit('block:remove', {
54
55
  blockId: this.blockId,
@@ -63,15 +64,17 @@ export class RemoveBlockCommand implements Command {
63
64
  return;
64
65
  }
65
66
 
66
- this.page.blocks[this.blockId] = this.removedBlock;
67
+ const page = this.getPage();
68
+
69
+ page.blocks[this.blockId] = this.removedBlock;
67
70
 
68
71
  if (this.originalParentId) {
69
- const parent = this.page.blocks[this.originalParentId];
72
+ const parent = page.blocks[this.originalParentId];
70
73
  if (parent) {
71
74
  parent.children.splice(this.originalIndex, 0, this.blockId);
72
75
  }
73
76
  } else if (this.regionId) {
74
- const region = this.page.regions.find((r) => getRegionId(r) === this.regionId);
77
+ const region = page.regions.find((r) => getRegionId(r) === this.regionId);
75
78
  if (region) {
76
79
  region.blocks.splice(this.originalIndex, 0, this.blockId);
77
80
  }
@@ -11,7 +11,7 @@ export interface ReplaceRegionOptions {
11
11
  }
12
12
 
13
13
  export class ReplaceRegionCommand implements Command {
14
- private page: Page;
14
+ private getPage: () => Page;
15
15
  private regionId: string;
16
16
  private regionIndex: number;
17
17
  private previousRegion: Page['regions'][number];
@@ -21,19 +21,21 @@ export class ReplaceRegionCommand implements Command {
21
21
  private emit: EngineEmitFn;
22
22
  private blocksManager: BlocksManager;
23
23
 
24
- constructor(page: Page, options: ReplaceRegionOptions) {
25
- this.page = page;
24
+ constructor(getPage: () => Page, options: ReplaceRegionOptions) {
25
+ this.getPage = getPage;
26
26
  this.regionId = options.regionId;
27
27
  this.blocksManager = options.blocksManager;
28
28
  this.emit = options.emit;
29
29
 
30
- this.regionIndex = this.page.regions.findIndex((region) => getRegionId(region) === this.regionId);
30
+ const page = this.getPage();
31
+
32
+ this.regionIndex = page.regions.findIndex((region) => getRegionId(region) === this.regionId);
31
33
  if (this.regionIndex === -1) {
32
34
  throw new Error(`Region not found: ${this.regionId}`);
33
35
  }
34
36
 
35
- this.previousRegion = structuredClone(this.page.regions[this.regionIndex]);
36
- this.removedBlocks = this.collectBlocksForRoots(this.previousRegion.blocks);
37
+ this.previousRegion = structuredClone(page.regions[this.regionIndex]);
38
+ this.removedBlocks = this.collectBlocksForRoots(page, this.previousRegion.blocks);
37
39
 
38
40
  this.newBlocks = {};
39
41
  const newRootIds = options.structures.map((structure) =>
@@ -97,11 +99,11 @@ export class ReplaceRegionCommand implements Command {
97
99
  return builtProperties;
98
100
  }
99
101
 
100
- private collectBlocksForRoots(rootBlockIds: string[]): Record<string, Block> {
102
+ private collectBlocksForRoots(page: Page, rootBlockIds: string[]): Record<string, Block> {
101
103
  const blocks: Record<string, Block> = {};
102
104
 
103
105
  const collectBlock = (blockId: string) => {
104
- const block = this.page.blocks[blockId];
106
+ const block = page.blocks[blockId];
105
107
  if (!block || blocks[blockId]) {
106
108
  return;
107
109
  }
@@ -121,15 +123,17 @@ export class ReplaceRegionCommand implements Command {
121
123
  removedBlocks: Record<string, Block>,
122
124
  newBlocks: Record<string, Block>
123
125
  ): void {
126
+ const page = this.getPage();
127
+
124
128
  Object.keys(removedBlocks).forEach((blockId) => {
125
- delete this.page.blocks[blockId];
129
+ delete page.blocks[blockId];
126
130
  });
127
131
 
128
132
  Object.entries(newBlocks).forEach(([blockId, block]) => {
129
- this.page.blocks[blockId] = block;
133
+ page.blocks[blockId] = block;
130
134
  });
131
135
 
132
- this.page.regions[this.regionIndex] = newRegion;
136
+ page.regions[this.regionIndex] = newRegion;
133
137
 
134
138
  this.emit('region:replace', {
135
139
  regionId: this.regionId,
@@ -1,4 +1,4 @@
1
- import type { Block, Page } from '@craftile/types';
1
+ import type { Page } from '@craftile/types';
2
2
  import type { Command, EngineEmitFn } from '../types';
3
3
 
4
4
  export interface SetBlockNameOptions {
@@ -8,37 +8,39 @@ export interface SetBlockNameOptions {
8
8
  }
9
9
 
10
10
  export class SetBlockNameCommand implements Command {
11
- private page: Page;
11
+ private getPage: () => Page;
12
12
  private blockId: string;
13
13
  private newName: string;
14
14
  private oldName: string | undefined;
15
- private block: Block | undefined;
16
15
  private emit: EngineEmitFn;
17
16
 
18
- constructor(page: Page, options: SetBlockNameOptions) {
19
- this.page = page;
17
+ constructor(getPage: () => Page, options: SetBlockNameOptions) {
18
+ this.getPage = getPage;
20
19
  this.blockId = options.blockId;
21
20
  this.newName = options.name;
22
21
  this.emit = options.emit;
23
- this.block = this.page.blocks[this.blockId];
24
22
 
25
- if (!this.block) {
23
+ const block = this.getPage().blocks[this.blockId];
24
+
25
+ if (!block) {
26
26
  throw new Error(`Block not found: ${this.blockId}`);
27
27
  }
28
28
 
29
- this.oldName = this.block.name;
29
+ this.oldName = block.name;
30
30
  }
31
31
 
32
32
  apply(): void {
33
- if (!this.block) {
33
+ const block = this.getPage().blocks[this.blockId];
34
+
35
+ if (!block) {
34
36
  throw new Error(`Block not found: ${this.blockId}`);
35
37
  }
36
38
 
37
- this.block.name = this.newName;
39
+ block.name = this.newName;
38
40
 
39
41
  this.emit('block:update', {
40
42
  blockId: this.blockId,
41
- block: this.block,
43
+ block,
42
44
  property: 'name',
43
45
  value: this.newName,
44
46
  oldValue: this.oldName,
@@ -46,15 +48,17 @@ export class SetBlockNameCommand implements Command {
46
48
  }
47
49
 
48
50
  revert(): void {
49
- if (!this.block) {
51
+ const block = this.getPage().blocks[this.blockId];
52
+
53
+ if (!block) {
50
54
  return;
51
55
  }
52
56
 
53
- this.block.name = this.oldName;
57
+ block.name = this.oldName;
54
58
 
55
59
  this.emit('block:update', {
56
60
  blockId: this.blockId,
57
- block: this.block,
61
+ block,
58
62
  property: 'name',
59
63
  value: this.oldName,
60
64
  oldValue: this.newName,
@@ -1,4 +1,4 @@
1
- import type { Block, Page } from '@craftile/types';
1
+ import type { Page } from '@craftile/types';
2
2
  import type { Command, EngineEmitFn } from '../types';
3
3
 
4
4
  export interface SetBlockPropertyOptions {
@@ -9,16 +9,15 @@ export interface SetBlockPropertyOptions {
9
9
  }
10
10
 
11
11
  export class SetBlockPropertyCommand implements Command {
12
- private page: Page;
12
+ private getPage: () => Page;
13
13
  private blockId: string;
14
14
  private propertyKey: string;
15
15
  private propertyValue: any;
16
16
  private originalValue: any;
17
- private block?: Block;
18
17
  private emit: EngineEmitFn;
19
18
 
20
- constructor(page: Page, options: SetBlockPropertyOptions) {
21
- this.page = page;
19
+ constructor(getPage: () => Page, options: SetBlockPropertyOptions) {
20
+ this.getPage = getPage;
22
21
  this.blockId = options.blockId;
23
22
  this.propertyKey = options.propertyKey;
24
23
  this.propertyValue = options.propertyValue;
@@ -26,18 +25,18 @@ export class SetBlockPropertyCommand implements Command {
26
25
  }
27
26
 
28
27
  apply(): void {
29
- this.block = this.page.blocks[this.blockId];
28
+ const block = this.getPage().blocks[this.blockId];
30
29
 
31
- if (!this.block) {
30
+ if (!block) {
32
31
  throw new Error(`Block not found: ${this.blockId}`);
33
32
  }
34
33
 
35
- if (!this.block.properties) {
36
- this.block.properties = {};
34
+ if (!block.properties) {
35
+ block.properties = {};
37
36
  }
38
37
 
39
- this.originalValue = this.block.properties[this.propertyKey];
40
- this.block.properties[this.propertyKey] = this.propertyValue;
38
+ this.originalValue = block.properties[this.propertyKey];
39
+ block.properties[this.propertyKey] = this.propertyValue;
41
40
 
42
41
  this.emit('block:property:set', {
43
42
  blockId: this.blockId,
@@ -48,14 +47,16 @@ export class SetBlockPropertyCommand implements Command {
48
47
  }
49
48
 
50
49
  revert(): void {
51
- if (!this.block || !this.block.properties) {
50
+ const block = this.getPage().blocks[this.blockId];
51
+
52
+ if (!block || !block.properties) {
52
53
  return;
53
54
  }
54
55
 
55
56
  if (this.originalValue !== undefined) {
56
- this.block.properties[this.propertyKey] = this.originalValue;
57
+ block.properties[this.propertyKey] = this.originalValue;
57
58
  } else {
58
- delete this.block.properties[this.propertyKey];
59
+ delete block.properties[this.propertyKey];
59
60
  }
60
61
 
61
62
  this.emit('block:property:set', {
@@ -1,4 +1,4 @@
1
- import type { Block, Page } from '@craftile/types';
1
+ import type { Page } from '@craftile/types';
2
2
  import type { Command, EngineEmitFn } from '../types';
3
3
 
4
4
  export interface ToggleBlockOptions {
@@ -8,56 +8,60 @@ export interface ToggleBlockOptions {
8
8
  }
9
9
 
10
10
  export class ToggleBlockCommand implements Command {
11
- private page: Page;
11
+ private getPage: () => Page;
12
12
  private blockId: string;
13
13
  private targetDisabled?: boolean;
14
14
  private originalDisabled?: boolean;
15
- private block?: Block;
15
+ private newDisabled?: boolean;
16
16
  private emit: EngineEmitFn;
17
17
 
18
- constructor(page: Page, options: ToggleBlockOptions) {
19
- this.page = page;
18
+ constructor(getPage: () => Page, options: ToggleBlockOptions) {
19
+ this.getPage = getPage;
20
20
  this.blockId = options.blockId;
21
21
  this.targetDisabled = options.disabled;
22
22
  this.emit = options.emit;
23
23
  }
24
24
 
25
25
  apply(): void {
26
- this.block = this.page.blocks[this.blockId];
26
+ const block = this.getPage().blocks[this.blockId];
27
27
 
28
- if (!this.block) {
28
+ if (!block) {
29
29
  throw new Error(`Block not found: ${this.blockId}`);
30
30
  }
31
31
 
32
- this.originalDisabled = this.block.disabled;
32
+ this.originalDisabled = block.disabled;
33
33
 
34
34
  if (this.targetDisabled !== undefined) {
35
- this.block.disabled = this.targetDisabled;
35
+ block.disabled = this.targetDisabled;
36
36
  } else {
37
- this.block.disabled = !this.block.disabled;
37
+ block.disabled = !block.disabled;
38
38
  }
39
39
 
40
+ this.newDisabled = block.disabled;
41
+
40
42
  this.emit('block:toggle', {
41
43
  blockId: this.blockId,
42
- disabled: this.block.disabled ?? false,
44
+ disabled: block.disabled ?? false,
43
45
  oldValue: this.originalDisabled,
44
46
  });
45
47
  }
46
48
 
47
49
  revert(): void {
48
- if (!this.block) {
50
+ const block = this.getPage().blocks[this.blockId];
51
+
52
+ if (!block) {
49
53
  return;
50
54
  }
51
55
 
52
56
  if (this.originalDisabled !== undefined) {
53
- this.block.disabled = this.originalDisabled;
57
+ block.disabled = this.originalDisabled;
54
58
  } else {
55
- this.block.disabled = !this.block.disabled;
59
+ block.disabled = !block.disabled;
56
60
  }
57
61
 
58
62
  this.emit('block:toggle', {
59
63
  blockId: this.blockId,
60
- disabled: this.block.disabled ?? false,
64
+ disabled: block.disabled ?? false,
61
65
  oldValue: this.targetDisabled !== undefined ? this.targetDisabled : !this.originalDisabled,
62
66
  });
63
67
  }
@@ -71,6 +75,6 @@ export class ToggleBlockCommand implements Command {
71
75
  }
72
76
 
73
77
  getNewDisabled(): boolean | undefined {
74
- return this.block?.disabled;
78
+ return this.newDisabled;
75
79
  }
76
80
  }
package/src/engine.ts CHANGED
@@ -3,6 +3,7 @@ import type { Command, EngineConfig, EngineEvents } from './types';
3
3
  import type { Block, BlockSchema, BlockStructure, Page } from '@craftile/types';
4
4
  import { BlocksManager } from './blocks-manager';
5
5
  import { HistoryManager } from './history-manager';
6
+ import { collectVanishedDescendants } from './utils';
6
7
  import { BatchCommand } from './commands/batch';
7
8
  import { InsertBlockCommand } from './commands/insert-block';
8
9
  import { InsertBlockFromPresetCommand } from './commands/insert-block-from-preset';
@@ -71,7 +72,7 @@ export class Engine extends EventBus<EngineEvents> {
71
72
  }
72
73
  }
73
74
 
74
- const command = new InsertBlockCommand(this.page, {
75
+ const command = new InsertBlockCommand(this.getLivePage, {
75
76
  blockType,
76
77
  parentId: options?.parentId,
77
78
  regionId: options?.regionId,
@@ -127,7 +128,7 @@ export class Engine extends EventBus<EngineEvents> {
127
128
  }
128
129
  }
129
130
 
130
- const command = new InsertBlockFromPresetCommand(this.page, {
131
+ const command = new InsertBlockFromPresetCommand(this.getLivePage, {
131
132
  blockType,
132
133
  presetIndex,
133
134
  parentId: options?.parentId,
@@ -150,7 +151,7 @@ export class Engine extends EventBus<EngineEvents> {
150
151
  * @emits block:remove - When the block is successfully removed
151
152
  */
152
153
  removeBlock(blockId: string): void {
153
- const command = new RemoveBlockCommand(this.page, {
154
+ const command = new RemoveBlockCommand(this.getLivePage, {
154
155
  blockId,
155
156
  emit: this.emit.bind(this),
156
157
  });
@@ -188,7 +189,7 @@ export class Engine extends EventBus<EngineEvents> {
188
189
  }
189
190
  }
190
191
 
191
- const command = new MoveBlockCommand(this.page, {
192
+ const command = new MoveBlockCommand(this.getLivePage, {
192
193
  blockId,
193
194
  targetParentId: options?.targetParentId,
194
195
  targetIndex: options?.targetIndex,
@@ -203,7 +204,7 @@ export class Engine extends EventBus<EngineEvents> {
203
204
  * Toggle a block's disabled state (enable/disable)
204
205
  */
205
206
  toggleBlock(blockId: string, disabled?: boolean): void {
206
- const command = new ToggleBlockCommand(this.page, {
207
+ const command = new ToggleBlockCommand(this.getLivePage, {
207
208
  blockId,
208
209
  disabled,
209
210
  emit: this.emit.bind(this),
@@ -222,7 +223,7 @@ export class Engine extends EventBus<EngineEvents> {
222
223
  * @emits block:property:set - When the property is successfully set
223
224
  */
224
225
  setBlockProperty(blockId: string, propertyKey: string, propertyValue: any): void {
225
- const command = new SetBlockPropertyCommand(this.page, {
226
+ const command = new SetBlockPropertyCommand(this.getLivePage, {
226
227
  blockId,
227
228
  propertyKey,
228
229
  propertyValue,
@@ -241,7 +242,7 @@ export class Engine extends EventBus<EngineEvents> {
241
242
  * @emits block:update - When the name is successfully set
242
243
  */
243
244
  setBlockName(blockId: string, name: string): void {
244
- const command = new SetBlockNameCommand(this.page, {
245
+ const command = new SetBlockNameCommand(this.getLivePage, {
245
246
  blockId,
246
247
  name,
247
248
  emit: this.emit.bind(this),
@@ -254,7 +255,7 @@ export class Engine extends EventBus<EngineEvents> {
254
255
  * Duplicate a block (creates a copy placed right after the original)
255
256
  */
256
257
  duplicateBlock(blockId: string): string {
257
- const command = new DuplicateBlockCommand(this.page, {
258
+ const command = new DuplicateBlockCommand(this.getLivePage, {
258
259
  blockId,
259
260
  emit: this.emit.bind(this),
260
261
  });
@@ -327,7 +328,7 @@ export class Engine extends EventBus<EngineEvents> {
327
328
  }
328
329
  }
329
330
 
330
- const command = new InsertBlockFromPresetCommand(this.page, {
331
+ const command = new InsertBlockFromPresetCommand(this.getLivePage, {
331
332
  blockType: structure.type,
332
333
  presetData: structure,
333
334
  parentId: options?.parentId,
@@ -397,7 +398,7 @@ export class Engine extends EventBus<EngineEvents> {
397
398
  throw new Error('replaceRegion cannot be called inside batch');
398
399
  }
399
400
 
400
- const command = new ReplaceRegionCommand(this.page, {
401
+ const command = new ReplaceRegionCommand(this.getLivePage, {
401
402
  regionId,
402
403
  structures,
403
404
  blocksManager: this.blocksManager,
@@ -407,6 +408,48 @@ export class Engine extends EventBus<EngineEvents> {
407
408
  this.applyCommand(command);
408
409
  }
409
410
 
411
+ /**
412
+ * Merge externally resolved blocks into the live page.
413
+ *
414
+ * Each given block replaces the existing one with the same id wholesale (no deep merge).
415
+ * Blocks are used as given: the caller supplies `parentId` and `children`, no parent
416
+ * back-fill or validation runs. When a patched block drops ids from its previous `children`,
417
+ * those blocks vanish with their whole subtree unless they are present in the patch.
418
+ * Regions are untouched. The page is mutated in place and history is neither cleared nor
419
+ * extended, so this operation is not undoable.
420
+ *
421
+ * @param blocks - Blocks keyed by id
422
+ * @throws {Error} When called inside a batch
423
+ * @emits blocks:patch - When at least one block was given
424
+ */
425
+ patchBlocks(blocks: Record<string, Block>): void {
426
+ if (this.activeBatch) {
427
+ throw new Error('patchBlocks cannot be called inside batch');
428
+ }
429
+
430
+ if (Object.keys(blocks).length === 0) {
431
+ return;
432
+ }
433
+
434
+ const previousPage = structuredClone(this.page);
435
+ const removed = [...collectVanishedDescendants(this.page.blocks, blocks)];
436
+
437
+ for (const blockId of removed) {
438
+ delete this.page.blocks[blockId];
439
+ }
440
+
441
+ for (const [blockId, block] of Object.entries(blocks)) {
442
+ this.page.blocks[blockId] = structuredClone(block);
443
+ }
444
+
445
+ this.emit('blocks:patch', {
446
+ blocks,
447
+ removed,
448
+ previousPage,
449
+ newPage: structuredClone(this.page),
450
+ });
451
+ }
452
+
410
453
  /**
411
454
  * Get the blocks manager for this engine instance
412
455
  */
@@ -538,6 +581,10 @@ export class Engine extends EventBus<EngineEvents> {
538
581
  this.page = page;
539
582
  }
540
583
 
584
+ private getLivePage = (): Page => {
585
+ return this.page;
586
+ };
587
+
541
588
  private applyCommand(command: Command): void {
542
589
  command.apply();
543
590
  this.recordCommand(command);
package/src/types.ts CHANGED
@@ -45,6 +45,7 @@ export interface EngineEvents {
45
45
  index?: number;
46
46
  regionId?: string;
47
47
  };
48
+ 'blocks:patch': { blocks: Record<string, Block>; removed: string[]; previousPage: Page; newPage: Page };
48
49
  undo: { command: any };
49
50
  redo: { command: any };
50
51
  }
package/src/utils.ts CHANGED
@@ -105,3 +105,46 @@ export const canInsertDynamicChildAt = (
105
105
  }
106
106
  return index >= first && index <= last + 1;
107
107
  };
108
+
109
+ /**
110
+ * Every block in a patch carries an authoritative `children` list. Ids in a patched block's
111
+ * previous `children` that are missing from its new `children` have vanished, together with
112
+ * their whole subtree (walked through `previousBlocks`). Blocks present in the patch are never
113
+ * pruned, even when their previous parent dropped them.
114
+ */
115
+ export const collectVanishedDescendants = (
116
+ previousBlocks: Record<string, Block>,
117
+ patchBlocks: Record<string, Block>
118
+ ): Set<string> => {
119
+ const vanished = new Set<string>();
120
+
121
+ const collectSubtree = (blockId: string): void => {
122
+ if (patchBlocks[blockId] || vanished.has(blockId)) {
123
+ return;
124
+ }
125
+
126
+ vanished.add(blockId);
127
+
128
+ for (const childId of previousBlocks[blockId]?.children ?? []) {
129
+ collectSubtree(childId);
130
+ }
131
+ };
132
+
133
+ for (const [blockId, patchBlock] of Object.entries(patchBlocks)) {
134
+ const previousBlock = previousBlocks[blockId];
135
+
136
+ if (!previousBlock) {
137
+ continue;
138
+ }
139
+
140
+ const keptChildren = new Set(patchBlock.children ?? []);
141
+
142
+ for (const childId of previousBlock.children ?? []) {
143
+ if (!keptChildren.has(childId)) {
144
+ collectSubtree(childId);
145
+ }
146
+ }
147
+ }
148
+
149
+ return vanished;
150
+ };
@@ -64,6 +64,7 @@ export interface UseBlocksEngineReturn {
64
64
  ) => string;
65
65
  replacePage: (newPage: Page) => void;
66
66
  replaceRegion: (regionId: string, structures: BlockStructure[]) => void;
67
+ patchBlocks: (blocks: Record<string, Block>) => void;
67
68
 
68
69
  undo: () => boolean;
69
70
  redo: () => boolean;
@@ -183,6 +184,7 @@ export function useBlocksEngine(
183
184
  'block:duplicate',
184
185
  'block:property:set',
185
186
  'block:update',
187
+ 'blocks:patch',
186
188
  'undo',
187
189
  'redo',
188
190
  ] as const;
@@ -321,6 +323,14 @@ export function useBlocksEngine(
321
323
  }
322
324
  };
323
325
 
326
+ const patchBlocks = (blocks: Record<string, Block>): void => {
327
+ engine.patchBlocks(blocks);
328
+
329
+ if (!autoSync) {
330
+ syncStateFromEngine();
331
+ }
332
+ };
333
+
324
334
  const undo = (): boolean => {
325
335
  const result = engine.undo();
326
336
 
@@ -393,6 +403,7 @@ export function useBlocksEngine(
393
403
  pasteBlock,
394
404
  replacePage,
395
405
  replaceRegion,
406
+ patchBlocks,
396
407
 
397
408
  // History methods
398
409
  undo,