@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/components/index.ts +14 -4
- package/components/ui/editable-array-item-controls.ts +61 -24
- package/helpers/checks.ts +8 -32
- package/helpers/cloudcannon.ts +19 -12
- package/helpers/hydrate-editable-regions.ts +18 -11
- package/helpers/loading.ts +7 -0
- package/integrations/astro/astro-integration.mjs +3 -7
- package/integrations/astro/index.mjs +2 -2
- package/nodes/editable-array-item.ts +105 -45
- package/nodes/editable-array.ts +115 -17
- package/nodes/editable-component.ts +108 -37
- package/nodes/editable-snippet.ts +0 -6
- package/nodes/editable-text.ts +2 -8
- package/nodes/editable.ts +212 -110
- package/package.json +3 -2
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
|
|
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
|
-
|
|
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(
|
|
102
|
-
|
|
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.
|
|
155
|
-
|
|
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 (
|
|
180
|
-
|
|
263
|
+
if (this.connectPromise) {
|
|
264
|
+
await this.connectPromise;
|
|
181
265
|
}
|
|
182
266
|
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
332
|
+
const { collection, file, dataset, source, absolute } =
|
|
333
|
+
this.parseSource(propPath);
|
|
227
334
|
|
|
228
|
-
|
|
229
|
-
|
|
335
|
+
const listener = {
|
|
336
|
+
editable: this,
|
|
337
|
+
key:
|
|
338
|
+
propName === "prop" ? undefined : propName.substring(4).toLowerCase(),
|
|
339
|
+
path: source,
|
|
340
|
+
};
|
|
230
341
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
+
"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.
|
|
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
|
}
|