@routevn/creator-model 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/model.js +934 -136
package/src/model.js CHANGED
@@ -30,8 +30,27 @@ const COLLECTION_KEYS = [
30
30
  "textStyles",
31
31
  "variables",
32
32
  "layouts",
33
+ "controls",
33
34
  ];
34
35
  const ROOT_KEYS = ["project", "story", ...COLLECTION_KEYS];
36
+ const createEmptyCollectionState = () => ({
37
+ items: {},
38
+ tree: [],
39
+ });
40
+ const normalizeStateCollections = (state) => {
41
+ if (!isPlainObject(state)) {
42
+ return state;
43
+ }
44
+
45
+ if (state.controls !== undefined) {
46
+ return state;
47
+ }
48
+
49
+ return {
50
+ ...state,
51
+ controls: createEmptyCollectionState(),
52
+ };
53
+ };
35
54
  const isString = (value) => typeof value === "string";
36
55
  const FILE_ITEM_TYPES = [
37
56
  "image",
@@ -115,7 +134,7 @@ const ANIMATION_EASING_KEYS = [
115
134
  ];
116
135
  const VARIABLE_SCOPE_KEYS = ["context", "global-device", "global-account"];
117
136
  const VARIABLE_TYPE_KEYS = ["string", "number", "boolean"];
118
- const LAYOUT_TYPE_KEYS = ["normal", "dialogue", "nvl", "choice", "base"];
137
+ const LAYOUT_TYPE_KEYS = ["normal", "dialogue", "nvl", "choice"];
119
138
  const LAYOUT_ELEMENT_TEXT_STYLE_ALIGN_KEYS = ["left", "center", "right"];
120
139
  const LAYOUT_ELEMENT_BASE_TYPES = [
121
140
  "folder",
@@ -2588,7 +2607,7 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2588
2607
  allowedKeys:
2589
2608
  item.type === "folder"
2590
2609
  ? ["id", "type", "name"]
2591
- : ["id", "type", "name", "layoutType", "elements", "keyboard"],
2610
+ : ["id", "type", "name", "layoutType", "elements"],
2592
2611
  path: itemPath,
2593
2612
  errorFactory,
2594
2613
  });
@@ -2622,7 +2641,7 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2622
2641
  if (!LAYOUT_TYPE_KEYS.includes(item.layoutType)) {
2623
2642
  return invalidFromErrorFactory(
2624
2643
  errorFactory,
2625
- `${itemPath}.layoutType must be 'normal', 'dialogue', 'nvl', 'choice', or 'base'`,
2644
+ `${itemPath}.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice'`,
2626
2645
  );
2627
2646
  }
2628
2647
 
@@ -2639,6 +2658,71 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2639
2658
  }
2640
2659
  }
2641
2660
 
