@cloudcannon/editable-regions 0.0.20-rc.3 → 0.0.20

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.
@@ -14,24 +14,26 @@
14
14
  * }} ExtendedWindow
15
15
  */
16
16
 
17
- /** @type {ExtendedWindow} */
18
- const extendedWindow = /** @type {any} */ (window);
17
+ /** @type {ExtendedWindow | undefined} */
18
+ const extendedWindow = /** @type {any} */ (
19
+ typeof window === "undefined" ? undefined : window
20
+ );
19
21
 
20
- /** @type {CloudCannonVisualEditorAPIV1} */
22
+ /** @type {CloudCannonVisualEditorAPIV1 | undefined} */
21
23
  let _cloudcannon;
22
24
 
23
25
  /** @type {Promise<void>} */
24
26
  export const apiLoadedPromise = new Promise((resolve) => {
25
- if (extendedWindow.CloudCannonAPI) {
27
+ if (extendedWindow?.CloudCannonAPI) {
26
28
  _cloudcannon = /** @type {any} */ (
27
29
  extendedWindow.CloudCannonAPI.useVersion("v1", true)
28
30
  );
29
31
  resolve();
30
- } else {
32
+ } else if (typeof document !== "undefined") {
31
33
  document.addEventListener(
32
34
  "cloudcannon:load",
33
35
  () => {
34
- if (extendedWindow.CloudCannonAPI) {
36
+ if (extendedWindow?.CloudCannonAPI) {
35
37
  _cloudcannon = /** @type {any} */ (
36
38
  extendedWindow.CloudCannonAPI.useVersion("v1", true)
37
39
  );
@@ -48,6 +50,9 @@ export const apiLoadedPromise = new Promise((resolve) => {
48
50
  * @param {ComponentRenderer} renderer
49
51
  */
50
52
  export const addEditableComponentRenderer = (key, renderer) => {
53
+ if (!extendedWindow) {
54
+ return;
55
+ }
51
56
  extendedWindow.cc_components = extendedWindow.cc_components || {};
52
57
  extendedWindow.cc_components[key] = renderer;
53
58
  };
@@ -57,15 +62,18 @@ export const addEditableComponentRenderer = (key, renderer) => {
57
62
  * @param {ComponentRenderer} renderer
58
63
  */
59
64
  export const addEditableSnippetRenderer = (key, renderer) => {
65
+ if (!extendedWindow) {
66
+ return;
67
+ }
60
68
  extendedWindow.cc_snippets = extendedWindow.cc_snippets || {};
61
69
  extendedWindow.cc_snippets[key] = renderer;
62
70
  };
63
71
 
64
72
  export const getEditableComponentRenderers = () =>
65
- extendedWindow.cc_components ?? {};
73
+ extendedWindow?.cc_components ?? {};
66
74
 
67
75
  export const getEditableSnippetRenderers = () =>
68
- extendedWindow.cc_snippets ?? {};
76
+ extendedWindow?.cc_snippets ?? {};
69
77
 
70
78
  /**
71
79
  * Resolves CloudCannon API objects (collections, files, datasets) to plain data.
@@ -91,13 +99,16 @@ export const realizeAPIValue = async (value) => {
91
99
  };
92
100
 
93
101
  export const addCustomEditableRegion = (key, region) => {
102
+ if (!extendedWindow || typeof document === "undefined") {
103
+ return;
104
+ }
94
105
  extendedWindow.editableRegionMap ??= {};
95
106
  extendedWindow.editableRegionMap[key] = region;
96
107
  extendedWindow.hydrateDataEditableRegions(document.body);
97
108
  };
98
109
 
99
110
  export const getCustomEditableRegions = () => {
100
- return extendedWindow.editableRegionMap ?? {};
111
+ return extendedWindow?.editableRegionMap ?? {};
101
112
  };
102
113
 
103
114
  export { _cloudcannon as CloudCannon };
@@ -10,6 +10,8 @@ import {
10
10
  import { hasEditable } from "../nodes/editable.js";
11
11
  import { isEditableWebcomponent } from "./checks";
12
12
 
13
+ const DYNAMIC_EDITABLE_TYPE = "_dynamic";
14
+
13
15
  const baseEditableMap: Record<string, typeof Editable | undefined> = {
14
16
  array: EditableArray,
15
17
  "array-item": EditableArrayItem,
@@ -20,12 +22,23 @@ const baseEditableMap: Record<string, typeof Editable | undefined> = {
20
22
  };
21
23
 
22
24
  export const dehydrateDataEditableRegions = (root: Element) => {
23
- if (root instanceof HTMLElement && hasEditable(root)) {
25
+ if (
26
+ root instanceof HTMLElement &&
27
+ hasEditable(root) &&
28
+ root.dataset.editable !== DYNAMIC_EDITABLE_TYPE
29
+ ) {
24
30
  root.editable.disconnect();
25
31
  }
26
32
 
27
33
  root.querySelectorAll("[data-editable]").forEach((element) => {
28
- if (element instanceof HTMLElement && hasEditable(element)) {
34
+ if (
35
+ !(element instanceof HTMLElement) ||
36
+ element.dataset.editable === DYNAMIC_EDITABLE_TYPE
37
+ ) {
38
+ return;
39
+ }
40
+
41
+ if (hasEditable(element)) {
29
42
  element.editable.disconnect();
30
43
  }
31
44
  });
@@ -39,6 +52,7 @@ export const hydrateDataEditableRegions = (root: Element) => {
39
52
  if (
40
53
  root instanceof HTMLElement &&
41
54
  root.dataset.editable &&
55
+ root.dataset.editable !== DYNAMIC_EDITABLE_TYPE &&
42
56
  !isEditableWebcomponent(root)
43
57
  ) {
44
58
  if ("editable" in root && root.editable instanceof Editable) {
@@ -64,6 +78,10 @@ export const hydrateDataEditableRegions = (root: Element) => {
64
78
  return;
65
79
  }
66
80
 
81
+ if (element.dataset.editable === DYNAMIC_EDITABLE_TYPE) {
82
+ return;
83
+ }
84
+
67
85
  const Editable = editableMap[element.dataset.editable];
68
86
  if (!Editable) {
69
87
  const error = document.createElement("editable-region-error-card");
@@ -95,4 +113,19 @@ export const hydrateDataEditableRegions = (root: Element) => {
95
113
  });
96
114
  };
97
115
 
98
- (window as any).hydrateDataEditableRegions = hydrateDataEditableRegions;
116
+ const extendedWindow = /** @type {any} */ (window);
117
+
118
+ Object.assign(extendedWindow, {
119
+ editableRegions: {
120
+ Editable,
121
+ EditableArray,
122
+ EditableArrayItem,
123
+ EditableComponent,
124
+ EditableImage,
125
+ EditableSource,
126
+ EditableText,
127
+ },
128
+ hydrateDataEditableRegions,
129
+ });
130
+
131
+ document.dispatchEvent(new CustomEvent("editable-regions:load"));
@@ -43,7 +43,13 @@ export function parseHugoTemplateError(raw: string): ParsedHugoError {
43
43
  return { frames, call, message: rest };
44
44
  }
45
45
 
46
- export function enhanceHugoError(message: string, componentKey: string): Error {
46
+ /** An error enhanced with an optional editor-context hint. */
47
+ export type HugoError = Error & { hint?: string };
48
+
49
+ export function enhanceHugoError(
50
+ message: string,
51
+ componentKey: string,
52
+ ): HugoError {
47
53
  const parsed = parseHugoTemplateError(message);
48
54
 
49
55
  let hint = "";
@@ -76,7 +82,7 @@ export function enhanceHugoError(message: string, componentKey: string): Error {
76
82
 
77
83
  const error = new Error(
78
84
  `Failed to render Hugo component "${componentKey}": ${sentence}`,
79
- ) as Error & { hint?: string };
85
+ ) as HugoError;
80
86
 
81
87
  if (hint) {
82
88
  error.hint = hint;
@@ -1,4 +1,6 @@
1
- import { createElement } from "react";
1
+ "use client";
2
+
3
+ import { createElement, useEffect, useRef } from "react";
2
4
  import { flushSync } from "react-dom";
3
5
  import { createRoot } from "react-dom/client";
4
6
  import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
@@ -26,3 +28,63 @@ export const registerReactComponent = (key, component) => {
26
28
 
27
29
  addEditableComponentRenderer(key, wrappedComponent);
28
30
  };
31
+
32
+ /**
33
+ * Root editable region component for React (e.g. Next.js App Router pages).
34
+ * Keeps editable regions from mutating the DOM before frameworks like Next.js finish hydrating.
35
+ *
36
+ * @param {{ tag?: string } & Record<string, any>} props
37
+ * All other props and attributes are forwarded to the rendered element.
38
+ */
39
+ export const EditableRegions = ({ tag: Tag = "div", ...attrs }) => {
40
+ const elementRef = useRef(null);
41
+ /** @type {{ current: any | null }} */
42
+ const editableRef = useRef(null);
43
+
44
+ useEffect(() => {
45
+ /** @param {any} regions */
46
+ const connectEditable = (regions) => {
47
+ if (!regions || editableRef.current) {
48
+ return;
49
+ }
50
+
51
+ editableRef.current = new regions.Editable(elementRef.current);
52
+ editableRef.current.connect();
53
+ };
54
+
55
+ const onLoad = () => {
56
+ connectEditable(/** @type {any} */ (window).editableRegions);
57
+ };
58
+
59
+ if (/** @type {any} */ (window).editableRegions) {
60
+ connectEditable(/** @type {any} */ (window).editableRegions);
61
+ } else {
62
+ document.addEventListener("editable-regions:load", onLoad, {
63
+ once: true,
64
+ });
65
+ }
66
+
67
+ return () => {
68
+ document.removeEventListener("editable-regions:load", onLoad);
69
+ editableRef.current?.disconnect();
70
+ editableRef.current = null;
71
+ };
72
+ }, []);
73
+
74
+ /** @type {Record<string, any>} */
75
+ const forwarded = {};
76
+ for (const [name, value] of Object.entries(attrs)) {
77
+ if (/^data-(prop|literal)/i.test(name)) {
78
+ continue;
79
+ }
80
+
81
+ forwarded[name] = value;
82
+ }
83
+
84
+ return createElement(Tag, {
85
+ ...forwarded,
86
+ ref: elementRef,
87
+ "data-editable": "_dynamic",
88
+ "data-prop": "",
89
+ });
90
+ };
@@ -0,0 +1,80 @@
1
+ <script>
2
+ import { onDestroy, onMount } from "svelte";
3
+
4
+ // Element type to render. Defaults to "div".
5
+ /** @type {string | undefined} */
6
+ export let tag = "div";
7
+
8
+ /** @type {any} */
9
+ let editable = null;
10
+ /** @type {HTMLElement | undefined} */
11
+ let element;
12
+ /** @type {(() => void) | null} */
13
+ let pendingLoadHandler = null;
14
+
15
+ // data-prop and data-literal attributes are controlled by this component
16
+ // itself; user-supplied ones are ignored.
17
+ $: restProps = filterProps($$restProps);
18
+
19
+ function filterProps(props) {
20
+ const filtered = {};
21
+ for (const [name, value] of Object.entries(props)) {
22
+ if (/^data-(prop|literal)/i.test(name)) {
23
+ continue;
24
+ }
25
+
26
+ filtered[name] = value;
27
+ }
28
+ return filtered;
29
+ }
30
+
31
+ onMount(() => {
32
+ /**
33
+ * @param {any} regions
34
+ */
35
+ const connectEditable = (regions) => {
36
+ if (!regions || editable) {
37
+ return;
38
+ }
39
+
40
+ editable = new regions.Editable(element);
41
+ editable.connect();
42
+ };
43
+
44
+ if (window.editableRegions) {
45
+ connectEditable(window.editableRegions);
46
+ } else {
47
+ pendingLoadHandler = () => {
48
+ connectEditable(window.editableRegions);
49
+ };
50
+ document.addEventListener(
51
+ "editable-regions:load",
52
+ pendingLoadHandler,
53
+ { once: true },
54
+ );
55
+ }
56
+ });
57
+
58
+ onDestroy(() => {
59
+ if (pendingLoadHandler) {
60
+ document.removeEventListener(
61
+ "editable-regions:load",
62
+ pendingLoadHandler,
63
+ );
64
+ pendingLoadHandler = null;
65
+ }
66
+
67
+ editable?.disconnect();
68
+ editable = null;
69
+ });
70
+ </script>
71
+
72
+ <svelte:element
73
+ this={tag}
74
+ {...restProps}
75
+ bind:this={element}
76
+ data-editable="_dynamic"
77
+ data-prop=""
78
+ >
79
+ <slot />
80
+ </svelte:element>
@@ -1,4 +1,6 @@
1
- import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
1
+ export { default as EditableRegions } from "./EditableRegions.svelte";
2
+
3
+ import { addEditableComponentRenderer } from "../../helpers/cloudcannon.mjs";
2
4
 
3
5
  /** @type{((component: any, args: { target: HTMLElement, props: unknown }) => void) | undefined} */
4
6
  let mount;
@@ -1,4 +1,11 @@
1
- import { createApp, h } from "vue";
1
+ import {
2
+ createApp,
3
+ defineComponent,
4
+ h,
5
+ onMounted,
6
+ onUnmounted,
7
+ ref,
8
+ } from "vue";
2
9
  import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
3
10
 
4
11
  /**
@@ -26,3 +33,92 @@ export const registerVueComponent = (key, component) => {
26
33
 
27
34
  addEditableComponentRenderer(key, wrappedComponent);
28
35
  };
36
+
37
+ /**
38
+ * Root editable region component for Vue.
39
+ * Keeps editable regions from mutating the DOM before frameworks like Nuxt finish hydrating.
40
+ *
41
+ * @param {string} [props.tag] - Element type to render. Defaults to "div".
42
+ * All other props and attributes are forwarded to the rendered element.
43
+ */
44
+ export const EditableRegions = defineComponent({
45
+ name: "EditableRegions",
46
+ props: {
47
+ tag: {
48
+ type: String,
49
+ default: "div",
50
+ },
51
+ },
52
+ inheritAttrs: false,
53
+ /**
54
+ * @param {{ tag: string }} props
55
+ * @param {{ attrs: any; slots: any }} context
56
+ */
57
+ setup(props, { attrs, slots }) {
58
+ const element = ref(null);
59
+ /** @type {any} */
60
+ let editable = null;
61
+ /** @type {(() => void) | null} */
62
+ let pendingLoadHandler = null;
63
+
64
+ /** @param {any} regions */
65
+ const connectEditable = (regions) => {
66
+ if (!regions || editable) {
67
+ return;
68
+ }
69
+
70
+ editable = new regions.Editable(element.value);
71
+ editable.connect();
72
+ };
73
+
74
+ onMounted(() => {
75
+ const regions = /** @type {any} */ (window).editableRegions;
76
+ if (regions) {
77
+ connectEditable(regions);
78
+ return;
79
+ }
80
+
81
+ pendingLoadHandler = () => {
82
+ connectEditable(/** @type {any} */ (window).editableRegions);
83
+ };
84
+ document.addEventListener("editable-regions:load", pendingLoadHandler, {
85
+ once: true,
86
+ });
87
+ });
88
+
89
+ onUnmounted(() => {
90
+ if (pendingLoadHandler) {
91
+ document.removeEventListener(
92
+ "editable-regions:load",
93
+ pendingLoadHandler,
94
+ );
95
+ pendingLoadHandler = null;
96
+ }
97
+
98
+ editable?.disconnect();
99
+ editable = null;
100
+ });
101
+
102
+ return () => {
103
+ /** @type {Record<string, any>} */
104
+ const forwarded = {};
105
+ for (const [name, value] of Object.entries(attrs)) {
106
+ if (/^data-(prop|literal)/i.test(name)) {
107
+ continue;
108
+ }
109
+ forwarded[name] = value;
110
+ }
111
+
112
+ return h(
113
+ props.tag,
114
+ {
115
+ ...forwarded,
116
+ ref: element,
117
+ "data-editable": "_dynamic",
118
+ "data-prop": "",
119
+ },
120
+ /** @type {any} */ (slots.default?.()),
121
+ );
122
+ };
123
+ },
124
+ });
@@ -18,12 +18,16 @@ export const hasEditableArrayItem = <T extends object>(
18
18
  export default class EditableArrayItem extends EditableComponent {
19
19
  parent: EditableArray | null = null;
20
20
 
21
- protected declare controlsElement?: EditableArrayItemControls;
21
+ declare protected controlsElement?: EditableArrayItemControls;
22
22
 
23
23
  private inputConfig?: any;
24
24
  private structureStrings: string[] = [];
25
25
 
26
26
  shouldMount(): boolean {
27
+ if (this.mounted) {
28
+ return false;
29
+ }
30
+
27
31
  return this.value !== undefined;
28
32
  }
29
33
 
@@ -25,13 +25,19 @@ export default class EditableComponent extends Editable {
25
25
  }
26
26
 
27
27
  shouldMount(): boolean {
28
- if (super.shouldMount()) {
29
- return true;
28
+ if (this.mounted) {
29
+ return false;
30
30
  }
31
31
 
32
- return !Object.keys(this.element.dataset).some((key) =>
33
- key.startsWith("prop"),
32
+ const hasNoProps = Object.keys(this.element.dataset).every(
33
+ (key) => !key.startsWith("prop"),
34
34
  );
35
+
36
+ if (hasNoProps) {
37
+ return true;
38
+ }
39
+
40
+ return super.shouldMount();
35
41
  }
36
42
 
37
43
  validateConfiguration(): boolean {
@@ -185,87 +185,92 @@ export default class EditableImage extends Editable {
185
185
  this.configuredTitle =
186
186
  !!this.element.dataset.propTitle || !!this.element.dataset.prop;
187
187
 
188
- this.loadInputConfig().then(() => {
189
- this.imageEl?.addEventListener("click", async (e) => {
190
- e.preventDefault();
188
+ // Loading the input config costs a round trip per configured prop, so the
189
+ // listener is attached up front and the click waits for it. Attaching after
190
+ // the load resolves instead leaves a window where clicks are dropped.
191
+ const inputConfigLoaded = this.loadInputConfig();
191
192
 
192
- if (!this.value) {
193
- throw new Error("Value is not defined");
194
- }
193
+ this.imageEl?.addEventListener("click", async (e) => {
194
+ e.preventDefault();
195
195
 
196
- const data: this["value"] = {};
197
- if ("src" in this.value) {
198
- data.src = this.value.src;
199
- }
200
- if ("alt" in this.value) {
201
- data.alt = this.value.alt;
202
- }
203
- if ("title" in this.value) {
204
- data.title = this.value.title;
205
- }
196
+ await inputConfigLoaded;
206
197
 
207
- this.panelId = await CloudCannon.createCustomDataPanel({
208
- title: "Edit Image",
209
- data,
210
- position: this.imageEl?.getBoundingClientRect(),
211
- config: {
212
- _inputs: {
213
- src: {
214
- label: "Image",
215
- type: "image",
216
- ...this.inputConfig.src,
217
- },
218
- alt: {
219
- comment:
220
- "A description which provides information about this image if for some reason it cannot be viewed.",
221
- ...this.inputConfig.alt,
222
- },
223
- title: {
224
- comment: "Displayed when hovering over the image.",
225
- ...this.inputConfig.title,
226
- },
198
+ if (!this.value) {
199
+ throw new Error("Value is not defined");
200
+ }
201
+
202
+ const data: this["value"] = {};
203
+ if ("src" in this.value) {
204
+ data.src = this.value.src;
205
+ }
206
+ if ("alt" in this.value) {
207
+ data.alt = this.value.alt;
208
+ }
209
+ if ("title" in this.value) {
210
+ data.title = this.value.title;
211
+ }
212
+
213
+ this.panelId = await CloudCannon.createCustomDataPanel({
214
+ title: "Edit Image",
215
+ data,
216
+ position: this.imageEl?.getBoundingClientRect(),
217
+ config: {
218
+ _inputs: {
219
+ src: {
220
+ label: "Image",
221
+ type: "image",
222
+ ...this.inputConfig.src,
223
+ },
224
+ alt: {
225
+ comment:
226
+ "A description which provides information about this image if for some reason it cannot be viewed.",
227
+ ...this.inputConfig.alt,
228
+ },
229
+ title: {
230
+ comment: "Displayed when hovering over the image.",
231
+ ...this.inputConfig.title,
227
232
  },
228
233
  },
229
- onChange: (value): void => {
230
- if (!value || typeof value !== "object") {
231
- throw new Error("Invalid image data");
232
- }
234
+ },
235
+ onChange: (value): void => {
236
+ if (!value || typeof value !== "object") {
237
+ throw new Error("Invalid image data");
238
+ }
233
239
 
234
- if (
235
- "src" in value &&
236
- this.configuredSrc &&
237
- value.src !== this.value?.src
238
- ) {
239
- this.dispatchSet(
240
- this.element.dataset.propSrc ??
241
- `${this.element.dataset.prop}.src`,
242
- value.src,
243
- );
244
- }
245
- if (
246
- "alt" in value &&
247
- this.configuredAlt &&
248
- value.alt !== this.value?.alt
249
- ) {
250
- this.dispatchSet(
251
- this.element.dataset.propAlt ??
252
- `${this.element.dataset.prop}.alt`,
253
- value.alt,
254
- );
255
- }
256
- if (
257
- "title" in value &&
258
- this.configuredTitle &&
259
- value.title !== this.value?.title
260
- ) {
261
- this.dispatchSet(
262
- this.element.dataset.propTitle ??
263
- `${this.element.dataset.prop}.title`,
264
- value.title,
265
- );
266
- }
267
- },
268
- });
240
+ if (
241
+ "src" in value &&
242
+ this.configuredSrc &&
243
+ value.src !== this.value?.src
244
+ ) {
245
+ this.dispatchSet(
246
+ this.element.dataset.propSrc ??
247
+ `${this.element.dataset.prop}.src`,
248
+ value.src,
249
+ );
250
+ }
251
+ if (
252
+ "alt" in value &&
253
+ this.configuredAlt &&
254
+ value.alt !== this.value?.alt
255
+ ) {
256
+ this.dispatchSet(
257
+ this.element.dataset.propAlt ??
258
+ `${this.element.dataset.prop}.alt`,
259
+ value.alt,
260
+ );
261
+ }
262
+ if (
263
+ "title" in value &&
264
+ this.configuredTitle &&
265
+ value.title !== this.value?.title
266
+ ) {
267
+ this.dispatchSet(
268
+ this.element.dataset.propTitle ??
269
+ `${this.element.dataset.prop}.title`,
270
+ value.title,
271
+ );
272
+ }
273
+ },
269
274
  });
270
275
  });
271
276
  }