@craftile/core 1.0.2 → 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 +3 -3
- package/src/commands/insert-block-from-preset.ts +12 -35
- package/src/commands/insert-block.ts +13 -34
- package/src/commands/move-block.ts +76 -50
- package/src/utils.ts +87 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@craftile/core",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
30
|
-
"@craftile/types": "1.0.
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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,
|
|
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 (
|
|
57
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
53
|
-
if (
|
|
54
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
};
|