@craftile/core 1.0.8 → 1.1.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.0.8",
3
+ "version": "1.1.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.0.8",
30
- "@craftile/types": "1.0.8"
29
+ "@craftile/event-bus": "1.1.0",
30
+ "@craftile/types": "1.1.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "vitest": "^3.2.4",
@@ -0,0 +1,19 @@
1
+ import type { Command } from '../types';
2
+
3
+ export class BatchCommand implements Command {
4
+ private commands: Command[];
5
+
6
+ constructor(commands: Command[]) {
7
+ this.commands = commands;
8
+ }
9
+
10
+ apply(): void {
11
+ this.commands.forEach((command) => command.apply());
12
+ }
13
+
14
+ revert(): void {
15
+ for (let i = this.commands.length - 1; i >= 0; i--) {
16
+ this.commands[i].revert();
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,34 @@
1
+ import type { Page } from '@craftile/types';
2
+ import type { Command, EngineEmitFn } from '../types';
3
+
4
+ export class ReplacePageCommand implements Command {
5
+ private previousPage: Page;
6
+ private newPage: Page;
7
+ private setPageState: (page: Page) => void;
8
+ private emit: EngineEmitFn;
9
+
10
+ constructor(previousPage: Page, newPage: Page, setPageState: (page: Page) => void, emit: EngineEmitFn) {
11
+ this.previousPage = previousPage;
12
+ this.newPage = newPage;
13
+ this.setPageState = setPageState;
14
+ this.emit = emit;
15
+ }
16
+
17
+ apply(): void {
18
+ this.setPageState(this.newPage);
19
+
20
+ this.emit('page:replace', {
21
+ previousPage: structuredClone(this.previousPage),
22
+ newPage: structuredClone(this.newPage),
23
+ });
24
+ }
25
+
26
+ revert(): void {
27
+ this.setPageState(this.previousPage);
28
+
29
+ this.emit('page:replace', {
30
+ previousPage: structuredClone(this.newPage),
31
+ newPage: structuredClone(this.previousPage),
32
+ });
33
+ }
34
+ }
@@ -0,0 +1,145 @@
1
+ import type { Block, BlockSchema, BlockStructure, Page } from '@craftile/types';
2
+ import type { BlocksManager } from '../blocks-manager';
3
+ import type { Command, EngineEmitFn } from '../types';
4
+ import { generateId, getRegionId } from '../utils';
5
+
6
+ export interface ReplaceRegionOptions {
7
+ regionId: string;
8
+ structures: BlockStructure[];
9
+ blocksManager: BlocksManager;
10
+ emit: EngineEmitFn;
11
+ }
12
+
13
+ export class ReplaceRegionCommand implements Command {
14
+ private page: Page;
15
+ private regionId: string;
16
+ private regionIndex: number;
17
+ private previousRegion: Page['regions'][number];
18
+ private newRegion: Page['regions'][number];
19
+ private removedBlocks: Record<string, Block>;
20
+ private newBlocks: Record<string, Block>;
21
+ private emit: EngineEmitFn;
22
+ private blocksManager: BlocksManager;
23
+
24
+ constructor(page: Page, options: ReplaceRegionOptions) {
25
+ this.page = page;
26
+ this.regionId = options.regionId;
27
+ this.blocksManager = options.blocksManager;
28
+ this.emit = options.emit;
29
+
30
+ this.regionIndex = this.page.regions.findIndex((region) => getRegionId(region) === this.regionId);
31
+ if (this.regionIndex === -1) {
32
+ throw new Error(`Region not found: ${this.regionId}`);
33
+ }
34
+
35
+ this.previousRegion = structuredClone(this.page.regions[this.regionIndex]);
36
+ this.removedBlocks = this.collectBlocksForRoots(this.previousRegion.blocks);
37
+
38
+ this.newBlocks = {};
39
+ const newRootIds = options.structures.map((structure) =>
40
+ this.createBlockFromStructure(structure, undefined, this.newBlocks)
41
+ );
42
+ this.newRegion = {
43
+ ...this.previousRegion,
44
+ blocks: newRootIds,
45
+ };
46
+ }
47
+
48
+ apply(): void {
49
+ this.replaceRegion(this.previousRegion, this.newRegion, this.removedBlocks, this.newBlocks);
50
+ }
51
+
52
+ revert(): void {
53
+ this.replaceRegion(this.newRegion, this.previousRegion, this.newBlocks, this.removedBlocks);
54
+ }
55
+
56
+ private createBlockFromStructure(
57
+ structure: BlockStructure,
58
+ parentId: string | undefined,
59
+ target: Record<string, Block>
60
+ ): string {
61
+ const blockSchema = this.blocksManager.get(structure.type);
62
+ if (!blockSchema) {
63
+ throw new Error(`Block type '${structure.type}' is not registered`);
64
+ }
65
+
66
+ const blockId = generateId();
67
+ const block: Block = {
68
+ type: structure.type,
69
+ id: blockId,
70
+ name: structure.name || blockSchema.meta?.name || structure.type,
71
+ semanticId: structure.semanticId || structure.id,
72
+ properties: this.buildProperties(blockSchema, structure.properties),
73
+ children: [],
74
+ parentId,
75
+ static: structure.static,
76
+ disabled: structure.disabled,
77
+ repeated: structure.repeated,
78
+ ghost: structure.ghost,
79
+ };
80
+
81
+ target[blockId] = block;
82
+ block.children = (structure.children ?? []).map((child) => this.createBlockFromStructure(child, blockId, target));
83
+
84
+ return blockId;
85
+ }
86
+
87
+ private buildProperties(blockSchema: BlockSchema, properties?: Record<string, any>): Record<string, any> {
88
+ const builtProperties: Record<string, any> = {};
89
+
90
+ for (const field of blockSchema.properties ?? []) {
91
+ if (field.default !== undefined) {
92
+ builtProperties[field.id] = field.default;
93
+ }
94
+ }
95
+
96
+ if (properties) {
97
+ Object.assign(builtProperties, properties);
98
+ }
99
+
100
+ return builtProperties;
101
+ }
102
+
103
+ private collectBlocksForRoots(rootBlockIds: string[]): Record<string, Block> {
104
+ const blocks: Record<string, Block> = {};
105
+
106
+ const collectBlock = (blockId: string) => {
107
+ const block = this.page.blocks[blockId];
108
+ if (!block || blocks[blockId]) {
109
+ return;
110
+ }
111
+
112
+ blocks[blockId] = block;
113
+ block.children.forEach(collectBlock);
114
+ };
115
+
116
+ rootBlockIds.forEach(collectBlock);
117
+
118
+ return blocks;
119
+ }
120
+
121
+ private replaceRegion(
122
+ previousRegion: Page['regions'][number],
123
+ newRegion: Page['regions'][number],
124
+ removedBlocks: Record<string, Block>,
125
+ newBlocks: Record<string, Block>
126
+ ): void {
127
+ Object.keys(removedBlocks).forEach((blockId) => {
128
+ delete this.page.blocks[blockId];
129
+ });
130
+
131
+ Object.entries(newBlocks).forEach(([blockId, block]) => {
132
+ this.page.blocks[blockId] = block;
133
+ });
134
+
135
+ this.page.regions[this.regionIndex] = newRegion;
136
+
137
+ this.emit('region:replace', {
138
+ regionId: this.regionId,
139
+ previousRegion: structuredClone(previousRegion),
140
+ newRegion: structuredClone(newRegion),
141
+ removedBlocks: structuredClone(removedBlocks),
142
+ newBlocks: structuredClone(newBlocks),
143
+ });
144
+ }
145
+ }
package/src/engine.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { EventBus } from '@craftile/event-bus';
2
- import type { EngineConfig, EngineEvents } from './types';
2
+ 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 { BatchCommand } from './commands/batch';
6
7
  import { InsertBlockCommand } from './commands/insert-block';
7
8
  import { InsertBlockFromPresetCommand } from './commands/insert-block-from-preset';
8
9
  import { RemoveBlockCommand } from './commands/remove-block';
@@ -11,11 +12,14 @@ import { ToggleBlockCommand } from './commands/toggle-block';
11
12
  import { SetBlockPropertyCommand } from './commands/set-block-property';
12
13
  import { DuplicateBlockCommand } from './commands/duplicate-block';
13
14
  import { SetBlockNameCommand } from './commands/set-block-name';
15
+ import { ReplacePageCommand } from './commands/replace-page';
16
+ import { ReplaceRegionCommand } from './commands/replace-region';
14
17
 
15
18
  export class Engine extends EventBus<EngineEvents> {
16
19
  protected page!: Page;
17
20
  protected blocksManager: BlocksManager;
18
21
  protected historyManager: HistoryManager;
22
+ private activeBatch: Command[] | null = null;
19
23
 
20
24
  constructor(config: EngineConfig = {}) {
21
25
  super();
@@ -76,8 +80,7 @@ export class Engine extends EventBus<EngineEvents> {
76
80
  emit: this.emit.bind(this),
77
81
  });
78
82
 
79
- command.apply();
80
- this.historyManager.addCommand(command);
83
+ this.applyCommand(command);
81
84
 
82
85
  return command.getBlockId();
83
86
  }
@@ -134,8 +137,7 @@ export class Engine extends EventBus<EngineEvents> {
134
137
  emit: this.emit.bind(this),
135
138
  });
136
139
 
137
- command.apply();
138
- this.historyManager.addCommand(command);
140
+ this.applyCommand(command);
139
141
 
140
142
  return command.getBlockId();
141
143
  }
@@ -153,8 +155,7 @@ export class Engine extends EventBus<EngineEvents> {
153
155
  emit: this.emit.bind(this),
154
156
  });
