@cloudcannon/editable-regions 0.0.9 → 0.0.11

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.
@@ -37,13 +37,16 @@ export default class EditableArray extends Editable {
37
37
  private updatePromise: Promise<void> | undefined;
38
38
  private needsReupdate = false;
39
39
  private addButton?: EditableRegionButton;
40
+ private pendingPartialSubtree?: ChildNode | null;
40
41
 
41
42
  async registerListener(listener: EditableListener): Promise<void> {
42
43
  if (!this.value) {
43
44
  return;
44
45
  }
45
46
 
46
- const __base_context = { ...this.contextBase };
47
+ const __base_context = {
48
+ ...this.contextBase,
49
+ };
47
50
  let value: unknown[] | CloudCannonJavaScriptV1APIFile[];
48
51
  if (CloudCannon.isAPICollection(this.value)) {
49
52
  value = await this.value.items();
@@ -90,7 +93,7 @@ export default class EditableArray extends Editable {
90
93
  }
91
94
 
92
95
  if (listener.path) {
93
- listener.editable.pushValue(value, listener, {
96
+ listener.editable.pushValue(value, this.specialProps, listener, {
94
97
  __base_context,
95
98
  });
96
99
  }
@@ -147,24 +150,29 @@ export default class EditableArray extends Editable {
147
150
  return value;
148
151
  }
149
152
 
150
- update(): Promise<void> {
153
+ update(partialSubtree?: ChildNode | null): Promise<void> {
151
154
  if (this.updatePromise) {
152
155
  this.needsReupdate = true;
156
+ this.pendingPartialSubtree = partialSubtree;
153
157
  return this.updatePromise;
154
158
  }
155
- this.updatePromise = this._update().then(() => {
159
+ this.updatePromise = this._update(partialSubtree).then(() => {
156
160
  this.updatePromise = undefined;
157
161
  if (this.needsReupdate) {
158
162
  this.needsReupdate = false;
159
- return this.update();
163
+ const savedPartialSubtree = this.pendingPartialSubtree;
164
+ this.pendingPartialSubtree = undefined;
165
+ return this.update(savedPartialSubtree);
160
166
  }
161
167
  });
162
168
  return this.updatePromise;
163
169
  }
164
170
 
165
- private async _update(): Promise<void> {
171
+ private async _update(partialSubtree?: ChildNode | null): Promise<void> {
166
172
  let value: unknown[] | CloudCannonJavaScriptV1APIFile[];
167
- const __base_context = { ...this.contextBase };
173
+ const __base_context = {
174
+ ...this.contextBase,
175
+ };
168
176
  if (CloudCannon.isAPICollection(this.value)) {
169
177
  value = await this.value.items();
170
178
  } else if (CloudCannon.isAPIDataset(this.value)) {
@@ -186,6 +194,24 @@ export default class EditableArray extends Editable {
186
194
  value = [];
187
195
  }
188
196
 
197
+ const partialChildren: (HTMLElement & { editable?: EditableArrayItem })[] =
198
+ [];
199
+ if (partialSubtree instanceof HTMLElement) {
200
+ for (const child of partialSubtree.querySelectorAll(
201
+ "editable-array-item,[data-editable='array-item']",
202
+ )) {
203
+ let parent = child.parentElement;
204
+ while (parent instanceof HTMLElement && !isEditableElement(parent)) {
205
+ parent = parent.parentElement;
206
+ }
207
+ if (parent !== partialSubtree) {
208
+ continue;
209
+ }
210
+
211
+ partialChildren.push(child as any);
212
+ }
213
+ }
214
+
189
215
  const templates: {
190
216
  keyed: Record<
191
217
  string,
@@ -241,23 +267,6 @@ export default class EditableArray extends Editable {
241
267
  children.push(child as any);
242
268
  }
243
269
 
244
- if (
245
- children.length === 0 &&
246
- !this.element.dataset.component &&
247
- !this.element.dataset.componentKey &&
248
- !templates.unkeyed &&
249
- Object.keys(templates.keyed).length === 0
250
- ) {
251
- const error = document.createElement("editable-region-error-card");
252
- error.setAttribute("heading", "Failed to render array editable region");
253
- error.setAttribute(
254
- "message",
255
- "Array editable regions with no child array items must have either a 'data-component' attribute or a 'data-component-key' attribute. Please add an item to this array then save and rebuild to see your changes or add a 'data-component' or 'data-component-key' attribute to this element.",
256
- );
257
- this.element.replaceChildren(error);
258
- return;
259
- }
260
-
261
270
  const key = this.element.dataset.idKey ?? this.element.dataset.componentKey;
262
271
 
263
272
  if (!key) {
@@ -274,8 +283,14 @@ export default class EditableArray extends Editable {
274
283
 
275
284
  for (let i = 0; i < value.length; i++) {
276
285
  let child = children[i];
286
+ const partialChild = partialChildren[i];
287
+
277
288
  if (!child) {
278
- if (templates.unkeyed) {
289
+ if (partialChild) {
290
+ child = partialChild.cloneNode(true) as HTMLElement & {
291
+ editable?: EditableArrayItem;
292
+ };
293
+ } else if (templates.unkeyed) {
279
294
  child = templates.unkeyed.cloneNode(true) as HTMLElement & {
280
295
  editable?: EditableArrayItem;
281
296
  };
@@ -284,11 +299,22 @@ export default class EditableArray extends Editable {
284
299
  child = document.createElement(
285
300
  "editable-array-item",
286
301
  ) as EditableArrayItemComponent;
287
- } else {
288
- // Empty arrays should be caught by the error case above so children[0] should always exist
302
+ } else if (children[0]) {
289
303
  child = children[0].cloneNode(true) as HTMLElement & {
290
304
  editable?: EditableArrayItem;
291
305
  };
306
+ } else {
307
+ const error = document.createElement("editable-region-error-card");
308
+ error.setAttribute(
309
+ "heading",
310
+ "Failed to render array editable region",
311
+ );
312
+ error.setAttribute(
313
+ "message",
314
+ "Array editable region contains an array item that cannot be rendered. Either a 'data-component' attribute or a 'data-component-key' attribute is required to render array items that weren't previously on the page. Please save and rebuild to see your changes or add a 'data-component' or 'data-component-key' attribute to this element.",
315
+ );
316
+ this.element.replaceChildren(error);
317
+ return;
292
318
  }
293
319
  this.element.appendChild(child);
294
320
  }
@@ -300,8 +326,10 @@ export default class EditableArray extends Editable {
300
326
  child.dataset.length = `${children.length}`;
301
327
  child.editable?.pushValue(
302
328
  value,
329
+ this.specialProps,
303
330
  { path: `${i}`, editable: child.editable },
304
331
  { __base_context },
332
+ partialChild,
305
333
  );
306
334
  }
307
335
  return;
@@ -351,8 +379,10 @@ export default class EditableArray extends Editable {
351
379
 
352
380
  child.editable?.pushValue(
353
381
  value,
382
+ this.specialProps,
354
383
  { path: `${index}`, editable: child.editable },
355
384
  { __base_context },
385
+ partialChildren[index],
356
386
  );
357
387
  });
358
388
 
@@ -380,6 +410,7 @@ export default class EditableArray extends Editable {
380
410
  const existingElement = children[i];
381
411
  const templateElement = templates.keyed[key] ?? templates.unkeyed;
382
412
  const componentKey = componentKeys[i] || this.element.dataset.component;
413
+ const partialChild = partialChildren[i];
383
414
 
384
415
  const matchingChildIndex = children.findIndex(
385
416
  (child, i) => child.dataset.id === key && !moved[i],
@@ -388,7 +419,9 @@ export default class EditableArray extends Editable {
388
419
  let matchingChild = children[matchingChildIndex];
389
420
  if (!matchingChild) {
390
421
  const clone = children.find((child) => child.dataset.id === key);
391
- if (templateElement) {
422
+ if (partialChild) {
423
+ matchingChild = partialChild.cloneNode(true) as any;
424
+ } else if (templateElement) {
392
425
  matchingChild = templateElement.cloneNode(true) as any;
393
426
  matchingChild.dataset.editable = "array-item";
394
427
  } else if (componentKey) {
@@ -448,8 +481,10 @@ export default class EditableArray extends Editable {
448
481
  matchingChild.editable.parent = this;
449
482
  matchingChild.editable.pushValue(
450
483
  value,
484
+ this.specialProps,
451
485
  { path: `${i}`, editable: matchingChild.editable },
452
486
  { __base_context },
487
+ partialChild,
453
488
  );
454
489
  }
455
490
  });
@@ -502,4 +537,15 @@ export default class EditableArray extends Editable {
502
537
  );
503
538
  });
504
539
  }
540
+
541
+ getSpecialProps(
542
+ incomingSpecialProps: Record<string, unknown> = {},
543
+ ): Record<string, unknown> {
544
+ return {
545
+ ...super.getSpecialProps(incomingSpecialProps),
546
+ "@length": Array.isArray(this.propsBase)
547
+ ? this.propsBase.length
548
+ : undefined,
549
+ };
550
+ }
505
551
  }
@@ -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");
@@ -120,94 +171,112 @@ export default class EditableComponent extends Editable {
120
171
  targetNode?: ChildNode | null,
121
172
  renderNode?: ChildNode | null,
122
173
  ): void {
123
- let targetChild: ChildNode | null | undefined =
174
+ let nextTargetChild: ChildNode | null | undefined =
124
175
  targetNode?.firstChild ?? undefined;
125
- let renderChild: ChildNode | null | undefined =
176
+ let nextRenderChild: ChildNode | null | undefined =
126
177
  renderNode?.firstChild ?? undefined;
127
- while (renderChild || targetChild) {
128
- const nextTargetChild: ChildNode | null | undefined =
129
- targetChild?.nextSibling ?? undefined;
130
- const nextRenderChild: ChildNode | null | undefined =
131
- renderChild?.nextSibling ?? undefined;
132
178
 
133
- if (
134
- targetChild instanceof Element &&
135
- renderChild &&
136
- !(renderChild instanceof Element)
137
- ) {
179
+ while (nextRenderChild || nextTargetChild) {
180
+ const targetChild: ChildNode | null | undefined = nextTargetChild;
181
+ const renderChild: ChildNode | null | undefined = nextRenderChild;
182
+
183
+ nextTargetChild = targetChild?.nextSibling ?? undefined;
184
+ nextRenderChild = renderChild?.nextSibling ?? undefined;
185
+
186
+ // There is an existing node in the DOM but no rendered node in it's place
187
+ if (!renderChild && targetChild) {
188
+ targetNode?.removeChild(targetChild);
189
+ continue;
190
+ }
191
+
192
+ // There is a node to be rendered, but no existing node in the DOM in it's place
193
+ if (renderChild && !targetChild) {
194
+ targetNode?.appendChild(renderChild);
195
+ continue;
196
+ }
197
+
198
+ if (!targetChild || !renderChild) {
199
+ throw new Error("Illegal rendering state, both children should exist ");
200
+ }
201
+
202
+ // The existing child is an element and the rendered child is a node (i.e. some text)
203
+ if (targetChild instanceof Element && !(renderChild instanceof Element)) {
204
+ // Render the (text) node before the element and skip to the next rendered child
138
205
  targetChild.before(renderChild);
139
- renderChild = nextRenderChild;
206
+ nextTargetChild = targetChild;
140
207
  continue;
141
208
  }
142
209
 
143
- if (
144
- renderChild instanceof Element &&
145
- targetChild &&
146
- !(targetChild instanceof Element)
147
- ) {
210
+ // The existing child is a node (i.e. some text) and the rendered child is an element
211
+ if (renderChild instanceof Element && !(targetChild instanceof Element)) {
212
+ // Delete the (text) node and skip to the next existing child
148
213
  targetChild.remove();
149
- targetChild = nextTargetChild;
214
+ nextRenderChild = renderChild;
150
215
  continue;
151
216
  }
152
217
 
218
+ // Both existing and rendered children are nodes (i.e. some text)
153
219
  if (
154
- renderChild &&
155
- targetChild &&
156
220
  !(renderChild instanceof Element) &&
157
221
  !(targetChild instanceof Element)
158
222
  ) {
223
+ // Render the offscreen node's content into the existing node.
159
224
  targetChild.nodeValue = renderChild.nodeValue;
160
- } else if (
161
- targetChild instanceof HTMLElement &&
162
- renderChild instanceof HTMLElement &&
163
- isEditableElement(renderChild) &&
164
- isEditableElement(targetChild)
225
+ continue;
226
+ }
227
+
228
+ // Both children are elements but are different types
229
+ if (renderChild.nodeName !== targetChild.nodeName) {
230
+ targetChild.replaceWith(renderChild);
231
+ continue;
232
+ }
233
+
234
+ // Both existing and rendered children are the same kind of element, and neither is editable
235
+ if (!isEditableElement(renderChild) && !isEditableElement(targetChild)) {
236
+ // Update the existing element to match the rendered element and recurse their subtrees
237
+ this.updateNode(targetChild, renderChild);
238
+ this.updateTree(targetChild, renderChild);
239
+ continue;
240
+ }
241
+
242
+ if (
243
+ !(renderChild instanceof HTMLElement) ||
244
+ !(targetChild instanceof HTMLElement)
165
245
  ) {
166
- if (!areEqualEditables(renderChild, targetChild)) {
167
- targetChild.replaceWith(renderChild);
168
- } else if (isEditableText(renderChild) && isEditableText(targetChild)) {
169
- if (
170
- hasEditableText(targetChild) &&
171
- !targetChild.editable.focused &&
172
- !targetChild?.isEqualNode(renderChild) &&
173
- hasEditable(renderChild)
174
- ) {
175
- targetChild.replaceWith(renderChild);
176
- for (let i = 0; i < this.listeners.length; i++) {
177
- const listener = this.listeners[i];
178
- if (listener.editable.element === targetChild) {
179
- listener.editable.element = renderChild;
180
- renderChild.editable.pushValue(this.value, listener, {
181
- ...this.contexts,
182
- __base_context: this.contextBase ?? {},
183
- });
184
- }
185
- }
186
- } else if (hasEditable(targetChild)) {
187
- this.updateEditable(renderChild, targetChild);
188
- }
189
- } else if (hasEditable(targetChild)) {
190
- this.updateEditable(renderChild, targetChild);
191
- }
192
- } else if (renderChild && targetChild) {
193
- if (
194
- renderChild.nodeName !== targetChild.nodeName ||
195
- isEditableElement(renderChild) ||
196
- isEditableElement(targetChild)
197
- ) {
198
- targetChild.replaceWith(renderChild);
199
- } else {
200
- this.updateNode(targetChild, renderChild);
201
- this.updateTree(targetChild, renderChild);
202
- }
203
- } else if (renderChild) {
204
- targetNode?.appendChild(renderChild);
205
- } else if (targetChild) {
206
- targetNode?.removeChild(targetChild);
246
+ throw new Error("Illegal state, both children should be elements");
247
+ }
248
+
249
+ // The existing and rendered child either aren't both editable or are different kinds of editables
250
+ if (!areEqualEditables(renderChild, targetChild)) {
251
+ targetChild.replaceWith(renderChild);
252
+ continue;
253
+ }
254
+
255
+ if (!isEditableElement(renderChild) || !isEditableElement(targetChild)) {
256
+ throw new Error("Illegal state, both children should be editable");
257
+ }
258
+
259
+ // Both elements are editable but the existing element hasn't hydrated
260
+ if (!hasEditable(targetChild)) {
261
+ targetChild.replaceWith(renderChild);
262
+ continue;
263
+ }
264
+
265
+ // The existing and rendered child are the same kind of non-text editable.
266
+ if (!isEditableText(targetChild)) {
267
+ this.updateEditable(renderChild, targetChild);
268
+ continue;
207
269
  }
208
270
 
209
- targetChild = nextTargetChild;
210
- renderChild = nextRenderChild;
271
+ // The existing element is currently focused or is already the same as the rendered element
272
+ if (
273
+ (hasEditableText(targetChild) && targetChild.editable.focused) ||
274
+ targetChild?.isEqualNode(renderChild)
275
+ ) {
276
+ this.updateEditable(renderChild, targetChild);
277
+ } else {
278
+ targetChild.replaceWith(renderChild);
279
+ }
211
280
  }
212
281
  }
213
282
 
@@ -259,10 +328,16 @@ export default class EditableComponent extends Editable {
259
328
  for (let i = 0; i < this.listeners.length; i++) {
260
329
  const listener = this.listeners[i];
261
330
  if (listener.editable.element === targetChild) {
262
- targetChild.editable.pushValue(this.value, listener, {
263
- ...this.contexts,
264
- __base_context: this.contextBase ?? {},
265
- });
331
+ targetChild.editable.pushValue(
332
+ this.value,
333
+ this.specialProps,
334
+ listener,
335
+ {
336
+ ...this.contexts,
337
+ __base_context: this.contextBase ?? {},
338
+ },
339
+ renderChild,
340
+ );
266
341
  }
267
342
  }
268
343
  }
@@ -288,23 +363,6 @@ export default class EditableComponent extends Editable {
288
363
  );
289
364
  }
290
365
 
291
- setupListeners(): void {
292
- super.setupListeners();
293
- const key = this.element.dataset.component;
294
- if (!key) {
295
- return;
296
- }
297
-
298
- const component = this.getComponents()?.[key];
299
- if (!component) {
300
- document.addEventListener(
301
- `editable-regions:registered-${key}`,
302
- () => this.update(),
303
- { once: true },
304
- );
305
- }
306
- }
307
-
308
366
  mount(): void {
309
367
  if (!this.controlsElement) {
310
368
  let editPath: string | undefined;
@@ -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 {