@cloudcannon/editable-regions 0.0.9 → 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.
@@ -9,7 +9,7 @@ export default class EditableSnippetComponent extends HTMLElement {
9
9
  }
10
10
 
11
11
  set snippetData(value: unknown) {
12
- this.editable.pushValue(value);
12
+ this.editable.pushValue(value, {}, { path: "", editable: this.editable });
13
13
  }
14
14
 
15
15
  connectedCallback(): void {
@@ -6,12 +6,10 @@ export { default as EditableSnippetComponent } from "./editable-snippet-componen
6
6
  export { default as EditableSourceComponent } from "./editable-source-component.js";
7
7
  export { default as EditableTextComponent } from "./editable-text-component.js";
8
8
 
9
- import { apiLoadedPromise } from "../helpers/cloudcannon.mjs";
10
9
  import {
11
10
  dehydrateDataEditableRegions,
12
11
  hydrateDataEditableRegions,
13
12
  } from "../helpers/hydrate-editable-regions";
14
- import { completeLoading } from "../helpers/loading.js";
15
13
 
16
14
  const observer = new MutationObserver((mutations) => {
17
15
  mutations.forEach((mutation) => {
@@ -32,14 +30,3 @@ const observer = new MutationObserver((mutations) => {
32
30
  hydrateDataEditableRegions(document.body);
33
31
 
34
32
  observer.observe(document, { childList: true, subtree: true });
35
-
36
- Promise.all([
37
- apiLoadedPromise,
38
- customElements.whenDefined("editable-array-item"),
39
- customElements.whenDefined("editable-array"),
40
- customElements.whenDefined("editable-text"),
41
- customElements.whenDefined("editable-component"),
42
- customElements.whenDefined("editable-image"),
43
- customElements.whenDefined("editable-source"),
44
- customElements.whenDefined("editable-snippet"),
45
- ]).then(() => completeLoading());
package/helpers/checks.ts CHANGED
@@ -2,32 +2,11 @@ import Editable from "../nodes/editable.js";
2
2
  import EditableArrayItem from "../nodes/editable-array-item.js";
3
3
  import EditableText from "../nodes/editable-text.js";
4
4
 
5
- const TAG_NAMES = [
6
- "EDITABLE-TEXT",
7
- "EDITABLE-COMPONENT",
8
- "EDITABLE-ARRAY-ITEM",
9
- "EDITABLE-ARRAY",
10
- "EDITABLE-IMAGE",
11
- "EDITABLE-SOURCE",
12
- "EDITABLE-SNIPPET",
13
- ];
14
-
15
- const EDITABLE_REGION_TYPES = [
16
- "text",
17
- "component",
18
- "array",
19
- "array-item",
20
- "image",
21
- "source",
22
- ];
23
-
24
- const CORRESPONDING_NAME: Record<string, string> = {
25
- "EDITABLE-TEXT": "text",
26
- "EDITABLE-COMPONENT": "component",
27
- "EDITABLE-ARRAY-ITEM": "array-item",
28
- "EDITABLE-ARRAY": "array",
29
- "EDITABLE-IMAGE": "image",
30
- "EDITABLE-SOURCE": "source",
5
+ const getEditableType = (el: HTMLElement): string | undefined => {
6
+ if (el.tagName.startsWith("EDITABLE-")) {
7
+ return el.tagName.slice(9).toLowerCase();
8
+ }
9
+ return el.dataset.editable;
31
10
  };
32
11
 
33
12
  export const hasEditable = <T extends object>(
@@ -53,7 +32,7 @@ export const isEditableWebcomponent = (el: unknown): boolean => {
53
32
  return false;
54
33
  }
55
34
 
56
- return TAG_NAMES.includes(el.tagName);
35
+ return el.tagName.startsWith("EDITABLE-");
57
36
  };
58
37
 
59
38
  export const isEditableElement = (el: unknown): boolean => {
@@ -62,19 +41,13 @@ export const isEditableElement = (el: unknown): boolean => {
62
41
  }
63
42
 
64
43
  return (
65
- TAG_NAMES.includes(el.tagName) ||
66
- (!!el.dataset.editable &&
67
- EDITABLE_REGION_TYPES.includes(el.dataset.editable))
44
+ el.tagName.startsWith("EDITABLE-") ||
45
+ typeof el.dataset.editable === "string"
68
46
  );
69
47
  };
70
48
 
71
49
  export const areEqualEditables = (a: HTMLElement, b: HTMLElement) => {
72
- if (
73
- a.tagName !== b.tagName &&
74
- a.dataset.editable !== b.dataset.editable &&
75
- CORRESPONDING_NAME[a.tagName] !== b.dataset.editable &&
76
- CORRESPONDING_NAME[b.tagName] !== a.dataset.editable
77
- ) {
50
+ if (getEditableType(a) !== getEditableType(b)) {
78
51
  return false;
79
52
  }
80
53
 
@@ -107,4 +107,14 @@ export const realizeAPIValue = async (value) => {
107
107
  return value;
108
108
  };
109
109
 
110
+ export const addCustomEditableRegion = (key, region) => {
111
+ extendedWindow.editableRegionMap ??= {};
112
+ extendedWindow.editableRegionMap[key] = region;
113
+ extendedWindow.hydrateDataEditableRegions(document.body);
114
+ };
115
+
116
+ export const getCustomEditableRegions = () => {
117
+ return extendedWindow.editableRegionMap ?? {};
118
+ };
119
+
110
120
  export { _cloudcannon as CloudCannon };
@@ -9,7 +9,7 @@ import {
9
9
  } from "../nodes";
10
10
  import { hasEditable, isEditableWebcomponent } from "./checks";
11
11
 
12
- const editableMap: Record<string, typeof Editable | undefined> = {
12
+ const baseEditableMap: Record<string, typeof Editable | undefined> = {
13
13
  array: EditableArray,
14
14
  "array-item": EditableArrayItem,
15
15
  component: EditableComponent,
@@ -31,6 +31,10 @@ export const dehydrateDataEditableRegions = (root: Element) => {
31
31
  };
32
32
 
33
33
  export const hydrateDataEditableRegions = (root: Element) => {
34
+ const editableMap: Record<string, typeof Editable | undefined> = {
35
+ ...baseEditableMap,
36
+ ...((window as any).editableRegionMap ?? {}),
37
+ };
34
38
  if (
35
39
  root instanceof HTMLElement &&
36
40
  root.dataset.editable &&
@@ -89,3 +93,5 @@ export const hydrateDataEditableRegions = (root: Element) => {
89
93
  }
90
94
  });
91
95
  };
96
+
97
+ (window as any).hydrateDataEditableRegions = hydrateDataEditableRegions;
@@ -1,26 +1,17 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
- const SUPPORTED_VIRTUAL_MODULES = [
5
- "actions",
6
- "assets",
7
- "content",
8
- "i18n",
9
- "middleware",
10
- "transitions",
11
- ];
4
+ /** @type{string[]} */
5
+ const SUPPORTED_VIRTUAL_MODULES = ["assets", "content"];
12
6
 
13
7
  /**
14
8
  * @return {import("astro").AstroIntegration}
15
9
  */
16
10
  export default () => {
17
- /** @type {import("astro").AstroConfig} */
18
- let astroConfig;
19
-
20
11
  return {
21
12
  name: "editable-regions",
22
13
  hooks: {
23
- "astro:config:setup": ({ config, updateConfig }) => {
14
+ "astro:config:setup": ({ updateConfig }) => {
24
15
  updateConfig({
25
16
  vite: {
26
17
  define: {
@@ -28,7 +19,6 @@ export default () => {
28
19
  },
29
20
  },
30
21
  });
31
- astroConfig = config;
32
22
  },
33
23
  "astro:build:setup": async ({ target, vite }) => {
34
24
  if (target === "client") {
@@ -68,14 +58,6 @@ export default () => {
68
58
  .replace("/client", "")
69
59
  .replace("/server", "");
70
60
 
71
- if (type === "env") {
72
- return "\0editable-region:env";
73
- }
74
-
75
- if (!SUPPORTED_VIRTUAL_MODULES.includes(type)) {
76
- return;
77
- }
78
-
79
61
  let dir = "";
80
62
  if (typeof __dirname !== "undefined") {
81
63
  dir = __dirname;
@@ -83,41 +65,15 @@ export default () => {
83
65
  dir = dirname(fileURLToPath(import.meta.url));
84
66
  }
85
67
 
86
- return join(dir, "modules", `${type}.js`);
87
- }
88
- },
68
+ if (type === "env" && id.endsWith("/server")) {
69
+ return join(dir, "modules", "secrets.js");
70
+ }
89
71
 
90
- load(id) {
91
- if (id === "\0editable-region:env") {
92
- let contents = "";
93
- Object.entries(astroConfig?.env?.schema ?? {}).forEach(
94
- ([key, schema]) => {
95
- if (
96
- schema.context !== "client" ||
97
- schema.access !== "public"
98
- ) {
99
- return;
100
- }
72
+ if (!SUPPORTED_VIRTUAL_MODULES.includes(type)) {
73
+ return;
74
+ }
101
75
 
102
- try {
103
- switch (schema.type) {
104
- case "boolean":
105
- contents += `export const ${key} = ${!!process.env[key]};\n`;
106
- break;
107
- case "number":
108
- contents += `export const ${key} = ${Number(process.env[key])};\n`;
109
- break;
110
- default:
111
- contents += `export const ${key} = ${JSON.stringify(process.env[key] ?? "")};\n`;
112
- }
113
- } catch (_e) {
114
- //Error intentionally ignored
115
- }
116
- },
117
- );
118
- contents +=
119
- 'export const getSecret = () => console.log("[CloudCannon] getSecret is not supported in an editable component. Please use an editing fallback instead.");';
120
- return contents;
76
+ return join(dir, "modules", `${type}.js`);
121
77
  }
122
78
  },
123
79
  });
@@ -26,12 +26,18 @@ export const getCollection = async (collectionKey, filter) => {
26
26
  slug = data.slug;
27
27
  }
28
28
 
29
+ const body = await file.get();
29
30
  return {
30
31
  collection: collectionKey,
31
32
  id: id,
32
33
  data: data,
33
34
  slug: slug,
34
- body: await file.get(),
35
+ body: body,
36
+ render: () => ({
37
+ Content: () => body ?? "Content is not available when live editing",
38
+ headings: [],
39
+ remarkPluginFrontmatter: {},
40
+ }),
35
41
  };
36
42
  });
37
43
 
@@ -0,0 +1,4 @@
1
+ export const getSecret = () =>
2
+ console.log(
3
+ "[CloudCannon] getSecret is not supported in an editable component. Please use an editing fallback instead.",
4
+ );
@@ -249,8 +249,8 @@ export default class EditableArrayItem extends EditableComponent {
249
249
  );
250
250
  }
251
251
 
252
- async update(): Promise<void> {
253
- await super.update();
252
+ async update(partialSubtree?: ChildNode | null): Promise<void> {
253
+ await super.update(partialSubtree);
254
254
  this.updateControls();
255
255
  }
256
256
 
@@ -485,12 +485,12 @@ export default class EditableArrayItem extends EditableComponent {
485
485
  }
486
486
  }
487
487
 
488
- setupListeners(): void {
489
- super.setupListeners();
490
- if (!this.element.dataset.prop) {
491
- this.parent?.registerListener({
492
- editable: this,
493
- });
494
- }
488
+ getSpecialProps(
489
+ incomingSpecialProps: Record<string, unknown> = {},
490
+ ): Record<string, unknown> {
491
+ return {
492
+ ...super.getSpecialProps(incomingSpecialProps),
493
+ "@index": Number(this.element.dataset.prop),
494
+ };
495
495
  }
496
496
  }
@@ -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");
@@ -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.9",
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
- ---
@@ -1,76 +0,0 @@
1
- export const getRelativeLocaleUrl = () => {
2
- console.warn(
3
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
4
- );
5
- return "";
6
- };
7
-
8
- export const getAbsoluteLocaleUrl = () => {
9
- console.warn(
10
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
11
- );
12
- return "";
13
- };
14
-
15
- export const getRelativeLocaleUrlList = () => {
16
- console.warn(
17
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
18
- );
19
- return [];
20
- };
21
-
22
- export const getAbsoluteLocaleUrlList = () => {
23
- console.warn(
24
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
25
- );
26
- return [];
27
- };
28
-
29
- export const getPathByLocale = () => {
30
- console.warn(
31
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
32
- );
33
- return "";
34
- };
35
-
36
- export const getLocaleByPath = () => {
37
- console.warn(
38
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
39
- );
40
- return "";
41
- };
42
-
43
- export const redirectToDefaultLocale = () => {
44
- console.warn(
45
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
46
- );
47
- return Promise.resolve(new Response());
48
- };
49
-
50
- export const redirectToFallback = () => {
51
- console.warn(
52
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
53
- );
54
- return Promise.resolve(new Response());
55
- };
56
-
57
- export const notFound = () => {
58
- console.warn(
59
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
60
- );
61
- return Promise.resolve(new Response());
62
- };
63
-
64
- export const middleware = () => {
65
- console.warn(
66
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
67
- );
68
- return () => {};
69
- };
70
-
71
- export const requestHasLocale = () => {
72
- console.warn(
73
- "[CloudCannon] i18n routing is not supported in an editable component. Please use an editing fallback instead.",
74
- );
75
- return false;
76
- };
@@ -1,27 +0,0 @@
1
- export const sequence = () => {
2
- console.warn(
3
- "[CloudCannon] middleware is not supported in an editable component. Please use an editing fallback instead.",
4
- );
5
- return () => {};
6
- };
7
-
8
- export const defineMiddleware = () => {
9
- console.warn(
10
- "[CloudCannon] middleware is not supported in an editable component. Please use an editing fallback instead.",
11
- );
12
- return () => {};
13
- };
14
-
15
- export const createContext = () => {
16
- console.warn(
17
- "[CloudCannon] middleware is not supported in an editable component. Please use an editing fallback instead.",
18
- );
19
- return {};
20
- };
21
-
22
- export const trySerializeLocals = () => {
23
- console.warn(
24
- "[CloudCannon] middleware is not supported in an editable component. Please use an editing fallback instead.",
25
- );
26
- return "";
27
- };
@@ -1,63 +0,0 @@
1
- import ClientRouterInternal from "./client-router.astro";
2
-
3
- export const ClientRouter = ClientRouterInternal;
4
-
5
- export const fade = () => {
6
- console.warn(
7
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
8
- );
9
- return {};
10
- };
11
-
12
- export const slide = () => {
13
- console.warn(
14
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
15
- );
16
- return {};
17
- };
18
-
19
- export const navigate = () => {
20
- console.warn(
21
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
22
- );
23
- };
24
-
25
- export const supportsViewTransitions = false;
26
-
27
- export const transitionEnabledOnThisPage = false;
28
-
29
- export const getFallback = () => {
30
- console.warn(
31
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
32
- );
33
- return "none";
34
- };
35
-
36
- export const swapFunctions = {
37
- deselectScripts: () => {
38
- console.warn(
39
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
40
- );
41
- },
42
- swapRootAttributes: () => {
43
- console.warn(
44
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
45
- );
46
- },
47
- swapHeadElements: () => {
48
- console.warn(
49
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
50
- );
51
- },
52
- saveFocus: () => {
53
- console.warn(
54
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
55
- );
56
- return () => {};
57
- },
58
- swapBodyElement: () => {
59
- console.warn(
60
- "[CloudCannon] view transitions are not supported in an editable component. Please use an editing fallback instead.",
61
- );
62
- },
63
- };