155
157
 
156
- command.apply();
157
- this.historyManager.addCommand(command);
158
+ this.applyCommand(command);
158
159
  }
159
160
 
160
161
  /**
@@ -195,8 +196,7 @@ export class Engine extends EventBus<EngineEvents> {
195
196
  emit: this.emit.bind(this),
196
197
  });
197
198
 
198
- command.apply();
199
- this.historyManager.addCommand(command);
199
+ this.applyCommand(command);
200
200
  }
201
201
 
202
202
  /**
@@ -209,8 +209,7 @@ export class Engine extends EventBus<EngineEvents> {
209
209
  emit: this.emit.bind(this),
210
210
  });
211
211
 
212
- command.apply();
213
- this.historyManager.addCommand(command);
212
+ this.applyCommand(command);
214
213
  }
215
214
 
216
215
  /**
@@ -230,8 +229,7 @@ export class Engine extends EventBus<EngineEvents> {
230
229
  emit: this.emit.bind(this),
231
230
  });
232
231
 
233
- command.apply();
234
- this.historyManager.addCommand(command);
232
+ this.applyCommand(command);
235
233
  }
236
234
 
237
235
  /**
@@ -249,8 +247,7 @@ export class Engine extends EventBus<EngineEvents> {
249
247
  emit: this.emit.bind(this),
250
248
  });
251
249
 
252
- command.apply();
253
- this.historyManager.addCommand(command);
250
+ this.applyCommand(command);
254
251
  }
255
252
 
256
253
  /**
@@ -262,8 +259,7 @@ export class Engine extends EventBus<EngineEvents> {
262
259
  emit: this.emit.bind(this),
263
260
  });
264
261
 
265
- command.apply();
266
- this.historyManager.addCommand(command);
262
+ this.applyCommand(command);
267
263
 
268
264
  return command.getDuplicatedBlockId();
269
265
  }
@@ -347,8 +343,7 @@ export class Engine extends EventBus<EngineEvents> {
347
343
  emit: this.emit.bind(this),
348
344
  });
349
345
 
350
- command.apply();
351
- this.historyManager.addCommand(command);
346
+ this.applyCommand(command);
352
347
 
353
348
  return command.getBlockId();
354
349
  }
@@ -370,26 +365,7 @@ export class Engine extends EventBus<EngineEvents> {
370
365
  setPage(newPage: Page): void {
371
366
  const beforePage = structuredClone(this.page);
372
367
 
373
- this.page = structuredClone(newPage);
374
-
375
- if (this.page.regions.length === 0) {
376
- this.page.regions = [{ id: 'main', name: 'main', blocks: Object.keys(this.page.blocks) }];
377
- }
378
-
379
- // Initialize regions if not present
380
- if (this.page.regions.length === 0) {
381
- this.page.regions = [
382
- {
383
- id: 'main',
384
- name: 'main',
385
- blocks: Object.values(this.page.blocks)
386
- .filter((block) => !block.parentId)
387
- .map((block) => block.id),
388
- },
389
- ];
390
- }
391
-
392
- this.initializeParentChildRelationships();
368
+ this.page = this.normalizePage(newPage);
393
369
 
394
370
  // Clear history since we're switching to a completely new page
395
371
  // maybe we should keep a separate history per page
@@ -401,6 +377,42 @@ export class Engine extends EventBus<EngineEvents> {
401
377
  });
402
378
  }
403
379
 
380
+ /**
381
+ * Replace the current page as a single undoable operation.
382
+ */
383
+ replacePage(newPage: Page): void {
384
+ if (this.activeBatch) {
385
+ throw new Error('replacePage cannot be called inside batch');
386
+ }
387
+
388
+ const command = new ReplacePageCommand(
389
+ this.page,
390
+ this.normalizePage(newPage),
391
+ this.replacePageState.bind(this),
392
+ this.emit.bind(this)
393
+ );
394
+
395
+ this.applyCommand(command);
396
+ }
397
+
398
+ /**
399
+ * Replace an existing region's root blocks as a single undoable operation.
400
+ */
401
+ replaceRegion(regionId: string, structures: BlockStructure[]): void {
402
+ if (this.activeBatch) {
403
+ throw new Error('replaceRegion cannot be called inside batch');
404
+ }
405
+
406
+ const command = new ReplaceRegionCommand(this.page, {
407
+ regionId,
408
+ structures,
409
+ blocksManager: this.blocksManager,
410
+ emit: this.emit.bind(this),
411
+ });
412
+
413
+ this.applyCommand(command);
414
+ }
415
+
404
416
  /**
405
417
  * Get the blocks manager for this engine instance
406
418
  */
