@craftile/core 1.2.0 → 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.0",
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.0",
30
- "@craftile/types": "1.2.0"
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,13 +69,15 @@ 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);
76
77
 
77
78
  if (this.presetData) {
78
79
  this.insertedBlock = {
80
+ ...structuredClone(this.presetData),
79
81
  type: this.blockType,
80
82
  id: this.blockId,
81
83
  name: this.presetData.name || blockSchema?.meta?.name || this.blockType,
@@ -83,17 +85,13 @@ export class InsertBlockFromPresetCommand implements Command {
83
85
  properties: this.properties,
84
86
  children: [],
85
87
  parentId: undefined,
86
- static: this.presetData.static,
87
- disabled: this.presetData.disabled,
88
- repeated: this.presetData.repeated,
89
- ghost: this.presetData.ghost,
90
88
  };
91
89
 
92
- this.page.blocks[this.blockId] = this.insertedBlock;
90
+ page.blocks[this.blockId] = this.insertedBlock;
93
91
  this.createdBlockIds.push(this.blockId);
94
92
 
95
93
  if (this.presetData.children && this.presetData.children.length > 0) {
96
- this.insertedBlock.children = this.createChildrenFromPreset(this.presetData.children, this.blockId);
94
+ this.insertedBlock.children = this.createChildrenFromPreset(page, this.presetData.children, this.blockId);
97
95
  }
98
96
  } else if (this.presetIndex !== undefined) {
99
97
  const preset = blockSchema?.presets?.[this.presetIndex];
@@ -110,11 +108,11 @@ export class InsertBlockFromPresetCommand implements Command {
110
108
  parentId: undefined,
111
109
  };
112
110
 
113
- this.page.blocks[this.blockId] = this.insertedBlock;
111
+ page.blocks[this.blockId] = this.insertedBlock;
114
112
  this.createdBlockIds.push(this.blockId);
115
113
 
116
114
  if (preset.children && preset.children.length > 0) {
117
- this.insertedBlock.children = this.createChildrenFromPreset(preset.children, this.blockId);
115
+ this.insertedBlock.children = this.createChildrenFromPreset(page, preset.children, this.blockId);
118
116
  }
119
117
  }
120
118
 
@@ -128,7 +126,7 @@ export class InsertBlockFromPresetCommand implements Command {
128
126
  this.insertedBlock.parentId = target.parent.id;
129
127
  target.parent.children.splice(target.index, 0, this.blockId);
130
128
  } else {
131
- const region = this.page.regions.find((r) => getRegionId(r) === target.regionId)!;
129
+ const region = page.regions.find((r) => getRegionId(r) === target.regionId)!;
132
130
  region.blocks.splice(target.index, 0, this.blockId);
133
131
  }
134
132
 
@@ -137,7 +135,7 @@ export class InsertBlockFromPresetCommand implements Command {
137
135
  block: this.insertedBlock,
138
136
  parentId: this.parentId,
139
137
  index: this.actualIndex,
140
- regionId: this.regionId || getRegionId(this.page.regions[0]),
138
+ regionId: this.regionId || getRegionId(page.regions[0]),
141
139
  });
142
140
  }
143
141
 
@@ -146,21 +144,24 @@ export class InsertBlockFromPresetCommand implements Command {
146
144
  return;
147
145
  }
148
146
 
147
+ const page = this.getPage();
148
+
149
149
  // Remove all created blocks
150
150
  for (const blockId of this.createdBlockIds) {
151
- delete this.page.blocks[blockId];
151
+ delete page.blocks[blockId];
152
152
  }
153
153
 
154
154
  if (this.parentId) {
155
- const parent = this.page.blocks[this.parentId];
156
- if (parent && this.actualIndex !== undefined) {
157
- 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);
158
159
  }
159
160
  } else if (this.resolvedRegionId) {
160
- const targetRegion = this.page.regions.find((r) => getRegionId(r) === this.resolvedRegionId);
161
-
162
- if (targetRegion && this.actualIndex !== undefined) {
163
- 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);
164
165
  }
165
166
  }
166
167
 
@@ -180,7 +181,7 @@ export class InsertBlockFromPresetCommand implements Command {
180
181
  return this.insertedBlock;
181
182
  }
182
183
 
