@cloudcannon/editable-regions 0.0.6 → 0.0.8

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.
@@ -1,12 +1,12 @@
1
1
  export { default as EditableArrayComponent } from "./editable-array-component.js";
2
2
  export { default as EditableArrayItemComponent } from "./editable-array-item-component.js";
3
- export { default as EditableTextComponent } from "./editable-text-component.js";
4
3
  export { default as EditableComponentComponent } from "./editable-component-component.js";
5
4
  export { default as EditableImageComponent } from "./editable-image-component.js";
6
- export { default as EditableSourceComponent } from "./editable-source-component.js";
7
5
  export { default as EditableSnippetComponent } from "./editable-snippet-component.js";
6
+ export { default as EditableSourceComponent } from "./editable-source-component.js";
7
+ export { default as EditableTextComponent } from "./editable-text-component.js";
8
8
 
9
- import { apiLoadedPromise } from "../helpers/cloudcannon.js";
9
+ import { apiLoadedPromise } from "../helpers/cloudcannon.mjs";
10
10
  import {
11
11
  dehydrateDataEditableRegions,
12
12
  hydrateDataEditableRegions,
@@ -92,15 +92,11 @@ export default class EditableArrayItemControls extends EditableComponentControls
92
92
  super.render(shadow);
93
93
 
94
94
  this.dragHandle = document.createElement("button");
95
+ this.dragHandle.type = "button";
95
96
  this.dragHandle.onclick = (e) => {
96
97
  e.stopPropagation();
97
- if (this.contextMenu?.classList.contains("open")) {
98
- this.contextMenu?.classList.remove("open");
99
- this.removeAttribute("open");
100
- } else if (this.contextMenu && this.contextMenu.childElementCount > 0) {
101
- this.contextMenu?.classList.add("open");
102
- this.setAttribute("open", "true");
103
- }
98
+ e.preventDefault();
99
+ this.toggleContextMenu();
104
100
  };
105
101
  this.dragHandle.ondragstart = () => {
106
102
  this.contextMenu?.classList.remove("open");
@@ -7,6 +7,16 @@ export default class EditableComponentControls extends HTMLElement {
7
7
 
8
8
  private editButton?: HTMLButtonElement;
9
9
 
10
+ toggleContextMenu() {
11
+ if (this.contextMenu?.classList.contains("open")) {
12
+ this.contextMenu?.classList.remove("open");
13
+ this.removeAttribute("open");
14
+ } else if (this.contextMenu && this.contextMenu.childElementCount > 0) {
15
+ this.contextMenu?.classList.add("open");
16
+ this.setAttribute("open", "true");
17
+ }
18
+ }
19
+
10
20
  render(shadow: ShadowRoot): void {
11
21
  const style = document.createElement("style");
12
22
  style.textContent = styleContent;
@@ -21,18 +31,23 @@ export default class EditableComponentControls extends HTMLElement {
21
31
  shadow.appendChild(this.contextMenu);
22
32
 
23
33
  this.editButton = document.createElement("button");
34
+ this.editButton.type = "button";
24
35
  this.editButton.innerHTML = '<cc-icon name="edit"></cc-icon>';
25
36
  this.editButton.onclick = () => {
26
37
  this.dispatchEvent(new CustomEvent("edit", { detail: this }));
27
38
  };
28
39
  this.buttonRow.append(this.editButton);
29
40
 
30
- this.onclick = () => {
41
+ this.onclick = (e) => {
42
+ e.preventDefault();
31
43
  this.contextMenu?.classList.remove("open");
32
44
  this.removeAttribute("open");
33
45
  };
34
46
 
35
- this.onblur = () => {
47
+ this.onblur = (e) => {
48
+ if ((e.relatedTarget as HTMLElement)?.tagName === "A") {
49
+ return;
50
+ }
36
51
  this.contextMenu?.classList.remove("open");
37
52
  this.removeAttribute("open");
38
53
  };
@@ -22,11 +22,13 @@ export default class EditableRegionButton extends HTMLElement {
22
22
  button.innerHTML = `<cc-icon name='${this.getAttribute("icon")}'></cc-icon>${this.getAttribute("text")}`;
23
23
  shadow.appendChild(button);
24
24
 
25
- button.addEventListener("click", (e) => {
26
- this.dispatchEvent(
27
- new CustomEvent("button-click", { detail: { originalEvent: e } }),
28
- );
29
- });
25
+ button.addEventListener("click", (e) => this.triggerClick(e));
26
+ }
27
+
28
+ triggerClick(e?: MouseEvent) {
29
+ this.dispatchEvent(
30
+ new CustomEvent("button-click", { detail: { originalEvent: e } }),
31
+ );
30
32
  }
31
33
  }
32
34
 
package/helpers/checks.ts CHANGED
@@ -1,6 +1,6 @@
1
+ import Editable from "../nodes/editable.js";
1
2
  import EditableArrayItem from "../nodes/editable-array-item.js";
2
3
  import EditableText from "../nodes/editable-text.js";
3
- import Editable from "../nodes/editable.js";
4
4
 
5
5
  const TAG_NAMES = [
6
6
  "EDITABLE-TEXT",
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @typedef {import("@cloudcannon/javascript-api").CloudCannonEditorWindow} CloudCannonEditorWindow
3
+ * @typedef {import("@cloudcannon/javascript-api").CloudCannonJavaScriptV1API} CloudCannonJavaScriptV1API
4
+ */
5
+
6
+ /**
7
+ * @typedef {(props: any) => HTMLElement | Promise<HTMLElement>} ComponentRenderer
8
+ */
9
+
10
+ /**
11
+ * @typedef {CloudCannonEditorWindow & {
12
+ * cc_components?: Record<string, ComponentRenderer>;
13
+ * cc_snippets?: Record<string, ComponentRenderer>;
14
+ * }} ExtendedWindow
15
+ */
16
+
17
+ /** @type {ExtendedWindow} */
18
+ const extendedWindow = /** @type {any} */ (window);
19
+
20
+ /** @type {CloudCannonJavaScriptV1API} */
21
+ let _cloudcannon;
22
+
23
+ /**
24
+ * Promise that resolves when the CloudCannon API is loaded
25
+ * @type {Promise<void>}
26
+ */
27
+ export const apiLoadedPromise = new Promise((resolve) => {
28
+ if (extendedWindow.CloudCannonAPI) {
29
+ _cloudcannon = /** @type {any} */ (
30
+ extendedWindow.CloudCannonAPI.useVersion("v1", true)
31
+ );
32
+ resolve();
33
+ } else {
34
+ document.addEventListener(
35
+ "cloudcannon:load",
36
+ () => {
37
+ if (extendedWindow.CloudCannonAPI) {
38
+ _cloudcannon = /** @type {any} */ (
39
+ extendedWindow.CloudCannonAPI.useVersion("v1", true)
40
+ );
41
+ }
42
+ return resolve();
43
+ },
44
+ { once: true },
45
+ );
46
+ }
47
+ });
48
+
49
+ /**
50
+ * Add a renderer for editable components
51
+ * @param {string} key - The component key
52
+ * @param {ComponentRenderer} renderer - The component renderer function
53
+ * @returns {void}
54
+ */
55
+ export const addEditableComponentRenderer = (key, renderer) => {
56
+ extendedWindow.cc_components = extendedWindow.cc_components || {};
57
+ extendedWindow.cc_components[key] = renderer;
58
+ document.dispatchEvent(new CustomEvent(`editable-regions:registered-${key}`));
59
+ };
60
+
61
+ /**
62
+ * Add a renderer for editable snippets
63
+ * @param {string} key - The snippet key
64
+ * @param {ComponentRenderer} renderer - The snippet renderer function
65
+ * @returns {void}
66
+ */
67
+ export const addEditableSnippetRenderer = (key, renderer) => {
68
+ extendedWindow.cc_snippets = extendedWindow.cc_snippets || {};
69
+ extendedWindow.cc_snippets[key] = renderer;
70
+ document.dispatchEvent(new CustomEvent(`editable-regions:registered-${key}`));
71
+ };
72
+
73
+ /**
74
+ * Get all registered editable component renderers
75
+ * @returns {Record<string, ComponentRenderer>}
76
+ */
77
+ export const getEditableComponentRenderers = () =>
78
+ extendedWindow.cc_components ?? {};
79
+
80
+ /**
81
+ * Get all registered editable snippet renderers
82
+ * @returns {Record<string, ComponentRenderer>}
83
+ */
84
+ export const getEditableSnippetRenderers = () =>
85
+ extendedWindow.cc_snippets ?? {};
86
+
87
+ /**
88
+ * Realize API values by converting CloudCannon API objects to their data representations
89
+ * @param {unknown} value - The value to realize
90
+ * @returns {Promise<unknown>}
91
+ */
92
+ export const realizeAPIValue = async (value) => {
93
+ if (_cloudcannon.isAPICollection(value)) {
94
+ const items = await value.items();
95
+ return Promise.all(items.map(realizeAPIValue));
96
+ }
97
+ if (_cloudcannon.isAPIFile(value)) {
98
+ return value.data.get();
99
+ }
100
+ if (_cloudcannon.isAPIDataset(value)) {
101
+ const items = await value.items();
102
+ if (Array.isArray(items)) {
103
+ return Promise.all(items.map(realizeAPIValue));
104
+ }
105
+ return items.data.get();
106
+ }
107
+ return value;
108
+ };
109
+
110
+ export { _cloudcannon as CloudCannon };
@@ -32,10 +32,32 @@ export default () => {
32
32
  },
33
33
  "astro:build:setup": async ({ target, vite }) => {
34
34
  if (target === "client") {
35
+ vite.plugins ??= [];
35
36
  vite.define ??= {};
36
37
  vite.define.ENV_CLIENT = true;
37
38
 
38
- vite.plugins?.unshift({
39
+ const flatPlugins = vite.plugins?.flat(10);
40
+ const astroBuildPlugin = flatPlugins?.find((obj) => {
41
+ return (
42
+ obj &&
43
+ typeof obj === "object" &&
44
+ "name" in obj &&
45
+ obj.name === "astro:build"
46
+ );
47
+ });
48
+
49
+ if (
50
+ astroBuildPlugin &&
51
+ "transform" in astroBuildPlugin &&
52
+ typeof astroBuildPlugin.transform === "function"
53
+ ) {
54
+ const original = astroBuildPlugin.transform;
55
+ astroBuildPlugin.transform = function (source, id, options) {
56
+ return original.bind(this)(source, id, { ...options, ssr: true });
57
+ };
58
+ }
59
+
60
+ vite.plugins.unshift({
39
61
  name: "vite-plugin-editable-regions",
40
62
  enforce: "pre",
41
63
 
@@ -88,7 +110,7 @@ export default () => {
88
110
  default:
89
111
  contents += `export const ${key} = ${JSON.stringify(process.env[key] ?? "")};\n`;
90
112
  }
91
- } catch (e) {
113
+ } catch (_e) {
92
114
  //Error intentionally ignored
93
115
  }
94
116
  },
@@ -2,7 +2,7 @@ import {
2
2
  renderSlotToString,
3
3
  renderToString,
4
4
  } from "astro/runtime/server/index.js";
5
- import { addEditableComponentRenderer } from "../../helpers/cloudcannon";
5
+ import { addEditableComponentRenderer } from "../../helpers/cloudcannon.mjs";
6
6
 
7
7
  /**
8
8
  * Queue of React components waiting to be rendered
@@ -86,7 +86,7 @@ export const registerAstroComponent = (key, component) => {
86
86
  true,
87
87
  ["encrypt", "decrypt"],
88
88
  );
89
- } catch (err) {
89
+ } catch (_err) {
90
90
  console.warn(
91
91
  "[CloudCannon] Could not generate a key for Astro component. This may cause issues with Astro components that use server-islands",
92
92
  );
@@ -1,4 +1,4 @@
1
- import { CloudCannon } from "../../../helpers/cloudcannon";
1
+ import { CloudCannon } from "../../../helpers/cloudcannon.mjs";
2
2
 
3
3
  /**
4
4
  * @param {string} collectionKey
@@ -24,7 +24,7 @@ addFrameworkRenderer({
24
24
  try {
25
25
  const reactNode = Component(props);
26
26
  return { html: renderToStaticMarkup(reactNode) };
27
- } catch (err) {
27
+ } catch (_err) {
28
28
  const id = queueForClientSideRender((node) => {
29
29
  const reactNode = createElement(Component, props, null);
30
30
  const root = createRoot(node);
@@ -1,7 +1,7 @@
1
1
  import { createElement } from "react";
2
2
  import { flushSync } from "react-dom";
3
3
  import { createRoot } from "react-dom/client";
4
- import { addEditableComponentRenderer } from "../helpers/cloudcannon";
4
+ import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
5
5
 
6
6
  /**
7
7
  * Registers a React component with the CloudCannon component system.
@@ -6,14 +6,14 @@ import {
6
6
  isEditableArrayItem,
7
7
  isEditableElement,
8
8
  } from "../helpers/checks.js";
9
- import { CloudCannon, realizeAPIValue } from "../helpers/cloudcannon.js";
9
+ import { CloudCannon, realizeAPIValue } from "../helpers/cloudcannon.mjs";
10
10
  import type EditableArray from "./editable-array.js";
11
11
  import EditableComponent from "./editable-component.js";
12
12
 
13
13
  export default class EditableArrayItem extends EditableComponent {
14
14
  parent: EditableArray | null = null;
15
15
 
16
- protected controlsElement?: EditableArrayItemControls;
16
+ protected declare controlsElement?: EditableArrayItemControls;
17
17
 
18
18
  private inputConfig?: any;
19
19
  private structureStrings: string[] = [];
@@ -44,11 +44,11 @@ export default class EditableArrayItem extends EditableComponent {
44
44
  }
45
45
 
46
46
  isValidDropzone(e: DragEvent): boolean {
47
- const source = this.parent?.contextBase?.filePath;
48
- if (!source || !e.dataTransfer) {
47
+ if (!e.dataTransfer) {
49
48
  return false;
50
49
  }
51
50
 
51
+ const source = this.parent?.contextBase?.fullPath ?? "@currentFile";
52
52
  if (e.dataTransfer?.types.includes(source.toLowerCase())) {
53
53
  return true;
54
54
  }
@@ -96,11 +96,11 @@ export default class EditableArrayItem extends EditableComponent {
96
96
  }
97
97
 
98
98
  onDragStart(e: DragEvent): void {
99
- const source = this.parent?.contextBase?.filePath;
100
- if (!source || !e.dataTransfer || !this.element.dataset.prop) {
99
+ if (!e.dataTransfer || !this.element.dataset.prop) {
101
100
  return;
102
101
  }
103
102
 
103
+ const source = this.parent?.contextBase?.fullPath ?? "@currentFile";
104
104
  const clientRect = this.element.getBoundingClientRect();
105
105
 
106
106
  e.stopPropagation();
@@ -292,7 +292,7 @@ export default class EditableArrayItem extends EditableComponent {
292
292
  this.controlsElement = document.createElement(
293
293
  "editable-array-item-controls",
294
294
  );
295
- this.controlsElement.addEventListener("edit", (e: any) => {
295
+ this.controlsElement.addEventListener("edit", (_e: any) => {
296
296
  this.dispatchEdit(this.element.dataset.prop);
297
297
  });
298
298
 
@@ -423,11 +423,7 @@ export default class EditableArrayItem extends EditableComponent {
423
423
  return;
424
424
  }
425
425
 
426
- const source = this.parent?.contextBase?.filePath;
427
- if (!source) {
428
- throw new Error("Source not found");
429
- }
430
-
426
+ const source = this.parent?.contextBase?.fullPath ?? "@currentFile";
431
427
  const dragType = this.getDragType();
432
428
  const sameArrayData = e.dataTransfer.getData(source);
433
429
  const otherArrayData = dragType
@@ -6,9 +6,9 @@ import type {
6
6
  import type EditableArrayItemComponent from "../components/editable-array-item-component.js";
7
7
  import type EditableRegionButton from "../components/ui/editable-region-button.js";
8
8
  import { isEditableElement } from "../helpers/checks.js";
9
- import { CloudCannon } from "../helpers/cloudcannon.js";
10
- import type EditableArrayItem from "./editable-array-item.js";
9
+ import { CloudCannon } from "../helpers/cloudcannon.mjs";
11
10
  import Editable, { type EditableListener } from "./editable.js";
11
+ import type EditableArrayItem from "./editable-array-item.js";
12
12
  import "../components/ui/editable-region-button.js";
13
13
 
14
14
  const arrayDirectionValues = [
@@ -29,6 +29,7 @@ export default class EditableArray extends Editable {
29
29
  value:
30
30
  | CloudCannonJavaScriptV1APICollection
31
31
  | CloudCannonJavaScriptV1APIDataset
32
+ | CloudCannonJavaScriptV1APIFile
32
33
  | unknown[]
33
34
  | null
34
35
  | undefined = undefined;
@@ -42,6 +43,7 @@ export default class EditableArray extends Editable {
42
43
  return;
43
44
  }
44
45
 
46
+ const __base_context = { ...this.contextBase };
45
47
  let value: unknown[] | CloudCannonJavaScriptV1APIFile[];
46
48
  if (CloudCannon.isAPICollection(this.value)) {
47
49
  value = await this.value.items();
@@ -51,8 +53,13 @@ export default class EditableArray extends Editable {
51
53
  value = items;
52
54
  } else {
53
55
  const data = await items.data.get();
56
+ __base_context.file = items;
54
57
  value = Array.isArray(data) ? data : [];
55
58
  }
59
+ } else if (CloudCannon.isAPIFile(this.value)) {
60
+ const data = await this.value.data.get();
61
+ __base_context.file = this.value;
62
+ value = Array.isArray(data) ? data : [];
56
63
  } else if (Array.isArray(this.value)) {
57
64
  value = this.value;
58
65
  } else {
@@ -84,7 +91,7 @@ export default class EditableArray extends Editable {
84
91
 
85
92
  if (listener.path) {
86
93
  listener.editable.pushValue(value, listener, {
87
- __base_context: this.contextBase ?? {},
94
+ __base_context,
88
95
  });
89
96
  }
90
97
  }
@@ -113,7 +120,8 @@ export default class EditableArray extends Editable {
113
120
  !Array.isArray(value) &&
114
121
  value !== null &&
115
122
  !CloudCannon.isAPICollection(value) &&
116
- !CloudCannon.isAPIDataset(value)
123
+ !CloudCannon.isAPIDataset(value) &&
124
+ !CloudCannon.isAPIFile(value)
117
125
  ) {
118
126
  this.element.classList.add("errored");
119
127
  const error = document.createElement("editable-region-error-card");
@@ -156,6 +164,7 @@ export default class EditableArray extends Editable {
156
164
 
157
165
  private async _update(): Promise<void> {
158
166
  let value: unknown[] | CloudCannonJavaScriptV1APIFile[];
167
+ const __base_context = { ...this.contextBase };
159
168
  if (CloudCannon.isAPICollection(this.value)) {
160
169
  value = await this.value.items();
161
170
  } else if (CloudCannon.isAPIDataset(this.value)) {
@@ -164,14 +173,57 @@ export default class EditableArray extends Editable {
164
173
  value = items;
165
174
  } else {
166
175
  const data = await items.data.get();
176
+ __base_context.file = items;
167
177
  value = Array.isArray(data) ? data : [];
168
178
  }
179
+ } else if (CloudCannon.isAPIFile(this.value)) {
180
+ const data = await this.value.data.get();
181
+ __base_context.file = this.value;
182
+ value = Array.isArray(data) ? data : [];
169
183
  } else if (Array.isArray(this.value)) {
170
184
  value = this.value;
171
185
  } else {
172
186
  value = [];
173
187
  }
174
188
 
189
+ const templates: {
190
+ keyed: Record<
191
+ string,
192
+ HTMLElement & {
193
+ editable?: EditableArrayItem;
194
+ }
195
+ >;
196
+ unkeyed?: HTMLElement & {
197
+ editable?: EditableArrayItem;
198
+ };
199
+ } = { keyed: {} };
200
+
201
+ for (let i = 0; i < this.element.children.length; i += 1) {
202
+ const childEl = this.element.children[i];
203
+ if (
204
+ childEl instanceof HTMLTemplateElement &&
205
+ typeof childEl.dataset.cloudcannonIgnore !== "string"
206
+ ) {
207
+ const key = childEl.dataset.id;
208
+ const content = childEl.content;
209
+ let templateEl: HTMLElement;
210
+ if (content.childElementCount === 1) {
211
+ templateEl = content.children[0] as HTMLElement;
212
+ } else {
213
+ templateEl = document.createElement(
214
+ "editable-array-item",
215
+ ) as HTMLElement;
216
+ templateEl.append(content.cloneNode(true));
217
+ }
218
+
219
+ if (typeof key === "string") {
220
+ templates.keyed[key] = templateEl;
221
+ } else {
222
+ templates.unkeyed = templateEl;
223
+ }
224
+ }
225
+ }
226
+
175
227
  const children: (HTMLElement & { editable?: EditableArrayItem })[] = [];
176
228
 
177
229
  for (const child of this.element.querySelectorAll(
@@ -192,7 +244,9 @@ export default class EditableArray extends Editable {
192
244
  if (
193
245
  children.length === 0 &&
194
246
  !this.element.dataset.component &&
195
- !this.element.dataset.componentKey
247
+ !this.element.dataset.componentKey &&
248
+ !templates.unkeyed &&
249
+ Object.keys(templates.keyed).length === 0
196
250
  ) {
197
251
  const error = document.createElement("editable-region-error-card");
198
252
  error.setAttribute("heading", "Failed to render array editable region");
@@ -204,7 +258,9 @@ export default class EditableArray extends Editable {
204
258
  return;
205
259
  }
206
260
 
207
- if (!this.element.dataset.idKey) {
261
+ const key = this.element.dataset.idKey ?? this.element.dataset.componentKey;
262
+
263
+ if (!key) {
208
264
  while (children.length > value.length) {
209
265
  children.pop()?.remove();
210
266
  }
@@ -219,7 +275,12 @@ export default class EditableArray extends Editable {
219
275
  for (let i = 0; i < value.length; i++) {
220
276
  let child = children[i];
221
277
  if (!child) {
222
- if (this.element.dataset.component) {
278
+ if (templates.unkeyed) {
279
+ child = templates.unkeyed.cloneNode(true) as HTMLElement & {
280
+ editable?: EditableArrayItem;
281
+ };
282
+ child.dataset.editable = "array-item";
283
+ } else if (this.element.dataset.component) {
223
284
  child = document.createElement(
224
285
  "editable-array-item",
225
286
  ) as EditableArrayItemComponent;
@@ -240,13 +301,12 @@ export default class EditableArray extends Editable {
240
301
  child.editable?.pushValue(
241
302
  value,
242
303
  { path: `${i}`, editable: child.editable },
243
- { __base_context: this.contextBase ?? {} },
304
+ { __base_context },
244
305
  );
245
306
  }
246
307
  return;
247
308
  }
248
309
 
249
- const key = this.element.dataset.idKey;
250
310
  const componentKey = this.element.dataset.componentKey;
251
311
  const dataKeys: (string | null)[] = [];
252
312
  const componentKeys: (string | null)[] = [];
@@ -273,18 +333,26 @@ export default class EditableArray extends Editable {
273
333
  }
274
334
 
275
335
  const childKeys = children.map((element) => {
276
- return element.dataset.id;
336
+ return element.dataset.id ?? element.dataset.component;
277
337
  });
278
338
 
279
339
  const equal = dataKeys?.every((key, i) => key === childKeys[i]);
280
340
  if (equal && children.length === dataKeys?.length) {
281
341
  children.forEach((child, index) => {
342
+ const componentKey =
343
+ componentKeys[index] || this.element.dataset.component;
344
+
345
+ child.dataset.id = String(childKeys[index]);
282
346
  child.dataset.prop = `${index}`;
283
347
  child.dataset.length = `${children.length}`;
348
+ if (componentKey) {
349
+ child.dataset.component = componentKey;
350
+ }
351
+
284
352
  child.editable?.pushValue(
285
353
  value,
286
354
  { path: `${index}`, editable: child.editable },
287
- { __base_context: this.contextBase ?? {} },
355
+ { __base_context },
288
356
  );
289
357
  });
290
358
 
@@ -310,6 +378,7 @@ export default class EditableArray extends Editable {
310
378
  }
311
379
  const placeholder = placeholders[i];
312
380
  const existingElement = children[i];
381
+ const templateElement = templates.keyed[key] ?? templates.unkeyed;
313
382
  const componentKey = componentKeys[i] || this.element.dataset.component;
314
383
 
315
384
  const matchingChildIndex = children.findIndex(
@@ -319,18 +388,19 @@ export default class EditableArray extends Editable {
319
388
  let matchingChild = children[matchingChildIndex];
320
389
  if (!matchingChild) {
321
390
  const clone = children.find((child) => child.dataset.id === key);
322
- if (clone) {
323
- matchingChild = clone.cloneNode(true) as any;
391
+ if (templateElement) {
392
+ matchingChild = templateElement.cloneNode(true) as any;
393
+ matchingChild.dataset.editable = "array-item";
324
394
  } else if (componentKey) {
325
395
  matchingChild = document.createElement(
326
396
  "editable-array-item",
327
397
  ) as EditableArrayItemComponent;
328
- matchingChild.dataset.id = key;
329
- matchingChild.dataset.component = componentKey;
398
+ } else if (clone) {
399
+ matchingChild = clone.cloneNode(true) as any;
330
400
  } else {
331
401
  const error = document.createElement("editable-region-error-card");
332
402
  error.setAttribute("heading", "Failed to render array item");
333
- if (typeof this.element.dataset.componentKey === "string") {
403
+ if (typeof componentKey === "string") {
334
404
  error.setAttribute(
335
405
  "message",
336
406
  "Array editable region has no child with a matching 'data-id' value for this element and the value has no key matching the 'data-component-key' attribute. Please check that the 'data-component-key' attribute for this element is correct and that each element has an entry for that key, or provide a fallback 'data-component' attribute.",
@@ -338,7 +408,7 @@ export default class EditableArray extends Editable {
338
408
  error.setAttribute(
339
409
  "hint",
340
410
  `This may mean that the value for 'data-component-key' is incorrect or that your array data is incorrectly formatted.
341
- The current value for 'data-component-key' is '${this.element.dataset.componentKey}' and the current value for 'data-id' is '${key}'.
411
+ The current value for 'data-component-key' is '${componentKey}' and the current value for 'data-id' is '${key}'.
342
412
  `,
343
413
  );
344
414
  } else {
@@ -360,6 +430,11 @@ export default class EditableArray extends Editable {
360
430
 
361
431
  matchingChild.dataset.id = String(key);
362
432
  matchingChild.dataset.prop = `${i}`;
433
+ matchingChild.dataset.length = `${dataKeys.length}`;
434
+
435
+ if (componentKey) {
436
+ matchingChild.dataset.component = componentKey;
437
+ }
363
438
 
364
439
  if (existingElement === matchingChild) {
365
440
  placeholder.remove();
@@ -374,7 +449,7 @@ export default class EditableArray extends Editable {
374
449
  matchingChild.editable.pushValue(
375
450
  value,
376
451
  { path: `${i}`, editable: matchingChild.editable },
377
- { __base_context: this.contextBase ?? {} },
452
+ { __base_context },
378
453
  );
379
454
  }
380
455
  });
@@ -12,7 +12,7 @@ import type EditableComponentControls from "../components/ui/editable-component-
12
12
  import {
13
13
  getEditableComponentRenderers,
14
14
  realizeAPIValue,
15
- } from "../helpers/cloudcannon.js";
15
+ } from "../helpers/cloudcannon.mjs";
16
16
 
17
17
  export default class EditableComponent extends Editable {
18
18
  protected controlsElement?: EditableComponentControls;
@@ -87,7 +87,7 @@ export default class EditableComponent extends Editable {
87
87
 
88
88
  let rootEl: HTMLElement;
89
89
  try {
90
- rootEl = await component(this.value);
90
+ rootEl = await component(value);
91
91
  } catch (err: unknown) {
92
92
  this.element.classList.add("errored");
93
93
  const error = document.createElement("editable-region-error-card");
@@ -332,7 +332,7 @@ export default class EditableComponent extends Editable {
332
332
  this.controlsElement = document.createElement(
333
333
  "editable-component-controls",
334
334
  );
335
- this.controlsElement.addEventListener("edit", (e: any) => {
335
+ this.controlsElement.addEventListener("edit", (_e: any) => {
336
336
  this.dispatchEdit(editPath);
337
337
  });
338
338
  this.element.append(this.controlsElement);
@@ -1,4 +1,4 @@
1
- import { CloudCannon } from "../helpers/cloudcannon.js";
1
+ import { CloudCannon } from "../helpers/cloudcannon.mjs";
2
2
  import Editable from "./editable.js";
3
3
 
4
4
  export default class EditableImage extends Editable {
@@ -185,7 +185,9 @@ export default class EditableImage extends Editable {
185
185
  !!this.element.dataset.propTitle || !!this.element.dataset.prop;
186
186
 
187
187
  this.loadInputConfig().then(() => {
188
- this.imageEl?.addEventListener("click", () => {
188
+ this.imageEl?.addEventListener("click", (e) => {
189
+ e.preventDefault();
190
+
189
191
  if (!this.value) {
190
192
  throw new Error("Value is not defined");
191
193
  }
@@ -1,6 +1,6 @@
1
- import { getEditableSnippetRenderers } from "../helpers/cloudcannon.js";
2
- import EditableComponent from "./editable-component.js";
1
+ import { getEditableSnippetRenderers } from "../helpers/cloudcannon.mjs";
3
2
  import Editable from "./editable.js";
3
+ import EditableComponent from "./editable-component.js";
4
4
 
5
5
  export default class EditableSnippet extends EditableComponent {
6
6
  getComponents() {
@@ -1,6 +1,6 @@
1
1
  import type { CloudCannonJavaScriptV1APIFile } from "@cloudcannon/javascript-api";
2
2
  import { html as beautifyHtml } from "js-beautify";
3
- import { CloudCannon } from "../helpers/cloudcannon.js";
3
+ import { CloudCannon } from "../helpers/cloudcannon.mjs";
4
4
  import EditableText from "./editable-text.js";
5
5
 
6
6
  const INDENTATION_REGEX = /^([ \t]+)[^\s]/gm;
@@ -1,4 +1,4 @@
1
- import { CloudCannon } from "../helpers/cloudcannon.js";
1
+ import { CloudCannon } from "../helpers/cloudcannon.mjs";
2
2
  import Editable from "./editable.js";
3
3
 
4
4
  type EditableFocusEvent = CustomEvent<number>;
@@ -7,7 +7,8 @@ export default class EditableText extends Editable {
7
7
  editor?: any;
8
8
  focused = false;
9
9
  focusIndex = 0;
10
- value: string | null | undefined;
10
+
11
+ declare value: string | null | undefined;
11
12
 
12
13
  validateConfiguration(): boolean {
13
14
  const prop = this.element.dataset.prop;
@@ -80,6 +81,8 @@ export default class EditableText extends Editable {
80
81
  }
81
82
 
82
83
  mount(): void {
84
+ this.element.addEventListener("click", (e) => e.preventDefault());
85
+
83
86
  this.element.addEventListener("blur", () => {
84
87
  this.focused = false;
85
88
  this.element.dispatchEvent(
package/nodes/editable.ts CHANGED
@@ -4,7 +4,7 @@ import type {
4
4
  CloudCannonJavaScriptV1APIFile,
5
5
  } from "@cloudcannon/javascript-api";
6
6
  import { hasEditable } from "../helpers/checks";
7
- import { CloudCannon } from "../helpers/cloudcannon";
7
+ import { CloudCannon } from "../helpers/cloudcannon.mjs";
8
8
  import { loadingPromise } from "../helpers/loading";
9
9
 
10
10
  export interface EditableListener {
@@ -88,14 +88,16 @@ export default class Editable {
88
88
  }
89
89
 
90
90
  async lookupPathAndContext(
91
- path: string,
92
- obj: unknown,
91
+ path?: string,
92
+ obj?: unknown,
93
93
  contexts: { [key: string]: EditableContext } = {},
94
94
  ): Promise<{ value: any; context: EditableContext }> {
95
95
  if (!path) {
96
96
  return {
97
97
  value: obj,
98
- context: {},
98
+ context: {
99
+ ...contexts.__base_context,
100
+ },
99
101
  };
100
102
  }
101
103
 
@@ -166,9 +168,9 @@ export default class Editable {
166
168
  ): Promise<unknown> {
167
169
  const { key, path } = listener ?? {};
168
170
 
169
- const { value: resolvedValue, context: newContext } = path
170
- ? await this.lookupPathAndContext(path, value, contexts)
171
- : { value, context: {} };
171
+ const { value: resolvedValue, context: newContext } =
172
+ await this.lookupPathAndContext(path, value, contexts);
173
+
172
174
  if (!key) {
173
175
  this.propsBase = resolvedValue;
174
176
  this.contextBase = newContext;
@@ -317,9 +319,12 @@ export default class Editable {
317
319
  let parentEditable: Editable | undefined;
318
320
  let parent = this.element.parentElement;
319
321
  while (parent) {
320
- if (hasEditable(parent)) {
322
+ if (hasEditable(parent) && !parentEditable) {
321
323
  parentEditable = parent.editable;
322
- break;
324
+ }
325
+
326
+ if (parent.tagName === "A") {
327
+ parent.draggable = false;
323
328
  }
324
329
  parent = parent.parentElement;
325
330
  }
@@ -331,7 +336,7 @@ export default class Editable {
331
336
  return;
332
337
  }
333
338
 
334
- const { collection, file, dataset, source, absolute } =
339
+ const { collection, file, dataset, source, absolute, currentFile } =
335
340
  this.parseSource(propPath);
336
341
 
337
342
  const listener = {
@@ -349,8 +354,15 @@ export default class Editable {
349
354
  // Any single data path should only be able to refer to a single absolute API object
350
355
  const obj = collection || dataset || file;
351
356
  if (obj) {
357
+ const fullPath = collection
358
+ ? `@collections[${collection.collectionKey}]`
359
+ : dataset
360
+ ? `@data[${dataset.datasetKey}]`
361
+ : currentFile
362
+ ? undefined
363
+ : `@file[${file?.path}]`;
352
364
  const handleAPIChange = () => {
353
- this.pushValue(obj, listener);
365
+ this.pushValue(obj, listener, { __base_context: { fullPath } });
354
366
  };
355
367
  this.APIListeners.push({
356
368
  obj,
@@ -425,22 +437,6 @@ export default class Editable {
425
437
  }
426
438
 
427
439
  filePromise.then((file) => {
428
- if (typeof source !== "string") {
429
- if (options.action === "get-input-config") {
430
- options.callback({
431
- options: {
432
- disable_reorder: true,
433
- disable_remove: true,
434
- disable_add: true,
435
- },
436
- });
437
- return true;
438
- }
439
- throw new Error(
440
- `Failed to resolve source for API call: ${options.source}`,
441
- );
442
- }
443
-
444
440
  switch (options.action) {
445
441
  case "edit":
446
442
  file?.data.edit({ slug: source, position: options.position });
@@ -474,7 +470,17 @@ export default class Editable {
474
470
  });
475
471
  break;
476
472
  case "get-input-config":
477
- file?.getInputConfig({ slug: source }).then(options.callback);
473
+ if (!file) {
474
+ options.callback({
475
+ options: {
476
+ disable_reorder: true,
477
+ disable_remove: true,
478
+ disable_add: true,
479
+ },
480
+ });
481
+ } else {
482
+ file?.getInputConfig({ slug: source }).then(options.callback);
483
+ }
478
484
  break;
479
485
  }
480
486
  });
@@ -516,40 +522,58 @@ export default class Editable {
516
522
  });
517
523
  }
518
524
 
525
+ matchSourcePart(type: "collections" | "data" | "file", source?: string) {
526
+ if (!source) {
527
+ return;
528
+ }
529
+
530
+ if (!source.startsWith(`@${type}`)) {
531
+ return;
532
+ }
533
+
534
+ const argsPart = source.slice(type.length + 1);
535
+ const brackets = argsPart.match(/^\[+/);
536
+ if (!brackets) {
537
+ return;
538
+ }
539
+ const bracketsLength = brackets[0].length;
540
+ return argsPart.match(
541
+ new RegExp(
542
+ `^\\[{${bracketsLength}}(?<key>.+?)\\]{${bracketsLength}}(\\.(?<rest>.+))?$`,
543
+ ),
544
+ )?.groups;
545
+ }
546
+
519
547
  parseSource(source?: string) {
520
548
  let collection: CloudCannonJavaScriptV1APICollection | undefined;
521
549
  let file: CloudCannonJavaScriptV1APIFile | undefined;
522
550
  let dataset: CloudCannonJavaScriptV1APIDataset | undefined;
523
551
  let absolute = false;
552
+ let currentFile = false;
524
553
 
525
- const collectionMatch = source?.match(
526
- /^@collections\[(?<key>[^\]]+)\](\.(?<rest>.+))?$/,
527
- );
528
- if (collectionMatch?.groups) {
529
- const { key, rest } = collectionMatch.groups;
554
+ const collectionMatch = this.matchSourcePart("collections", source);
555
+ if (collectionMatch) {
556
+ const { key, rest } = collectionMatch;
530
557
  collection = CloudCannon.collection(key);
531
558
  source = rest;
532
559
  absolute = true;
533
560
  } else {
534
- const fileMatch = source?.match(
535
- /^@file\[(?<path>[^\]]+)\]\.(?<rest>.+)$/,
536
- );
537
- if (fileMatch?.groups) {
538
- const { path, rest } = fileMatch.groups;
539
- file = CloudCannon.file(path);
561
+ const fileMatch = this.matchSourcePart("file", source);
562
+ if (fileMatch) {
563
+ const { key, rest } = fileMatch;
564
+ file = CloudCannon.file(key);
540
565
  source = rest;
541
566
  absolute = true;
542
567
  } else {
543
- const dataMatch = source?.match(
544
- /^@data\[(?<key>[^\]]+)\](\.(?<rest>.+))?$/,
545
- );
546
- if (dataMatch?.groups) {
547
- const { key, rest } = dataMatch.groups;
568
+ const dataMatch = this.matchSourcePart("data", source);
569
+ if (dataMatch) {
570
+ const { key, rest } = dataMatch;
548
571
  dataset = CloudCannon.dataset(key);
549
572
  source = rest;
550
573
  absolute = true;
551
574
  } else {
552
575
  file = CloudCannon.currentFile();
576
+ currentFile = true;
553
577
  }
554
578
  }
555
579
  }
@@ -566,10 +590,11 @@ export default class Editable {
566
590
  return {
567
591
  collection,
568
592
  file,
569
- source,
593
+ source: source ?? "",
570
594
  absolute,
571
595
  snippets,
572
596
  dataset,
597
+ currentFile,
573
598
  };
574
599
  }
575
600
  }
package/nodes/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { default as Editable } from "./editable.js";
2
2
  export { default as EditableArray } from "./editable-array.js";
3
3
  export { default as EditableArrayItem } from "./editable-array-item.js";
4
- export { default as EditableText } from "./editable-text.js";
5
4
  export { default as EditableComponent } from "./editable-component.js";
6
5
  export { default as EditableImage } from "./editable-image.js";
7
- export { default as EditableSource } from "./editable-source.js";
8
6
  export { default as EditableSnippet } from "./editable-snippet.js";
7
+ export { default as EditableSource } from "./editable-source.js";
8
+ export { default as EditableText } from "./editable-text.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcannon/editable-regions",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "description": "Visual Editing for the CloudCannon CMS.",
6
6
  "keywords": [
@@ -53,10 +53,10 @@
53
53
  "./internal/styles": "./styles/index.js"
54
54
  },
55
55
  "devDependencies": {
56
- "@biomejs/biome": "1.9.4",
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": "18.3.12",
59
+ "@types/react": "19.2.6",
60
60
  "@types/react-dom": "18.3.1",
61
61
  "astro": "^5.14.1",
62
62
  "js-beautify": "^1.15.4",
@@ -1,5 +1,12 @@
1
1
  editable-array-item {
2
2
  display: block;
3
+ /*
4
+ In Chrome 143.0.7499.41 setting display: block sometimes causes text elements to not be rendered.
5
+ But in Firefox, not setting display: block causes the positioning of the controls element to be wrong.
6
+ If you want to remove this line (either because Chrome has fixed it or because you're fixing the positioning logic)
7
+ make sure you test against [this ticket](https://www.notion.so/cloudcannon/Text-elements-in-editable-regions-in-Chrome-sometimes-don-t-render-2beb2cf63b238067bdbdfc5efb2b2ead).
8
+ */
9
+ will-change: contents;
3
10
  }
4
11
 
5
12
  editable-array-item,
@@ -1,5 +1,12 @@
1
1
  editable-component {
2
2
  display: block;
3
+ /*
4
+ In Chrome 143.0.7499.41 setting display: block sometimes causes text elements to not be rendered.
5
+ But in Firefox, not setting display: block causes the positioning of the controls element to be wrong.
6
+ If you want to remove this line (either because Chrome has fixed it or because you're fixing the positioning logic)
7
+ make sure you test against [this ticket](https://www.notion.so/cloudcannon/Text-elements-in-editable-regions-in-Chrome-sometimes-don-t-render-2beb2cf63b238067bdbdfc5efb2b2ead).
8
+ */
9
+ will-change: contents;
3
10
  }
4
11
 
5
12
  editable-component,
@@ -10,6 +10,30 @@
10
10
  align-items: end;
11
11
  }
12
12
 
13
+ button {
14
+ background-color: transparent;
15
+ border: 0;
16
+ padding: 0;
17
+
18
+ border-radius: var(--ccve-border-radius);
19
+ cursor: pointer;
20
+
21
+ &[draggable="true"] {
22
+ cursor: grab;
23
+ }
24
+
25
+ &:disabled {
26
+ color: #979797;
27
+ --cc-icon-fill: #979797;
28
+ cursor: not-allowed;
29
+
30
+ &:hover {
31
+ background-color: transparent;
32
+ --cc-icon-fill: #979797;
33
+ }
34
+ }
35
+ }
36
+
13
37
  .button-row {
14
38
  display: flex;
15
39
  background: #fff;
@@ -40,7 +64,6 @@
40
64
  padding: 10px 6px;
41
65
  margin: 4px;
42
66
  width: 154px;
43
- border: 1px solid #eee;
44
67
  box-shadow: 0 0 16px rgba(0, 0, 0, 0.1);
45
68
  border: 2px solid #cfcfcf;
46
69
  display: none;
@@ -67,30 +90,6 @@
67
90
  }
68
91
  }
69
92
 
70
- button {
71
- background-color: transparent;
72
- border: 0;
73
- padding: 0;
74
-
75
- border-radius: var(--ccve-border-radius);
76
- cursor: pointer;
77
-
78
- &[draggable="true"] {
79
- cursor: grab;
80
- }
81
-
82
- &:disabled {
83
- color: #979797;
84
- --cc-icon-fill: #979797;
85
- cursor: not-allowed;
86
-
87
- &:hover {
88
- background-color: transparent;
89
- --cc-icon-fill: #979797;
90
- }
91
- }
92
- }
93
-
94
93
  @keyframes fade-in-up {
95
94
  from {
96
95
  opacity: 0.5;
package/types/react.d.ts CHANGED
@@ -1,5 +1,49 @@
1
1
  /// <reference path="./cloudcannon.d.ts" />
2
2
 
3
- declare module "@cloudcannon/editable-regions/react" {
4
- export function registerReactComponent(key: string, component: unknown): void;
3
+ import type { HTMLAttributes, RefAttributes } from "react";
4
+
5
+ export function registerReactComponent(key: string, component: unknown): void;
6
+
7
+ declare global {
8
+ namespace React.JSX {
9
+ interface IntrinsicElements {
10
+ "editable-component": RefAttributes<HTMLElement> &
11
+ Omit<HTMLAttributes<HTMLElement>, "className"> & {
12
+ class?: string;
13
+ "data-prop": string;
14
+ "data-component": string;
15
+ };
16
+ "editable-text": RefAttributes<HTMLElement> &
17
+ Omit<HTMLAttributes<HTMLElement>, "className"> & {
18
+ class?: string;
19
+ "data-prop": string;
20
+ "data-type"?: "block" | "text" | "span";
21
+ };
22
+ "editable-source": RefAttributes<HTMLElement> &
23
+ Omit<HTMLAttributes<HTMLElement>, "className"> & {
24
+ class?: string;
25
+ "data-path": string;
26
+ "data-key": string;
27
+ };
28
+ "editable-array": RefAttributes<HTMLElement> &
29
+ Omit<HTMLAttributes<HTMLElement>, "className"> & {
30
+ class?: string;
31
+ "data-prop": string;
32
+ "data-id-key"?: string;
33
+ "data-component-key"?: string;
34
+ "data-component"?: string;
35
+ "data-direction"?:
36
+ | "column"
37
+ | "row"
38
+ | "column-reverse"
39
+ | "row-reverse";
40
+ };
41
+ "editable-array-item": RefAttributes<HTMLElement> &
42
+ Omit<HTMLAttributes<HTMLElement>, "className"> & {
43
+ class?: string;
44
+ "data-id"?: string;
45
+ "data-component"?: string;
46
+ };
47
+ }
48
+ }
5
49
  }
@@ -1,74 +0,0 @@
1
- import type {
2
- CloudCannonEditorWindow,
3
- CloudCannonJavaScriptV1API,
4
- } from "@cloudcannon/javascript-api";
5
-
6
- export type ComponentRenderer = (
7
- props: any,
8
- ) => HTMLElement | Promise<HTMLElement>;
9
-
10
- declare const window: CloudCannonEditorWindow & {
11
- cc_components?: Record<string, ComponentRenderer>;
12
- cc_snippets?: Record<string, ComponentRenderer>;
13
- };
14
-
15
- let _cloudcannon: CloudCannonJavaScriptV1API;
16
-
17
- export const apiLoadedPromise = new Promise<void>((resolve) => {
18
- if (window.CloudCannonAPI) {
19
- _cloudcannon = window.CloudCannonAPI.useVersion("v1", true) as any;
20
- resolve();
21
- } else {
22
- document.addEventListener(
23
- "cloudcannon:load",
24
- () => {
25
- if (window.CloudCannonAPI) {
26
- _cloudcannon = window.CloudCannonAPI.useVersion("v1", true) as any;
27
- }
28
- return resolve();
29
- },
30
- { once: true },
31
- );
32
- }
33
- });
34
-
35
- export const addEditableComponentRenderer = (
36
- key: string,
37
- renderer: ComponentRenderer,
38
- ) => {
39
- window.cc_components = window.cc_components || {};
40
- window.cc_components[key] = renderer;
41
- document.dispatchEvent(new CustomEvent(`editable-regions:registered-${key}`));
42
- };
43
-
44
- export const addEditableSnippetRenderer = (
45
- key: string,
46
- renderer: ComponentRenderer,
47
- ) => {
48
- window.cc_snippets = window.cc_snippets || {};
49
- window.cc_snippets[key] = renderer;
50
- document.dispatchEvent(new CustomEvent(`editable-regions:registered-${key}`));
51
- };
52
-
53
- export const getEditableComponentRenderers = () => window.cc_components ?? {};
54
- export const getEditableSnippetRenderers = () => window.cc_snippets ?? {};
55
-
56
- export const realizeAPIValue = async (value: unknown): Promise<unknown> => {
57
- if (_cloudcannon.isAPICollection(value)) {
58
- const items = await value.items();
59
- return Promise.all(items.map(realizeAPIValue));
60
- }
61
- if (_cloudcannon.isAPIFile(value)) {
62
- return value.data.get();
63
- }
64
- if (_cloudcannon.isAPIDataset(value)) {
65
- const items = await value.items();
66
- if (Array.isArray(items)) {
67
- return Promise.all(items.map(realizeAPIValue));
68
- }
69
- return items.data.get();
70
- }
71
- return value;
72
- };
73
-
74
- export { _cloudcannon as CloudCannon };