@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.
@@ -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
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export { Engine } from './engine';
3
+ // export { Engine }
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;