@cloudcannon/editable-regions 0.0.8 → 0.0.10

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.
@@ -19,6 +19,7 @@ export default class EditableComponent extends Editable {
19
19
 
20
20
  private updatePromise: Promise<void> | undefined;
21
21
  private needsReupdate = false;
22
+ private pendingPartialSubtree?: ChildNode | null;
22
23
 
23
24
  getComponents() {
24
25
  return getEditableComponentRenderers();
@@ -51,31 +52,81 @@ export default class EditableComponent extends Editable {
51
52
  return true;
52
53
  }
53
54
 
54
- update(): Promise<void> {
55
+ update(partialSubtree?: ChildNode | null): Promise<void> {
55
56
  if (this.updatePromise) {
56
57
  this.needsReupdate = true;
58
+ this.pendingPartialSubtree = partialSubtree;
57
59
  return this.updatePromise;
58
60
  }
59
- this.updatePromise = this._update().then(() => {
61
+ this.updatePromise = this._update(partialSubtree).then(() => {
60
62
  this.updatePromise = undefined;
61
63
  if (this.needsReupdate) {
62
64
  this.needsReupdate = false;
63
- return this.update();
65
+ const savedPartialSubtree = this.pendingPartialSubtree;
66
+ this.pendingPartialSubtree = undefined;
67
+ return this.update(savedPartialSubtree);
64
68
  }
65
69
  });
66
70
  return this.updatePromise;
67
71
  }
68
72
 
69
- async _update(): Promise<void> {
73
+ santiseComponentOutput(el: HTMLElement): HTMLElement {
74
+ el.querySelectorAll("noscript").forEach((el) => el.remove());
75
+ return el;
76
+ }
77
+
78
+ async _update(partialSubtree?: ChildNode | null): Promise<void> {
79
+ if (partialSubtree) {
80
+ if (this.controlsElement) {
81
+ this.controlsElement.remove();
82
+ }
83
+ this.updateTree(this.element, partialSubtree);
84
+ if (this.controlsElement) {
85
+ this.element.appendChild(this.controlsElement);
86
+ }
87
+ return;
88
+ }
89
+
70
90
  this.element.classList.remove("errored");
71
91
 
72
92
  const key = this.element.dataset.component;
73
93
  if (!key) {
74
94
  return super.update();
75
95
  }
76
- const component = this.getComponents()?.[key];
96
+
97
+ let component = this.getComponents()?.[key];
98
+ for (let i = 0; !component && i < 20; i++) {
99
+ await new Promise((resolve) => setTimeout(resolve, 200));
100
+ component = this.getComponents()?.[key];
101
+ }
102
+
77
103
  if (!component) {
78
- return super.update();
104
+ this.element.classList.add("errored");
105
+ const error = document.createElement("editable-region-error-card");
106
+ error.setAttribute("heading", "Failed to render component");
107
+ error.setAttribute(
108
+ "message",
109
+ `Failed to find a registered component with the key "${key}". This may mean that the provided "data-component" attribute is incorrect or that the component hasn't been registered.`,
110
+ );
111
+
112
+ const caseMatch = Object.keys(this.getComponents()).find(
113
+ (k) => k.toLowerCase() === key.toLowerCase(),
114
+ );
115
+
116
+ if (Object.keys(this.getComponents()).length === 0) {
117
+ error.setAttribute(
118
+ "hint",
119
+ "There are no registered components currently available. Please check that you've included your registration script and that it's running correctly.",
120
+ );
121
+ } else if (caseMatch) {
122
+ error.setAttribute(
123
+ "hint",
124
+ `The component key "${key}" is not case-sensitive. Did you mean "${caseMatch}"?`,
125
+ );
126
+ }
127
+
128
+ this.element.replaceChildren(error);
129
+ return;
79
130
  }
80
131
 
81
132
  const value = await realizeAPIValue(this.value);
@@ -87,7 +138,7 @@ export default class EditableComponent extends Editable {
87
138
 
88
139
  let rootEl: HTMLElement;
89
140
  try {
90
- rootEl = await component(value);
141
+ rootEl = this.santiseComponentOutput(await component(value));
91
142
  } catch (err: unknown) {
92
143
  this.element.classList.add("errored");
93
144
  const error = document.createElement("editable-region-error-card");
@@ -177,10 +228,16 @@ export default class EditableComponent extends Editable {
177
228
  const listener = this.listeners[i];
178
229
  if (listener.editable.element === targetChild) {
179
230
  listener.editable.element = renderChild;
180
- renderChild.editable.pushValue(this.value, listener, {
181
- ...this.contexts,
182
- __base_context: this.contextBase ?? {},
183
- });
231
+ renderChild.editable.pushValue(
232
+ this.value,
233
+ this.specialProps,
234
+ listener,
235
+ {
236
+ ...this.contexts,
237
+ __base_context: this.contextBase ?? {},
238
+ },
239
+ renderChild,
240
+ );
184
241
  }
185
242
  }
186
243
  } else if (hasEditable(targetChild)) {
@@ -259,10 +316,16 @@ export default class EditableComponent extends Editable {
259
316
  for (let i = 0; i < this.listeners.length; i++) {
260
317
  const listener = this.listeners[i];
261
318
  if (listener.editable.element === targetChild) {
262
- targetChild.editable.pushValue(this.value, listener, {
263
- ...this.contexts,
264
- __base_context: this.contextBase ?? {},
265
- });
319
+ targetChild.editable.pushValue(
320
+ this.value,
321
+ this.specialProps,
322
+ listener,
323
+ {
324
+ ...this.contexts,
325
+ __base_context: this.contextBase ?? {},
326
+ },
327
+ renderChild,
328
+ );
266
329
  }
267
330
  }
268
331
  }
@@ -45,9 +45,15 @@ export default class EditableSource extends EditableText {
45
45
 
46
46
  this.file = CloudCannon.file(this.element.dataset.path);
47
47
  this.file.addEventListener("change", () => {
48
- this.file?.get().then(this.pushValue.bind(this));
48
+ this.file
49
+ ?.get()
50
+ .then((source) =>
51
+ this.pushValue(source, {}, { path: "", editable: this }),
52
+ );
53
+ });
54
+ this.file.get().then((source) => {
55
+ this.pushValue(source, {}, { path: "", editable: this });
49
56
  });
50
- this.file.get().then(this.pushValue.bind(this));
51
57
  }
52
58
 
53
59
  validateConfiguration(): boolean {
package/nodes/editable.ts CHANGED
@@ -4,8 +4,13 @@ import type {
4
4
  CloudCannonJavaScriptV1APIFile,
5
5
  } from "@cloudcannon/javascript-api";
6
6
  import { hasEditable } from "../helpers/checks";
7
- import { CloudCannon } from "../helpers/cloudcannon.mjs";
8
- import { loadingPromise } from "../helpers/loading";
7
+ import { apiLoadedPromise, CloudCannon } from "../helpers/cloudcannon.mjs";
8
+
9
+ declare global {
10
+ interface HTMLElement {
11
+ __pendingEditableListeners?: EditableListener[];
12
+ }
13
+ }
9
14
 
10
15
  export interface EditableListener {
11
16
  editable: Editable;
@@ -41,12 +46,15 @@ export default class Editable {
41
46
  domListeners: DOMListener[] = [];
42
47
  value: unknown = undefined;
43
48
  parent: Editable | null = null;
49
+ pendingParentElement: HTMLElement | null = null;
44
50
  element: HTMLElement;
45
51
  mounted = false;
46
52
  connected = false;
47
53
  disconnecting = false;
48
54
  needsReconnect = false;
49
55
 
56
+ specialPropListeners: EditableListener[] = [];
57
+ specialProps: Record<string, unknown> = {};
50
58
  propsBase: unknown;
51
59
  contextBase?: EditableContext;
52
60
  props: Record<string, unknown> = {};
@@ -150,7 +158,10 @@ export default class Editable {
150
158
  : key;
151
159
  }
152
160
  }
153
- return { value, context: context ?? {} };
161
+ return {
162
+ value,
163
+ context: context ?? {},
164
+ };
154
165
  }
155
166
 
156
167
  shouldUpdate(_value: unknown) {
@@ -161,45 +172,126 @@ export default class Editable {
161
172
  return this.value !== undefined;
162
173
  }
163
174
 
175
+ getLiteralProps() {
176
+ let literalPropsBase: unknown;
177
+ const literalProps: Record<string, unknown> = {};
178
+ Object.entries(this.element.dataset).forEach(([propName, propPath]) => {
179
+ if (!propName.startsWith("literal") || typeof propPath !== "string") {
180
+ return;
181
+ }
182
+
183
+ const key =
184
+ propName === "prop" ? undefined : propName.substring(7).toLowerCase();
185
+ let value = propPath;
186
+ try {
187
+ value = JSON.parse(value);
188
+ } catch (_error) {
189
+ // Error intentionally ignored
190
+ }
191
+
192
+ if (key) {
193
+ literalProps[key] = value;
194
+ } else {
195
+ literalPropsBase = value;
196
+ }
197
+ });
198
+ return { literalPropsBase, literalProps };
199
+ }
200
+
164
201
  async getNewValue(
165
202
  value: unknown,
203
+ specialProps: Record<string, unknown>,
166
204
  listener?: EditableListener,
167
205
  contexts?: { [key: string]: EditableContext },
168
206
  ): Promise<unknown> {
169
207
  const { key, path } = listener ?? {};
170
208
 
171
- const { value: resolvedValue, context: newContext } =
172
- await this.lookupPathAndContext(path, value, contexts);
209
+ if (typeof path === "string") {
210
+ const { value: resolvedValue, context: newContext } =
211
+ await this.lookupPathAndContext(path, value, contexts);
173
212
 
174
- if (!key) {
175
- this.propsBase = resolvedValue;
176
- this.contextBase = newContext;
177
- } else {
178
- this.props[key] = resolvedValue;
179
- this.contexts[key] = newContext;
213
+ if (!key) {
214
+ this.propsBase = resolvedValue;
215
+ this.contextBase = newContext;
216
+ } else {
217
+ this.props[key] = resolvedValue;
218
+ this.contexts[key] = newContext;
219
+ }
180
220
  }
181
221
 
182
- if (Object.entries(this.props).length === 0) {
183
- return this.validateValue(this.propsBase);
222
+ this.specialProps = this.getSpecialProps(specialProps);
223
+ const { literalPropsBase, literalProps } = this.getLiteralProps();
224
+
225
+ let newValue: unknown;
226
+ const specialPropsBase = this.specialPropListeners.find(({ key }) => !key);
227
+
228
+ if (this.propsBase !== undefined) {
229
+ newValue = this.propsBase;
230
+ } else if (specialPropsBase?.path) {
231
+ newValue = structuredClone(this.specialProps[specialPropsBase.path]);
232
+ } else if (literalPropsBase !== undefined) {
233
+ newValue = literalPropsBase;
184
234
  }
185
235
 
186
- const newValue = Object.entries(this.props).reduce(
187
- (acc, [key, val]) => {
188
- (acc as any)[key] = structuredClone(val);
189
- return acc;
190
- },
191
- structuredClone(this.propsBase ?? {}),
236
+ if (Object.entries(this.props).length > 0) {
237
+ newValue = Object.entries(this.props).reduce(
238
+ (acc, [key, val]) => {
239
+ (acc as any)[key] = structuredClone(val);
240
+ return acc;
241
+ },
242
+ newValue && typeof newValue === "object"
243
+ ? structuredClone(newValue)
244
+ : {},
245
+ );
246
+ }
247
+
248
+ const filteredSpecialPropsListener = this.specialPropListeners.filter(
249
+ ({ key }) => !!key,
192
250
  );
251
+ if (filteredSpecialPropsListener.length > 0) {
252
+ newValue = filteredSpecialPropsListener.reduce(
253
+ (acc, { key, path }) => {
254
+ if (key && path) {
255
+ (acc as any)[key] = structuredClone(this.specialProps[path]);
256
+ }
257
+ return acc;
258
+ },
259
+ structuredClone(
260
+ newValue && typeof newValue === "object"
261
+ ? structuredClone(newValue)
262
+ : {},
263
+ ),
264
+ );
265
+ }
266
+
267
+ if (Object.entries(literalProps).length > 0) {
268
+ newValue = Object.entries(literalProps).reduce(
269
+ (acc, [key, val]) => {
270
+ (acc as any)[key] = structuredClone(val);
271
+ return acc;
272
+ },
273
+ newValue && typeof newValue === "object"
274
+ ? structuredClone(newValue)
275
+ : {},
276
+ );
277
+ }
193
278
 
194
279
  return this.validateValue(newValue);
195
280
  }
196
281
 
197
282
  async pushValue(
198
283
  value: unknown,
284
+ specialProps: Record<string, unknown>,
199
285
  listener?: EditableListener,
200
286
  contexts?: { [key: string]: EditableContext },
287
+ partialSubtree?: ChildNode | null,
201
288
  ): Promise<void> {
202
- const newValue = await this.getNewValue(value, listener, contexts);
289
+ const newValue = await this.getNewValue(
290
+ value,
291
+ specialProps,
292
+ listener,
293
+ contexts,
294
+ );
203
295
 
204
296
  if (typeof newValue === "undefined" || !this.shouldUpdate(newValue)) {
205
297
  return;
@@ -209,17 +301,17 @@ export default class Editable {
209
301
  if (this.connected && !this.mounted) {
210
302
  this.mounted = true;
211
303
  this.mount();
212
- return this.update();
304
+ return this.update(partialSubtree);
213
305
  }
214
306
 
215
307
  if (this.mounted) {
216
- return this.update();
308
+ return this.update(partialSubtree);
217
309
  }
218
310
  }
219
311
 
220
- update(): void {
312
+ update(_partialSubtree?: ChildNode | null): void {
221
313
  this.listeners.forEach((listener) =>
222
- listener.editable.pushValue(this.value, listener, {
314
+ listener.editable.pushValue(this.value, this.specialProps, listener, {
223
315
  ...this.contexts,
224
316
  __base_context: this.contextBase ?? {},
225
317
  }),
@@ -232,7 +324,7 @@ export default class Editable {
232
324
 
233
325
  registerListener(listener: EditableListener): void {
234
326
  if (this.value !== undefined) {
235
- listener.editable.pushValue(this.value, listener, {
327
+ listener.editable.pushValue(this.value, this.specialProps, listener, {
236
328
  ...this.contexts,
237
329
  __base_context: this.contextBase ?? {},
238
330
  });
@@ -256,6 +348,29 @@ export default class Editable {
256
348
  );
257
349
  }
258
350
 
351
+ private queueListenerOnParent(
352
+ parentElement: HTMLElement,
353
+ listener: EditableListener,
354
+ ): void {
355
+ if (!parentElement.__pendingEditableListeners) {
356
+ parentElement.__pendingEditableListeners = [];
357
+ }
358
+ parentElement.__pendingEditableListeners.push(listener);
359
+ }
360
+
361
+ private replayPendingListeners(): void {
362
+ const pending = this.element.__pendingEditableListeners;
363
+ if (!pending || pending.length === 0) {
364
+ return;
365
+ }
366
+ this.element.__pendingEditableListeners = [];
367
+ for (const listener of pending) {
368
+ listener.editable.parent = this;
369
+ listener.editable.pendingParentElement = null;
370
+ this.registerListener(listener);
371
+ }
372
+ }
373
+
259
374
  async disconnect(): Promise<void> {
260
375
  if (this.disconnecting) {
261
376
  return;
@@ -268,6 +383,15 @@ export default class Editable {
268
383
 
269
384
  this.parent?.deregisterListener(this);
270
385
  this.parent = null;
386
+ if (this.pendingParentElement) {
387
+ const pending = this.pendingParentElement.__pendingEditableListeners;
388
+ if (pending) {
389
+ this.pendingParentElement.__pendingEditableListeners = pending.filter(
390
+ (listener) => listener.editable !== this,
391
+ );
392
+ }
393
+ this.pendingParentElement = null;
394
+ }
271
395
  this.APIListeners.forEach(({ obj, fn }) => {
272
396
  obj.removeEventListener("change", fn);
273
397
  obj.removeEventListener("delete", fn);
@@ -277,6 +401,7 @@ export default class Editable {
277
401
  this.element.removeEventListener(event, fn);
278
402
  });
279
403
  this.domListeners = [];
404
+ this.specialPropListeners = [];
280
405
  this.connected = false;
281
406
  this.connectPromise = undefined;
282
407
  this.disconnecting = false;
@@ -299,7 +424,7 @@ export default class Editable {
299
424
  if (this.connectPromise) {
300
425
  return;
301
426
  }
302
- this.connectPromise = loadingPromise.then(() => {
427
+ this.connectPromise = apiLoadedPromise.then(() => {
303
428
  this.setupListeners();
304
429
  this.connected = true;
305
430
  if (!this.mounted && this.shouldMount()) {
@@ -316,11 +441,14 @@ export default class Editable {
316
441
  }
317
442
 
318
443
  setupListeners(): void {
319
- let parentEditable: Editable | undefined;
444
+ let parentElement: HTMLElement | null = null;
320
445
  let parent = this.element.parentElement;
321
446
  while (parent) {
322
- if (hasEditable(parent) && !parentEditable) {
323
- parentEditable = parent.editable;
447
+ if (
448
+ parent.tagName.startsWith("EDITABLE-") ||
449
+ "editable" in parent.dataset
450
+ ) {
451
+ parentElement ??= parent;
324
452
  }
325
453
 
326
454
  if (parent.tagName === "A") {
@@ -329,7 +457,13 @@ export default class Editable {
329
457
  parent = parent.parentElement;
330
458
  }
331
459
 
332
- this.parent = parentEditable || null;
460
+ let hasParentListener = false;
461
+
462
+ if (parentElement && hasEditable(parentElement)) {
463
+ this.parent = parentElement.editable;
464
+ } else if (parentElement) {
465
+ this.pendingParentElement = parentElement;
466
+ }
333
467
 
334
468
  Object.entries(this.element.dataset).forEach(([propName, propPath]) => {
335
469
  if (!propName.startsWith("prop") || typeof propPath !== "string") {
@@ -346,8 +480,20 @@ export default class Editable {
346
480
  path: source,
347
481
  };
348
482
 
349
- if (!absolute && parentEditable) {
350
- parentEditable.registerListener(listener);
483
+ if (!absolute && source.startsWith("@") && source !== "@content") {
484
+ this.specialPropListeners.push(listener);
485
+ return;
486
+ }
487
+
488
+ if (!absolute && this.parent) {
489
+ hasParentListener = true;
490
+ this.parent.registerListener(listener);
491
+ return;
492
+ }
493
+
494
+ if (!absolute && this.pendingParentElement) {
495
+ hasParentListener = true;
496
+ this.queueListenerOnParent(this.pendingParentElement, listener);
351
497
  return;
352
498
  }
353
499
 
@@ -362,7 +508,9 @@ export default class Editable {
362
508
  ? undefined
363
509
  : `@file[${file?.path}]`;
364
510
  const handleAPIChange = () => {
365
- this.pushValue(obj, listener, { __base_context: { fullPath } });
511
+ this.pushValue(obj, {}, listener, {
512
+ __base_context: { fullPath },
513
+ });
366
514
  };
367
515
  this.APIListeners.push({
368
516
  obj,
@@ -374,7 +522,14 @@ export default class Editable {
374
522
  }
375
523
  });
376
524
 
525
+ if (this.parent && !hasParentListener) {
526
+ this.parent.registerListener({ editable: this });
527
+ } else if (this.pendingParentElement && !hasParentListener) {
528
+ this.queueListenerOnParent(this.pendingParentElement, { editable: this });
529
+ }
530
+
377
531
  this.addEventListener("cloudcannon-api", this.handleApiEvent.bind(this));
532
+ this.replayPendingListeners();
378
533
  }
379
534
 
380
535
  handleApiEvent(e: any): void {
@@ -597,4 +752,13 @@ export default class Editable {
597
752
  currentFile,
598
753
  };
599
754
  }
755
+
756
+ getSpecialProps(
757
+ incomingSpecialProps: Record<string, unknown> = {},
758
+ ): Record<string, unknown> {
759
+ return {
760
+ ...this.specialProps,
761
+ ...incomingSpecialProps,
762
+ };
763
+ }
600
764
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcannon/editable-regions",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "description": "Visual Editing for the CloudCannon CMS.",
6
6
  "keywords": [
@@ -56,7 +56,7 @@
56
56
  "@biomejs/biome": "2.3.6",
57
57
  "@cloudcannon/javascript-api": "0.0.10",
58
58
  "@types/js-beautify": "1.14.3",
59
- "@types/react": "19.2.6",
59
+ "@types/react": "19.2.8",
60
60
  "@types/react-dom": "18.3.1",
61
61
  "astro": "^5.14.1",
62
62
  "js-beautify": "^1.15.4",
@@ -1,7 +0,0 @@
1
- let completeLoading: () => void;
2
-
3
- export const loadingPromise = new Promise<void>((resolve) => {
4
- completeLoading = resolve;
5
- });
6
-
7
- export { completeLoading };
@@ -1,74 +0,0 @@
1
- export const actions = new Proxy(
2
- {},
3
- {
4
- get() {
5
- console.warn(
6
- "[CloudCannon] actions is not supported in an editable component. Please use an editing fallback instead.",
7
- );
8
- return () => {};
9
- },
10
- },
11
- );
12
-
13
- export const defineAction = () => {
14
- console.warn(
15
- "[CloudCannon] defineAction is not supported in an editable component. Please use an editing fallback instead.",
16
- );
17
- return {
18
- handler: () => {},
19
- input: null,
20
- };
21
- };
22
-
23
- export const isInputError = () => {
24
- console.warn(
25
- "[CloudCannon] isInputError is not supported in an editable component. Please use an editing fallback instead.",
26
- );
27
- return false;
28
- };
29
-
30
- export const isActionError = () => {
31
- console.warn(
32
- "[CloudCannon] isActionError is not supported in an editable component. Please use an editing fallback instead.",
33
- );
34
- return false;
35
- };
36
-
37
- export class ActionError extends Error {
38
- /**
39
- * @param {any} code
40
- * @param {any} message
41
- */
42
- constructor(code, message) {
43
- super(message);
44
- console.warn(
45
- "[CloudCannon] ActionError is not supported in an editable component. Please use an editing fallback instead.",
46
- );
47
- this.code = code;
48
- }
49
- }
50
-
51
- export const getActionContext = () => {
52
- console.warn(
53
- "[CloudCannon] getActionContext is not supported in an editable component. Please use an editing fallback instead.",
54
- );
55
- return {
56
- action: undefined,
57
- setActionResult: () => {},
58
- serializeActionResult: () => ({}),
59
- };
60
- };
61
-
62
- export const deserializeActionResult = () => {
63
- console.warn(
64
- "[CloudCannon] deserializeActionResult is not supported in an editable component. Please use an editing fallback instead.",
65
- );
66
- return {};
67
- };
68
-
69
- export const getActionPath = () => {
70
- console.warn(
71
- "[CloudCannon] getActionPath is not supported in an editable component. Please use an editing fallback instead.",
72
- );
73
- return "";
74
- };
@@ -1,5 +0,0 @@
1
- ---
2
- console.warn(
3
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
4
- );
5
- ---