@cloudcannon/editable-regions 0.0.3 → 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/components/index.ts +14 -4
- package/components/ui/editable-array-item-controls.ts +61 -24
- package/helpers/checks.ts +8 -0
- 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 +100 -44
- package/nodes/editable-array.ts +35 -4
- package/nodes/editable-component.ts +93 -35
- package/nodes/editable-snippet.ts +0 -6
- package/nodes/editable-text.ts +2 -8
- package/nodes/editable.ts +203 -107
- package/package.json +2 -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
|
|
|
15
|
-
export interface
|
|
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
|
-
|
|
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(
|
|
102
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
326
|
+
const { collection, file, dataset, source, absolute } =
|
|
327
|
+
this.parseSource(propPath);
|
|
227
328
|
|
|
228
|
-
|
|
229
|
-
|
|
329
|
+
const listener = {
|
|
330
|
+
editable: this,
|
|
331
|
+
key:
|
|
332
|
+
propName === "prop" ? undefined : propName.substring(4).toLowerCase(),
|
|
333
|
+
path: source,
|
|
334
|
+
};
|
|
230
335
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
);
|
|
357
|
+
this.addEventListener("cloudcannon-api", this.handleApiEvent.bind(this));
|
|
358
|
+
}
|
|
261
359
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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.
|
|
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.
|
|
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",
|