@craftile/core 1.0.3 → 1.0.4

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.3",
3
+ "version": "1.0.4",
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.3",
30
- "@craftile/types": "1.0.3"
29
+ "@craftile/event-bus": "1.0.4",
30
+ "@craftile/types": "1.0.4"
31
31
  },
32
32
  "devDependencies": {
33
33
  "vitest": "^3.2.4",
@@ -1,7 +1,6 @@
1
1
  import type { Block, BlockProperties, BlockSchema, BlockStructure, Page } from '@craftile/types';
2
- import { getRegionId } from '../utils';
2
+ import { generateId, getRegionId, resolveInsertTarget } from '../utils';
3
3
  import type { Command, EngineEmitFn } from '../types';
4
- import { generateId } from '../utils';
5
4
  import type { BlocksManager } from '../blocks-manager';
6
5
 
7
6
  export interface InsertBlockFromPresetOptions {
@@ -69,10 +68,11 @@ export class InsertBlockFromPresetCommand implements Command {
69
68
  }
70
69
 
71
70
  apply(): void {
71
+ const target = resolveInsertTarget(this.page, this.parentId, this.regionId, this.index);
72
+
72
73
  const blockSchema = this.blocksManager.get(this.blockType);
73
74
 
74
75
  if (this.presetData) {
75
- // Handle BlockStructure (paste operation)
76
76
  this.insertedBlock = {
77
77
  type: this.blockType,
78
78
  id: this.blockId,
@@ -94,7 +94,6 @@ export class InsertBlockFromPresetCommand implements Command {
94
94
  this.insertedBlock.children = this.createChildrenFromPreset(this.presetData.children, this.blockId);
95
95
  }
96
96
  } else if (this.presetIndex !== undefined) {
97
- // Handle BlockPreset from schema (preset insertion)
98
97
  const preset = blockSchema?.presets?.[this.presetIndex];
99
98
  if (!preset) {
100
99
  throw new Error(`Preset at index ${this.presetIndex} not found`);
@@ -121,40 +120,18 @@ export class InsertBlockFromPresetCommand implements Command {
121
120
  throw new Error('Failed to create block');
122
121
  }
123
122
 
124
- // Insert the root block in the appropriate location
125
- if (this.parentId) {
126
- const parent = this.page.blocks[this.parentId];
127
-
128
- if (!parent) {
129
- throw new Error(`Parent block not found: ${this.parentId}`);
130
- }
131
-
132
- this.insertedBlock.parentId = parent.id;
123
+ this.actualIndex = target.index;
133
124
 
134
- if (this.index !== undefined && this.index >= 0 && this.index <= parent.children.length) {
135
- parent.children.splice(this.index, 0, this.blockId);
136
- this.actualIndex = this.index;
137
- } else {
138
- parent.children.push(this.blockId);
139
- this.actualIndex = parent.children.length - 1;
140
- }
125
+ if (target.kind === 'parent') {
126
+ this.insertedBlock.parentId = target.parent.id;
127
+ target.parent.children.splice(target.index, 0, this.blockId);
141
128
  } else {
142
- // Insert as top-level block
143
- const targetRegionId = this.regionId || getRegionId(this.page.regions[0]);
144
- let targetRegion = this.page.regions.find((r) => getRegionId(r) === targetRegionId);
145
-
146
- if (!targetRegion) {
147
- targetRegion = { id: targetRegionId, name: targetRegionId, blocks: [] };
148
- this.page.regions.push(targetRegion);
149
- }
150
-
151
- if (this.index !== undefined && this.index >= 0 && this.index <= targetRegion.blocks.length) {
152
- targetRegion.blocks.splice(this.index, 0, this.blockId);
153
- this.actualIndex = this.index;
154
- } else {
155
- targetRegion.blocks.push(this.blockId);
156
- this.actualIndex = targetRegion.blocks.length - 1;
129
+ let region = this.page.regions.find((r) => getRegionId(r) === target.regionId);
130
+ if (!region) {
131
+ region = { id: target.regionId, name: target.regionId, blocks: [] };
132
+ this.page.regions.push(region);
157
133
  }
134
+ region.blocks.splice(target.index, 0, this.blockId);
158
135
  }
159
136
 
160
137
  this.emit('block:insert', {
@@ -1,7 +1,6 @@
1
1
  import type { Block, BlockSchema, Page } from '@craftile/types';
2
- import { getRegionId } from '../utils';
2
+ import { generateId, getRegionId, resolveInsertTarget } from '../utils';
3
3
  import type { Command, EngineEmitFn } from '../types';
4
- import { generateId } from '../utils';
5
4
 
6
5
  export interface InsertBlockOptions {
7
6
  blockType: string;
@@ -40,6 +39,8 @@ export class InsertBlockCommand implements Command {
40
39
  }
41
40
 
42
41
  apply(): void {
42
+ const target = resolveInsertTarget(this.page, this.parentId, this.regionId, this.index);
43
+
43
44
  const blockName = this.blockSchema?.meta?.name || this.blockType;
44
45
 
45
46
  this.insertedBlock = {
@@ -48,44 +49,22 @@ export class InsertBlockCommand implements Command {
48
49
  name: blockName,
49
50
  properties: this.properties,
50
51
  children: [],
51
- parentId: undefined, // Will be set if inserting as child
52
+ parentId: undefined,
52
53
  };
53
54
 
54
55
  this.page.blocks[this.blockId] = this.insertedBlock;
56
+ this.actualIndex = target.index;
55
57
 
56
- if (this.parentId) {
57
- const parent = this.page.blocks[this.parentId];
58
-
59
- if (!parent) {
60
- throw new Error(`Parent block not found: ${this.parentId}`);
61
- }
62
-
63
- this.insertedBlock.parentId = parent.id;
64
-
65
- if (this.index !== undefined && this.index >= 0 && this.index <= parent.children.length) {
66
- parent.children.splice(this.index, 0, this.blockId);
67
- this.actualIndex = this.index;
68
- } else {
69
- parent.children.push(this.blockId);
70
- this.actualIndex = parent.children.length - 1;
71
- }
58
+ if (target.kind === 'parent') {
59
+ this.insertedBlock.parentId = target.parent.id;
60
+ target.parent.children.splice(target.index, 0, this.blockId);
72
61
  } else {
73
- // Insert as top-level block (to the specified region)
74
- const targetRegionId = this.regionId || getRegionId(this.page.regions[0]);
75
- let targetRegion = this.page.regions.find((r) => getRegionId(r) === targetRegionId);
76
-
77
- if (!targetRegion) {
78
- targetRegion = { id: targetRegionId, name: targetRegionId, blocks: [] };
79
- this.page.regions.push(targetRegion);
80
- }
81
-
82
- if (this.index !== undefined && this.index >= 0 && this.index <= targetRegion.blocks.length) {
83
- targetRegion.blocks.splice(this.index, 0, this.blockId);
84
- this.actualIndex = this.index;
85
- } else {
86
- targetRegion.blocks.push(this.blockId);
87
- this.actualIndex = targetRegion.blocks.length - 1;
62
+ let region = this.page.regions.find((r) => getRegionId(r) === target.regionId);
63
+ if (!region) {
64
+ region = { id: target.regionId, name: target.regionId, blocks: [] };
65
+ this.page.regions.push(region);
88
66
  }
67
+ region.blocks.splice(target.index, 0, this.blockId);
89
68
  }
90
69
 
91
70
  this.emit('block:insert', {
@@ -1,6 +1,7 @@
1
1
  import type { Block, Page } from '@craftile/types';
2
2
  import type { Command, EngineEmitFn } from '../types';
3
- import { getRegionId } from '../utils';
3
+ import type { ResolvedTarget } from '../utils';
4
+ import { canInsertDynamicChildAt, clampIndex, getRegionId, resolveRegionId } from '../utils';
4
5
 
5
6
  export interface MoveBlockOptions {
6
7
  blockId: string;
@@ -35,63 +36,31 @@ export class MoveBlockCommand implements Command {
35
36
  }
36
37
 
37
38
  apply(): void {
38
- this.blockToMove = this.page.blocks[this.blockId];
39
-
40
- if (!this.blockToMove) {
41
- throw new Error(`Block not found: ${this.blockId}`);
42
- }
43
-
44
- if (this.targetParentId && !this.page.blocks[this.targetParentId]) {
45
- throw new Error(`Target parent not found: ${this.targetParentId}`);
46
- }
47
-
48
- this.originalParentId = this.blockToMove.parentId;
39
+ const target = this.validateAndResolveTarget();
49
40
 
50
- // First we remove the block from the current position
51
41
  if (this.originalParentId) {
52
- const parent = this.page.blocks[this.originalParentId];
53
- if (parent) {
54
- this.originalIndex = parent.children.indexOf(this.blockId);
55
- if (this.originalIndex !== -1) {
56
- parent.children.splice(this.originalIndex, 1);
57
- }
42
+ const sourceParent = this.page.blocks[this.originalParentId];
43
+ if (sourceParent && this.originalIndex !== -1) {
44
+ sourceParent.children.splice(this.originalIndex, 1);
58
45
  }
59
- } else {
60
- // it is a region level block
61
- const region = this.page.regions.find((r) => r.blocks.includes(this.blockId));
62
- if (region) {
63
- this.originalIndex = region.blocks.indexOf(this.blockId);
64
- this.originalRegionId = getRegionId(region);
65
- region.blocks.splice(this.originalIndex, 1);
46
+ } else if (this.originalRegionId) {
47
+ const sourceRegion = this.page.regions.find((r) => getRegionId(r) === this.originalRegionId);
48
+ if (sourceRegion && this.originalIndex !== -1) {
49
+ sourceRegion.blocks.splice(this.originalIndex, 1);
66
50
  }
67
51
  }
68
52
 
69
- // the we insert the block at the new position
70
- if (this.targetParentId) {
71
- const targetParent = this.page.blocks[this.targetParentId];
72
- this.blockToMove.parentId = this.targetParentId;
73
-
74
- if (this.targetIndex !== undefined && this.targetIndex >= 0 && this.targetIndex <= targetParent.children.length) {
75
- targetParent.children.splice(this.targetIndex, 0, this.blockId);
76
- } else {
77
- targetParent.children.push(this.blockId);
78
- }
53
+ if (target.kind === 'parent') {
54
+ this.blockToMove!.parentId = target.parent.id;
55
+ target.parent.children.splice(target.index, 0, this.blockId);
79
56
  } else {
80
- this.blockToMove.parentId = undefined;
81
-
82
- const regionId = this.targetRegionId || getRegionId(this.page.regions[0]) || 'main';
83
- let targetRegion = this.page.regions.find((r) => getRegionId(r) === regionId);
84
-
85
- if (!targetRegion) {
86
- targetRegion = { id: regionId, name: regionId, blocks: [] };
87
- this.page.regions.push(targetRegion);
88
- }
89
-
90
- if (this.targetIndex !== undefined && this.targetIndex >= 0 && this.targetIndex <= targetRegion.blocks.length) {
91
- targetRegion.blocks.splice(this.targetIndex, 0, this.blockId);
92
- } else {
93
- targetRegion.blocks.push(this.blockId);
57
+ this.blockToMove!.parentId = undefined;
58
+ let region = this.page.regions.find((r) => getRegionId(r) === target.regionId);
59
+ if (!region) {
60
+ region = { id: target.regionId, name: target.regionId, blocks: [] };
61
+ this.page.regions.push(region);
94
62
  }
63
+ region.blocks.splice(target.index, 0, this.blockId);
95
64
  }
96
65
 
97
66
  this.emit('block:move', {
@@ -105,6 +74,63 @@ export class MoveBlockCommand implements Command {
105
74
  });
106
75
  }
107
76
 
77
+ /**
78
+ * Resolve the target parent/region and the splice index, asserting the
79
+ * dynamic-child placement rules against the prospective post-move layout.
80
+ * Captures the source position too so `revert()` can restore it. Pure read —
81
+ * does not mutate `page`.
82
+ */
83
+ private validateAndResolveTarget(): ResolvedTarget {
84
+ this.blockToMove = this.page.blocks[this.blockId];
85
+ if (!this.blockToMove) {
86
+ throw new Error(`Block not found: ${this.blockId}`);
87
+ }
88
+
89
+ this.originalParentId = this.blockToMove.parentId;
90
+ if (this.originalParentId) {
91
+ const sourceParent = this.page.blocks[this.originalParentId];
92
+ this.originalIndex = sourceParent ? sourceParent.children.indexOf(this.blockId) : -1;
93
+ } else {
94
+ const sourceRegion = this.page.regions.find((r) => r.blocks.includes(this.blockId));
95
+ if (sourceRegion) {
96
+ this.originalRegionId = getRegionId(sourceRegion);
97
+ this.originalIndex = sourceRegion.blocks.indexOf(this.blockId);
98
+ } else {
99
+ this.originalIndex = -1;
100
+ }
101
+ }
102
+
103
+ if (this.targetParentId) {
104
+ const parent = this.page.blocks[this.targetParentId];
105
+ if (!parent) {
106
+ throw new Error(`Target parent not found: ${this.targetParentId}`);
107
+ }
108
+
109
+ const sourceIsTarget = this.originalParentId === this.targetParentId;
110
+ const prospective = sourceIsTarget ? parent.children.filter((id) => id !== this.blockId) : parent.children;
111
+ const index = clampIndex(prospective.length, this.targetIndex);
112
+
113
+ if (!canInsertDynamicChildAt(prospective, this.page.blocks, index)) {
114
+ throw new Error(
115
+ `Cannot place at index ${index} of ${this.targetParentId}: not a valid slot for a dynamic child`
116
+ );
117
+ }
118
+
119
+ return { kind: 'parent', parent, index };
120
+ }
121
+
122
+ const regionId = resolveRegionId(this.page, this.targetRegionId);
123
+ const existing = this.page.regions.find((r) => getRegionId(r) === regionId);
124
+ const sourceIsTarget = this.originalRegionId === regionId;
125
+ const prospective = existing
126
+ ? sourceIsTarget
127
+ ? existing.blocks.filter((id) => id !== this.blockId)
128
+ : existing.blocks
129
+ : [];
130
+
131
+ return { kind: 'region', regionId, index: clampIndex(prospective.length, this.targetIndex) };
132
+ }
133
+
108
134
  revert(): void {
109
135
  if (!this.blockToMove || this.originalIndex === undefined) {
110
136
  return;
package/src/utils.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { Region } from '@craftile/types';
1
+ import type { Block, Page, Region } from '@craftile/types';
2
+
3
+ export type ResolvedTarget =
4
+ | { kind: 'parent'; parent: Block; index: number }
5
+ | { kind: 'region'; regionId: string; index: number };
2
6
 
3
7
  export const generateId = (): string => {
4
8
  if (crypto.randomUUID) {
@@ -15,3 +19,85 @@ export const generateId = (): string => {
15
19
  export const getRegionId = (region: Region): string => {
16
20
  return region.id || region.name;
17
21
  };
22
+
23
+ /**
24
+ * Clamp `index` to a valid splice position for an array of `length`. An
25
+ * undefined or out-of-range index falls back to `length` (i.e. append).
26
+ */
27
+ export const clampIndex = (length: number, index: number | undefined): number => {
28
+ return index !== undefined && index >= 0 && index <= length ? index : length;
29
+ };
30
+
31
+ /**
32
+ * Resolve the effective region id, falling back to the first region or to
33
+ * `'main'` when none exist. Safe against an empty `page.regions` array.
34
+ */
35
+ export const resolveRegionId = (page: Page, regionId: string | undefined): string => {
36
+ return regionId || (page.regions[0] && getRegionId(page.regions[0])) || 'main';
37
+ };
38
+
39
+ /**
40
+ * Resolve the target parent/region and the splice index, asserting the
41
+ * dynamic-child placement rules.
42
+ */
43
+ export const resolveInsertTarget = (
44
+ page: Page,
45
+ parentId: string | undefined,
46
+ regionId: string | undefined,
47
+ index: number | undefined
48
+ ): ResolvedTarget => {
49
+ if (parentId) {
50
+ const parent = page.blocks[parentId];
51
+ if (!parent) {
52
+ throw new Error(`Parent block not found: ${parentId}`);
53
+ }
54
+ const resolvedIndex = clampIndex(parent.children.length, index);
55
+ if (!canInsertDynamicChildAt(parent.children, page.blocks, resolvedIndex)) {
56
+ throw new Error(`Cannot place at index ${resolvedIndex} of ${parentId}: not a valid slot for a dynamic child`);
57
+ }
58
+ return { kind: 'parent', parent, index: resolvedIndex };
59
+ }
60
+
61
+ const targetRegionId = resolveRegionId(page, regionId);
62
+ const existing = page.regions.find((r) => getRegionId(r) === targetRegionId);
63
+ return {
64
+ kind: 'region',
65
+ regionId: targetRegionId,
66
+ index: clampIndex(existing?.blocks.length ?? 0, index),
67
+ };
68
+ };
69
+
70
+ /**
71
+ * Returns true when inserting a dynamic block at `index` of the given children
72
+ * keeps all dynamic siblings contiguous and does not wedge between two adjacent
73
+ * static blocks.
74
+ */
75
+ export const canInsertDynamicChildAt = (
76
+ childrenIds: string[],
77
+ blocks: Record<string, Block>,
78
+ index: number
79
+ ): boolean => {
80
+ if (index > 0 && index < childrenIds.length) {
81
+ const before = blocks[childrenIds[index - 1]];
82
+ const after = blocks[childrenIds[index]];
83
+ if (before?.static === true && after?.static === true) {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ let first = -1;
89
+ let last = -1;
90
+ for (let i = 0; i < childrenIds.length; i++) {
91
+ const child = blocks[childrenIds[i]];
92
+ if (child && child.static !== true) {
93
+ if (first === -1) {
94
+ first = i;
95
+ }
96
+ last = i;
97
+ }
98
+ }
99
+ if (first === -1) {
100
+ return true;
101
+ }
102
+ return index >= first && index <= last + 1;
103
+ };