@cloudcannon/editable-regions 0.0.2 → 0.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/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
 
15
- export interface APIListener {
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
+
30
+ export interface APIListener extends DOMListener {
31
+ event: "change" | "delete";
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,92 @@ 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
+ context.isContent = true;
123
+ value = await value.content.get();
124
+ } else {
125
+ value = await value.data.get();
126
+ }
127
+ } else if (CloudCannon.isAPIDataset(value)) {
128
+ context.dataset = value;
129
+ const items = await value.items();
130
+ if (Array.isArray(items)) {
131
+ value = items;
132
+ } else {
133
+ context.file = items;
134
+ value = await items.data.get();
135
+ }
136
+ }
137
+
138
+ if (value && typeof value === "object" && key in value) {
139
+ value = (value as any)[key];
140
+ }
141
+
142
+ context.fullPath = context.fullPath ? `${context.fullPath}.${key}` : key;
143
+ if (context.file) {
144
+ context.filePath = context.filePath
145
+ ? `${context.filePath}.${key}`
146
+ : key;
147
+ }
148
+ }
149
+ return { value, context: context ?? {} };
150
+ }
151
+
70
152
  shouldUpdate(_value: unknown) {
71
153
  return true;
72
154
  }
73
155
 
156
+ shouldMount() {
157
+ return this.value !== undefined;
158
+ }
159
+
74
160
  async getNewValue(
75
161
  value: unknown,
76
162
  listener?: EditableListener,
163
+ contexts?: { [key: string]: EditableContext },
77
164
  ): Promise<unknown> {
78
165
  const { key, path } = listener ?? {};
79
- const resolvedValue = path ? await this.lookupPath(path, value) : value;
166
+
167
+ const { value: resolvedValue, context: newContext } = path
168
+ ? await this.lookupPathAndContext(path, value, contexts)
169
+ : { value, context: {} };
80
170
  if (!key) {
81
171
  this.propsBase = resolvedValue;
172
+ this.contextBase = newContext;
82
173
  } else {
83
174
  this.props[key] = resolvedValue;
175
+ this.contexts[key] = newContext;
84
176
  }
85
177
 
86
178
  if (Object.entries(this.props).length === 0) {
@@ -98,8 +190,12 @@ export default class Editable {
98
190
  return this.validateValue(newValue);
99
191
  }
100
192
 
101
- async pushValue(value: unknown, listener?: EditableListener): Promise<void> {
102
- const newValue = await this.getNewValue(value, listener);
193
+ async pushValue(
194
+ value: unknown,
195
+ listener?: EditableListener,
196
+ contexts?: { [key: string]: EditableContext },
197
+ ): Promise<void> {
198
+ const newValue = await this.getNewValue(value, listener, contexts);
103
199
 
104
200
  if (typeof newValue === "undefined" || !this.shouldUpdate(newValue)) {
105
201
  return;
@@ -119,7 +215,10 @@ export default class Editable {
119
215
 
120
216
  update(): void {
121
217
  this.listeners.forEach((listener) =>
122
- listener.editable.pushValue(this.value, listener),
218
+ listener.editable.pushValue(this.value, listener, {
219
+ ...this.contexts,
220
+ __base_context: this.contextBase ?? {},
221
+ }),
123
222
  );
124
223
  }
125
224
 
@@ -138,7 +237,10 @@ export default class Editable {
138
237
  }
139
238
 
140
239
  if (this.value !== undefined) {
141
- listener.editable.pushValue(this.value, listener);
240
+ listener.editable.pushValue(this.value, listener, {
241
+ ...this.contexts,
242
+ __base_context: this.contextBase ?? {},
243
+ });
142
244
  }
143
245
 
144
246
  this.listeners.push(listener);
@@ -150,51 +252,46 @@ export default class Editable {
150
252
  );
151
253
  }
152
254
 
153
- disconnect(): void {
255
+ async disconnect(): Promise<void> {
256
+ this.disconnecting = true;
257
+
258
+ if (this.connectPromise) {
259
+ await this.connectPromise;
260
+ }
261
+
154
262
  this.parent?.deregisterListener(this);
155
263
  this.parent = null;
156
264
  this.APIListeners.forEach(({ obj, fn, event }) =>
157
265
  obj.removeEventListener(event, fn),
158
266
  );
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(".");
177
- }
178
-
179
- if (typeof this.element.dataset.prop !== "string") {
180
- throw new Error(`Failed to resolve source "${source}"`);
181
- }
182
-
183
- if (this.element.dataset.prop) {
184
- source = `${this.element.dataset.prop}.${source}`;
267
+ this.APIListeners = [];
268
+ this.domListeners.forEach(({ event, fn }) => {
269
+ this.element.removeEventListener(event, fn);
270
+ });
271
+ this.domListeners = [];
272
+ this.connected = false;
273
+ this.connectPromise = undefined;
274
+ this.disconnecting = false;
275
+
276
+ if (this.needsReconnect) {
277
+ this.needsReconnect = false;
278
+ this.connect();
185
279
  }
186
-
187
- return this.parent && !source.startsWith("@")
188
- ? this.parent.resolveSource(source)
189
- : source;
190
280
  }
191
281
 
192
282
  connect(): void {
193
- loadedPromise.then(() => {
283
+ if (this.disconnecting) {
284
+ this.needsReconnect = true;
285
+ return;
286
+ }
287
+ if (this.connectPromise) {
288
+ return;
289
+ }
290
+ this.connectPromise = loadingPromise.then(() => {
194
291
  this.setupListeners();
195
292
  if (this.validateConfiguration()) {
196
293
  this.connected = true;
197
- if (this.value !== undefined && !this.mounted) {
294
+ if (!this.mounted && this.shouldMount()) {
198
295
  this.mounted = true;
199
296
  this.mount();
200
297
  this.update();
@@ -203,6 +300,11 @@ export default class Editable {
203
300
  });
204
301
  }
205
302
 
303
+ addEventListener(event: string, fn: (e: any) => void): void {
304
+ this.domListeners.push({ event, fn });
305
+ this.element.addEventListener(event, fn);
306
+ }
307
+
206
308
  setupListeners(): void {
207
309
  let parentEditable: Editable | undefined;
208
310
  let parent = this.element.parentElement;
@@ -216,78 +318,69 @@ export default class Editable {
216
318
 
217
319
  this.parent = parentEditable || null;
218
320
 
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
- }
321
+ Object.entries(this.element.dataset).forEach(([propName, propPath]) => {
322
+ if (!propName.startsWith("prop") || typeof propPath !== "string") {
323
+ return;
324
+ }
225
325
 
226
- hasProps = true;
326
+ const { collection, file, dataset, source, absolute } =
327
+ this.parseSource(propPath);
227
328
 
228
- const { collection, file, dataset, source, absolute } =
229
- this.parseSource(propPath);
329
+ const listener = {
330
+ editable: this,
331
+ key:
332
+ propName === "prop" ? undefined : propName.substring(4).toLowerCase(),
333
+ path: source,
334
+ };
230
335
 
231
- const listener = {
232
- editable: this,
233
- key:
234
- propName === "prop"
235
- ? undefined
236
- : propName.substring(4).toLowerCase(),
237
- path: source,
238
- };
336
+ if (!absolute && parentEditable) {
337
+ parentEditable.registerListener(listener);
338
+ return;
339
+ }
239
340
 
240
- if (!absolute && parentEditable) {
241
- parentEditable.registerListener(listener);
242
- return;
243
- }
341
+ // Any single data path should only be able to refer to a single absolute API object
342
+ const obj = collection || dataset || file;
343
+ if (obj) {
344
+ const handleAPIChange = () => {
345
+ this.pushValue(obj, listener);
346
+ };
347
+ this.APIListeners.push({
348
+ obj,
349
+ fn: handleAPIChange,
350
+ event: "change",
351
+ });
352
+ obj.addEventListener("change", handleAPIChange);
353
+ handleAPIChange();
354
+ }
355
+ });
244
356
 
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
- );
357
+ this.addEventListener("cloudcannon-api", this.handleApiEvent.bind(this));
358
+ }
261
359
 
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
- }
360
+ handleApiEvent(e: any): void {
361
+ if (e.target !== this.element) {
362
+ if (!e.detail.source) {
363
+ e.detail.source = this.element.dataset.prop;
364
+ } else {
365
+ const source = e.detail.source;
366
+ const [part, ...rest] = source.split(".");
367
+ const propKey = part.charAt(0).toUpperCase() + part.slice(1);
368
+ const propPath = this.element.dataset[`prop${propKey}`];
369
+
370
+ if (propPath) {
371
+ rest.unshift(propPath);
372
+ e.detail.source = rest.join(".");
373
+ } else if (this.element.dataset.prop) {
374
+ e.detail.source = `${this.element.dataset.prop}.${source}`;
278
375
  }
279
376
  }
377
+ }
280
378
 
281
- const { absolute } = this.parseSource(e.detail.source);
282
- if (!this.parent || absolute) {
283
- if (this.executeApiCall(e.detail)) {
284
- e.stopPropagation();
285
- }
379
+ const { absolute } = this.parseSource(e.detail.source);
380
+ if (!this.parent || absolute) {
381
+ if (this.executeApiCall(e.detail)) {
382
+ e.stopPropagation();
286
383
  }
287
- });
288
-
289
- if (!hasProps) {
290
- this.mount();
291
384
  }
292
385
  }
293
386
 
@@ -330,6 +423,7 @@ export default class Editable {
330
423
  options: {
331
424
  disable_reorder: true,
332
425
  disable_remove: true,
426
+ disable_add: true,
333
427
  },
334
428
  });
335
429
  return true;
@@ -338,9 +432,10 @@ export default class Editable {
338
432
  `Failed to resolve source for API call: ${options.source}`,
339
433
  );
340
434
  }
435
+
341
436
  switch (options.action) {
342
437
  case "edit":
343
- file?.data.edit({ slug: source });
438
+ file?.data.edit({ slug: source, position: options.position });
344
439
  break;
345
440
  case "set":
346
441
  if (source?.endsWith("@content")) {
@@ -364,8 +459,9 @@ export default class Editable {
364
459
  break;
365
460
  case "move-array-item":
366
461
  file?.data.moveArrayItem({
367
- slug: source,
368
- index: options.fromIndex,
462
+ fromSlug: options.fromSlug ?? source,
463
+ fromIndex: options.fromIndex,
464
+ toSlug: source,
369
465
  toIndex: options.toIndex,
370
466
  });
371
467
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcannon/editable-regions",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "description": "Visual Editing for the CloudCannon CMS.",
6
6
  "keywords": [
@@ -54,7 +54,7 @@
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",