@cloudcannon/editable-regions 0.0.3 → 0.0.5

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/nodes/editable.ts CHANGED
@@ -4,7 +4,8 @@ import type {
4
4
  CloudCannonJavaScriptV1APIFile,
5
5
  } from "@cloudcannon/javascript-api";
6
6
  import { hasEditable } from "../helpers/checks";
7
- import { CloudCannon, loadedPromise } from "../helpers/cloudcannon";
7
+ import { CloudCannon } from "../helpers/cloudcannon";
8
+ import { loadingPromise } from "../helpers/loading";
8
9
 
9
10
  export interface EditableListener {
10
11
  editable: Editable;
@@ -12,26 +13,45 @@ export interface EditableListener {
12
13
  path?: string;
13
14
  }
14
15
 
16
+ export interface EditableContext {
17
+ fullPath?: string;
18
+ filePath?: string;
19
+ isContent?: boolean;
20
+ file?: CloudCannonJavaScriptV1APIFile;
21
+ collection?: CloudCannonJavaScriptV1APICollection;
22
+ dataset?: CloudCannonJavaScriptV1APIDataset;
23
+ }
24
+
25
+ export interface DOMListener {
26
+ fn: (e: any) => void;
27
+ event: string;
28
+ }
29
+
15
30
  export interface APIListener {
31
+ fn: (e: any) => void;
16
32
  obj:
17
33
  | CloudCannonJavaScriptV1APIFile
18
34
  | CloudCannonJavaScriptV1APICollection
19
35
  | CloudCannonJavaScriptV1APIDataset;
20
- fn: () => void;
21
- event: "change" | "delete";
22
36
  }
23
37
 
24
38
  export default class Editable {
25
39
  APIListeners: APIListener[] = [];
26
40
  listeners: EditableListener[] = [];
41
+ domListeners: DOMListener[] = [];
27
42
  value: unknown = undefined;
28
43
  parent: Editable | null = null;
29
44
  element: HTMLElement;
30
45
  mounted = false;
31
46
  connected = false;
47
+ disconnecting = false;
48
+ needsReconnect = false;
32
49
 
33
50
  propsBase: unknown;
51
+ contextBase?: EditableContext;
34
52
  props: Record<string, unknown> = {};
53
+ contexts: Record<string, EditableContext> = {};
54
+ connectPromise?: Promise<void>;
35
55
 
36
56
  constructor(element: HTMLElement) {
37
57
  this.element = element;
@@ -67,20 +87,94 @@ export default class Editable {
67
87
  }, obj);
68
88
  }
69
89
 
90
+ async lookupPathAndContext(
91
+ path: string,
92
+ obj: unknown,
93
+ contexts: { [key: string]: EditableContext } = {},
94
+ ): Promise<{ value: any; context: EditableContext }> {
95
+ if (!path) {
96
+ return {
97
+ value: obj,
98
+ context: {},
99
+ };
100
+ }
101
+
102
+ let value: any = obj;
103
+ let context: EditableContext | undefined;
104
+
105
+ for (const key of path.split(".")) {
106
+ if (!context && contexts[key]) {
107
+ context = {
108
+ ...contexts[key],
109
+ };
110
+ } else {
111
+ context = context ?? {
112
+ ...contexts.__base_context,
113
+ };
114
+ }
115
+
116
+ if (CloudCannon.isAPICollection(value)) {
117
+ context.collection = value;
118
+ value = await value.items();
119
+ } else if (CloudCannon.isAPIFile(value)) {
120
+ context.file = value;
121
+ if (key !== "@content") {
122
+ value = await value.data.get();
123
+ }
124
+ } else if (CloudCannon.isAPIDataset(value)) {
125
+ context.dataset = value;
126
+ const items = await value.items();
127
+ if (Array.isArray(items)) {
128
+ value = items;
129
+ } else {
130
+ context.file = items;
131
+ value = await items.data.get();
132
+ }
133
+ }
134
+
135
+ if (key === "@content" && CloudCannon.isAPIFile(value)) {
136
+ context.isContent = true;
137
+ value = await value.content.get();
138
+ } else if (value && typeof value === "object" && key in value) {
139
+ value = (value as any)[key];
140
+ } else {
141
+ value = undefined;
142
+ }
143
+
144
+ context.fullPath = context.fullPath ? `${context.fullPath}.${key}` : key;
145
+ if (context.file) {
146
+ context.filePath = context.filePath
147
+ ? `${context.filePath}.${key}`
148
+ : key;
149
+ }
150
+ }
151
+ return { value, context: context ?? {} };
152
+ }
153
+
70
154
  shouldUpdate(_value: unknown) {
71
155
  return true;
72
156
  }
73
157
 
158
+ shouldMount() {
159
+ return this.value !== undefined;
160
+ }
161
+
74
162
  async getNewValue(
75
163
  value: unknown,
76
164
  listener?: EditableListener,
165
+ contexts?: { [key: string]: EditableContext },
77
166
  ): Promise<unknown> {
78
167
  const { key, path } = listener ?? {};
79
- const resolvedValue = path ? await this.lookupPath(path, value) : value;
168
+
169
+ const { value: resolvedValue, context: newContext } = path
170
+ ? await this.lookupPathAndContext(path, value, contexts)
171
+ : { value, context: {} };
80
172
  if (!key) {
81
173
  this.propsBase = resolvedValue;
174
+ this.contextBase = newContext;
82
175
  } else {
83
176
  this.props[key] = resolvedValue;
177
+ this.contexts[key] = newContext;
84
178
  }
85
179
 
86
180
  if (Object.entries(this.props).length === 0) {
@@ -98,8 +192,12 @@ export default class Editable {
98
192
  return this.validateValue(newValue);
99
193
  }
100
194
 
101
- async pushValue(value: unknown, listener?: EditableListener): Promise<void> {
102
- const newValue = await this.getNewValue(value, listener);
195
+ async pushValue(
196
+ value: unknown,
197
+ listener?: EditableListener,
198
+ contexts?: { [key: string]: EditableContext },
199
+ ): Promise<void> {
200
+ const newValue = await this.getNewValue(value, listener, contexts);
103
201
 
104
202
  if (typeof newValue === "undefined" || !this.shouldUpdate(newValue)) {
105
203
  return;
@@ -119,7 +217,10 @@ export default class Editable {
119
217
 
120
218
  update(): void {
121
219
  this.listeners.forEach((listener) =>
122
- listener.editable.pushValue(this.value, listener),
220
+ listener.editable.pushValue(this.value, listener, {
221
+ ...this.contexts,
222
+ __base_context: this.contextBase ?? {},
223
+ }),
123
224
  );
124
225
  }
125
226
 
@@ -128,6 +229,13 @@ export default class Editable {
128
229
  }
129
230
 
130
231
  registerListener(listener: EditableListener): void {
232
+ if (this.value !== undefined) {
233
+ listener.editable.pushValue(this.value, listener, {
234
+ ...this.contexts,
235
+ __base_context: this.contextBase ?? {},
236
+ });
237
+ }
238
+
131
239
  if (
132
240
  this.listeners.find(
133
241
  ({ editable: other, key }) =>
@@ -137,10 +245,6 @@ export default class Editable {
137
245
  return;
138
246
  }
139
247
 
140
- if (this.value !== undefined) {
141
- listener.editable.pushValue(this.value, listener);
142
- }
143
-
144
248
  this.listeners.push(listener);
145
249
  }
146
250
 
@@ -150,51 +254,50 @@ export default class Editable {
150
254
  );
151
255
  }
152
256
 
153
- disconnect(): void {
154
- this.parent?.deregisterListener(this);
155
- this.parent = null;
156
- this.APIListeners.forEach(({ obj, fn, event }) =>
157
- obj.removeEventListener(event, fn),
158
- );
159
- }
160
-
161
- resolveSource(source?: string): string | undefined {
162
- if (typeof source !== "string") {
163
- return this.parent
164
- ? this.parent.resolveSource(this.element.dataset.prop)
165
- : this.element.dataset.prop;
166
- }
167
-
168
- const [part, ...rest] = source.split(".");
169
- const propKey = part.charAt(0).toUpperCase() + part.slice(1);
170
- const propPath = this.element.dataset[`prop${propKey}`];
171
-
172
- if (propPath) {
173
- rest.unshift(propPath);
174
- return this.parent
175
- ? this.parent.resolveSource(rest.join("."))
176
- : rest.join(".");
257
+ async disconnect(): Promise<void> {
258
+ if (this.disconnecting) {
259
+ return;
177
260
  }
261
+ this.disconnecting = true;
178
262
 
179
- if (typeof this.element.dataset.prop !== "string") {
180
- throw new Error(`Failed to resolve source "${source}"`);
263
+ if (this.connectPromise) {
264
+ await this.connectPromise;
181
265
  }
182
266
 
183
- if (this.element.dataset.prop) {
184
- source = `${this.element.dataset.prop}.${source}`;
267
+ this.parent?.deregisterListener(this);
268
+ this.parent = null;
269
+ this.APIListeners.forEach(({ obj, fn }) => {
270
+ obj.removeEventListener("change", fn);
271
+ obj.removeEventListener("delete", fn);
272
+ });
273
+ this.APIListeners = [];
274
+ this.domListeners.forEach(({ event, fn }) => {
275
+ this.element.removeEventListener(event, fn);
276
+ });
277
+ this.domListeners = [];
278
+ this.connected = false;
279
+ this.connectPromise = undefined;
280
+ this.disconnecting = false;
281
+
282
+ if (this.needsReconnect) {
283
+ this.needsReconnect = false;
284
+ this.connect();
185
285
  }
186
-
187
- return this.parent && !source.startsWith("@")
188
- ? this.parent.resolveSource(source)
189
- : source;
190
286
  }
191
287
 
192
288
  connect(): void {
193
- loadedPromise.then(() => {
289
+ if (this.disconnecting) {
290
+ this.needsReconnect = true;
291
+ return;
292
+ }
293
+ if (this.connectPromise) {
294
+ return;
295
+ }
296
+ this.connectPromise = loadingPromise.then(() => {
194
297
  this.setupListeners();
195
298
  if (this.validateConfiguration()) {
196
299
  this.connected = true;
197
- if (this.value !== undefined && !this.mounted) {
300
+ if (!this.mounted && this.shouldMount()) {
198
301
  this.mounted = true;
199
302
  this.mount();
200
303
  this.update();
@@ -203,6 +306,11 @@ export default class Editable {
203
306
  });
204
307
  }
205
308
 
309
+ addEventListener(event: string, fn: (e: any) => void): void {
310
+ this.domListeners.push({ event, fn });
311
+ this.element.addEventListener(event, fn);
312
+ }
313
+
206
314
  setupListeners(): void {
207
315
  let parentEditable: Editable | undefined;
208
316
  let parent = this.element.parentElement;
@@ -216,78 +324,69 @@ export default class Editable {
216
324
 
217
325
  this.parent = parentEditable || null;
218
326
 
219
- let hasProps = false;
220
- Object.entries(this.element.dataset).forEach(
221
- async ([propName, propPath]) => {
222
- if (!propName.startsWith("prop") || typeof propPath !== "string") {
223
- return;
224
- }
327
+ Object.entries(this.element.dataset).forEach(([propName, propPath]) => {
328
+ if (!propName.startsWith("prop") || typeof propPath !== "string") {
329
+ return;
330
+ }
225
331
 
226
- hasProps = true;
332
+ const { collection, file, dataset, source, absolute } =
333
+ this.parseSource(propPath);
227
334
 
228
- const { collection, file, dataset, source, absolute } =
229
- this.parseSource(propPath);
335
+ const listener = {
336
+ editable: this,
337
+ key:
338
+ propName === "prop" ? undefined : propName.substring(4).toLowerCase(),
339
+ path: source,
340
+ };
230
341
 
231
- const listener = {
232
- editable: this,
233
- key:
234
- propName === "prop"
235
- ? undefined
236
- : propName.substring(4).toLowerCase(),
237
- path: source,
238
- };
342
+ if (!absolute && parentEditable) {
343
+ parentEditable.registerListener(listener);
344
+ return;
345
+ }
239
346
 
240
- if (!absolute && parentEditable) {
241
- parentEditable.registerListener(listener);
242
- return;
243
- }
347
+ // Any single data path should only be able to refer to a single absolute API object
348
+ const obj = collection || dataset || file;
349
+ if (obj) {
350
+ const handleAPIChange = () => {
351
+ this.pushValue(obj, listener);
352
+ };
353
+ this.APIListeners.push({
354
+ obj,
355
+ fn: handleAPIChange,
356
+ });
357
+ obj.addEventListener("change", handleAPIChange);
358
+ obj.addEventListener("delete", handleAPIChange);
359
+ handleAPIChange();
360
+ }
361
+ });
244
362
 
245
- // Any single data path should only be able to refer to a single absolute API object
246
- const obj = collection || dataset || file;
247
- if (obj) {
248
- const handleAPIChange = () => {
249
- this.pushValue(obj, listener);
250
- };
251
- this.APIListeners.push({
252
- obj,
253
- fn: handleAPIChange,
254
- event: "change",
255
- });
256
- obj.addEventListener("change", handleAPIChange);
257
- handleAPIChange();
258
- }
259
- },
260
- );
363
+ this.addEventListener("cloudcannon-api", this.handleApiEvent.bind(this));
364
+ }
261
365
 
262
- this.element.addEventListener("cloudcannon-api", async (e: any) => {
263
- if (e.target !== this.element) {
264
- if (!e.detail.source) {
265
- e.detail.source = this.element.dataset.prop;
266
- } else {
267
- const source = e.detail.source;
268
- const [part, ...rest] = source.split(".");
269
- const propKey = part.charAt(0).toUpperCase() + part.slice(1);
270
- const propPath = this.element.dataset[`prop${propKey}`];
271
-
272
- if (propPath) {
273
- rest.unshift(propPath);
274
- e.detail.source = rest.join(".");
275
- } else if (this.element.dataset.prop) {
276
- e.detail.source = `${this.element.dataset.prop}.${source}`;
277
- }
366
+ handleApiEvent(e: any): void {
367
+ if (e.target !== this.element) {
368
+ if (!e.detail.source) {
369
+ e.detail.source = this.element.dataset.prop;
370
+ } else {
371
+ const source = e.detail.source;
372
+ const [part, ...rest] = source.split(".");
373
+ const propKey = part.charAt(0).toUpperCase() + part.slice(1);
374
+ const propPath = this.element.dataset[`prop${propKey}`];
375
+
376
+ if (propPath) {
377
+ rest.unshift(propPath);
378
+ e.detail.source = rest.join(".");
379
+ } else if (this.element.dataset.prop) {
380
+ e.detail.source = `${this.element.dataset.prop}.${source}`;
278
381
  }
279
382
  }
383
+ }
280
384
 
281
- const { absolute } = this.parseSource(e.detail.source);
282
- if (!this.parent || absolute) {
283
- if (this.executeApiCall(e.detail)) {
284
- e.stopPropagation();
285
- }
385
+ const { absolute } = this.parseSource(e.detail.source);
386
+ if (!this.parent || absolute) {
387
+ if (this.executeApiCall(e.detail)) {
388
+ e.stopPropagation();
286
389
  }
287
- });
288
-
289
- if (!hasProps) {
290
- this.mount();
291
390
  }
292
391
  }
293
392
 
@@ -330,6 +429,7 @@ export default class Editable {
330
429
  options: {
331
430
  disable_reorder: true,
332
431
  disable_remove: true,
432
+ disable_add: true,
333
433
  },
334
434
  });
335
435
  return true;
@@ -338,9 +438,10 @@ export default class Editable {
338
438
  `Failed to resolve source for API call: ${options.source}`,
339
439
  );
340
440
  }
441
+
341
442
  switch (options.action) {
342
443
  case "edit":
343
- file?.data.edit({ slug: source });
444
+ file?.data.edit({ slug: source, position: options.position });
344
445
  break;
345
446
  case "set":
346
447
  if (source?.endsWith("@content")) {
@@ -364,8 +465,9 @@ export default class Editable {
364
465
  break;
365
466
  case "move-array-item":
366
467
  file?.data.moveArrayItem({
367
- slug: source,
368
- index: options.fromIndex,
468
+ fromSlug: options.fromSlug ?? source,
469
+ fromIndex: options.fromIndex,
470
+ toSlug: source,
369
471
  toIndex: options.toIndex,
370
472
  });
371
473
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcannon/editable-regions",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "description": "Visual Editing for the CloudCannon CMS.",
6
6
  "keywords": [
@@ -54,11 +54,12 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@biomejs/biome": "1.9.4",
57
- "@cloudcannon/javascript-api": "0.0.9",
57
+ "@cloudcannon/javascript-api": "0.0.10",
58
58
  "@types/js-beautify": "1.14.3",
59
59
  "@types/react": "18.3.12",
60
60
  "@types/react-dom": "18.3.1",
61
61
  "astro": "^5.14.1",
62
+ "js-beautify": "^1.15.4",
62
63
  "typescript": "5.9.3"
63
64
  }
64
65
  }