@@ -466,14 +478,54 @@ export class Engine extends EventBus<EngineEvents> {
466
478
  }
467
479
 
468
480
  /**
469
- * Initialize parent-child relationships for blocks that have children but missing parentId
470
- * Handles deep nesting recursively
481
+ * Group multiple operations into a single undo/redo history entry.
471
482
  */
472
- private initializeParentChildRelationships(): void {
483
+ batch<T>(callback: () => T): T {
484
+ const isOuterBatch = this.activeBatch === null;
485
+
486
+ if (isOuterBatch) {
487
+ this.activeBatch = [];
488
+ }
489
+
490
+ const batch = this.activeBatch!;
491
+ const startIndex = batch.length;
492
+
493
+ try {
494
+ const result = callback();
495
+
496
+ if (isOuterBatch && batch.length > 0) {
497
+ this.historyManager.addCommand(new BatchCommand(batch.slice()));
498
+ }
499
+
500
+ return result;
501
+ } catch (error) {
502
+ this.rollbackCommands(batch.slice(startIndex));
503
+ batch.splice(startIndex);
504
+ throw error;
505
+ } finally {
506
+ if (isOuterBatch) {
507
+ this.activeBatch = null;
508
+ }
509
+ }
510
+ }
511
+
512
+ private normalizePage(page: Page): Page {
513
+ const normalizedPage = structuredClone(page);
514
+
515
+ if (normalizedPage.regions.length === 0) {
516
+ normalizedPage.regions = [{ id: 'main', name: 'main', blocks: Object.keys(normalizedPage.blocks) }];
517
+ }
518
+
519
+ this.initializeParentChildRelationshipsForPage(normalizedPage);
520
+
521
+ return normalizedPage;
522
+ }
523
+
524
+ private initializeParentChildRelationshipsForPage(page: Page): void {
473
525
  const processBlock = (block: Block): void => {
474
526
  if (block.children && block.children.length > 0) {
475
527
  block.children.forEach((childId: string) => {
476
- const childBlock = this.page.blocks[childId];
528
+ const childBlock = page.blocks[childId];
477
529
  if (childBlock) {
478
530
  if (!childBlock.parentId) {
479
531
  childBlock.parentId = block.id;
@@ -485,6 +537,30 @@ export class Engine extends EventBus<EngineEvents> {
485
537
  }
486
538
  };
487
539
 
488
- Object.values(this.page.blocks).forEach(processBlock);
540
+ Object.values(page.blocks).forEach(processBlock);
541
+ }
542
+
543
+ private replacePageState(page: Page): void {
544
+ this.page = page;
545
+ }
546
+
547
+ private applyCommand(command: Command): void {
548
+ command.apply();
549
+ this.recordCommand(command);
550
+ }
551
+
552
+ private recordCommand(command: Command): void {
553
+ if (this.activeBatch) {
554
+ this.activeBatch.push(command);
555
+ return;
556
+ }
557
+
558
+ this.historyManager.addCommand(command);
559
+ }
560
+
561
+ private rollbackCommands(commands: Command[]): void {
562
+ for (let i = commands.length - 1; i >= 0; i--) {
563
+ commands[i].revert();
564
+ }
489
565
  }
490
566
  }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Block, BlockSchema, Page } from '@craftile/types';
1
+ import type { Block, BlockSchema, Page, Region } from '@craftile/types';
2
2
  import type { BlocksManager } from './blocks-manager';
3
3
 
4
4
  export interface EngineConfig {
@@ -15,6 +15,14 @@ export interface Command {
15
15
  // Event system types
16
16
  export interface EngineEvents {
17
17
  'page:set': { previousPage?: Page; newPage: Page };
18
+ 'page:replace': { previousPage: Page; newPage: Page };
19
+ 'region:replace': {
20
+ regionId: string;
21
+ previousRegion: Region;
22
+ newRegion: Region;
23
+ removedBlocks: Record<string, Block>;
24
+ newBlocks: Record<string, Block>;
25
+ };
18
26
  'block:insert': { blockId: string; block: Block; parentId?: string; index?: number; regionId?: string };
19
27
  'block:remove': { blockId: string; block: Block; parentId?: string; regionId?: string };
20
28
  'block:toggle': { blockId: string; disabled: boolean; oldValue?: boolean };
@@ -1,4 +1,4 @@
1
- import { ref, onUnmounted, computed, toRaw, nextTick, type Ref } from 'vue';
1
+ import { ref, onUnmounted, computed, toRaw, nextTick, getCurrentInstance, type Ref } from 'vue';
2
2
  import type { Block, BlockStructure, Page, Region } from '@craftile/types';
3
3
  import type { EngineConfig } from '../types';
4
4
  import { Engine } from '../engine';
@@ -62,9 +62,12 @@ export interface UseBlocksEngineReturn {
62
62
  index?: number;
63
63
  }
64
64
  ) => string;
65
+ replacePage: (newPage: Page) => void;
66
+ replaceRegion: (regionId: string, structures: BlockStructure[]) => void;
65
67
 
66
68
  undo: () => boolean;
67
69
  redo: () => boolean;
70
+ batch: <T>(callback: () => T) => T;
68
71
  canUndo: Ref<boolean>;
69
72
  canRedo: Ref<boolean>;
70
73
 
@@ -171,6 +174,8 @@ export function useBlocksEngine(
171
174
  if (autoSync) {
172
175
  const eventTypes = [
173
176
  'page:set',
177
+ 'page:replace',
178
+ 'region:replace',
174
179
  'block:insert',
175
180
  'block:remove',
176
181
  'block:move',
@@ -300,6 +305,22 @@ export function useBlocksEngine(
300
305
  return blockId;
301
306
  };
302
307
 
308
+ const replacePage = (newPage: Page): void => {
309
+ engine.replacePage(newPage);
310
+
311
+ if (!autoSync) {
312
+ syncStateFromEngine();
313
+ }
314
+ };
315
+
316
+ const replaceRegion = (regionId: string, structures: BlockStructure[]): void => {
317
+ engine.replaceRegion(regionId, structures);
318
+
319
+ if (!autoSync) {
320
+ syncStateFromEngine();
321
+ }
322
+ };
323
+
303
324
  const undo = (): boolean => {
304
325
  const result = engine.undo();
305
326
 
@@ -320,6 +341,16 @@ export function useBlocksEngine(
320
341
  return result;
321
342
  };
322
343
 
344
+ const batch = <T>(callback: () => T): T => {
345
+ const result = engine.batch(callback);
346
+
347
+ if (!autoSync) {
348
+ syncStateFromEngine();
349
+ }
350
+
351
+ return result;
352
+ };
353
+
323
354
  const refresh = () => {
324
355
  syncStateFromEngine();
325
356
  };
@@ -329,9 +360,11 @@ export function useBlocksEngine(
329
360
  eventCleanups.length = 0;
330
361
  };
331
362
 
332
- onUnmounted(() => {
333
- destroy();
334
- });
363
+ if (getCurrentInstance()) {
364
+ onUnmounted(() => {
365
+ destroy();
366
+ });
367
+ }
335
368
 
336
369
  return {
337
370
  // Reactive state
@@ -358,10 +391,13 @@ export function useBlocksEngine(
358
391
  toggleBlock,
359
392
  duplicateBlock,
360
393
  pasteBlock,
394
+ replacePage,
395
+ replaceRegion,
361
396
 
362
397
  // History methods
363
398
  undo,
364
399
  redo,
400
+ batch,
365
401
  canUndo,
366
402
  canRedo,
367
403