@cloudcannon/editable-regions 0.0.2

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.
Files changed (54) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +5 -0
  3. package/components/editable-array-component.ts +26 -0
  4. package/components/editable-array-item-component.ts +26 -0
  5. package/components/editable-component-component.ts +26 -0
  6. package/components/editable-image-component.ts +26 -0
  7. package/components/editable-snippet-component.ts +30 -0
  8. package/components/editable-source-component.ts +26 -0
  9. package/components/editable-text-component.ts +26 -0
  10. package/components/index.ts +35 -0
  11. package/components/ui/editable-array-item-controls.ts +123 -0
  12. package/components/ui/editable-component-controls.ts +57 -0
  13. package/components/ui/editable-region-error-card.ts +77 -0
  14. package/helpers/checks.ts +126 -0
  15. package/helpers/cloudcannon.ts +67 -0
  16. package/helpers/hydrate-editable-regions.ts +71 -0
  17. package/integrations/astro/astro-integration.mjs +110 -0
  18. package/integrations/astro/index.mjs +193 -0
  19. package/integrations/astro/modules/actions.js +74 -0
  20. package/integrations/astro/modules/assets.js +38 -0
  21. package/integrations/astro/modules/client-router.astro +5 -0
  22. package/integrations/astro/modules/content.js +116 -0
  23. package/integrations/astro/modules/i18n.js +76 -0
  24. package/integrations/astro/modules/image.astro +33 -0
  25. package/integrations/astro/modules/middleware.js +27 -0
  26. package/integrations/astro/modules/picture.astro +7 -0
  27. package/integrations/astro/modules/transitions.js +63 -0
  28. package/integrations/astro/react-renderer.mjs +40 -0
  29. package/integrations/react.mjs +32 -0
  30. package/nodes/editable-array-item.ts +427 -0
  31. package/nodes/editable-array.ts +241 -0
  32. package/nodes/editable-component.ts +273 -0
  33. package/nodes/editable-image.ts +253 -0
  34. package/nodes/editable-snippet.ts +148 -0
  35. package/nodes/editable-source.ts +245 -0
  36. package/nodes/editable-text.ts +163 -0
  37. package/nodes/editable.ts +471 -0
  38. package/nodes/index.ts +8 -0
  39. package/package.json +64 -0
  40. package/styles/editable-array-item.css +8 -0
  41. package/styles/editable-component.css +8 -0
  42. package/styles/editable-image.css +4 -0
  43. package/styles/editable-snippet.css +12 -0
  44. package/styles/editable-source.css +3 -0
  45. package/styles/editable-text.css +3 -0
  46. package/styles/index.css +101 -0
  47. package/styles/index.ts +6 -0
  48. package/styles/ui/editable-component-controls.css +104 -0
  49. package/styles/ui/editable-region-error-card.css +25 -0
  50. package/types/astro.d.ts +19 -0
  51. package/types/cloudcannon.d.ts +11 -0
  52. package/types/modules.d.ts +12 -0
  53. package/types/react.d.ts +5 -0
  54. package/types/vite.d.ts +9 -0
