@cloudcannon/editable-regions 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ export default class EditableSnippetComponent extends HTMLElement {
9
9
  }
10
10
 
11
11
  set snippetData(value: unknown) {
12
- this.editable.pushValue(value);
12
+ this.editable.pushValue(value, {}, { path: "", editable: this.editable });
13
13
  }
14
14
 
15
15
  connectedCallback(): void {
@@ -6,12 +6,10 @@ export { default as EditableSnippetComponent } from "./editable-snippet-componen
6
6
  export { default as EditableSourceComponent } from "./editable-source-component.js";
7
7
  export { default as EditableTextComponent } from "./editable-text-component.js";
8
8
 
9
- import { apiLoadedPromise } from "../helpers/cloudcannon.mjs";
10
9
  import {
11
10
  dehydrateDataEditableRegions,
12
11
  hydrateDataEditableRegions,
13
12
  } from "../helpers/hydrate-editable-regions";
14
- import { completeLoading } from "../helpers/loading.js";
15
13
 
16
14
  const observer = new MutationObserver((mutations) => {
17
15
  mutations.forEach((mutation) => {
@@ -32,14 +30,3 @@ const observer = new MutationObserver((mutations) => {
32
30
  hydrateDataEditableRegions(document.body);
33
31
 
34
32
  observer.observe(document, { childList: true, subtree: true });
35
-
36
- Promise.all([
37
- apiLoadedPromise,
38
- customElements.whenDefined("editable-array-item"),
39
- customElements.whenDefined("editable-array"),
40
- customElements.whenDefined("editable-text"),
41
- customElements.whenDefined("editable-component"),
42
- customElements.whenDefined("editable-image"),
43
- customElements.whenDefined("editable-source"),
44
- customElements.whenDefined("editable-snippet"),
45
- ]).then(() => completeLoading());
@@ -93,7 +93,16 @@ export default class EditableArrayItemControls extends EditableComponentControls
93
93
 
94
94
  this.dragHandle = document.createElement("button");
95
95
  this.dragHandle.type = "button";
96
+
97
+ if (this.usePopoverAPI) {
98
+ //@ts-expect-error: Typescript doesn't yet support the popover API
99
+ this.dragHandle.style.anchorName = this.anchorName;
100
+ }
101
+
96
102
  this.dragHandle.onclick = (e) => {
103
+ if (this.hasAttribute("open")) {
104
+ return;
105
+ }
97
106
  e.stopPropagation();
98
107
  e.preventDefault();
99
108
  this.toggleContextMenu();
@@ -6,13 +6,28 @@ export default class EditableComponentControls extends HTMLElement {
6
6
  protected buttonRow?: HTMLDivElement;
7
7
 
8
8
  private editButton?: HTMLButtonElement;
9
+ protected usePopoverAPI = false;
10
+ protected contextMenuId =
11
+ `context-menu-popover-${Math.random().toString(36).substring(2, 11)}`;
12
+ protected anchorName = `--${this.contextMenuId}`;
13
+ protected hostAnchorName =
14
+ `--cc-editable-host-${Math.random().toString(36).substring(2, 11)}`;
9
15
 
10
16
  toggleContextMenu() {
11
- if (this.contextMenu?.classList.contains("open")) {
12
- this.contextMenu?.classList.remove("open");
17
+ if (!this.contextMenu || this.contextMenu.childElementCount === 0) {
18
+ return;
19
+ }
20
+
21
+ if (this.usePopoverAPI) {
22
+ this.contextMenu.togglePopover();
23
+ return;
24
+ }
25
+
26
+ if (this.contextMenu.classList.contains("open")) {
27
+ this.contextMenu.classList.remove("open");
13
28
  this.removeAttribute("open");
14
- } else if (this.contextMenu && this.contextMenu.childElementCount > 0) {
15
- this.contextMenu?.classList.add("open");
29
+ } else {
30
+ this.contextMenu.classList.add("open");
16
31
  this.setAttribute("open", "true");
17
32
  }
18
33
  }
@@ -28,6 +43,13 @@ export default class EditableComponentControls extends HTMLElement {
28
43
 
29
44
  this.contextMenu = document.createElement("ul");
30
45
  this.contextMenu.classList.add("context-menu");
46
+
47
+ if (this.usePopoverAPI) {
48
+ this.contextMenu.setAttribute("popover", "auto");
49
+ //@ts-expect-error: Typescript doesn't yet support the popover API
50
+ this.contextMenu.style.positionAnchor = this.anchorName;
51
+ }
52
+
31
53
  shadow.appendChild(this.contextMenu);
32
54
 
33
55
  this.editButton = document.createElement("button");
@@ -40,17 +62,32 @@ export default class EditableComponentControls extends HTMLElement {
40
62
 
41
63
  this.onclick = (e) => {
42
64
  e.preventDefault();
43
- this.contextMenu?.classList.remove("open");
44
65
  this.removeAttribute("open");
45
- };
46
66
 
47
- this.onblur = (e) => {
48
- if ((e.relatedTarget as HTMLElement)?.tagName === "A") {
49
- return;
67
+ if (this.usePopoverAPI) {
68
+ this.contextMenu?.hidePopover();
69
+ } else {
70
+ this.contextMenu?.classList.remove("open");
50
71
  }
51
- this.contextMenu?.classList.remove("open");
52
- this.removeAttribute("open");
53
72
  };
73
+
74
+ if (this.usePopoverAPI && this.contextMenu) {
75
+ this.contextMenu.ontoggle = (e: ToggleEvent) => {
76
+ if (e.newState === "open") {
77
+ this.setAttribute("open", "true");
78
+ } else {
79
+ this.removeAttribute("open");
80
+ }
81
+ };
82
+ } else {
83
+ this.onblur = (e) => {
84
+ if ((e.relatedTarget as HTMLElement)?.tagName === "A") {
85
+ return;
86
+ }
87
+ this.removeAttribute("open");
88
+ this.contextMenu?.classList.remove("open");
89
+ };
90
+ }
54
91
  }
55
92
 
56
93
  connectedCallback() {
@@ -58,8 +95,27 @@ export default class EditableComponentControls extends HTMLElement {
58
95
  return;
59
96
  }
60
97
 
98
+ this.usePopoverAPI =
99
+ "popover" in HTMLElement.prototype && CSS.supports("anchor-name", "--a");
100
+
61
101
  this.shadow = this.attachShadow({ mode: "open" });
62
102
  this.render(this.shadow);
103
+
104
+ if (this.usePopoverAPI && this.parentElement) {
105
+ const existing = getComputedStyle(this.parentElement)
106
+ .getPropertyValue("anchor-name")
107
+ .trim();
108
+
109
+ if (existing && existing !== "none") {
110
+ this.hostAnchorName = existing.split(",")[0].trim();
111
+ } else {
112
+ // @ts-expect-error
113
+ this.parentElement.style.anchorName = this.hostAnchorName;
114
+ }
115
+
116
+ // @ts-expect-error
117
+ this.style.positionAnchor = this.hostAnchorName;
118
+ }
63
119
  }
64
120
  }
65
121
 
package/helpers/checks.ts CHANGED
@@ -2,32 +2,11 @@ import Editable from "../nodes/editable.js";
2
2
  import EditableArrayItem from "../nodes/editable-array-item.js";
3
3
  import EditableText from "../nodes/editable-text.js";
4
4
 
5
- const TAG_NAMES = [
6
- "EDITABLE-TEXT",
7
- "EDITABLE-COMPONENT",
8
- "EDITABLE-ARRAY-ITEM",
9
- "EDITABLE-ARRAY",
10
- "EDITABLE-IMAGE",
11
- "EDITABLE-SOURCE",
12
- "EDITABLE-SNIPPET",
13
- ];
14
-
15
- const EDITABLE_REGION_TYPES = [
16
- "text",
17
- "component",
18
- "array",
19
- "array-item",
20
- "image",
21
- "source",
22
- ];
23
-
24
- const CORRESPONDING_NAME: Record<string, string> = {
25
- "EDITABLE-TEXT": "text",
26
- "EDITABLE-COMPONENT": "component",
27
- "EDITABLE-ARRAY-ITEM": "array-item",
28
- "EDITABLE-ARRAY": "array",
29
- "EDITABLE-IMAGE": "image",
30
- "EDITABLE-SOURCE": "source",
5
+ const getEditableType = (el: HTMLElement): string | undefined => {
6
+ if (el.tagName.startsWith("EDITABLE-")) {
7
+ return el.tagName.slice(9).toLowerCase();
8
+ }
9
+ return el.dataset.editable;
31
10
  };
32
11
 
33
12
  export const hasEditable = <T extends object>(
@@ -53,7 +32,7 @@ export const isEditableWebcomponent = (el: unknown): boolean => {
53
32
  return false;
54
33
  }
55
34
 
56
- return TAG_NAMES.includes(el.tagName);
35
+ return el.tagName.startsWith("EDITABLE-");
57
36
  };
58
37
 
59
38
  export const isEditableElement = (el: unknown): boolean => {
@@ -62,19 +41,17 @@ export const isEditableElement = (el: unknown): boolean => {
62
41
  }
63
42
 
64
43
  return (
65
- TAG_NAMES.includes(el.tagName) ||
66
- (!!el.dataset.editable &&
67
- EDITABLE_REGION_TYPES.includes(el.dataset.editable))
44
+ el.tagName.startsWith("EDITABLE-") ||
45
+ typeof el.dataset.editable === "string"
68
46
  );
69
47
  };
70
48
 
71
- export const areEqualEditables = (a: HTMLElement, b: HTMLElement) => {
72
- if (
73
- a.tagName !== b.tagName &&
74
- a.dataset.editable !== b.dataset.editable &&
75
- CORRESPONDING_NAME[a.tagName] !== b.dataset.editable &&
76
- CORRESPONDING_NAME[b.tagName] !== a.dataset.editable
77
- ) {
49
+ export const areEqualEditables = (a: Element, b: Element) => {
50
+ if (!(a instanceof HTMLElement) || !(b instanceof HTMLElement)) {
51
+ return false;
52
+ }
53
+
54
+ if (getEditableType(a) !== getEditableType(b)) {
78
55
  return false;
79
56
  }
80
57
 
@@ -55,7 +55,6 @@ export const apiLoadedPromise = new Promise((resolve) => {
55
55
  export const addEditableComponentRenderer = (key, renderer) => {
56
56
  extendedWindow.cc_components = extendedWindow.cc_components || {};
57
57
  extendedWindow.cc_components[key] = renderer;
58
- document.dispatchEvent(new CustomEvent(`editable-regions:registered-${key}`));
59
58
  };
60
59
 
61
60
  /**
@@ -67,7 +66,6 @@ export const addEditableComponentRenderer = (key, renderer) => {
67
66
  export const addEditableSnippetRenderer = (key, renderer) => {
68
67
  extendedWindow.cc_snippets = extendedWindow.cc_snippets || {};
69
68
  extendedWindow.cc_snippets[key] = renderer;
70
- document.dispatchEvent(new CustomEvent(`editable-regions:registered-${key}`));
71
69
  };
72
70
 
73
71
  /**
@@ -107,4 +105,14 @@ export const realizeAPIValue = async (value) => {
107
105
  return value;
108
106
  };
109
107
 
108
+ export const addCustomEditableRegion = (key, region) => {
109
+ extendedWindow.editableRegionMap ??= {};
110
+ extendedWindow.editableRegionMap[key] = region;
111
+ extendedWindow.hydrateDataEditableRegions(document.body);
112
+ };
113
+
114
+ export const getCustomEditableRegions = () => {
115
+ return extendedWindow.editableRegionMap ?? {};
116
+ };
117
+
110
118
  export { _cloudcannon as CloudCannon };
@@ -9,7 +9,7 @@ import {
9
9
  } from "../nodes";
10
10
  import { hasEditable, isEditableWebcomponent } from "./checks";
11
11
 
12
- const editableMap: Record<string, typeof Editable | undefined> = {
12
+ const baseEditableMap: Record<string, typeof Editable | undefined> = {
13
13
  array: EditableArray,
14
14
  "array-item": EditableArrayItem,
15
15
  component: EditableComponent,
@@ -31,6 +31,10 @@ export const dehydrateDataEditableRegions = (root: Element) => {
31
31
  };
32
32
 
33
33
  export const hydrateDataEditableRegions = (root: Element) => {
34
+ const editableMap: Record<string, typeof Editable | undefined> = {
35
+ ...baseEditableMap,
36
+ ...((window as any).editableRegionMap ?? {}),
37
+ };
34
38
  if (
35
39
  root instanceof HTMLElement &&
36
40
  root.dataset.editable &&
@@ -89,3 +93,5 @@ export const hydrateDataEditableRegions = (root: Element) => {
89
93
  }
90
94
  });
91
95
  };
96
+
97
+ (window as any).hydrateDataEditableRegions = hydrateDataEditableRegions;
@@ -1,127 +1,136 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
- const SUPPORTED_VIRTUAL_MODULES = [
5
- "actions",
6
- "assets",
7
- "content",
8
- "i18n",
9
- "middleware",
10
- "transitions",
11
- ];
4
+ /** @type{string[]} */
5
+ const SUPPORTED_VIRTUAL_MODULES = ["assets", "content"];
6
+
7
+ /**
8
+ * @param {*} original
9
+ * @returns
10
+ */
11
+ function wrapTransform(original) {
12
+ /**
13
+ * @this {*}
14
+ * @param {*} source
15
+ * @param {*} id
16
+ * @param {*} options
17
+ */
18
+ return function (source, id, options) {
19
+ if (this.environment?.name === "client") {
20
+ const proxy = new Proxy(this, {
21
+ get(target, prop) {
22
+ if (prop === "environment") {
23
+ return { ...target.environment, name: "ssr" };
24
+ }
25
+ return Reflect.get(target, prop);
26
+ },
27
+ });
28
+ return original.call(proxy, source, id, { ...options, ssr: true });
29
+ }
30
+ return original.call(this, source, id, options);
31
+ };
32
+ }
12
33
 
13
34
  /**
14
35
  * @return {import("astro").AstroIntegration}
15
36
  */
16
37
  export default () => {
17
- /** @type {import("astro").AstroConfig} */
18
- let astroConfig;
19
-
20
38
  return {
21
39
  name: "editable-regions",
22
40
  hooks: {
23
- "astro:config:setup": ({ config, updateConfig }) => {
41
+ "astro:config:setup": ({ updateConfig }) => {
24
42
  updateConfig({
25
43
  vite: {
26
- define: {
27
- ENV_CLIENT: false,
28
- },
29
- },
30
- });
31
- astroConfig = config;
32
- },
33
- "astro:build:setup": async ({ target, vite }) => {
34
- if (target === "client") {
35
- vite.plugins ??= [];
36
- vite.define ??= {};
37
- vite.define.ENV_CLIENT = true;
44
+ plugins: [
45
+ {
46
+ enforce: "pre",
47
+ name: "vite-plugin-editable-regions",
48
+ applyToEnvironment(environment) {
49
+ return environment.name === "client";
50
+ },
51
+ config(config) {
52
+ config.environments ??= {};
38
53
 
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
- });
54
+ config.environments.ssr ??= {};
55
+ config.environments.ssr.define ??= {};
56
+ config.environments.ssr.define.ENV_CLIENT = false;
48
57
 
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({
61
- name: "vite-plugin-editable-regions",
62
- enforce: "pre",
63
-
64
- resolveId(id) {
65
- if (id.startsWith("astro:")) {
66
- const type = id
67
- .replace("astro:", "")
68
- .replace("/client", "")
69
- .replace("/server", "");
58
+ config.environments.astro ??= {};
59
+ config.environments.astro.define ??= {};
60
+ config.environments.astro.define.ENV_CLIENT = false;
70
61
 
71
- if (type === "env") {
72
- return "\0editable-region:env";
73
- }
62
+ config.environments.prerender ??= {};
63
+ config.environments.prerender.define ??= {};
64
+ config.environments.prerender.define.ENV_CLIENT = false;
74
65
 
75
- if (!SUPPORTED_VIRTUAL_MODULES.includes(type)) {
76
- return;
77
- }
66
+ config.environments.client ??= {};
67
+ config.environments.client.define ??= {};
68
+ config.environments.client.define.ENV_CLIENT = true;
69
+ },
70
+ configResolved(config) {
71
+ const flatPlugins = config.plugins?.flat(10);
72
+ const astroBuildPlugin = flatPlugins?.find((obj) => {
73
+ return (
74
+ obj &&
75
+ typeof obj === "object" &&
76
+ "name" in obj &&
77
+ obj.name === "astro:build"
78
+ );
79
+ });
78
80
 
79
- let dir = "";
80
- if (typeof __dirname !== "undefined") {
81
- dir = __dirname;
82
- } else {
83
- dir = dirname(fileURLToPath(import.meta.url));
84
- }
81
+ if (
82
+ astroBuildPlugin &&
83
+ "transform" in astroBuildPlugin &&
84
+ typeof astroBuildPlugin.transform === "object" &&
85
+ astroBuildPlugin.transform &&
86
+ "handler" in astroBuildPlugin.transform &&
87
+ typeof astroBuildPlugin.transform.handler === "function"
88
+ ) {
89
+ astroBuildPlugin.transform.handler = wrapTransform(
90
+ astroBuildPlugin.transform.handler,
91
+ );
92
+ } else if (
93
+ astroBuildPlugin &&
94
+ "transform" in astroBuildPlugin &&
95
+ typeof astroBuildPlugin.transform === "function"
96
+ ) {
97
+ astroBuildPlugin.transform = wrapTransform(
98
+ astroBuildPlugin.transform,
99
+ );
100
+ }
101
+ },
102
+ resolveId: {
103
+ order: "pre",
104
+ handler(id) {
105
+ if (id.startsWith("astro:")) {
106
+ const type = id
107
+ .replace("astro:", "")
108
+ .replace("/client", "")
109
+ .replace("/server", "");
85
110
 
86
- return join(dir, "modules", `${type}.js`);
87
- }
88
- },
111
+ let dir = "";
112
+ if (typeof __dirname !== "undefined") {
113
+ dir = __dirname;
114
+ } else {
115
+ dir = dirname(fileURLToPath(import.meta.url));
116
+ }
89
117
 
90
- load(id) {
91
- if (id === "\0editable-region:env") {
92
- let contents = "";
93
- Object.entries(astroConfig?.env?.schema ?? {}).forEach(
94
- ([key, schema]) => {
95
- if (
96
- schema.context !== "client" ||
97
- schema.access !== "public"
98
- ) {
99
- return;
100
- }
118
+ if (type === "env" && id.endsWith("/server")) {
119
+ return join(dir, "modules", "secrets.js");
120
+ }
101
121
 
102
- try {
103
- switch (schema.type) {
104
- case "boolean":
105
- contents += `export const ${key} = ${!!process.env[key]};\n`;
106
- break;
107
- case "number":
108
- contents += `export const ${key} = ${Number(process.env[key])};\n`;
109
- break;
110
- default:
111
- contents += `export const ${key} = ${JSON.stringify(process.env[key] ?? "")};\n`;
122
+ if (!SUPPORTED_VIRTUAL_MODULES.includes(type)) {
123
+ return;
112
124
  }
113
- } catch (_e) {
114
- //Error intentionally ignored
125
+
126
+ return join(dir, "modules", `${type}.js`);
115
127
  }
116
128
  },
117
- );
118
- contents +=
119
- 'export const getSecret = () => console.log("[CloudCannon] getSecret is not supported in an editable component. Please use an editing fallback instead.");';
120
- return contents;
121
- }
122
- },
123
- });
124
- }
129
+ },
130
+ },
131
+ ],
132
+ },
133
+ });
125
134
  },
126
135
  },
127
136
  };
@@ -131,31 +131,44 @@ export const registerAstroComponent = (key, component) => {
131
131
  props,
132
132
  resolve: () => "editable-region-placeholder",
133
133
  /**
134
- * @param {*} astroGlobal
135
- * @param {*} props
136
- * @param {*} slots
134
+ * @param {*} args
137
135
  */
138
- createAstro(astroGlobal, props, slots) {
136
+ createAstro(...args) {
137
+ if (args.length < 2 || args.length > 3) {
138
+ console.warn(
139
+ `[CloudCannon] createAstro called with unexpected number of arguments (${args.length})`,
140
+ );
141
+ }
142
+
143
+ let astroGlobal = SSRResult;
144
+ let componentProps, componentSlots;
145
+
146
+ if (args.length === 2) {
147
+ [componentProps, componentSlots] = args;
148
+ } else {
149
+ [astroGlobal, componentProps, componentSlots] = args;
150
+ }
151
+
139
152
  const astroSlots = {
140
153
  /**
141
154
  * @param {string} name
142
155
  * @returns boolean
143
156
  */
144
157
  has: (name) => {
145
- if (!slots) return false;
146
- return Boolean(slots[name]);
158
+ if (!componentSlots) return false;
159
+ return Boolean(componentSlots[name]);
147
160
  },
148
161
  /**
149
162
  * @param {string} name
150
163
  * @returns string
151
164
  */
152
165
  render: (name) => {
153
- return renderSlotToString(SSRResult, slots[name]);
166
+ return renderSlotToString(SSRResult, componentSlots[name]);
154
167
  },
155
168
  };
156
169
  return {
157
170
  __proto__: astroGlobal,
158
- props,
171
+ props: componentProps,
159
172
  slots: astroSlots,
160
173
  request: new Request(window.location.href),
161
174
  };
@@ -26,12 +26,18 @@ export const getCollection = async (collectionKey, filter) => {
26
26
  slug = data.slug;
27
27
  }
28
28
 
29
+ const body = await file.get();
29
30
  return {
30
31
  collection: collectionKey,
31
32
  id: id,
32
33
  data: data,
33
34
  slug: slug,
34
- body: await file.get(),
35
+ body: body,
36
+ render: () => ({
37
+ Content: () => body ?? "Content is not available when live editing",
38
+ headings: [],
39
+ remarkPluginFrontmatter: {},
40
+ }),
35
41
  };
36
42
  });
37
43
 
@@ -0,0 +1,4 @@
1
+ export const getSecret = () =>
2
+ console.log(
3
+ "[CloudCannon] getSecret is not supported in an editable component. Please use an editing fallback instead.",
4
+ );
@@ -249,8 +249,8 @@ export default class EditableArrayItem extends EditableComponent {
249
249
  );
250
250
  }
251
251
 
252
- async update(): Promise<void> {
253
- await super.update();
252
+ async update(partialSubtree?: ChildNode | null): Promise<void> {
253
+ await super.update(partialSubtree);
254
254
  this.updateControls();
255
255
  }
256
256
 
@@ -485,12 +485,12 @@ export default class EditableArrayItem extends EditableComponent {
485
485
  }
486
486
  }
487
487
 
488
- setupListeners(): void {
489
- super.setupListeners();
490
- if (!this.element.dataset.prop) {
491
- this.parent?.registerListener({
492
- editable: this,
493
- });
494
- }
488
+ getSpecialProps(
489
+ incomingSpecialProps: Record<string, unknown> = {},
490
+ ): Record<string, unknown> {
491
+ return {
492
+ ...super.getSpecialProps(incomingSpecialProps),
493
+ "@index": Number(this.element.dataset.prop),
494
+ };
495
495
  }
496
496
  }