183
- private createChildrenFromPreset(structures: BlockStructure[], parentId: string): string[] {
184
+ private createChildrenFromPreset(page: Page, structures: BlockStructure[], parentId: string): string[] {
184
185
  const childIds: string[] = [];
185
186
 
186
187
  for (const structure of structures) {
@@ -195,6 +196,7 @@ export class InsertBlockFromPresetCommand implements Command {
195
196
  const childProperties = this.buildProperties(childSchema, structure.properties);
196
197
 
197
198
  const childBlock: Block = {
199
+ ...structuredClone(structure),
198
200
  type: structure.type,
199
201
  id: childId,
200
202
  name: structure.name || childSchema.meta?.name || structure.type,
@@ -202,18 +204,14 @@ export class InsertBlockFromPresetCommand implements Command {
202
204
  properties: childProperties,
203
205
  children: [],
204
206
  parentId,
205
- static: structure.static,
206
- disabled: structure.disabled,
207
- repeated: structure.repeated,
208
- ghost: structure.ghost,
209
207
  };
210
208
 
211
- this.page.blocks[childId] = childBlock;
209
+ page.blocks[childId] = childBlock;
212
210
  this.createdBlockIds.push(childId);
213
211
 
214
212
  // Recursively create nested children
215
213
  if (structure.children && structure.children.length > 0) {
216
- childBlock.children = this.createChildrenFromPreset(structure.children, childId);
214
+ childBlock.children = this.createChildrenFromPreset(page, structure.children, childId);
217
215
  }
218
216
 
219
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) =>
@@ -65,6 +67,7 @@ export class ReplaceRegionCommand implements Command {
65
67
 
66
68
  const blockId = generateId();
67
69
  const block: Block = {
70
+ ...structuredClone(structure),
68
71
  type: structure.type,
69
72
  id: blockId,
70
73
  name: structure.name || blockSchema.meta?.name || structure.type,
@@ -72,10 +75,6 @@ export class ReplaceRegionCommand implements Command {
72
75
  properties: this.buildProperties(blockSchema, structure.properties),
73
76
  children: [],
74
77
  parentId,
75
- static: structure.static,
76
- disabled: structure.disabled,
77
- repeated: structure.repeated,
78
- ghost: structure.ghost,
79
78
  };
80
79
 
81
80
  target[blockId] = block;
@@ -100,11 +99,11 @@ export class ReplaceRegionCommand implements Command {
100
99
  return builtProperties;
101
100
  }
102
101
 
103
- private collectBlocksForRoots(rootBlockIds: string[]): Record<string, Block> {
102
+ private collectBlocksForRoots(page: Page, rootBlockIds: string[]): Record<string, Block> {
104
103
  const blocks: Record<string, Block> = {};
105
104
 
106
105
  const collectBlock = (blockId: string) => {
107
- const block = this.page.blocks[blockId];
106
+ const block = page.blocks[blockId];
108
107
  if (!block || blocks[blockId]) {
109
108
  return;
110
109
  }
@@ -124,15 +123,17 @@ export class ReplaceRegionCommand implements Command {
124
123
  removedBlocks: Record<string, Block>,
125
124
  newBlocks: Record<string, Block>
126
125
  ): void {
126
+ const page = this.getPage();
127
+
127
128
  Object.keys(removedBlocks).forEach((blockId) => {
128
- delete this.page.blocks[blockId];
129
+ delete page.blocks[blockId];
129
130
  });
130
131
 
131
132
  Object.entries(newBlocks).forEach(([blockId, block]) => {
132
- this.page.blocks[blockId] = block;
133
+ page.blocks[blockId] = block;
133
134
  });
134
135
 
135
- this.page.regions[this.regionIndex] = newRegion;
136
+ page.regions[this.regionIndex] = newRegion;
136
137
 
137
138
  this.emit('region:replace', {
138
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
  });
@@ -278,15 +279,9 @@ export class Engine extends EventBus<EngineEvents> {
278
279
  throw new Error(`Block not found: ${blockId}`);
279
280
  }
280
281
 
282
+ const { parentId: _parentId, children: _children, ...rest } = structuredClone(block);
281
283
  const structure: BlockStructure = {
282
- type: block.type,
283
- id: block.id,
284
- semanticId: block.semanticId,
285
- properties: structuredClone(block.properties),
286
- name: block.name,
287
- static: block.static,
288
- disabled: block.disabled,
289
- repeated: block.repeated,
284
+ ...rest,
290
285
  children: [],
291
286
  };
292
287
 
@@ -333,7 +328,7 @@ export class Engine extends EventBus<EngineEvents> {
333
328
  }
334
329
  }
335
330
 
336
- const command = new InsertBlockFromPresetCommand(this.page, {
331
+ const command = new InsertBlockFromPresetCommand(this.getLivePage, {
337
332
  blockType: structure.type,
338
333
  presetData: structure,
339
334
  parentId: options?.parentId,
@@ -403,7 +398,7 @@ export class Engine extends EventBus<EngineEvents> {
403
398
  throw new Error('replaceRegion cannot be called inside batch');
404
399
  }
405
400
 
406
- const command = new ReplaceRegionCommand(this.page, {
401
+ const command = new ReplaceRegionCommand(this.getLivePage, {
407
402
  regionId,
408
403
  structures,
409
404
  blocksManager: this.blocksManager,
@@ -413,6 +408,48 @@ export class Engine extends EventBus<EngineEvents> {
413
408
  this.applyCommand(command);
414
409
  }
415
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
+
416
453
  /**
417
454
  * Get the blocks manager for this engine instance
418
455
  */
@@ -544,6 +581,10 @@ export class Engine extends EventBus<EngineEvents> {
544
581
  this.page = page;
545
582
  }
546
583
 
584
+ private getLivePage = (): Page => {
585
+ return this.page;
586
+ };
587
+
547
588
  private applyCommand(command: Command): void {
548
589
  command.apply();
549
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,