2661
+ }
2662
+ }
2663
+ };
2664
+
2665
+ const validateControlItems = ({ items, path, errorFactory }) => {
2666
+ for (const [itemId, item] of Object.entries(items)) {
2667
+ const itemPath = `${path}.${itemId}`;
2668
+
2669
+ if (item?.type !== "folder" && item?.type !== "control") {
2670
+ return invalidFromErrorFactory(
2671
+ errorFactory,
2672
+ `${itemPath}.type must be 'folder' or 'control'`,
2673
+ );
2674
+ }
2675
+
2676
+ {
2677
+ const result = validateAllowedKeys({
2678
+ value: item,
2679
+ allowedKeys:
2680
+ item.type === "folder"
2681
+ ? ["id", "type", "name"]
2682
+ : ["id", "type", "name", "elements", "keyboard"],
2683
+ path: itemPath,
2684
+ errorFactory,
2685
+ });
2686
+ if (result?.valid === false) {
2687
+ return result;
2688
+ }
2689
+ }
2690
+
2691
+ if (!isNonEmptyString(item.id)) {
2692
+ return invalidFromErrorFactory(
2693
+ errorFactory,
2694
+ `${itemPath}.id must be a non-empty string`,
2695
+ );
2696
+ }
2697
+
2698
+ if (item.id !== itemId) {
2699
+ return invalidFromErrorFactory(
2700
+ errorFactory,
2701
+ `${itemPath}.id must match item key '${itemId}'`,
2702
+ );
2703
+ }
2704
+
2705
+ if (!isNonEmptyString(item.name)) {
2706
+ return invalidFromErrorFactory(
2707
+ errorFactory,
2708
+ `${itemPath}.name must be a non-empty string`,
2709
+ );
2710
+ }
2711
+
2712
+ if (item.type === "control") {
2713
+ {
2714
+ const result = validateNestedCollection({
2715
+ collection: item.elements,
2716
+ path: `${itemPath}.elements`,
2717
+ itemValidator: validateLayoutElementItems,
2718
+ treeValidator: validateLayoutElementTreeOwnership,
2719
+ treeNodeLabel: "control element",
2720
+ });
2721
+ if (result?.valid === false) {
2722
+ return result;
2723
+ }
2724
+ }
2725
+
2642
2726
  {
2643
2727
  const result = validateKeyboardMap({
2644
2728
  value: item.keyboard,
@@ -3264,6 +3348,17 @@ const validateCollection = ({ collection, path }) => {
3264
3348
  return result;
3265
3349
  }
3266
3350
  }
3351
+ } else if (path === "state.controls") {
3352
+ {
3353
+ const result = validateControlItems({
3354
+ items: collection.items,
3355
+ path: `${path}.items`,
3356
+ errorFactory: createStateValidationError,
3357
+ });
3358
+ if (result?.valid === false) {
3359
+ return result;
3360
+ }
3361
+ }
3267
3362
  }
3268
3363
 
3269
3364
  if (!Array.isArray(collection.tree)) {
@@ -3366,7 +3461,8 @@ const validateCollection = ({ collection, path }) => {
3366
3461
  path === "state.variables" ||
3367
3462
  path === "state.textStyles" ||
3368
3463
  path === "state.characters" ||
3369
- path === "state.layouts"
3464
+ path === "state.layouts" ||
3465
+ path === "state.controls"
3370
3466
  ) {
3371
3467
  {
3372
3468
  const result = validateGenericFolderOwnership({
@@ -3374,7 +3470,11 @@ const validateCollection = ({ collection, path }) => {
3374
3470
  items: collection.items,
3375
3471
  path: `${path}.tree`,
3376
3472
  folderLabel:
3377
- path === "state.layouts" ? "folder layout item" : "folder item",
3473
+ path === "state.layouts"
3474
+ ? "folder layout item"
3475
+ : path === "state.controls"
3476
+ ? "folder control item"
3477
+ : "folder item",
3378
3478
  });
3379
3479
  if (result?.valid === false) {
3380
3480
  return result;
@@ -3714,13 +3814,20 @@ export const assertInvariants = ({ state }) => {
3714
3814
  }
3715
3815
  }
3716
3816
 
3717
- const assertImageReference = ({ layoutId, elementId, field, targetId }) => {
3817
+ const assertImageReference = ({
3818
+ ownerIdField,
3819
+ ownerId,
3820
+ ownerLabel,
3821
+ elementId,
3822
+ field,
3823
+ targetId,
3824
+ }) => {
3718
3825
  const image = state.images.items[targetId];
3719
3826
  if (!isPlainObject(image) || image.type === "folder") {
3720
3827
  return invalidInvariant(
3721
- `layout element ${field} must reference an existing non-folder image`,
3828
+ `${ownerLabel} element ${field} must reference an existing non-folder image`,
3722
3829
  {
3723
- layoutId,
3830
+ [ownerIdField]: ownerId,
3724
3831
  elementId,
3725
3832
  field,
3726
3833
  targetId,
@@ -3732,7 +3839,9 @@ export const assertInvariants = ({ state }) => {
3732
3839
  };
3733
3840
 
3734
3841
  const assertTextStyleReference = ({
3735
- layoutId,
3842
+ ownerIdField,
3843
+ ownerId,
3844
+ ownerLabel,
3736
3845
  elementId,
3737
3846
  field,
3738
3847
  targetId,
@@ -3740,9 +3849,9 @@ export const assertInvariants = ({ state }) => {
3740
3849
  const textStyle = state.textStyles.items[targetId];
3741
3850
  if (!isPlainObject(textStyle) || textStyle.type === "folder") {
3742
3851
  return invalidInvariant(
3743
- `layout element ${field} must reference an existing non-folder text style`,
3852
+ `${ownerLabel} element ${field} must reference an existing non-folder text style`,
3744
3853
  {
3745
- layoutId,
3854
+ [ownerIdField]: ownerId,
3746
3855
  elementId,
3747
3856
  field,
3748
3857
  targetId,
@@ -3753,13 +3862,19 @@ export const assertInvariants = ({ state }) => {
3753
3862
  return VALID_RESULT;
3754
3863
  };
3755
3864
 
3756
- const assertVariableReference = ({ layoutId, elementId, targetId }) => {
3865
+ const assertVariableReference = ({
3866
+ ownerIdField,
3867
+ ownerId,
3868
+ ownerLabel,
3869
+ elementId,
3870
+ targetId,
3871
+ }) => {
3757
3872
  const variable = state.variables.items[targetId];
3758
3873
  if (!isPlainObject(variable) || variable.type === "folder") {
3759
3874
  return invalidInvariant(
3760
- "layout element variableId must reference an existing non-folder variable",
3875
+ `${ownerLabel} element variableId must reference an existing non-folder variable`,
3761
3876
  {
3762
- layoutId,
3877
+ [ownerIdField]: ownerId,
3763
3878
  elementId,
3764
3879
  variableId: targetId,
3765
3880
  },
@@ -3769,62 +3884,101 @@ export const assertInvariants = ({ state }) => {
3769
3884
  return VALID_RESULT;
3770
3885
  };
3771
3886
 
3772
- for (const [layoutId, layout] of Object.entries(state.layouts.items)) {
3773
- if (layout.type === "folder") {
3774
- continue;
3775
- }
3887
+ const assertElementReferencesForCollection = ({
3888
+ items,
3889
+ ownerIdField,
3890
+ ownerLabel,
3891
+ ownerType,
3892
+ }) => {
3893
+ for (const [ownerId, owner] of Object.entries(items)) {
3894
+ if (owner.type !== ownerType) {
3895
+ continue;
3896
+ }
3776
3897
 
3777
- for (const [elementId, element] of Object.entries(layout.elements.items)) {
3778
- for (const field of [
3779
- "imageId",
3780
- "hoverImageId",
3781
- "clickImageId",
3782
- "thumbImageId",
3783
- "barImageId",
3784
- "hoverThumbImageId",
3785
- "hoverBarImageId",
3786
- ]) {
3787
- if (element[field] !== undefined) {
3788
- const result = assertImageReference({
3789
- layoutId,
3790
- elementId,
3791
- field,
3792
- targetId: element[field],
3793
- });
3794
- if (!result.valid) {
3795
- return result;
3898
+ for (const [elementId, element] of Object.entries(owner.elements.items)) {
3899
+ for (const field of [
3900
+ "imageId",
3901
+ "hoverImageId",
3902
+ "clickImageId",
3903
+ "thumbImageId",
3904
+ "barImageId",
3905
+ "hoverThumbImageId",
3906
+ "hoverBarImageId",
3907
+ ]) {
3908
+ if (element[field] !== undefined) {
3909
+ const result = assertImageReference({
3910
+ ownerIdField,
3911
+ ownerId,
3912
+ ownerLabel,
3913
+ elementId,
3914
+ field,
3915
+ targetId: element[field],
3916
+ });
3917
+ if (!result.valid) {
3918
+ return result;
3919
+ }
3796
3920
  }
3797
3921
  }
3798
- }
3799
3922
 
3800
- for (const field of [
3801
- "textStyleId",
3802
- "hoverTextStyleId",
3803
- "clickTextStyleId",
3804
- ]) {
3805
- if (element[field] !== undefined) {
3806
- const result = assertTextStyleReference({
3807
- layoutId,
3923
+ for (const field of [
3924
+ "textStyleId",
3925
+ "hoverTextStyleId",
3926
+ "clickTextStyleId",
3927
+ ]) {
3928
+ if (element[field] !== undefined) {
3929
+ const result = assertTextStyleReference({
3930
+ ownerIdField,
3931
+ ownerId,
3932
+ ownerLabel,
3933
+ elementId,
3934
+ field,
3935
+ targetId: element[field],
3936
+ });
3937
+ if (!result.valid) {
3938
+ return result;
3939
+ }
3940
+ }
3941
+ }
3942
+
3943
+ if (element.variableId !== undefined) {
3944
+ const result = assertVariableReference({
3945
+ ownerIdField,
3946
+ ownerId,
3947
+ ownerLabel,
3808
3948
  elementId,
3809
- field,
3810
- targetId: element[field],
3949
+ targetId: element.variableId,
3811
3950
  });
3812
3951
  if (!result.valid) {
3813
3952
  return result;
3814
3953
  }
3815
3954
  }
3816
3955
  }
3956
+ }
3817
3957
 
3818
- if (element.variableId !== undefined) {
3819
- const result = assertVariableReference({
3820
- layoutId,
3821
- elementId,
3822
- targetId: element.variableId,
3823
- });
3824
- if (!result.valid) {
3825
- return result;
3826
- }
3827
- }
3958
+ return VALID_RESULT;
3959
+ };
3960
+
3961
+ {
3962
+ const result = assertElementReferencesForCollection({
3963
+ items: state.layouts.items,
3964
+ ownerIdField: "layoutId",
3965
+ ownerLabel: "layout",
3966
+ ownerType: "layout",
3967
+ });
3968
+ if (!result.valid) {
3969
+ return result;
3970
+ }
3971
+ }
3972
+
3973
+ {
3974
+ const result = assertElementReferencesForCollection({
3975
+ items: state.controls.items,
3976
+ ownerIdField: "controlId",
3977
+ ownerLabel: "control",
3978
+ ownerType: "control",
3979
+ });
3980
+ if (!result.valid) {
3981
+ return result;
3828
3982
  }
3829
3983
  }
3830
3984
 
@@ -3833,9 +3987,11 @@ export const assertInvariants = ({ state }) => {
3833
3987
 
3834
3988
  const runValidateState = ({ state }) => {
3835
3989
  return captureValidation(() => {
3990
+ const normalizedState = normalizeStateCollections(state);
3991
+
3836
3992
  {
3837
3993
  const result = validateExactKeys({
3838
- value: state,
3994
+ value: normalizedState,
3839
3995
  expectedKeys: ROOT_KEYS,
3840
3996
  path: "state",
3841
3997
  errorFactory: createStateValidationError,
@@ -3847,7 +4003,7 @@ const runValidateState = ({ state }) => {
3847
4003
 
3848
4004
  {
3849
4005
  const result = validateAllowedKeys({
3850
- value: state.project,
4006
+ value: normalizedState.project,
3851
4007
  allowedKeys: ["resolution"],
3852
4008
  path: "state.project",
3853
4009
  errorFactory: createStateValidationError,
@@ -3857,10 +4013,10 @@ const runValidateState = ({ state }) => {
3857
4013
  }
3858
4014
  }
3859
4015
 
3860
- if (state.project.resolution !== undefined) {
4016
+ if (normalizedState.project.resolution !== undefined) {
3861
4017
  {
3862
4018
  const result = validateExactKeys({
3863
- value: state.project.resolution,
4019
+ value: normalizedState.project.resolution,
3864
4020
  expectedKeys: ["width", "height"],
3865
4021
  path: "state.project.resolution",
3866
4022
  errorFactory: createStateValidationError,
@@ -3870,13 +4026,13 @@ const runValidateState = ({ state }) => {
3870
4026
  }
3871
4027
  }
3872
4028
 
3873
- if (!isFiniteNumber(state.project.resolution.width)) {
4029
+ if (!isFiniteNumber(normalizedState.project.resolution.width)) {
3874
4030
  return invalidState(
3875
4031
  "state.project.resolution.width must be a finite number",
3876
4032
  );
3877
4033
  }
3878
4034
 
3879
- if (!isFiniteNumber(state.project.resolution.height)) {
4035
+ if (!isFiniteNumber(normalizedState.project.resolution.height)) {
3880
4036
  return invalidState(
3881
4037
  "state.project.resolution.height must be a finite number",
3882
4038
  );
@@ -3885,7 +4041,7 @@ const runValidateState = ({ state }) => {
3885
4041
 
3886
4042
  {
3887
4043
  const result = validateExactKeys({
3888
- value: state.story,
4044
+ value: normalizedState.story,
3889
4045
  expectedKeys: ["initialSceneId"],
3890
4046
  path: "state.story",
3891
4047
  errorFactory: createStateValidationError,
@@ -3896,8 +4052,8 @@ const runValidateState = ({ state }) => {
3896
4052
  }
3897
4053
 
3898
4054
  if (
3899
- state.story.initialSceneId !== null &&
3900
- !isNonEmptyString(state.story.initialSceneId)
4055
+ normalizedState.story.initialSceneId !== null &&
4056
+ !isNonEmptyString(normalizedState.story.initialSceneId)
3901
4057
  ) {
3902
4058
  return invalidState(
3903
4059
  "state.story.initialSceneId must be a non-empty string or null",
@@ -3907,7 +4063,7 @@ const runValidateState = ({ state }) => {
3907
4063
  for (const collectionKey of COLLECTION_KEYS) {
3908
4064
  {
3909
4065
  const result = validateCollection({
3910
- collection: state[collectionKey],
4066
+ collection: normalizedState[collectionKey],
3911
4067
  path: `state.${collectionKey}`,
3912
4068
  });
3913
4069
  if (result?.valid === false) {
@@ -3916,7 +4072,7 @@ const runValidateState = ({ state }) => {
3916
4072
  }
3917
4073
  }
3918
4074
 
3919
- const invariantResult = assertInvariants({ state });
4075
+ const invariantResult = assertInvariants({ state: normalizedState });
3920
4076
  if (!invariantResult.valid) {
3921
4077
  return invariantResult;
3922
4078
  }
@@ -5928,7 +6084,7 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
5928
6084
  allowedKeys:
5929
6085
  data.type === "folder"
5930
6086
  ? ["type", "name"]
5931
- : ["type", "name", "layoutType", "elements", "keyboard"],
6087
+ : ["type", "name", "layoutType", "elements"],
5932
6088
  path: "payload.data",
5933
6089
  errorFactory,
5934
6090
  });
@@ -5948,7 +6104,7 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
5948
6104
  if (!LAYOUT_TYPE_KEYS.includes(data.layoutType)) {
5949
6105
  return invalidFromErrorFactory(
5950
6106
  errorFactory,
5951
- "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', 'choice', or 'base'",
6107
+ "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice'",
5952
6108
  );
5953
6109
  }
5954
6110
 
@@ -5965,16 +6121,6 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
5965
6121
  }
5966
6122
  }
5967
6123
 
5968
- {
5969
- const result = validateKeyboardMap({
5970
- value: data.keyboard,
5971
- path: "payload.data.keyboard",
5972
- errorFactory,
5973
- });
5974
- if (result?.valid === false) {
5975
- return result;
5976
- }
5977
- }
5978
6124
  }
5979
6125
  };
5980
6126
 
@@ -5982,7 +6128,7 @@ const validateLayoutUpdateData = ({ data, errorFactory }) => {
5982
6128
  {
5983
6129
  const result = validateAllowedKeys({
5984
6130
  value: data,
5985
- allowedKeys: ["name", "layoutType", "keyboard"],
6131
+ allowedKeys: ["name", "layoutType"],
5986
6132
  path: "payload.data",
5987
6133
  errorFactory,
5988
6134
  });
@@ -6011,7 +6157,100 @@ const validateLayoutUpdateData = ({ data, errorFactory }) => {
6011
6157
  ) {
6012
6158
  return invalidFromErrorFactory(
6013
6159
  errorFactory,
6014
- "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', 'choice', or 'base' when provided",
6160
+ "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice' when provided",
6161
+ );
6162
+ }
6163
+
6164
+ };
6165
+
6166
+ const validateControlCreateData = ({ data, errorFactory }) => {
6167
+ if (!isPlainObject(data)) {
6168
+ return invalidFromErrorFactory(
6169
+ errorFactory,
6170
+ "payload.data must be an object",
6171
+ );
6172
+ }
6173
+
6174
+ if (data.type !== "folder" && data.type !== "control") {
6175
+ return invalidFromErrorFactory(
6176
+ errorFactory,
6177
+ "payload.data.type must be 'folder' or 'control'",
6178
+ );
6179
+ }
6180
+
6181
+ {
6182
+ const result = validateAllowedKeys({
6183
+ value: data,
6184
+ allowedKeys:
6185
+ data.type === "folder"
6186
+ ? ["type", "name"]
6187
+ : ["type", "name", "elements", "keyboard"],
6188
+ path: "payload.data",
6189
+ errorFactory,
6190
+ });
6191
+ if (result?.valid === false) {
6192
+ return result;
6193
+ }
6194
+ }
6195
+
6196
+ if (!isNonEmptyString(data.name)) {
6197
+ return invalidFromErrorFactory(
6198
+ errorFactory,
6199
+ "payload.data.name must be a non-empty string",
6200
+ );
6201
+ }
6202
+
6203
+ if (data.type === "control") {
6204
+ {
6205
+ const result = validateNestedCollection({
6206
+ collection: data.elements,
6207
+ path: "payload.data.elements",
6208
+ itemValidator: validateLayoutElementItems,
6209
+ treeValidator: validateLayoutElementTreeOwnership,
6210
+ errorFactory,
6211
+ });
6212
+ if (result?.valid === false) {
6213
+ return result;
6214
+ }
6215
+ }
6216
+
6217
+ {
6218
+ const result = validateKeyboardMap({
6219
+ value: data.keyboard,
6220
+ path: "payload.data.keyboard",
6221
+ errorFactory,
6222
+ });
6223
+ if (result?.valid === false) {
6224
+ return result;
6225
+ }
6226
+ }
6227
+ }
6228
+ };
6229
+
6230
+ const validateControlUpdateData = ({ data, errorFactory }) => {
6231
+ {
6232
+ const result = validateAllowedKeys({
6233
+ value: data,
6234
+ allowedKeys: ["name", "keyboard"],
6235
+ path: "payload.data",
6236
+ errorFactory,
6237
+ });
6238
+ if (result?.valid === false) {
6239
+ return result;
6240
+ }
6241
+ }
6242
+
6243
+ if (Object.keys(data).length === 0) {
6244
+ return invalidFromErrorFactory(
6245
+ errorFactory,
6246
+ "payload.data must include at least one updatable field",
6247
+ );
6248
+ }
6249
+
6250
+ if (data.name !== undefined && !isNonEmptyString(data.name)) {
6251
+ return invalidFromErrorFactory(
6252
+ errorFactory,
6253
+ "payload.data.name must be a non-empty string when provided",
6015
6254
  );
6016
6255
  }
6017
6256
 
@@ -6061,8 +6300,10 @@ const validateLayoutElementUpdateData = ({ data, errorFactory, replace }) => {
6061
6300
  }
6062
6301
  };
6063
6302
 
6064
- const validateLayoutElementReferenceTargets = ({
6065
- layoutId,
6303
+ const validateVisualElementReferenceTargets = ({
6304
+ ownerIdField,
6305
+ ownerId,
6306
+ ownerLabel,
6066
6307
  elementId,
6067
6308
  data,
6068
6309
  state,
@@ -6073,9 +6314,9 @@ const validateLayoutElementReferenceTargets = ({
6073
6314
  if (!isPlainObject(image) || image.type === "folder") {
6074
6315
  return invalidFromErrorFactory(
6075
6316
  errorFactory,
6076
- "layout element imageId must reference an existing non-folder image",
6317
+ `${ownerLabel} element imageId must reference an existing non-folder image`,
6077
6318
  {
6078
- layoutId,
6319
+ [ownerIdField]: ownerId,
6079
6320
  elementId,
6080
6321
  field: "imageId",
6081
6322
  targetId: data.imageId,
@@ -6089,9 +6330,9 @@ const validateLayoutElementReferenceTargets = ({
6089
6330
  if (!isPlainObject(image) || image.type === "folder") {
6090
6331
  return invalidFromErrorFactory(
6091
6332
  errorFactory,
6092
- "layout element hoverImageId must reference an existing non-folder image",
6333
+ `${ownerLabel} element hoverImageId must reference an existing non-folder image`,
6093
6334
  {
6094
- layoutId,
6335
+ [ownerIdField]: ownerId,
6095
6336
  elementId,
6096
6337
  field: "hoverImageId",
6097
6338
  targetId: data.hoverImageId,
@@ -6105,9 +6346,9 @@ const validateLayoutElementReferenceTargets = ({
6105
6346
  if (!isPlainObject(image) || image.type === "folder") {
6106
6347
  return invalidFromErrorFactory(
6107
6348
  errorFactory,
6108
- "layout element clickImageId must reference an existing non-folder image",
6349
+ `${ownerLabel} element clickImageId must reference an existing non-folder image`,
6109
6350
  {
6110
- layoutId,
6351
+ [ownerIdField]: ownerId,
6111
6352
  elementId,
6112
6353
  field: "clickImageId",
6113
6354
  targetId: data.clickImageId,
@@ -6121,9 +6362,9 @@ const validateLayoutElementReferenceTargets = ({
6121
6362
  if (!isPlainObject(image) || image.type === "folder") {
6122
6363
  return invalidFromErrorFactory(
6123
6364
  errorFactory,
6124
- "layout element thumbImageId must reference an existing non-folder image",
6365
+ `${ownerLabel} element thumbImageId must reference an existing non-folder image`,
6125
6366
  {
6126
- layoutId,
6367
+ [ownerIdField]: ownerId,
6127
6368
  elementId,
6128
6369
  field: "thumbImageId",
6129
6370
  targetId: data.thumbImageId,
@@ -6137,9 +6378,9 @@ const validateLayoutElementReferenceTargets = ({
6137
6378
  if (!isPlainObject(image) || image.type === "folder") {
6138
6379
  return invalidFromErrorFactory(
6139
6380
  errorFactory,
6140
- "layout element hoverThumbImageId must reference an existing non-folder image",
6381
+ `${ownerLabel} element hoverThumbImageId must reference an existing non-folder image`,
6141
6382
  {
6142
- layoutId,
6383
+ [ownerIdField]: ownerId,
6143
6384
  elementId,
6144
6385
  field: "hoverThumbImageId",
6145
6386
  targetId: data.hoverThumbImageId,
@@ -6153,9 +6394,9 @@ const validateLayoutElementReferenceTargets = ({
6153
6394
  if (!isPlainObject(image) || image.type === "folder") {
6154
6395
  return invalidFromErrorFactory(
6155
6396
  errorFactory,
6156
- "layout element barImageId must reference an existing non-folder image",
6397
+ `${ownerLabel} element barImageId must reference an existing non-folder image`,
6157
6398
  {
6158
- layoutId,
6399
+ [ownerIdField]: ownerId,
6159
6400
  elementId,
6160
6401
  field: "barImageId",
6161
6402
  targetId: data.barImageId,
@@ -6169,9 +6410,9 @@ const validateLayoutElementReferenceTargets = ({
6169
6410
  if (!isPlainObject(image) || image.type === "folder") {
6170
6411
  return invalidFromErrorFactory(
6171
6412
  errorFactory,
6172
- "layout element hoverBarImageId must reference an existing non-folder image",
6413
+ `${ownerLabel} element hoverBarImageId must reference an existing non-folder image`,
6173
6414
  {
6174
- layoutId,
6415
+ [ownerIdField]: ownerId,
6175
6416
  elementId,
6176
6417
  field: "hoverBarImageId",
6177
6418
  targetId: data.hoverBarImageId,
@@ -6185,9 +6426,9 @@ const validateLayoutElementReferenceTargets = ({
6185
6426
  if (!isPlainObject(textStyle) || textStyle.type === "folder") {
6186
6427
  return invalidFromErrorFactory(
6187
6428
  errorFactory,
6188
- "layout element textStyleId must reference an existing non-folder text style",
6429
+ `${ownerLabel} element textStyleId must reference an existing non-folder text style`,
6189
6430
  {
6190
- layoutId,
6431
+ [ownerIdField]: ownerId,
6191
6432
  elementId,
6192
6433
  field: "textStyleId",
6193
6434
  targetId: data.textStyleId,
@@ -6201,9 +6442,9 @@ const validateLayoutElementReferenceTargets = ({
6201
6442
  if (!isPlainObject(textStyle) || textStyle.type === "folder") {
6202
6443
  return invalidFromErrorFactory(
6203
6444
  errorFactory,
6204
- "layout element hoverTextStyleId must reference an existing non-folder text style",
6445
+ `${ownerLabel} element hoverTextStyleId must reference an existing non-folder text style`,
6205
6446
  {
6206
- layoutId,
6447
+ [ownerIdField]: ownerId,
6207
6448
  elementId,
6208
6449
  field: "hoverTextStyleId",
6209
6450
  targetId: data.hoverTextStyleId,
@@ -6217,9 +6458,9 @@ const validateLayoutElementReferenceTargets = ({
6217
6458
  if (!isPlainObject(textStyle) || textStyle.type === "folder") {
6218
6459
  return invalidFromErrorFactory(
6219
6460
  errorFactory,
6220
- "layout element clickTextStyleId must reference an existing non-folder text style",
6461
+ `${ownerLabel} element clickTextStyleId must reference an existing non-folder text style`,
6221
6462
  {
6222
- layoutId,
6463
+ [ownerIdField]: ownerId,
6223
6464
  elementId,
6224
6465
  field: "clickTextStyleId",
6225
6466
  targetId: data.clickTextStyleId,
@@ -6233,9 +6474,9 @@ const validateLayoutElementReferenceTargets = ({
6233
6474
  if (!isPlainObject(variable) || variable.type === "folder") {
6234
6475
  return invalidFromErrorFactory(
6235
6476
  errorFactory,
6236
- "layout element variableId must reference an existing non-folder variable",
6477
+ `${ownerLabel} element variableId must reference an existing non-folder variable`,
6237
6478
  {
6238
- layoutId,
6479
+ [ownerIdField]: ownerId,
6239
6480
  elementId,
6240
6481
  variableId: data.variableId,
6241
6482
  },
@@ -6774,6 +7015,9 @@ const getCharacterSpriteCollection = ({ state, characterId }) =>
6774
7015
  const getLayoutElementCollection = ({ state, layoutId }) =>
6775
7016
  state.layouts.items[layoutId]?.elements;
6776
7017
 
7018
+ const getControlElementCollection = ({ state, controlId }) =>
7019
+ state.controls.items[controlId]?.elements;
7020
+
6777
7021
  const findReferencedFileUsage = ({ state, fileId }) => {
6778
7022
  for (const [imageId, image] of Object.entries(state.images.items)) {
6779
7023
  if (image.type !== "image") {
@@ -10911,11 +11155,6 @@ const COMMAND_DEFINITIONS = [
10911
11155
  ? {
10912
11156
  layoutType: payload.data.layoutType,
10913
11157
  elements: structuredClone(payload.data.elements),
10914
- ...(payload.data.keyboard !== undefined
10915
- ? {
10916
- keyboard: structuredClone(payload.data.keyboard),
10917
- }
10918
- : {}),
10919
11158
  }
10920
11159
  : {}),
10921
11160
  }),
@@ -10930,21 +11169,54 @@ const COMMAND_DEFINITIONS = [
10930
11169
  }
10931
11170
  },
10932
11171
  }),
10933
- {
10934
- type: "character.sprite.create",
10935
- validatePayload: ({ payload }) => {
10936
- {
10937
- const result = validateAllowedKeys({
10938
- value: payload,
10939
- allowedKeys: [
10940
- "characterId",
10941
- "spriteId",
10942
- "parentId",
10943
- "data",
10944
- "index",
10945
- "position",
10946
- "positionTargetId",
10947
- ],
11172
+ ...createFolderedCollectionCommandDefinitions({
11173
+ familyName: "control",
11174
+ collectionKey: "controls",
11175
+ idField: "controlId",
11176
+ itemLabel: "control item",
11177
+ createDataValidator: validateControlCreateData,
11178
+ updateDataValidator: validateControlUpdateData,
11179
+ createItem: ({ payload }) => ({
11180
+ id: payload.controlId,
11181
+ type: payload.data.type,
11182
+ name: payload.data.name,
11183
+ ...(payload.data.type === "control"
11184
+ ? {
11185
+ elements: structuredClone(payload.data.elements),
11186
+ ...(payload.data.keyboard !== undefined
11187
+ ? {
11188
+ keyboard: structuredClone(payload.data.keyboard),
11189
+ }
11190
+ : {}),
11191
+ }
11192
+ : {}),
11193
+ }),
11194
+ validateUpdateState: ({ payload, currentItem }) => {
11195
+ if (
11196
+ currentItem.type === "folder" &&
11197
+ Object.keys(payload.data).some((key) => key !== "name")
11198
+ ) {
11199
+ return invalidPrecondition(
11200
+ "folder control items cannot update control fields",
11201
+ );
11202
+ }
11203
+ },
11204
+ }),
11205
+ {
11206
+ type: "character.sprite.create",
11207
+ validatePayload: ({ payload }) => {
11208
+ {
11209
+ const result = validateAllowedKeys({
11210
+ value: payload,
11211
+ allowedKeys: [
11212
+ "characterId",
11213
+ "spriteId",
11214
+ "parentId",
11215
+ "data",
11216
+ "index",
11217
+ "position",
11218
+ "positionTargetId",
11219
+ ],
10948
11220
  path: "payload",
10949
11221
  errorFactory: createPayloadValidationError,
10950
11222
  });
@@ -11510,8 +11782,10 @@ const COMMAND_DEFINITIONS = [
11510
11782
  }
11511
11783
 
11512
11784
  {
11513
- const result = validateLayoutElementReferenceTargets({
11514
- layoutId: payload.layoutId,
11785
+ const result = validateVisualElementReferenceTargets({
11786
+ ownerIdField: "layoutId",
11787
+ ownerId: payload.layoutId,
11788
+ ownerLabel: "layout",
11515
11789
  elementId: payload.elementId,
11516
11790
  data: payload.data,
11517
11791
  state,
@@ -11628,8 +11902,10 @@ const COMMAND_DEFINITIONS = [
11628
11902
  };
11629
11903
 
11630
11904
  {
11631
- const result = validateLayoutElementReferenceTargets({
11632
- layoutId: payload.layoutId,
11905
+ const result = validateVisualElementReferenceTargets({
11906
+ ownerIdField: "layoutId",
11907
+ ownerId: payload.layoutId,
11908
+ ownerLabel: "layout",
11633
11909
  elementId: payload.elementId,
11634
11910
  data: mergedData,
11635
11911
  state,
@@ -11754,6 +12030,515 @@ const COMMAND_DEFINITIONS = [
11754
12030
  return state;
11755
12031
  },
11756
12032
  },
12033
+ {
12034
+ type: "control.element.create",
12035
+ validatePayload: ({ payload }) => {
12036
+ {
12037
+ const result = validateAllowedKeys({
12038
+ value: payload,
12039
+ allowedKeys: [
12040
+ "controlId",
12041
+ "elementId",
12042
+ "parentId",
12043
+ "data",
12044
+ "index",
12045
+ "position",
12046
+ "positionTargetId",
12047
+ ],
12048
+ path: "payload",
12049
+ errorFactory: createPayloadValidationError,
12050
+ });
12051
+ if (result?.valid === false) {
12052
+ return result;
12053
+ }
12054
+ }
12055
+
12056
+ if (!isNonEmptyString(payload.controlId)) {
12057
+ return invalidPayload("payload.controlId must be a non-empty string");
12058
+ }
12059
+
12060
+ if (!isNonEmptyString(payload.elementId)) {
12061
+ return invalidPayload("payload.elementId must be a non-empty string");
12062
+ }
12063
+
12064
+ if (
12065
+ payload.parentId !== undefined &&
12066
+ payload.parentId !== null &&
12067
+ !isNonEmptyString(payload.parentId)
12068
+ ) {
12069
+ return invalidPayload(
12070
+ "payload.parentId must be a non-empty string when provided",
12071
+ );
12072
+ }
12073
+
12074
+ {
12075
+ const result = validateLayoutElementCreateData({
12076
+ data: payload.data,
12077
+ errorFactory: createPayloadValidationError,
12078
+ });
12079
+ if (result?.valid === false) {
12080
+ return result;
12081
+ }
12082
+ }
12083
+
12084
+ {
12085
+ const result = validatePlacementFields({
12086
+ payload,
12087
+ errorFactory: createPayloadValidationError,
12088
+ });
12089
+ if (result?.valid === false) {
12090
+ return result;
12091
+ }
12092
+ }
12093
+ },
12094
+ validateAgainstState: ({ state, payload }) => {
12095
+ const control = state.controls.items[payload.controlId];
12096
+ if (!isPlainObject(control) || control.type !== "control") {
12097
+ return invalidPrecondition(
12098
+ "payload.controlId must reference an existing control",
12099
+ );
12100
+ }
12101
+
12102
+ const collection = getControlElementCollection({
12103
+ state,
12104
+ controlId: payload.controlId,
12105
+ });
12106
+
12107
+ if (isPlainObject(collection.items[payload.elementId])) {
12108
+ return invalidPrecondition("payload.elementId must not already exist");
12109
+ }
12110
+
12111
+ const parentId = payload.parentId ?? null;
12112
+ if (parentId !== null) {
12113
+ const parentItem = collection.items[parentId];
12114
+ if (
12115
+ !isPlainObject(parentItem) ||
12116
+ !LAYOUT_CONTAINER_ELEMENT_TYPES.includes(parentItem.type)
12117
+ ) {
12118
+ return invalidPrecondition(
12119
+ "payload.parentId must reference a folder or container control element",
12120
+ );
12121
+ }
12122
+ }
12123
+
12124
+ if (payload.positionTargetId !== undefined) {
12125
+ if (!isPlainObject(collection.items[payload.positionTargetId])) {
12126
+ return invalidPrecondition(
12127
+ "payload.positionTargetId must reference an existing control element",
12128
+ );
12129
+ }
12130
+
12131
+ const targetParentId = getNodeParentId({
12132
+ tree: collection.tree,
12133
+ nodeId: payload.positionTargetId,
12134
+ });
12135
+
12136
+ if (targetParentId !== parentId) {
12137
+ return invalidPrecondition(
12138
+ "payload.positionTargetId must reference a sibling under payload.parentId",
12139
+ );
12140
+ }
12141
+ }
12142
+
12143
+ {
12144
+ const result = validateVisualElementReferenceTargets({
12145
+ ownerIdField: "controlId",
12146
+ ownerId: payload.controlId,
12147
+ ownerLabel: "control",
12148
+ elementId: payload.elementId,
12149
+ data: payload.data,
12150
+ state,
12151
+ errorFactory: createPreconditionValidationError,
12152
+ });
12153
+ if (result?.valid === false) {
12154
+ return result;
12155
+ }
12156
+ }
12157
+ },
12158
+ reduce: ({ state, payload }) => {
12159
+ const collection = getControlElementCollection({
12160
+ state,
12161
+ controlId: payload.controlId,
12162
+ });
12163
+
12164
+ collection.items[payload.elementId] = {
12165
+ id: payload.elementId,
12166
+ ...structuredClone(payload.data),
12167
+ };
12168
+
12169
+ insertTreeNode({
12170
+ tree: collection.tree,
12171
+ node: {
12172
+ id: payload.elementId,
12173
+ children: [],
12174
+ },
12175
+ parentId: payload.parentId ?? null,
12176
+ index: payload.index,
12177
+ position: payload.position,
12178
+ positionTargetId: payload.positionTargetId,
12179
+ });
12180
+
12181
+ return state;
12182
+ },
12183
+ },
12184
+ {
12185
+ type: "control.element.update",
12186
+ validatePayload: ({ payload }) => {
12187
+ let result = captureValidation(() =>
12188
+ validateAllowedKeys({
12189
+ value: payload,
12190
+ allowedKeys: ["controlId", "elementId", "data", "replace"],
12191
+ path: "payload",
12192
+ errorFactory: createPayloadValidationError,
12193
+ }),
12194
+ );
12195
+ if (!result.valid) {
12196
+ return result;
12197
+ }
12198
+
12199
+ if (!isNonEmptyString(payload.controlId)) {
12200
+ return invalidPayload("payload.controlId must be a non-empty string");
12201
+ }
12202
+
12203
+ if (!isNonEmptyString(payload.elementId)) {
12204
+ return invalidPayload("payload.elementId must be a non-empty string");
12205
+ }
12206
+
12207
+ if (
12208
+ payload.replace !== undefined &&
12209
+ typeof payload.replace !== "boolean"
12210
+ ) {
12211
+ return invalidPayload(
12212
+ "payload.replace must be a boolean when provided",
12213
+ );
12214
+ }
12215
+
12216
+ result = captureValidation(() =>
12217
+ validateLayoutElementUpdateData({
12218
+ data: payload.data,
12219
+ replace: payload.replace,
12220
+ errorFactory: createPayloadValidationError,
12221
+ }),
12222
+ );
12223
+ if (!result.valid) {
12224
+ return result;
12225
+ }
12226
+
12227
+ return VALID_RESULT;
12228
+ },
12229
+ validateAgainstState: ({ state, payload }) => {
12230
+ const control = state.controls.items[payload.controlId];
12231
+ if (!isPlainObject(control) || control.type !== "control") {
12232
+ return invalidPrecondition(
12233
+ "payload.controlId must reference an existing control",
12234
+ );
12235
+ }
12236
+
12237
+ const collection = getControlElementCollection({
12238
+ state,
12239
+ controlId: payload.controlId,
12240
+ });
12241
+ const currentItem = collection.items[payload.elementId];
12242
+ if (!isPlainObject(currentItem)) {
12243
+ return invalidPrecondition(
12244
+ "payload.elementId must reference an existing control element",
12245
+ );
12246
+ }
12247
+
12248
+ if (
12249
+ payload.data.type !== undefined &&
12250
+ payload.data.type !== currentItem.type
12251
+ ) {
12252
+ return invalidPrecondition("control element type cannot be changed");
12253
+ }
12254
+
12255
+ if (currentItem.type !== "folder") {
12256
+ const mergedData = payload.replace
12257
+ ? { ...structuredClone(payload.data) }
12258
+ : {
12259
+ ...structuredClone(currentItem),
12260
+ ...structuredClone(payload.data),
12261
+ };
12262
+
12263
+ {
12264
+ const result = validateVisualElementReferenceTargets({
12265
+ ownerIdField: "controlId",
12266
+ ownerId: payload.controlId,
12267
+ ownerLabel: "control",
12268
+ elementId: payload.elementId,
12269
+ data: mergedData,
12270
+ state,
12271
+ errorFactory: createPreconditionValidationError,
12272
+ });
12273
+ if (result?.valid === false) {
12274
+ return result;
12275
+ }
12276
+ }
12277
+ }
12278
+
12279
+ if (
12280
+ currentItem.type === "folder" &&
12281
+ Object.keys(payload.data).some((key) => key !== "name")
12282
+ ) {
12283
+ return invalidPrecondition(
12284
+ "folder control elements cannot update non-name fields",
12285
+ );
12286
+ }
12287
+
12288
+ return VALID_RESULT;
12289
+ },
12290
+ reduce: ({ state, payload }) => {
12291
+ const collection = getControlElementCollection({
12292
+ state,
12293
+ controlId: payload.controlId,
12294
+ });
12295
+ const currentItem = collection.items[payload.elementId];
12296
+
12297
+ collection.items[payload.elementId] =
12298
+ payload.replace === true
12299
+ ? {
12300
+ id: payload.elementId,
12301
+ ...structuredClone(payload.data),
12302
+ }
12303
+ : {
12304
+ ...structuredClone(currentItem),
12305
+ ...structuredClone(payload.data),
12306
+ };
12307
+
12308
+ return state;
12309
+ },
12310
+ },
12311
+ {
12312
+ type: "control.element.delete",
12313
+ validatePayload: ({ payload }) => {
12314
+ {
12315
+ const result = validateExactKeys({
12316
+ value: payload,
12317
+ expectedKeys: ["controlId", "elementIds"],
12318
+ path: "payload",
12319
+ errorFactory: createPayloadValidationError,
12320
+ });
12321
+ if (result?.valid === false) {
12322
+ return result;
12323
+ }
12324
+ }
12325
+
12326
+ if (!isNonEmptyString(payload.controlId)) {
12327
+ return invalidPayload("payload.controlId must be a non-empty string");
12328
+ }
12329
+
12330
+ {
12331
+ const result = validateRequiredUniqueIdArray({
12332
+ value: payload.elementIds,
12333
+ path: "payload.elementIds",
12334
+ errorFactory: createPayloadValidationError,
12335
+ });
12336
+ if (result?.valid === false) {
12337
+ return result;
12338
+ }
12339
+ }
12340
+ },
12341
+ validateAgainstState: ({ state, payload }) => {
12342
+ const control = state.controls.items[payload.controlId];
12343
+ if (!isPlainObject(control) || control.type !== "control") {
12344
+ return invalidPrecondition(
12345
+ "payload.controlId must reference an existing control",
12346
+ );
12347
+ }
12348
+
12349
+ const collection = getControlElementCollection({
12350
+ state,
12351
+ controlId: payload.controlId,
12352
+ });
12353
+
12354
+ for (const elementId of payload.elementIds) {
12355
+ if (!isPlainObject(collection.items[elementId])) {
12356
+ return invalidPrecondition(
12357
+ "payload.elementIds must reference existing control elements",
12358
+ { elementId },
12359
+ );
12360
+ }
12361
+ }
12362
+ },
12363
+ reduce: ({ state, payload }) => {
12364
+ const collection = getControlElementCollection({
12365
+ state,
12366
+ controlId: payload.controlId,
12367
+ });
12368
+ const deletedIds = new Set();
12369
+
12370
+ for (const elementId of payload.elementIds) {
12371
+ const removedNode = removeTreeNode({
12372
+ nodes: collection.tree,
12373
+ nodeId: elementId,
12374
+ });
12375
+
12376
+ if (!removedNode) {
12377
+ continue;
12378
+ }
12379
+
12380
+ for (const id of collectTreeDescendantIds({ node: removedNode })) {
12381
+ deletedIds.add(id);
12382
+ }
12383
+ }
12384
+
12385
+ for (const elementId of deletedIds) {
12386
+ delete collection.items[elementId];
12387
+ }
12388
+
12389
+ return state;
12390
+ },
12391
+ },
12392
+ {
12393
+ type: "control.element.move",
12394
+ validatePayload: ({ payload }) => {
12395
+ {
12396
+ const result = validateAllowedKeys({
12397
+ value: payload,
12398
+ allowedKeys: [
12399
+ "controlId",
12400
+ "elementId",
12401
+ "parentId",
12402
+ "index",
12403
+ "position",
12404
+ "positionTargetId",
12405
+ ],
12406
+ path: "payload",
12407
+ errorFactory: createPayloadValidationError,
12408
+ });
12409
+ if (result?.valid === false) {
12410
+ return result;
12411
+ }
12412
+ }
12413
+
12414
+ if (!isNonEmptyString(payload.controlId)) {
12415
+ return invalidPayload("payload.controlId must be a non-empty string");
12416
+ }
12417
+
12418
+ if (!isNonEmptyString(payload.elementId)) {
12419
+ return invalidPayload("payload.elementId must be a non-empty string");
12420
+ }
12421
+
12422
+ if (
12423
+ payload.parentId !== undefined &&
12424
+ payload.parentId !== null &&
12425
+ !isNonEmptyString(payload.parentId)
12426
+ ) {
12427
+ return invalidPayload(
12428
+ "payload.parentId must be a non-empty string when provided",
12429
+ );
12430
+ }
12431
+
12432
+ {
12433
+ const result = validatePlacementFields({
12434
+ payload,
12435
+ errorFactory: createPayloadValidationError,
12436
+ });
12437
+ if (result?.valid === false) {
12438
+ return result;
12439
+ }
12440
+ }
12441
+ },
12442
+ validateAgainstState: ({ state, payload }) => {
12443
+ const control = state.controls.items[payload.controlId];
12444
+ if (!isPlainObject(control) || control.type !== "control") {
12445
+ return invalidPrecondition(
12446
+ "payload.controlId must reference an existing control",
12447
+ );
12448
+ }
12449
+
12450
+ const collection = getControlElementCollection({
12451
+ state,
12452
+ controlId: payload.controlId,
12453
+ });
12454
+ const currentItem = collection.items[payload.elementId];
12455
+
12456
+ if (!isPlainObject(currentItem)) {
12457
+ return invalidPrecondition(
12458
+ "payload.elementId must reference an existing control element",
12459
+ );
12460
+ }
12461
+
12462
+ const currentNode = findTreeNode({
12463
+ nodes: collection.tree,
12464
+ nodeId: payload.elementId,
12465
+ });
12466
+
12467
+ if (payload.parentId !== undefined && payload.parentId !== null) {
12468
+ const parentItem = collection.items[payload.parentId];
12469
+ if (
12470
+ !isPlainObject(parentItem) ||
12471
+ !LAYOUT_CONTAINER_ELEMENT_TYPES.includes(parentItem.type)
12472
+ ) {
12473
+ return invalidPrecondition(
12474
+ "payload.parentId must reference a folder or container control element",
12475
+ );
12476
+ }
12477
+
12478
+ const descendantIds = new Set(
12479
+ collectTreeDescendantIds({
12480
+ node: currentNode,
12481
+ }),
12482
+ );
12483
+
12484
+ if (descendantIds.has(payload.parentId)) {
12485
+ return invalidPrecondition(
12486
+ "payload.parentId must not target the moved control element or its descendants",
12487
+ );
12488
+ }
12489
+ }
12490
+
12491
+ if (payload.positionTargetId !== undefined) {
12492
+ if (payload.positionTargetId === payload.elementId) {
12493
+ return invalidPrecondition(
12494
+ "payload.positionTargetId must not reference the moved control element",
12495
+ );
12496
+ }
12497
+
12498
+ if (!isPlainObject(collection.items[payload.positionTargetId])) {
12499
+ return invalidPrecondition(
12500
+ "payload.positionTargetId must reference an existing control element",
12501
+ );
12502
+ }
12503
+
12504
+ const targetParentId = getNodeParentId({
12505
+ tree: collection.tree,
12506
+ nodeId: payload.positionTargetId,
12507
+ });
12508
+
12509
+ if (targetParentId !== (payload.parentId ?? null)) {
12510
+ return invalidPrecondition(
12511
+ "payload.positionTargetId must reference a sibling under payload.parentId",
12512
+ );
12513
+ }
12514
+ }
12515
+ },
12516
+ reduce: ({ state, payload }) => {
12517
+ const collection = getControlElementCollection({
12518
+ state,
12519
+ controlId: payload.controlId,
12520
+ });
12521
+ const nodeResult = removeNodeOrResult({
12522
+ tree: collection.tree,
12523
+ nodeId: payload.elementId,
12524
+ errorMessage: "control element move target missing from tree",
12525
+ });
12526
+ if (!nodeResult.valid) {
12527
+ return nodeResult;
12528
+ }
12529
+
12530
+ insertTreeNode({
12531
+ tree: collection.tree,
12532
+ node: nodeResult.node,
12533
+ parentId: payload.parentId ?? null,
12534
+ index: payload.index,
12535
+ position: payload.position,
12536
+ positionTargetId: payload.positionTargetId,
12537
+ });
12538
+
12539
+ return state;
12540
+ },
12541
+ },
11757
12542
  {
11758
12543
  type: "layout.element.move",
11759
12544
  validatePayload: ({ payload }) => {
@@ -11942,6 +12727,8 @@ export const validatePayload = ({ type, payload }) => {
11942
12727
 
11943
12728
  export const validateAgainstState = ({ state, command }) => {
11944
12729
  return captureValidation(() => {
12730
+ const normalizedState = normalizeStateCollections(state);
12731
+
11945
12732
  if (!isPlainObject(command)) {
11946
12733
  return invalidPrecondition("command must be an object");
11947
12734
  }
@@ -11954,7 +12741,7 @@ export const validateAgainstState = ({ state, command }) => {
11954
12741
  );
11955
12742
  }
11956
12743
 
11957
- const stateResult = validateState({ state });
12744
+ const stateResult = validateState({ state: normalizedState });
11958
12745
  if (!stateResult.valid) {
11959
12746
  if (stateResult.error.kind === "invariant") {
11960
12747
  return invalidInvariant(
@@ -11976,7 +12763,7 @@ export const validateAgainstState = ({ state, command }) => {
11976
12763
 
11977
12764
  const validationResult = captureValidation(() =>
11978
12765
  definition.validateAgainstState({
11979
- state,
12766
+ state: normalizedState,
11980
12767
  payload: command.payload,
11981
12768
  }),
11982
12769
  );
@@ -11987,12 +12774,16 @@ export const validateAgainstState = ({ state, command }) => {
11987
12774
 
11988
12775
  export const processCommand = ({ state, command }) => {
11989
12776
  return captureValidation(() => {
12777
+ const normalizedState = normalizeStateCollections(state);
12778
+ const shouldMaterializeControls =
12779
+ typeof command?.type === "string" && command.type.startsWith("control.");
12780
+
11990
12781
  if (!isPlainObject(command)) {
11991
12782
  return invalidPrecondition("command must be an object");
11992
12783
  }
11993
12784
 
11994
12785
  const preconditionResult = validateAgainstState({
11995
- state,
12786
+ state: normalizedState,
11996
12787
  command,
11997
12788
  });
11998
12789
  if (!preconditionResult.valid) {
@@ -12005,14 +12796,21 @@ export const processCommand = ({ state, command }) => {
12005
12796
  }
12006
12797
 
12007
12798
  const nextState = definition.reduce({
12008
- state: structuredClone(state),
12799
+ state: structuredClone(
12800
+ shouldMaterializeControls ? normalizedState : state,
12801
+ ),
12009
12802
  payload: command.payload,
12010
12803
  });
12011
12804
  if (nextState?.valid === false) {
12012
12805
  return nextState;
12013
12806
  }
12014
12807
 
12015
- const finalState = nextState === undefined ? state : nextState;
12808
+ const finalState =
12809
+ nextState === undefined
12810
+ ? shouldMaterializeControls
12811
+ ? normalizedState
12812
+ : state
12813
+ : nextState;
12016
12814
  const stateResult = validateState({
12017
12815
  state: finalState,
12018
12816
  });