@craftile/core 0.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/LICENSE.md +21 -0
- package/package.json +49 -0
- package/src/blocks-manager.ts +115 -0
- package/src/commands/duplicate-block.ts +203 -0
- package/src/commands/insert-block.ts +143 -0
- package/src/commands/move-block.ts +182 -0
- package/src/commands/remove-block.ts +94 -0
- package/src/commands/set-block-property.ts +84 -0
- package/src/commands/toggle-block.ts +76 -0
- package/src/engine.ts +329 -0
- package/src/history-manager.ts +78 -0
- package/src/index.ts +3 -0
- package/src/types.ts +46 -0
- package/src/utils.ts +7 -0
- package/src/vue/index.ts +6 -0
- package/src/vue/useBlocksEngine.ts +307 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Block, Page } from '@craftile/types';
|
|
2
|
+
import type { Command, EngineEmitFn } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface RemoveBlockOptions {
|
|
5
|
+
blockId: string;
|
|
6
|
+
emit: EngineEmitFn;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class RemoveBlockCommand implements Command {
|
|
10
|
+
private page: Page;
|
|
11
|
+
private blockId: string;
|
|
12
|
+
private removedBlock?: Block;
|
|
13
|
+
private originalIndex?: number;
|
|
14
|
+
private originalParentId?: string;
|
|
15
|
+
private regionName?: string;
|
|
16
|
+
private emit: EngineEmitFn;
|
|
17
|
+
|
|
18
|
+
constructor(page: Page, options: RemoveBlockOptions) {
|
|
19
|
+
this.page = page;
|
|
20
|
+
this.blockId = options.blockId;
|
|
21
|
+
this.emit = options.emit;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
apply(): void {
|
|
25
|
+
this.removedBlock = this.page.blocks[this.blockId];
|
|
26
|
+
|
|
27
|
+
if (!this.removedBlock) {
|
|
28
|
+
throw new Error(`Block not found: ${this.blockId}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.originalParentId = this.removedBlock.parentId;
|
|
32
|
+
|
|
33
|
+
if (this.originalParentId) {
|
|
34
|
+
const parent = this.page.blocks[this.originalParentId];
|
|
35
|
+
if (parent) {
|
|
36
|
+
this.originalIndex = parent.children.indexOf(this.blockId);
|
|
37
|
+
if (this.originalIndex !== -1) {
|
|
38
|
+
parent.children.splice(this.originalIndex, 1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
const region = this.page.regions.find((r) => r.blocks.includes(this.blockId));
|
|
43
|
+
if (region) {
|
|
44
|
+
this.originalIndex = region.blocks.indexOf(this.blockId);
|
|
45
|
+
this.regionName = region.name;
|
|
46
|
+
region.blocks.splice(this.originalIndex, 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
delete this.page.blocks[this.blockId];
|
|
51
|
+
|
|
52
|
+
this.emit('block:remove', {
|
|
53
|
+
blockId: this.blockId,
|
|
54
|
+
block: this.removedBlock,
|
|
55
|
+
parentId: this.originalParentId,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
revert(): void {
|
|
60
|
+
if (!this.removedBlock || this.originalIndex === undefined) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.page.blocks[this.blockId] = this.removedBlock;
|
|
65
|
+
|
|
66
|
+
if (this.originalParentId) {
|
|
67
|
+
const parent = this.page.blocks[this.originalParentId];
|
|
68
|
+
if (parent) {
|
|
69
|
+
parent.children.splice(this.originalIndex, 0, this.blockId);
|
|
70
|
+
}
|
|
71
|
+
} else if (this.regionName) {
|
|
72
|
+
const region = this.page.regions.find((r) => r.name === this.regionName);
|
|
73
|
+
if (region) {
|
|
74
|
+
region.blocks.splice(this.originalIndex, 0, this.blockId);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.emit('block:insert', {
|
|
79
|
+
blockId: this.blockId,
|
|
80
|
+
block: this.removedBlock,
|
|
81
|
+
parentId: this.originalParentId,
|
|
82
|
+
index: this.originalIndex,
|
|
83
|
+
regionName: this.regionName,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getBlockId(): string {
|
|
88
|
+
return this.blockId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getRemovedBlock(): Block | undefined {
|
|
92
|
+
return this.removedBlock;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Block, Page } from '@craftile/types';
|
|
2
|
+
import type { Command, EngineEmitFn } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface SetBlockPropertyOptions {
|
|
5
|
+
blockId: string;
|
|
6
|
+
propertyKey: string;
|
|
7
|
+
propertyValue: any;
|
|
8
|
+
emit: EngineEmitFn;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SetBlockPropertyCommand implements Command {
|
|
12
|
+
private page: Page;
|
|
13
|
+
private blockId: string;
|
|
14
|
+
private propertyKey: string;
|
|
15
|
+
private propertyValue: any;
|
|
16
|
+
private originalValue: any;
|
|
17
|
+
private block?: Block;
|
|
18
|
+
private emit: EngineEmitFn;
|
|
19
|
+
|
|
20
|
+
constructor(page: Page, options: SetBlockPropertyOptions) {
|
|
21
|
+
this.page = page;
|
|
22
|
+
this.blockId = options.blockId;
|
|
23
|
+
this.propertyKey = options.propertyKey;
|
|
24
|
+
this.propertyValue = options.propertyValue;
|
|
25
|
+
this.emit = options.emit;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
apply(): void {
|
|
29
|
+
this.block = this.page.blocks[this.blockId];
|
|
30
|
+
|
|
31
|
+
if (!this.block) {
|
|
32
|
+
throw new Error(`Block not found: ${this.blockId}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!this.block.properties) {
|
|
36
|
+
this.block.properties = {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.originalValue = this.block.properties[this.propertyKey];
|
|
40
|
+
this.block.properties[this.propertyKey] = this.propertyValue;
|
|
41
|
+
|
|
42
|
+
this.emit('block:property:set', {
|
|
43
|
+
blockId: this.blockId,
|
|
44
|
+
key: this.propertyKey,
|
|
45
|
+
value: this.propertyValue,
|
|
46
|
+
oldValue: this.originalValue,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
revert(): void {
|
|
51
|
+
if (!this.block || !this.block.properties) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (this.originalValue !== undefined) {
|
|
56
|
+
this.block.properties[this.propertyKey] = this.originalValue;
|
|
57
|
+
} else {
|
|
58
|
+
delete this.block.properties[this.propertyKey];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.emit('block:property:set', {
|
|
62
|
+
blockId: this.blockId,
|
|
63
|
+
key: this.propertyKey,
|
|
64
|
+
value: this.originalValue,
|
|
65
|
+
oldValue: this.propertyValue,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getBlockId(): string {
|
|
70
|
+
return this.blockId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getPropertyKey(): string {
|
|
74
|
+
return this.propertyKey;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getPropertyValue(): any {
|
|
78
|
+
return this.propertyValue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getOriginalValue(): any {
|
|
82
|
+
return this.originalValue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Block, Page } from '@craftile/types';
|
|
2
|
+
import type { Command, EngineEmitFn } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface ToggleBlockOptions {
|
|
5
|
+
blockId: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
emit: EngineEmitFn;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ToggleBlockCommand implements Command {
|
|
11
|
+
private page: Page;
|
|
12
|
+
private blockId: string;
|
|
13
|
+
private targetDisabled?: boolean;
|
|
14
|
+
private originalDisabled?: boolean;
|
|
15
|
+
private block?: Block;
|
|
16
|
+
private emit: EngineEmitFn;
|
|
17
|
+
|
|
18
|
+
constructor(page: Page, options: ToggleBlockOptions) {
|
|
19
|
+
this.page = page;
|
|
20
|
+
this.blockId = options.blockId;
|
|
21
|
+
this.targetDisabled = options.disabled;
|
|
22
|
+
this.emit = options.emit;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
apply(): void {
|
|
26
|
+
this.block = this.page.blocks[this.blockId];
|
|
27
|
+
|
|
28
|
+
if (!this.block) {
|
|
29
|
+
throw new Error(`Block not found: ${this.blockId}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.originalDisabled = this.block.disabled;
|
|
33
|
+
|
|
34
|
+
if (this.targetDisabled !== undefined) {
|
|
35
|
+
this.block.disabled = this.targetDisabled;
|
|
36
|
+
} else {
|
|
37
|
+
this.block.disabled = !this.block.disabled;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.emit('block:toggle', {
|
|
41
|
+
blockId: this.blockId,
|
|
42
|
+
disabled: this.block.disabled ?? false,
|
|
43
|
+
oldValue: this.originalDisabled,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
revert(): void {
|
|
48
|
+
if (!this.block) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (this.originalDisabled !== undefined) {
|
|
53
|
+
this.block.disabled = this.originalDisabled;
|
|
54
|
+
} else {
|
|
55
|
+
this.block.disabled = !this.block.disabled;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.emit('block:toggle', {
|
|
59
|
+
blockId: this.blockId,
|
|
60
|
+
disabled: this.block.disabled ?? false,
|
|
61
|
+
oldValue: this.targetDisabled !== undefined ? this.targetDisabled : !this.originalDisabled,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getBlockId(): string {
|
|
66
|
+
return this.blockId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getOriginalDisabled(): boolean | undefined {
|
|
70
|
+
return this.originalDisabled;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getNewDisabled(): boolean | undefined {
|
|
74
|
+
return this.block?.disabled;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { EventBus } from '@craftile/event-bus';
|
|
2
|
+
import type { EngineConfig, EngineEvents } from './types';
|
|
3
|
+
import type { Block, BlockSchema, Page } from '@craftile/types';
|
|
4
|
+
import { BlocksManager } from './blocks-manager';
|
|
5
|
+
import { HistoryManager } from './history-manager';
|
|
6
|
+
import { InsertBlockCommand } from './commands/insert-block';
|
|
7
|
+
import { RemoveBlockCommand } from './commands/remove-block';
|
|
8
|
+
import { MoveBlockCommand } from './commands/move-block';
|
|
9
|
+
import { ToggleBlockCommand } from './commands/toggle-block';
|
|
10
|
+
import { SetBlockPropertyCommand } from './commands/set-block-property';
|
|
11
|
+
import { DuplicateBlockCommand } from './commands/duplicate-block';
|
|
12
|
+
|
|
13
|
+
export class Engine extends EventBus<EngineEvents> {
|
|
14
|
+
protected page!: Page;
|
|
15
|
+
protected blocksManager: BlocksManager;
|
|
16
|
+
protected historyManager: HistoryManager;
|
|
17
|
+
|
|
18
|
+
constructor(config: EngineConfig = {}) {
|
|
19
|
+
super();
|
|
20
|
+
|
|
21
|
+
this.blocksManager = config.blocksManager || new BlocksManager();
|
|
22
|
+
this.historyManager = new HistoryManager();
|
|
23
|
+
this.setPage(config.page ? config.page : { blocks: {}, regions: [{ name: 'main', blocks: [] }] });
|
|
24
|
+
|
|
25
|
+
if (config.blockSchemas && config.blockSchemas.length > 0) {
|
|
26
|
+
config.blockSchemas.forEach((schema) => {
|
|
27
|
+
this.blocksManager.register(schema.type, schema);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Insert a new block into the page
|
|
34
|
+
*
|
|
35
|
+
* @param blockType - The type of block to insert (must be registered)
|
|
36
|
+
* @param options - Optional configuration for block insertion
|
|
37
|
+
* @param options.parentId - ID of parent block (for nested blocks)
|
|
38
|
+
* @param options.regionName - Target region name (defaults to 'main')
|
|
39
|
+
* @param options.index - Position to insert at (defaults to end)
|
|
40
|
+
* @returns The ID of the newly inserted block
|
|
41
|
+
* @throws {Error} When block type is not registered or parent-child relationship is invalid
|
|
42
|
+
* @emits block:insert - When the block is successfully inserted
|
|
43
|
+
*/
|
|
44
|
+
insertBlock(
|
|
45
|
+
blockType: string,
|
|
46
|
+
options?: {
|
|
47
|
+
parentId?: string;
|
|
48
|
+
regionName?: string;
|
|
49
|
+
index?: number;
|
|
50
|
+
}
|
|
51
|
+
): string {
|
|
52
|
+
const blockSchema = this.blocksManager.get(blockType);
|
|
53
|
+
if (!blockSchema) {
|
|
54
|
+
throw new Error(`Block type '${blockType}' is not registered`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (options?.parentId) {
|
|
58
|
+
const parentBlock = this.page.blocks[options.parentId];
|
|
59
|
+
if (!parentBlock) {
|
|
60
|
+
throw new Error(`Parent block not found: ${options.parentId}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!this.blocksManager.canBeChild(blockType, parentBlock.type)) {
|
|
64
|
+
throw new Error(`Block type '${blockType}' cannot be a child of '${parentBlock.type}'`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const command = new InsertBlockCommand(this.page, {
|
|
69
|
+
blockType,
|
|
70
|
+
parentId: options?.parentId,
|
|
71
|
+
regionName: options?.regionName,
|
|
72
|
+
index: options?.index,
|
|
73
|
+
blockSchema,
|
|
74
|
+
emit: this.emit.bind(this),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
command.apply();
|
|
78
|
+
this.historyManager.addCommand(command);
|
|
79
|
+
|
|
80
|
+
return command.getBlockId();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Remove a block from the page
|
|
85
|
+
*
|
|
86
|
+
* @param blockId - ID of the block to remove
|
|
87
|
+
* @throws {Error} When block is not found
|
|
88
|
+
* @emits block:remove - When the block is successfully removed
|
|
89
|
+
*/
|
|
90
|
+
removeBlock(blockId: string): void {
|
|
91
|
+
const command = new RemoveBlockCommand(this.page, {
|
|
92
|
+
blockId,
|
|
93
|
+
emit: this.emit.bind(this),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
command.apply();
|
|
97
|
+
this.historyManager.addCommand(command);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Move a block to a new location (reorder within parent or move to different parent/region)
|
|
102
|
+
*
|
|
103
|
+
* @param blockId - ID of the block to remove
|
|
104
|
+
* @param options - Optional configuration for block move operation
|
|
105
|
+
* @param options.targetParentId - ID of target parent block
|
|
106
|
+
* @param options.targetIndex - position in the target parent block children
|
|
107
|
+
* @param options.targetRegionName - target region name
|
|
108
|
+
*/
|
|
109
|
+
moveBlock(
|
|
110
|
+
blockId: string,
|
|
111
|
+
options?: {
|
|
112
|
+
targetParentId?: string;
|
|
113
|
+
targetIndex?: number;
|
|
114
|
+
targetRegionName?: string;
|
|
115
|
+
}
|
|
116
|
+
): void {
|
|
117
|
+
const block = this.page.blocks[blockId];
|
|
118
|
+
|
|
119
|
+
if (options?.targetParentId) {
|
|
120
|
+
const targetParent = this.page.blocks[options.targetParentId];
|
|
121
|
+
if (!targetParent) {
|
|
122
|
+
throw new Error(`Target parent block not found: ${options.targetParentId}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!this.blocksManager.canBeChild(block.type, targetParent.type)) {
|
|
126
|
+
throw new Error(`Block type '${block.type}' cannot be a child of '${targetParent.type}'`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const command = new MoveBlockCommand(this.page, {
|
|
131
|
+
blockId,
|
|
132
|
+
targetParentId: options?.targetParentId,
|
|
133
|
+
targetIndex: options?.targetIndex,
|
|
134
|
+
targetRegionName: options?.targetRegionName,
|
|
135
|
+
emit: this.emit.bind(this),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
command.apply();
|
|
139
|
+
this.historyManager.addCommand(command);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Toggle a block's disabled state (enable/disable)
|
|
144
|
+
*/
|
|
145
|
+
toggleBlock(blockId: string, disabled?: boolean): void {
|
|
146
|
+
const command = new ToggleBlockCommand(this.page, {
|
|
147
|
+
blockId,
|
|
148
|
+
disabled,
|
|
149
|
+
emit: this.emit.bind(this),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
command.apply();
|
|
153
|
+
this.historyManager.addCommand(command);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Set a property value for a block
|
|
158
|
+
*
|
|
159
|
+
* @param blockId - ID of the target block
|
|
160
|
+
* @param propertyKey - The property key to set
|
|
161
|
+
* @param propertyValue - The new value for the property
|
|
162
|
+
* @throws {Error} When block is not found
|
|
163
|
+
* @emits block:property:set - When the property is successfully set
|
|
164
|
+
*/
|
|
165
|
+
setBlockProperty(blockId: string, propertyKey: string, propertyValue: any): void {
|
|
166
|
+
const command = new SetBlockPropertyCommand(this.page, {
|
|
167
|
+
blockId,
|
|
168
|
+
propertyKey,
|
|
169
|
+
propertyValue,
|
|
170
|
+
emit: this.emit.bind(this),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
command.apply();
|
|
174
|
+
this.historyManager.addCommand(command);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Duplicate a block (creates a copy placed right after the original)
|
|
179
|
+
*/
|
|
180
|
+
duplicateBlock(blockId: string): string {
|
|
181
|
+
const command = new DuplicateBlockCommand(this.page, {
|
|
182
|
+
blockId,
|
|
183
|
+
emit: this.emit.bind(this),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
command.apply();
|
|
187
|
+
this.historyManager.addCommand(command);
|
|
188
|
+
|
|
189
|
+
return command.getDuplicatedBlockId();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getBlockById(blockId: string): Block | undefined {
|
|
193
|
+
return this.page.blocks[blockId];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get current page state
|
|
198
|
+
*/
|
|
199
|
+
getPage(): Page {
|
|
200
|
+
return structuredClone(this.page);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Set a new page (replaces current page and clears history)
|
|
205
|
+
*
|
|
206
|
+
* @param newPage - The new page object to set
|
|
207
|
+
* @throws {Error} When the page structure is invalid
|
|
208
|
+
* @emits page:set - When the page is successfully set
|
|
209
|
+
*/
|
|
210
|
+
setPage(newPage: Page): void {
|
|
211
|
+
const beforePage = structuredClone(this.page);
|
|
212
|
+
|
|
213
|
+
this.page = structuredClone(newPage);
|
|
214
|
+
|
|
215
|
+
if (this.page.regions.length === 0) {
|
|
216
|
+
this.page.regions = [{ name: 'main', blocks: Object.keys(this.page.blocks) }];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Initialize regions if not present
|
|
220
|
+
if (this.page.regions.length === 0) {
|
|
221
|
+
this.page.regions = [
|
|
222
|
+
{
|
|
223
|
+
name: 'main',
|
|
224
|
+
blocks: Object.values(this.page.blocks)
|
|
225
|
+
.filter((block) => !block.parentId)
|
|
226
|
+
.map((block) => block.id),
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.initializeParentChildRelationships();
|
|
232
|
+
|
|
233
|
+
// Clear history since we're switching to a completely new page
|
|
234
|
+
// maybe we should keep a separate history per page
|
|
235
|
+
this.historyManager.clear();
|
|
236
|
+
|
|
237
|
+
this.emit('page:set', {
|
|
238
|
+
previousPage: beforePage,
|
|
239
|
+
newPage: structuredClone(this.page),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get the blocks manager for this engine instance
|
|
245
|
+
*/
|
|
246
|
+
getBlocksManager(): BlocksManager {
|
|
247
|
+
return this.blocksManager;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get block schemas
|
|
252
|
+
*/
|
|
253
|
+
getBlockSchemas(): Record<string, BlockSchema> {
|
|
254
|
+
return this.blocksManager.getAll();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get a specific block schema by type
|
|
259
|
+
*
|
|
260
|
+
* @param type - The block type to get the schema for
|
|
261
|
+
* @returns The block schema or undefined if not found
|
|
262
|
+
*/
|
|
263
|
+
getBlockSchema(type: string): BlockSchema | undefined {
|
|
264
|
+
return this.blocksManager.get(type);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Undo the last operation
|
|
269
|
+
*/
|
|
270
|
+
undo(): boolean {
|
|
271
|
+
const command = this.historyManager.undo();
|
|
272
|
+
|
|
273
|
+
if (command !== null) {
|
|
274
|
+
this.emit('undo', { command });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return command !== null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Redo the last undone operation
|
|
282
|
+
*/
|
|
283
|
+
redo(): boolean {
|
|
284
|
+
const command = this.historyManager.redo();
|
|
285
|
+
|
|
286
|
+
if (command !== null) {
|
|
287
|
+
this.emit('redo', { command });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return command !== null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Check if undo is possible
|
|
295
|
+
*/
|
|
296
|
+
canUndo(): boolean {
|
|
297
|
+
return this.historyManager.canUndo();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Check if redo is possible
|
|
302
|
+
*/
|
|
303
|
+
canRedo(): boolean {
|
|
304
|
+
return this.historyManager.canRedo();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Initialize parent-child relationships for blocks that have children but missing parentId
|
|
309
|
+
* Handles deep nesting recursively
|
|
310
|
+
*/
|
|
311
|
+
private initializeParentChildRelationships(): void {
|
|
312
|
+
const processBlock = (block: Block): void => {
|
|
313
|
+
if (block.children && block.children.length > 0) {
|
|
314
|
+
block.children.forEach((childId: string) => {
|
|
315
|
+
const childBlock = this.page.blocks[childId];
|
|
316
|
+
if (childBlock) {
|
|
317
|
+
if (!childBlock.parentId) {
|
|
318
|
+
childBlock.parentId = block.id;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
processBlock(childBlock);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
Object.values(this.page.blocks).forEach(processBlock);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Command } from './types';
|
|
2
|
+
|
|
3
|
+
export class HistoryManager {
|
|
4
|
+
private history: Command[] = [];
|
|
5
|
+
private currentIndex = -1;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param maxHistorySize Max number of commands to retain in memory.
|
|
9
|
+
*/
|
|
10
|
+
private readonly maxHistorySize: number;
|
|
11
|
+
|
|
12
|
+
constructor(maxHistorySize = 100) {
|
|
13
|
+
this.maxHistorySize = maxHistorySize;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Add command to the history.
|
|
18
|
+
* The command is expected to be executed by the caller.
|
|
19
|
+
*/
|
|
20
|
+
addCommand(cmd: Command): void {
|
|
21
|
+
if (this.currentIndex < this.history.length - 1) {
|
|
22
|
+
this.history.splice(this.currentIndex + 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (this.history.length >= this.maxHistorySize) {
|
|
26
|
+
this.history.shift();
|
|
27
|
+
this.currentIndex--;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.history.push(cmd);
|
|
31
|
+
this.currentIndex++;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
undo(): Command | null {
|
|
35
|
+
if (!this.canUndo()) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cmd = this.history[this.currentIndex];
|
|
40
|
+
cmd.revert();
|
|
41
|
+
this.currentIndex--;
|
|
42
|
+
|
|
43
|
+
return cmd;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
redo(): Command | null {
|
|
47
|
+
if (!this.canRedo()) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.currentIndex++;
|
|
52
|
+
const cmd = this.history[this.currentIndex];
|
|
53
|
+
cmd.apply();
|
|
54
|
+
|
|
55
|
+
return cmd;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
canUndo(): boolean {
|
|
59
|
+
return this.currentIndex >= 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
canRedo(): boolean {
|
|
63
|
+
return this.currentIndex < this.history.length - 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
clear(): void {
|
|
67
|
+
this.history = [];
|
|
68
|
+
this.currentIndex = -1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getCurrentIndex(): number {
|
|
72
|
+
return this.currentIndex;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getHistorySize(): number {
|
|
76
|
+
return this.history.length;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Block, BlockSchema, Page } from '@craftile/types';
|
|
2
|
+
import type { BlocksManager } from './blocks-manager';
|
|
3
|
+
|
|
4
|
+
export interface EngineConfig {
|
|
5
|
+
page?: Page;
|
|
6
|
+
blockSchemas?: BlockSchema[];
|
|
7
|
+
blocksManager?: BlocksManager;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Command {
|
|
11
|
+
apply(): void;
|
|
12
|
+
revert(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Event system types
|
|
16
|
+
export interface EngineEvents {
|
|
17
|
+
'page:set': { previousPage?: Page; newPage: Page };
|
|
18
|
+
'block:insert': { blockId: string; block: Block; parentId?: string; index?: number; regionName?: string };
|
|
19
|
+
'block:remove': { blockId: string; block: Block; parentId?: string };
|
|
20
|
+
'block:toggle': { blockId: string; disabled: boolean; oldValue?: boolean };
|
|
21
|
+
'block:property:set': { blockId: string; key: string; value: any; oldValue: any };
|
|
22
|
+
'block:move': {
|
|
23
|
+
blockId: string;
|
|
24
|
+
targetParentId?: string;
|
|
25
|
+
targetIndex?: number;
|
|
26
|
+
targetRegionName?: string;
|
|
27
|
+
sourceParentId?: string | null;
|
|
28
|
+
sourceRegionName?: string | null;
|
|
29
|
+
sourceIndex: number;
|
|
30
|
+
};
|
|
31
|
+
'block:duplicate': {
|
|
32
|
+
originalBlockId: string;
|
|
33
|
+
newBlockId: string;
|
|
34
|
+
newBlock: Block;
|
|
35
|
+
parentId?: string;
|
|
36
|
+
index?: number;
|
|
37
|
+
regionName?: string;
|
|
38
|
+
};
|
|
39
|
+
undo: { command: any };
|
|
40
|
+
redo: { command: any };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type EngineEmitFn = <K extends keyof EngineEvents>(
|
|
44
|
+
event: K,
|
|
45
|
+
...args: EngineEvents[K] extends void ? [] : [EngineEvents[K]]
|
|
46
|
+
) => void;
|