@@ -0,0 +1,241 @@
1
+ import type {
2
+ CloudCannonJavaScriptV1APICollection,
3
+ CloudCannonJavaScriptV1APIDataset,
4
+ CloudCannonJavaScriptV1APIFile,
5
+ } from "@cloudcannon/javascript-api";
6
+ import type EditableArrayItemComponent from "../components/editable-array-item-component.js";
7
+ import { hasEditableArrayItem, isEditableElement } from "../helpers/checks.js";
8
+ import { CloudCannon } from "../helpers/cloudcannon.js";
9
+ import type EditableArrayItem from "./editable-array-item.js";
10
+ import Editable from "./editable.js";
11
+
12
+ const arrayDirectionValues = [
13
+ "row",
14
+ "column",
15
+ "row-reverse",
16
+ "column-reverse",
17
+ ] as const;
18
+
19
+ export type ArrayDirection = (typeof arrayDirectionValues)[number];
20
+
21
+ function isArrayDirection(value: unknown): value is ArrayDirection {
22
+ return arrayDirectionValues.includes(value as ArrayDirection);
23
+ }
24
+
25
+ export default class EditableArray extends Editable {
26
+ arrayDirection?: ArrayDirection;
27
+ value:
28
+ | CloudCannonJavaScriptV1APICollection
29
+ | CloudCannonJavaScriptV1APIDataset
30
+ | unknown[]
31
+ | null
32
+ | undefined = undefined;
33
+
34
+ validateConfiguration(): boolean {
35
+ const prop = this.element.dataset.prop;
36
+ if (typeof prop !== "string") {
37
+ this.element.classList.add("errored");
38
+ const error = document.createElement("editable-region-error-card");
39
+ error.setAttribute("heading", "Failed to render array editable");
40
+ error.setAttribute("message", "Missing required attribute data-prop");
41
+ this.element.replaceChildren(error);
42
+ return false;
43
+ }
44
+
45
+ return true;
46
+ }
47
+
48
+ validateValue(value: unknown): this["value"] {
49
+ if (
50
+ !Array.isArray(value) &&
51
+ value !== null &&
52
+ !CloudCannon.isAPICollection(value) &&
53
+ !CloudCannon.isAPIDataset(value)
54
+ ) {
55
+ this.element.classList.add("errored");
56
+ const error = document.createElement("editable-region-error-card");
57
+ error.setAttribute("heading", "Failed to render array editable");
58
+ error.setAttribute(
59
+ "message",
60
+ `Illegal value type: ${typeof value}. Supported types are array.`,
61
+ );
62
+ this.element.replaceChildren(error);
63
+ return;
64
+ }
65
+ return value;
66
+ }
67
+
68
+ async update(): Promise<void> {
69
+ let value: unknown[] | CloudCannonJavaScriptV1APIFile[];
70
+ if (CloudCannon.isAPICollection(this.value)) {
71
+ value = await this.value.items();
72
+ } else if (CloudCannon.isAPIDataset(this.value)) {
73
+ const items = await this.value.items();
74
+ if (Array.isArray(items)) {
75
+ value = items;
76
+ } else {
77
+ const data = await items.data.get();
78
+ value = Array.isArray(data) ? data : [];
79
+ }
80
+ } else if (Array.isArray(this.value)) {
81
+ value = this.value;
82
+ } else {
83
+ value = [];
84
+ }
85
+
86
+ const children: (HTMLElement & { editable: EditableArrayItem })[] = [];
87
+
88
+ for (const child of this.element.querySelectorAll(
89
+ "editable-array-item,[data-editable='array-item']",
90
+ )) {
91
+ if (!hasEditableArrayItem(child)) {
92
+ continue;
93
+ }
94
+
95
+ let parent = child.parentElement;
96
+ while (parent instanceof HTMLElement && !isEditableElement(parent)) {
97
+ parent = parent.parentElement;
98
+ }
99
+
100
+ if (!parent || ("editable" in parent && parent.editable !== this)) {
101
+ continue;
102
+ }
103
+
104
+ children.push(child as any);
105
+ }
106
+
107
+ if (!this.element.dataset.idKey) {
108
+ while (children.length > value.length) {
109
+ children.pop()?.remove();
110
+ }
111
+
112
+ for (let i = 0; i < value.length; i++) {
113
+ let child = children[i];
114
+ if (!child) {
115
+ child = children[0].cloneNode(true) as HTMLElement & {
116
+ editable: EditableArrayItem;
117
+ };
118
+ this.element.appendChild(child);
119
+ }
120
+ child.dataset.prop = `${i}`;
121
+ child.dataset.length = `${children.length}`;
122
+ child.editable?.pushValue(value[i]);
123
+ }
124
+ return;
125
+ }
126
+
127
+ const key = this.element.dataset.idKey;
128
+ const componentKey = this.element.dataset.componentKey;
129
+ const dataKeys: (string | null)[] = [];
130
+ const componentKeys: (string | null)[] = [];
131
+ for (const item of value) {
132
+ let data = item;
133
+ if (CloudCannon.isAPIFile(item)) {
134
+ data = await item.data.get();
135
+ }
136
+
137
+ if (data && typeof data === "object" && key) {
138
+ dataKeys.push(String((data as any)[key]));
139
+ } else {
140
+ dataKeys.push(null);
141
+ }
142
+
143
+ if (data && typeof data === "object" && componentKey) {
144
+ componentKeys.push(String((data as any)[componentKey]));
145
+ } else {
146
+ componentKeys.push(null);
147
+ }
148
+ }
149
+
150
+ const childKeys = children.map((element) => {
151
+ return element.dataset.id;
152
+ });
153
+
154
+ const equal = dataKeys?.every((key, i) => key === childKeys[i]);
155
+ if (equal && children.length === dataKeys?.length) {
156
+ children.forEach((child, index) => {
157
+ child.dataset.prop = `${index}`;
158
+ child.editable.pushValue(value[index]);
159
+ });
160
+ return;
161
+ }
162
+
163
+ const placeholders = children.map((child) => {
164
+ const placeholder = document.createElement("array-placeholder");
165
+ child.after(placeholder);
166
+ return placeholder;
167
+ });
168
+
169
+ const moved: Record<number, boolean> = {};
170
+
171
+ dataKeys?.forEach((key, i) => {
172
+ if (!key) {
173
+ return;
174
+ }
175
+ const placeholder = placeholders[i];
176
+ const existingElement = children[i];
177
+ const matchingChildIndex = children.findIndex(
178
+ (child, i) => child.dataset.id === key && !moved[i],
179
+ );
180
+
181
+ let matchingChild = children[matchingChildIndex];
182
+ if (!matchingChild) {
183
+ const clone = children.find((child) => child.dataset.id === key);
184
+ if (clone) {
185
+ matchingChild = clone.cloneNode(true) as any;
186
+ } else {
187
+ matchingChild = document.createElement(
188
+ "editable-array-item",
189
+ ) as EditableArrayItemComponent;
190
+ matchingChild.dataset.id = key;
191
+
192
+ if (componentKeys[i]) {
193
+ matchingChild.dataset.component = componentKeys[i];
194
+ }
195
+ }
196
+ } else {
197
+ moved[matchingChildIndex] = true;
198
+ }
199
+
200
+ matchingChild.dataset.id = String(key);
201
+ matchingChild.dataset.prop = `${i}`;
202
+
203
+ if (existingElement === matchingChild) {
204
+ placeholder.remove();
205
+ } else if (placeholder) {
206
+ placeholder.replaceWith(matchingChild);
207
+ } else {
208
+ this.element.appendChild(matchingChild);
209
+ }
210
+
211
+ matchingChild.editable.parent = this;
212
+ matchingChild.editable.pushValue(value[i]);
213
+ });
214
+
215
+ children.forEach((child, i) => {
216
+ if (!moved[i]) {
217
+ child.remove();
218
+ }
219
+ });
220
+ }
221
+
222
+ calculateArrayDirection(): ArrayDirection {
223
+ if (isArrayDirection(this.element.dataset.direction)) {
224
+ return this.element.dataset.direction;
225
+ }
226
+
227
+ const computedStyles = getComputedStyle(this.element);
228
+ if (
229
+ computedStyles.display === "flex" &&
230
+ isArrayDirection(computedStyles.flexDirection)
231
+ ) {
232
+ return computedStyles.flexDirection;
233
+ }
234
+
235
+ return "column";
236
+ }
237
+
238
+ mount(): void {
239
+ this.arrayDirection = this.calculateArrayDirection();
240
+ }
241
+ }
@@ -0,0 +1,273 @@
1
+ import {
2
+ areEqualEditables,
3
+ areEqualNodes,
4
+ hasEditable,
5
+ hasEditableText,
6
+ isEditableElement,
7
+ isEditableText,
8
+ } from "../helpers/checks.js";
9
+ import Editable from "./editable.js";
10
+ import "../components/ui/editable-region-error-card.js";
11
+ import "../components/ui/editable-component-controls.js";
12
+ import type EditableComponentControls from "../components/ui/editable-component-controls.js";
13
+ import {
14
+ CloudCannon,
15
+ getEditableComponentRenderers,
16
+ } from "../helpers/cloudcannon.js";
17
+
18
+ const realizeAPIValue = async (value: unknown): Promise<unknown> => {
19
+ if (CloudCannon.isAPICollection(value)) {
20
+ const items = await value.items();
21
+ return Promise.all(items.map(realizeAPIValue));
22
+ }
23
+ if (CloudCannon.isAPIFile(value)) {
24
+ return value.data.get();
25
+ }
26
+ if (CloudCannon.isAPIDataset(value)) {
27
+ const items = await value.items();
28
+ if (Array.isArray(items)) {
29
+ return Promise.all(items.map(realizeAPIValue));
30
+ }
31
+ return items.data.get();
32
+ }
33
+ return value;
34
+ };
35
+
36
+ export default class EditableComponent extends Editable {
37
+ protected controlsElement?: EditableComponentControls;
38
+
39
+ getComponents() {
40
+ return getEditableComponentRenderers();
41
+ }
42
+
43
+ validateConfiguration(): boolean {
44
+ const key = this.element.dataset.component;
45
+ if (!key) {
46
+ this.element.classList.add("errored");
47
+ const error = document.createElement("editable-region-error-card");
48
+ error.setAttribute("heading", "Failed to render component");
49
+ error.setAttribute(
50
+ "message",
51
+ "Component key(data-component) not provided",
52
+ );
53
+ this.element.replaceChildren(error);
54
+ return false;
55
+ }
56
+
57
+ return true;
58
+ }
59
+
60
+ async update(): Promise<void> {
61
+ this.element.classList.remove("errored");
62
+
63
+ const key = this.element.dataset.component;
64
+ if (!key) {
65
+ return super.update();
66
+ }
67
+ const component = this.getComponents()?.[key];
68
+ if (!component) {
69
+ return super.update();
70
+ }
71
+
72
+ const value = await realizeAPIValue(this.value);
73
+ if (value && typeof value === "object" && !Array.isArray(value)) {
74
+ for (const key of Object.keys(value)) {
75
+ (value as any)[key] = await realizeAPIValue((value as any)[key]);
76
+ }
77
+ }
78
+
79
+ let rootEl: HTMLElement;
80
+ try {
81
+ rootEl = await component(this.value);
82
+ } catch (err: unknown) {
83
+ this.element.classList.add("errored");
84
+ const error = document.createElement("editable-region-error-card");
85
+ error.setAttribute("heading", `Failed to render component: ${key}`);
86
+ error.error = err;
87
+ this.element.replaceChildren(error);
88
+ return;
89
+ }
90
+
91
+ const child = rootEl.firstElementChild;
92
+ if (
93
+ child instanceof HTMLElement &&
94
+ "editable" in child &&
95
+ child.editable instanceof EditableComponent &&
96
+ child.dataset.component === key
97
+ ) {
98
+ rootEl = child;
99
+ }
100
+
101
+ if (this.controlsElement) {
102
+ this.controlsElement.remove();
103
+ }
104
+ this.updateTree(this.element, rootEl);
105
+ if (this.controlsElement) {
106
+ this.element.appendChild(this.controlsElement);
107
+ }
108
+ }
109
+
110
+ updateTree(
111
+ targetNode?: ChildNode | null,
112
+ renderNode?: ChildNode | null,
113
+ ): void {
114
+ let targetChild: ChildNode | null | undefined =
115
+ targetNode?.firstChild ?? undefined;
116
+ let renderChild: ChildNode | null | undefined =
117
+ renderNode?.firstChild ?? undefined;
118
+ while (renderChild || targetChild) {
119
+ const nextTargetChild: ChildNode | null | undefined =
120
+ targetChild?.nextSibling ?? undefined;
121
+ const nextRenderChild: ChildNode | null | undefined =
122
+ renderChild?.nextSibling ?? undefined;
123
+
124
+ if (
125
+ targetChild instanceof Element &&
126
+ renderChild &&
127
+ !(renderChild instanceof Element)
128
+ ) {
129
+ targetChild.before(renderChild);
130
+ renderChild = nextRenderChild;
131
+ continue;
132
+ }
133
+
134
+ if (
135
+ renderChild instanceof Element &&
136
+ targetChild &&
137
+ !(targetChild instanceof Element)
138
+ ) {
139
+ targetChild.remove();
140
+ targetChild = nextTargetChild;
141
+ continue;
142
+ }
143
+
144
+ if (
145
+ renderChild &&
146
+ targetChild &&
147
+ !(renderChild instanceof Element) &&
148
+ !(targetChild instanceof Element)
149
+ ) {
150
+ targetChild.nodeValue = renderChild.nodeValue;
151
+ } else if (
152
+ targetChild instanceof HTMLElement &&
153
+ renderChild instanceof HTMLElement &&
154
+ isEditableElement(renderChild) &&
155
+ isEditableElement(targetChild)
156
+ ) {
157
+ if (!areEqualEditables(renderChild, targetChild)) {
158
+ targetChild.replaceWith(renderChild);
159
+ } else if (isEditableText(renderChild) && isEditableText(targetChild)) {
160
+ if (
161
+ hasEditableText(targetChild) &&
162
+ !targetChild.editable.focused &&
163
+ !targetChild?.isEqualNode(renderChild) &&
164
+ hasEditable(renderChild)
165
+ ) {
166
+ targetChild.replaceWith(renderChild);
167
+ for (let i = 0; i < this.listeners.length; i++) {
168
+ const listener = this.listeners[i];
169
+ if (listener.editable.element === targetChild) {
170
+ listener.editable.element = renderChild;
171
+ renderChild.editable.pushValue(this.value, listener);
172
+ }
173
+ }
174
+ } else if (hasEditable(targetChild)) {
175
+ for (let i = 0; i < this.listeners.length; i++) {
176
+ const listener = this.listeners[i];
177
+ if (listener.editable.element === targetChild) {
178
+ targetChild.editable.pushValue(this.value, listener);
179
+ }
180
+ }
181
+ }
182
+ } else if (hasEditable(targetChild)) {
183
+ for (let i = 0; i < this.listeners.length; i++) {
184
+ const listener = this.listeners[i];
185
+ if (listener.editable.element === targetChild) {
186
+ targetChild.editable.pushValue(this.value, listener);
187
+ }
188
+ }
189
+ }
190
+ } else if (renderChild && targetChild) {
191
+ if (
192
+ !areEqualNodes(targetChild, renderChild) ||
193
+ isEditableElement(renderChild) ||
194
+ isEditableElement(targetChild)
195
+ ) {
196
+ targetChild.replaceWith(renderChild);
197
+ } else {
198
+ this.updateTree(targetChild, renderChild);
199
+ }
200
+ } else if (renderChild) {
201
+ targetNode?.appendChild(renderChild);
202
+ } else if (targetChild) {
203
+ targetNode?.removeChild(targetChild);
204
+ }
205
+
206
+ targetChild = nextTargetChild;
207
+ renderChild = nextRenderChild;
208
+ }
209
+ }
210
+
211
+ dispatchEdit(source?: string) {
212
+ this.element.dispatchEvent(
213
+ new CustomEvent("cloudcannon-api", {
214
+ bubbles: true,
215
+ detail: { action: "edit", source },
216
+ }),
217
+ );
218
+ }
219
+
220
+ setupListeners(): void {
221
+ super.setupListeners();
222
+ const key = this.element.dataset.component;
223
+ if (!key) {
224
+ return;
225
+ }
226
+
227
+ const component = this.getComponents()?.[key];
228
+ if (!component) {
229
+ document.addEventListener(
230
+ `editable-regions:registered-${key}`,
231
+ () => this.update(),
232
+ { once: true },
233
+ );
234
+ }
235
+ }
236
+
237
+ mount(): void {
238
+ if (!this.controlsElement) {
239
+ let editPath: string | undefined;
240
+ Object.entries(this.element.dataset).forEach(([propName, propPath]) => {
241
+ if (!propName.startsWith("prop")) {
242
+ return;
243
+ }
244
+
245
+ if (typeof editPath !== "string") {
246
+ editPath = propPath;
247
+ return;
248
+ }
249
+
250
+ while (!propPath?.startsWith(editPath)) {
251
+ editPath = editPath?.replace(/(\.|^)[^.]*$/, "");
252
+ }
253
+ });
254
+
255
+ editPath ||= this.element.dataset.prop;
256
+ editPath ||= Object.entries(this.element.dataset).find(([propName]) =>
257
+ propName.startsWith("prop"),
258
+ )?.[1];
259
+
260
+ if (editPath) {
261
+ this.controlsElement = document.createElement(
262
+ "editable-component-controls",
263
+ );
264
+ this.controlsElement.addEventListener("edit", (e: any) => {
265
+ this.dispatchEdit(editPath);
266
+ });
267
+ this.element.append(this.controlsElement);
268
+ }
269
+
270
+ this.update();
271
+ }
272
+ }
273
+ }