@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.
- package/LICENSE +5 -0
- package/README.md +5 -0
- package/components/editable-array-component.ts +26 -0
- package/components/editable-array-item-component.ts +26 -0
- package/components/editable-component-component.ts +26 -0
- package/components/editable-image-component.ts +26 -0
- package/components/editable-snippet-component.ts +30 -0
- package/components/editable-source-component.ts +26 -0
- package/components/editable-text-component.ts +26 -0
- package/components/index.ts +35 -0
- package/components/ui/editable-array-item-controls.ts +123 -0
- package/components/ui/editable-component-controls.ts +57 -0
- package/components/ui/editable-region-error-card.ts +77 -0
- package/helpers/checks.ts +126 -0
- package/helpers/cloudcannon.ts +67 -0
- package/helpers/hydrate-editable-regions.ts +71 -0
- package/integrations/astro/astro-integration.mjs +110 -0
- package/integrations/astro/index.mjs +193 -0
- package/integrations/astro/modules/actions.js +74 -0
- package/integrations/astro/modules/assets.js +38 -0
- package/integrations/astro/modules/client-router.astro +5 -0
- package/integrations/astro/modules/content.js +116 -0
- package/integrations/astro/modules/i18n.js +76 -0
- package/integrations/astro/modules/image.astro +33 -0
- package/integrations/astro/modules/middleware.js +27 -0
- package/integrations/astro/modules/picture.astro +7 -0
- package/integrations/astro/modules/transitions.js +63 -0
- package/integrations/astro/react-renderer.mjs +40 -0
- package/integrations/react.mjs +32 -0
- package/nodes/editable-array-item.ts +427 -0
- package/nodes/editable-array.ts +241 -0
- package/nodes/editable-component.ts +273 -0
- package/nodes/editable-image.ts +253 -0
- package/nodes/editable-snippet.ts +148 -0
- package/nodes/editable-source.ts +245 -0
- package/nodes/editable-text.ts +163 -0
- package/nodes/editable.ts +471 -0
- package/nodes/index.ts +8 -0
- package/package.json +64 -0
- package/styles/editable-array-item.css +8 -0
- package/styles/editable-component.css +8 -0
- package/styles/editable-image.css +4 -0
- package/styles/editable-snippet.css +12 -0
- package/styles/editable-source.css +3 -0
- package/styles/editable-text.css +3 -0
- package/styles/index.css +101 -0
- package/styles/index.ts +6 -0
- package/styles/ui/editable-component-controls.css +104 -0
- package/styles/ui/editable-region-error-card.css +25 -0
- package/types/astro.d.ts +19 -0
- package/types/cloudcannon.d.ts +11 -0
- package/types/modules.d.ts +12 -0
- package/types/react.d.ts +5 -0
- package/types/vite.d.ts +9 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { CloudCannon } from "../helpers/cloudcannon.js";
|
|
2
|
+
import Editable from "./editable.js";
|
|
3
|
+
|
|
4
|
+
export default class EditableImage extends Editable {
|
|
5
|
+
value: { src?: string; alt?: string; title?: string } | null | undefined =
|
|
6
|
+
undefined;
|
|
7
|
+
inputConfig: { src?: any; alt?: any; title?: any } = {};
|
|
8
|
+
imageEl?: HTMLImageElement;
|
|
9
|
+
|
|
10
|
+
configuredSrc = false;
|
|
11
|
+
configuredAlt = false;
|
|
12
|
+
configuredTitle = false;
|
|
13
|
+
|
|
14
|
+
displayError(heading: string, message: string) {
|
|
15
|
+
this.element.classList.add("errored");
|
|
16
|
+
const error = document.createElement("editable-region-error-card");
|
|
17
|
+
error.setAttribute("heading", heading);
|
|
18
|
+
error.setAttribute("message", message);
|
|
19
|
+
if (this.imageEl) {
|
|
20
|
+
this.imageEl?.replaceWith(error);
|
|
21
|
+
} else {
|
|
22
|
+
this.element.replaceChildren(error);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
validateConfiguration(): boolean {
|
|
27
|
+
const child =
|
|
28
|
+
this.element instanceof HTMLImageElement
|
|
29
|
+
? this.element
|
|
30
|
+
: this.element.querySelector("img");
|
|
31
|
+
|
|
32
|
+
if (!(child instanceof HTMLImageElement)) {
|
|
33
|
+
this.displayError(
|
|
34
|
+
"Failed to render image editable region",
|
|
35
|
+
"Image editable region requires an image element as its child.",
|
|
36
|
+
);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.imageEl = child;
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
this.element.dataset.prop === undefined &&
|
|
44
|
+
this.element.dataset.propSrc === undefined &&
|
|
45
|
+
this.element.dataset.propAlt === undefined &&
|
|
46
|
+
this.element.dataset.propTitle === undefined
|
|
47
|
+
) {
|
|
48
|
+
this.displayError(
|
|
49
|
+
"Failed to render image editable region",
|
|
50
|
+
"Atleast one of data-prop, data-prop-src, data-prop-alt, or data-prop-title is required.",
|
|
51
|
+
);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
validateValue(value: unknown): this["value"] {
|
|
58
|
+
if (typeof value !== "object") {
|
|
59
|
+
this.displayError(
|
|
60
|
+
"Failed to render image editable region",
|
|
61
|
+
`Illegal value type: ${typeof value}. Supported types are object.`,
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (value === null) {
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if ("src" in value && typeof value.src !== "string" && value.src !== null) {
|
|
71
|
+
this.displayError(
|
|
72
|
+
"Failed to render image editable region",
|
|
73
|
+
`Illegal value type for "src": ${typeof value.src}. Supported types are string.`,
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if ("alt" in value && typeof value.alt !== "string" && value.alt !== null) {
|
|
79
|
+
this.displayError(
|
|
80
|
+
"Failed to render image editable region",
|
|
81
|
+
`Illegal value type for "alt": ${typeof value.alt}. Supported types are string.`,
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
"title" in value &&
|
|
88
|
+
typeof value.title !== "string" &&
|
|
89
|
+
value.title !== null
|
|
90
|
+
) {
|
|
91
|
+
this.displayError(
|
|
92
|
+
"Failed to render image editable region",
|
|
93
|
+
`Illegal value type for "title": ${typeof value.title}. Supported types are string.`,
|
|
94
|
+
);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const unexpectedKey = Object.keys(value).find(
|
|
99
|
+
(key) => key !== "src" && key !== "alt" && key !== "title",
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (unexpectedKey) {
|
|
103
|
+
this.displayError(
|
|
104
|
+
"Failed to render image editable region",
|
|
105
|
+
`Unexpected key "${unexpectedKey}" in image editable region. Supported keys are "src", "alt", and "title".`,
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async update(): Promise<void> {
|
|
114
|
+
if (!this.imageEl) {
|
|
115
|
+
throw new Error("Element is not an HTMLImageElement");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this.configuredSrc && this.imageEl.src !== this.value?.src) {
|
|
119
|
+
const previewUrl = await CloudCannon.getPreviewUrl(
|
|
120
|
+
this.value?.src ?? "",
|
|
121
|
+
this.inputConfig.src,
|
|
122
|
+
);
|
|
123
|
+
this.imageEl.src = previewUrl;
|
|
124
|
+
const parent = this.imageEl.parentElement;
|
|
125
|
+
if (parent instanceof HTMLPictureElement) {
|
|
126
|
+
for (const sourceEl of parent.children) {
|
|
127
|
+
if (sourceEl instanceof HTMLSourceElement) {
|
|
128
|
+
sourceEl.src = previewUrl;
|
|
129
|
+
sourceEl.srcset = previewUrl;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (this.configuredAlt && this.imageEl.alt !== this.value?.alt) {
|
|
136
|
+
this.imageEl.alt = this.value?.alt ?? "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (this.configuredTitle && this.imageEl.title !== this.value?.title) {
|
|
140
|
+
this.imageEl.title = this.value?.title ?? "";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async loadInputConfig(): Promise<void> {
|
|
145
|
+
this.inputConfig = {};
|
|
146
|
+
if (this.configuredSrc) {
|
|
147
|
+
this.inputConfig.src = await this.dispatchGetInputConfig(
|
|
148
|
+
this.element.dataset.propSrc ?? `${this.element.dataset.prop}.src`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (this.configuredAlt) {
|
|
152
|
+
this.inputConfig.alt = await this.dispatchGetInputConfig(
|
|
153
|
+
this.element.dataset.propAlt ?? `${this.element.dataset.prop}.alt`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (this.configuredTitle) {
|
|
157
|
+
this.inputConfig.title = await this.dispatchGetInputConfig(
|
|
158
|
+
this.element.dataset.propTitle ?? `${this.element.dataset.prop}.title`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
mount(): void {
|
|
164
|
+
this.configuredSrc =
|
|
165
|
+
!!this.element.dataset.propSrc || !!this.element.dataset.prop;
|
|
166
|
+
this.configuredAlt =
|
|
167
|
+
!!this.element.dataset.propAlt || !!this.element.dataset.prop;
|
|
168
|
+
this.configuredTitle =
|
|
169
|
+
!!this.element.dataset.propTitle || !!this.element.dataset.prop;
|
|
170
|
+
|
|
171
|
+
this.loadInputConfig().then(() => {
|
|
172
|
+
this.imageEl?.addEventListener("click", () => {
|
|
173
|
+
if (!this.value) {
|
|
174
|
+
throw new Error("Value is not defined");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const data: this["value"] = {};
|
|
178
|
+
if ("src" in this.value) {
|
|
179
|
+
data.src = this.value.src;
|
|
180
|
+
}
|
|
181
|
+
if ("alt" in this.value) {
|
|
182
|
+
data.alt = this.value.alt;
|
|
183
|
+
}
|
|
184
|
+
if ("title" in this.value) {
|
|
185
|
+
data.title = this.value.title;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
CloudCannon.createCustomDataPanel({
|
|
189
|
+
title: "Edit Image",
|
|
190
|
+
data,
|
|
191
|
+
position: this.imageEl?.getBoundingClientRect(),
|
|
192
|
+
config: {
|
|
193
|
+
_inputs: {
|
|
194
|
+
src: {
|
|
195
|
+
label: "Image",
|
|
196
|
+
type: "image",
|
|
197
|
+
...this.inputConfig.src,
|
|
198
|
+
},
|
|
199
|
+
alt: {
|
|
200
|
+
comment:
|
|
201
|
+
"A description which provides information about this image if for some reason it cannot be viewed.",
|
|
202
|
+
...this.inputConfig.alt,
|
|
203
|
+
},
|
|
204
|
+
title: {
|
|
205
|
+
comment: "Displayed when hovering over the image.",
|
|
206
|
+
...this.inputConfig.title,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
onChange: (value): void => {
|
|
211
|
+
if (!value || typeof value !== "object") {
|
|
212
|
+
throw new Error("Invalid image data");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (
|
|
216
|
+
"src" in value &&
|
|
217
|
+
this.configuredSrc &&
|
|
218
|
+
value.src !== this.value?.src
|
|
219
|
+
) {
|
|
220
|
+
this.dispatchSet(
|
|
221
|
+
this.element.dataset.propSrc ??
|
|
222
|
+
`${this.element.dataset.prop}.src`,
|
|
223
|
+
value.src,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
"alt" in value &&
|
|
228
|
+
this.configuredAlt &&
|
|
229
|
+
value.alt !== this.value?.alt
|
|
230
|
+
) {
|
|
231
|
+
this.dispatchSet(
|
|
232
|
+
this.element.dataset.propAlt ??
|
|
233
|
+
`${this.element.dataset.prop}.alt`,
|
|
234
|
+
value.alt,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
"title" in value &&
|
|
239
|
+
this.configuredTitle &&
|
|
240
|
+
value.title !== this.value?.title
|
|
241
|
+
) {
|
|
242
|
+
this.dispatchSet(
|
|
243
|
+
this.element.dataset.propTitle ??
|
|
244
|
+
`${this.element.dataset.prop}.title`,
|
|
245
|
+
value.title,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { getEditableSnippetRenderers } from "../helpers/cloudcannon.js";
|
|
2
|
+
import EditableComponent from "./editable-component.js";
|
|
3
|
+
import Editable from "./editable.js";
|
|
4
|
+
|
|
5
|
+
export default class EditableSnippet extends EditableComponent {
|
|
6
|
+
getComponents() {
|
|
7
|
+
return getEditableSnippetRenderers();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
setupListeners(): void {
|
|
11
|
+
this.element.addEventListener("cloudcannon-api", async (e: any) => {
|
|
12
|
+
if (e.target !== this.element) {
|
|
13
|
+
if (!e.detail.source) {
|
|
14
|
+
e.detail.source = `@snippet[${this.element.getAttribute("data-cms-snippet-id")}]`;
|
|
15
|
+
} else {
|
|
16
|
+
e.detail.source = `@snippet[${this.element.getAttribute("data-cms-snippet-id")}].${e.detail.source}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { absolute, snippets } = this.parseSource(e.detail.source);
|
|
21
|
+
if (!this.parent || absolute || snippets) {
|
|
22
|
+
if (this.executeApiCall(e.detail)) {
|
|
23
|
+
e.stopPropagation();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
validateValue(value: unknown): unknown {
|
|
30
|
+
if (typeof value !== "object") {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!value) {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (
|
|
39
|
+
!("_snippet_type" in value) ||
|
|
40
|
+
typeof value._snippet_type !== "string"
|
|
41
|
+
) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.element.dataset.component = value._snippet_type;
|
|
46
|
+
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
executeApiCall(options: any): boolean {
|
|
51
|
+
const { snippets, source } = this.parseSource(options.source);
|
|
52
|
+
|
|
53
|
+
if (options.action === "get-input-config") {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const snippet = snippets.pop();
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
snippet &&
|
|
61
|
+
snippet !== this.element.getAttribute("data-cms-snippet-id")
|
|
62
|
+
) {
|
|
63
|
+
const snippetEl = document.querySelector(
|
|
64
|
+
`[data-cms-snippet-id="${snippet}"]`,
|
|
65
|
+
);
|
|
66
|
+
if (
|
|
67
|
+
!snippetEl ||
|
|
68
|
+
!("editable" in snippetEl) ||
|
|
69
|
+
!(snippetEl.editable instanceof Editable)
|
|
70
|
+
) {
|
|
71
|
+
console.error(`Error: Snippet with ID "${snippet}" not found`);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return snippetEl.editable.executeApiCall({
|
|
75
|
+
...options,
|
|
76
|
+
source,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (source) {
|
|
81
|
+
this.dispatchSnippetChange(source, options);
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async dispatchSnippetChange(source: string, options: any) {
|
|
87
|
+
switch (options.action) {
|
|
88
|
+
case "edit":
|
|
89
|
+
break;
|
|
90
|
+
case "set": {
|
|
91
|
+
const parts = source.split(".");
|
|
92
|
+
const lastPart = parts.pop();
|
|
93
|
+
const temp = await this.lookupPath(parts.join("."), this.value);
|
|
94
|
+
|
|
95
|
+
if (lastPart && temp && typeof temp === "object") {
|
|
96
|
+
temp[lastPart] = options.value;
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "move-array-item": {
|
|
101
|
+
const temp = await this.lookupPath(source, this.value);
|
|
102
|
+
if (Array.isArray(temp)) {
|
|
103
|
+
const value = temp.splice(options.fromIndex, 1)[0];
|
|
104
|
+
temp.splice(options.toIndex, 0, value);
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "remove-array-item": {
|
|
109
|
+
const temp = await this.lookupPath(source, this.value);
|
|
110
|
+
if (Array.isArray(temp)) {
|
|
111
|
+
temp.splice(options.fromIndex, 1);
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "add-array-item": {
|
|
116
|
+
const temp = await this.lookupPath(source, this.value);
|
|
117
|
+
if (Array.isArray(temp)) {
|
|
118
|
+
temp.splice(options.toIndex, 0, options.value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.element.dispatchEvent(
|
|
126
|
+
new CustomEvent("snippet-change", {
|
|
127
|
+
detail: {
|
|
128
|
+
snippetId: this.element.getAttribute("data-cms-snippet-id"),
|
|
129
|
+
isValid: true,
|
|
130
|
+
snippetData: this.value,
|
|
131
|
+
},
|
|
132
|
+
bubbles: true,
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
mount(): void {}
|
|
138
|
+
|
|
139
|
+
validateConfiguration(): boolean {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
resolveSource(source?: string): string | undefined {
|
|
144
|
+
return this.parent
|
|
145
|
+
? `${this.parent.resolveSource()}.@snippet[${this.element.getAttribute("data-cms-snippet-id")}].${source}`
|
|
146
|
+
: `@snippet[${this.element.getAttribute("data-cms-snippet-id")}].${source}`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { CloudCannonJavaScriptV1APIFile } from "@cloudcannon/javascript-api";
|
|
2
|
+
import { html as beautifyHtml } from "js-beautify";
|
|
3
|
+
import { CloudCannon } from "../helpers/cloudcannon.js";
|
|
4
|
+
import EditableText from "./editable-text.js";
|
|
5
|
+
|
|
6
|
+
const INDENTATION_REGEX = /^([ \t]+)[^\s]/gm;
|
|
7
|
+
const TAG_REGEX =
|
|
8
|
+
/<\s*(?<closing>\/?)\s*(?<tagname>[-a-z]+)(\s+[^>]+)*?\s*(?<selfclosing>\/?)\s*>/gi;
|
|
9
|
+
|
|
10
|
+
const HTML_VOID_ELEMENT: Record<string, boolean> = {
|
|
11
|
+
area: true,
|
|
12
|
+
base: true,
|
|
13
|
+
br: true,
|
|
14
|
+
col: true,
|
|
15
|
+
embed: true,
|
|
16
|
+
hr: true,
|
|
17
|
+
img: true,
|
|
18
|
+
input: true,
|
|
19
|
+
link: true,
|
|
20
|
+
meta: true,
|
|
21
|
+
param: true,
|
|
22
|
+
source: true,
|
|
23
|
+
track: true,
|
|
24
|
+
wbr: true,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default class EditableSource extends EditableText {
|
|
28
|
+
file?: CloudCannonJavaScriptV1APIFile;
|
|
29
|
+
format = {
|
|
30
|
+
leading: "",
|
|
31
|
+
trailing: "",
|
|
32
|
+
indent: "",
|
|
33
|
+
indentSize: 0,
|
|
34
|
+
indentChar: "",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
setupListeners(): void {
|
|
38
|
+
if (!this.element.dataset.path) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this.file = CloudCannon.file(this.element.dataset.path);
|
|
42
|
+
this.file.addEventListener("change", () => {
|
|
43
|
+
this.file?.get().then(this.pushValue.bind(this));
|
|
44
|
+
});
|
|
45
|
+
this.file.get().then(this.pushValue.bind(this));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
validateConfiguration(): boolean {
|
|
49
|
+
const path = this.element.dataset.path;
|
|
50
|
+
if (typeof path !== "string") {
|
|
51
|
+
this.element.classList.add("errored");
|
|
52
|
+
const error = document.createElement("editable-region-error-card");
|
|
53
|
+
error.setAttribute("heading", "Failed to render source editable region");
|
|
54
|
+
error.setAttribute("message", "Missing required attribute data-path");
|
|
55
|
+
this.element.replaceChildren(error);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const key = this.element.dataset.key;
|
|
60
|
+
if (typeof key !== "string") {
|
|
61
|
+
this.element.classList.add("errored");
|
|
62
|
+
const error = document.createElement("editable-region-error-card");
|
|
63
|
+
error.setAttribute("heading", "Failed to render source editable region");
|
|
64
|
+
error.setAttribute("message", "Missing required attribute data-key");
|
|
65
|
+
this.element.replaceChildren(error);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
validateValue(value: unknown): string | null | undefined {
|
|
72
|
+
if (typeof value !== "string" && value !== null) {
|
|
73
|
+
this.element.classList.add("errored");
|
|
74
|
+
const error = document.createElement("editable-region-error-card");
|
|
75
|
+
error.setAttribute("heading", "Failed to render source editable region");
|
|
76
|
+
error.setAttribute(
|
|
77
|
+
"message",
|
|
78
|
+
`Illegal value type: ${typeof value}. Supported types are string.`,
|
|
79
|
+
);
|
|
80
|
+
this.element.replaceChildren(error);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
const keyIndex = value.indexOf(`data-key="${this.element.dataset.key}"`);
|
|
86
|
+
if (keyIndex === -1) {
|
|
87
|
+
this.element.classList.add("errored");
|
|
88
|
+
const error = document.createElement("editable-region-error-card");
|
|
89
|
+
error.setAttribute(
|
|
90
|
+
"heading",
|
|
91
|
+
"Failed to render source editable region",
|
|
92
|
+
);
|
|
93
|
+
error.setAttribute(
|
|
94
|
+
"message",
|
|
95
|
+
"Failed to find element with matching data-key attribute",
|
|
96
|
+
);
|
|
97
|
+
this.element.replaceChildren(error);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const nextKeyIndex = value.indexOf(
|
|
102
|
+
`data-key="${this.element.dataset.key}"`,
|
|
103
|
+
keyIndex + 1,
|
|
104
|
+
);
|
|
105
|
+
if (nextKeyIndex !== -1) {
|
|
106
|
+
this.element.classList.add("errored");
|
|
107
|
+
const error = document.createElement("editable-region-error-card");
|
|
108
|
+
error.setAttribute(
|
|
109
|
+
"heading",
|
|
110
|
+
"Failed to render source editable region",
|
|
111
|
+
);
|
|
112
|
+
error.setAttribute(
|
|
113
|
+
"message",
|
|
114
|
+
"Found duplicate data-key attribute. Make sure all source editables have unique data-key attributes",
|
|
115
|
+
);
|
|
116
|
+
this.element.replaceChildren(error);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getSourceIndices(source: string): { start: number; end: number } {
|
|
125
|
+
const keyIndex = source.indexOf(`data-key="${this.element.dataset.key}"`);
|
|
126
|
+
let tagNameIndex = keyIndex;
|
|
127
|
+
while (tagNameIndex >= 0 && source[tagNameIndex] !== "<") {
|
|
128
|
+
tagNameIndex -= 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const stack = [
|
|
132
|
+
source.substring(tagNameIndex + 1, source.indexOf(" ", tagNameIndex)),
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const start = source.indexOf(">", keyIndex);
|
|
136
|
+
source = source.substring(start + 1);
|
|
137
|
+
|
|
138
|
+
for (const tagMatch of source.matchAll(TAG_REGEX)) {
|
|
139
|
+
if (!tagMatch?.groups) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { closing, tagname, selfclosing } = tagMatch.groups;
|
|
144
|
+
if (closing) {
|
|
145
|
+
while (stack.length > 0) {
|
|
146
|
+
if (stack.pop() === tagname) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else if (!selfclosing && !HTML_VOID_ELEMENT[tagname]) {
|
|
151
|
+
stack.push(tagname);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (stack.length === 0) {
|
|
155
|
+
return { start: start + 1, end: start + 1 + tagMatch.index };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { start: start + 1, end: start + 1 + source.length };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
update(): void {
|
|
163
|
+
if (!this.value) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const source = this.value;
|
|
167
|
+
for (const indentation of source.matchAll(INDENTATION_REGEX)) {
|
|
168
|
+
if (
|
|
169
|
+
!this.format.indentSize ||
|
|
170
|
+
indentation[1].length < this.format.indentSize
|
|
171
|
+
) {
|
|
172
|
+
this.format.indentSize = indentation[1].length;
|
|
173
|
+
this.format.indentChar = indentation[0][0];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const { start, end } = this.getSourceIndices(source);
|
|
178
|
+
const content = source.substring(start, end);
|
|
179
|
+
|
|
180
|
+
this.format.leading = content.match(/^(\s*\n)[^\n]*?\S/)?.[1] ?? "";
|
|
181
|
+
this.format.trailing = content.match(/\S(\n\s*)$/)?.[1] ?? "";
|
|
182
|
+
this.format.indent =
|
|
183
|
+
content
|
|
184
|
+
.split("\n")
|
|
185
|
+
.filter((line) => line.trim().length > 0)
|
|
186
|
+
.reduce((acc: string | null, line) => {
|
|
187
|
+
if (typeof acc !== "string" || !line.startsWith(acc)) {
|
|
188
|
+
return line.match(/^\s*/)?.[0] ?? "";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return acc;
|
|
192
|
+
}, null) ?? "";
|
|
193
|
+
|
|
194
|
+
this.editor?.setContent(content);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async mountEditor(): Promise<any> {
|
|
198
|
+
if (this.editor) {
|
|
199
|
+
return this.editor;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this.editor = await CloudCannon.createTextEditableRegion(
|
|
203
|
+
this.element,
|
|
204
|
+
this.onChange.bind(this),
|
|
205
|
+
{
|
|
206
|
+
elementType: this.element.dataset.type,
|
|
207
|
+
editableType: "content",
|
|
208
|
+
inputConfig: { type: "html" },
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (typeof this.value === "string") {
|
|
213
|
+
this.update();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return this.editor;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
onChange(value?: string | null) {
|
|
220
|
+
this.file?.get().then((source) => {
|
|
221
|
+
value = beautifyHtml(value ?? "", {
|
|
222
|
+
indent_char: this.format.indentChar,
|
|
223
|
+
indent_size: this.format.indentSize,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const { start, end } = this.getSourceIndices(source);
|
|
227
|
+
const content =
|
|
228
|
+
source.substring(0, start) +
|
|
229
|
+
this.format.leading +
|
|
230
|
+
value
|
|
231
|
+
.split("\n")
|
|
232
|
+
.map((line) => this.format.indent + line)
|
|
233
|
+
.join("\n") +
|
|
234
|
+
this.format.trailing +
|
|
235
|
+
source.substring(end);
|
|
236
|
+
|
|
237
|
+
if (content === this.value) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.value = content;
|
|
242
|
+
this.file?.set(content);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|