@cocreate/elements 1.46.0 → 2.0.0

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,53 +1,36 @@
1
1
  import {
2
- getAttributes,
3
- checkValue
4
- } from "@cocreate/utils";
2
+ elements,
3
+ forms,
4
+ keys,
5
+ elementSubscriptions,
6
+ subscriptions
7
+ } from "./mapRegistry.js";
8
+ import { getSubscription } from "./subscribeHandler.js";
9
+ import { read, save } from "./crudHandler.js";
5
10
 
6
- import { elements, forms, keys } from "./mapRegistry.js";
7
- import { subscribe } from "./subscribeHandler.js";
11
+ import { environment, getDataObject } from "./dataObject.js";
12
+ export { getDataObject } from "./dataObject.js";
8
13
 
9
- /**
10
- * Parses and normalizes HTML attributes from an element into a canonical CRUD data object.
11
- * Infers execution type (object, index, array, database, storage, data) dynamically.
12
- *
13
- * @param {Element} element
14
- * @returns {Object|undefined} Normalized data payload object
15
- */
16
- export function getDataObject(element) {
17
- const data = getAttributes(element);
18
- const crudType = ["storage", "database", "array", "index", "object"];
19
-
20
- for (let i = 0; i < crudType.length; i++) {
21
- if (!checkValue(data[crudType[i]])) return;
14
+ function registerFormElement(form, element, data) {
15
+ if (!form || !data?.type) return;
22
16
 
23
- if (data[crudType[i]] && data[crudType[i]].includes(",")) {
24
- const array = data[crudType[i]].split(",");
25
- data[crudType[i]] = [];
17
+ if (!form.save) form.save = () => save(form);
18
+ if (!form.getData) form.getData = () => getData(form);
26
19
 
27
- for (let j = 0; j < array.length; j++) {
28
- const item = array[j].trim();
29
- if (crudType[i] === "object") {
30
- data[crudType[i]].push({ _id: item });
31
- } else {
32
- data[crudType[i]].push(item);
33
- }
34
- }
35
- }
20
+ let formObject = forms.get(form);
21
+ if (!formObject) {
22
+ formObject = {
23
+ elements: new Map(),
24
+ types: new Map()
25
+ };
26
+ forms.set(form, formObject);
36
27
  }
37
28
 
38
- if (data.object || data.object === "" || data.key || data.key === "") {
39
- if (!data.array || (data.array && !data.array.length)) return;
40
- data.type = "object";
41
- } else if (data.index || data.index === "") {
42
- if (!data.array || (data.array && !data.array.length)) return;
43
- data.type = "index";
44
- } else if (data.array || data.array === "") data.type = "array";
45
- else if (data.database || data.database === "") data.type = "database";
46
- else if (data.storage || data.storage === "") data.type = "storage";
47
- else if (data.data) data.type = "data";
48
-
49
- delete data.isRealtime;
50
- return data;
29
+ formObject.elements.set(element, data);
30
+ if (!formObject.types.has(data.type)) {
31
+ formObject.types.set(data.type, new Map());
32
+ }
33
+ formObject.types.get(data.type).set(element, data);
51
34
  }
52
35
 
53
36
  /**
@@ -59,7 +42,10 @@ export function getDataObject(element) {
59
42
  * @returns {{ string: string, object: Object }}
60
43
  */
61
44
  export function initDataKey(element, data) {
45
+ if (!data.database && environment) data.database = environment;
46
+
62
47
  const dataKey = getDataKey(data);
48
+ const subscription = getSubscription(data);
63
49
 
64
50
  if (keys.has(dataKey.string)) {
65
51
  keys.get(dataKey.string).elements.set(element, "");
@@ -69,34 +55,27 @@ export function initDataKey(element, data) {
69
55
  data,
70
56
  dataKey
71
57
  });
72
- subscribe(dataKey, data);
73
58
  }
74
59
 
75
- elements.set(element, dataKey.string);
76
-
77
- if (element.parentElement) {
78
- const form = element.closest("form");
79
- if (form) {
80
- if (!form.save) form.save = () => save(form);
81
- if (!form.getData) form.getData = () => getData(form);
82
-
83
- let formObject = forms.get(form);
84
- if (formObject) {
85
- formObject.elements.set(element, data);
86
- if (formObject.types.has(data.type)) {
87
- formObject.types.get(data.type).set(element, data);
88
- } else {
89
- formObject.types.set(data.type, new Map([[element, data]]));
90
- }
91
- } else {
92
- forms.set(form, {
93
- elements: new Map([[element, data]]),
94
- types: new Map([[data.type, new Map([[element, data]])]])
95
- });
96
- }
60
+ if (subscription) {
61
+ let subscriptionEntry = subscriptions.get(subscription.string);
62
+ if (subscriptionEntry) {
63
+ subscriptionEntry.elements.add(element);
64
+ } else {
65
+ subscriptionEntry = {
66
+ elements: new Set([element]),
67
+ subscription
68
+ };
69
+ subscriptions.set(subscription.string, subscriptionEntry);
97
70
  }
71
+ elementSubscriptions.set(element, subscription.string);
98
72
  }
99
73
 
74
+ elements.set(element, dataKey.string);
75
+
76
+ const form = element.form || element.closest?.("form");
77
+ registerFormElement(form, element, data);
78
+
100
79
  if (!element.read) element.read = () => read(element);
101
80
  if (!element.save) element.save = () => save(element);
102
81
 
@@ -113,6 +92,8 @@ export function initDataKey(element, data) {
113
92
  */
114
93
  export function getDataKey(data) {
115
94
  const dataKey = {};
95
+ if (!data.database && environment) data.database = environment;
96
+
116
97
  const attributes = [
117
98
  "host",
118
99
  "organization_id",
@@ -126,13 +107,13 @@ export function getDataKey(data) {
126
107
  "object",
127
108
  "key",
128
109
  "updateKey",
129
- "$filter",
110
+ "filter",
130
111
  "upsert",
131
112
  "namespace",
132
113
  "room",
133
114
  "broadcast",
134
115
  "broadcastSender",
135
- "broadcastBrowser"
116
+ "broadcastClient"
136
117
  ];
137
118
 
138
119
  for (const attribute of attributes) {
@@ -193,7 +174,7 @@ export function getDataKeys(data) {
193
174
  "array",
194
175
  "index",
195
176
  "object",
196
- "$filter"
177
+ "filter"
197
178
  ];
198
179
 
199
180
  for (const [keyString, sortedKey] of keys.entries()) {
@@ -201,8 +182,8 @@ export function getDataKeys(data) {
201
182
 
202
183
  for (const key of targetKeys) {
203
184
  if (
204
- !data.$filter &&
205
- key === "$filter" &&
185
+ !data.filter &&
186
+ key === "filter" &&
206
187
  sortedKey.data.hasOwnProperty(key)
207
188
  ) {
208
189
  hasMatch = true;
@@ -211,25 +192,28 @@ export function getDataKeys(data) {
211
192
  hasMatch = false;
212
193
  break;
213
194
  }
195
+
196
+ // Filtered object elements watch a collection query, not one
197
+ // specific object. Creates and updates contain the affected
198
+ // object(s), so their object shape will not match the empty
199
+ // object scope used when the filtered element was registered.
200
+ if (key === "object" && sortedKey.data.filter) continue;
201
+
214
202
  if (
215
203
  Array.isArray(sortedKey.data[key]) &&
216
204
  Array.isArray(data[key])
217
205
  ) {
218
- if (key === "object" && sortedKey.data.$filter) {
219
- hasMatch = true;
220
- } else {
221
- const matches = sortedKey.data[key].some((value) => {
222
- if (key === "object") {
223
- return data[key].some((obj) => obj._id === value._id);
224
- } else {
225
- return data[key].includes(value);
226
- }
227
- });
228
-
229
- if (!matches) {
230
- hasMatch = false;
231
- break;
206
+ const matches = sortedKey.data[key].some((value) => {
207
+ if (key === "object") {
208
+ return data[key].some((obj) => obj._id === value._id);
209
+ } else {
210
+ return data[key].includes(value);
232
211
  }
212
+ });
213
+
214
+ if (!matches) {
215
+ hasMatch = false;
216
+ break;
233
217
  }
234
218
  } else if (sortedKey.data[key] !== data[key]) {
235
219
  hasMatch = false;
@@ -264,7 +248,7 @@ function formatPayload(payload, value, element) {
264
248
  } else payload.object = { ...payload.object, ...value };
265
249
  } else payload.object[payload.key] = value;
266
250
  }
267
- if (payload.isUpsert && payload.$filter)
251
+ if (payload.isUpsert && payload.filter)
268
252
  payload.method = "object.update";
269
253
  } else payload[payload.type] = value;
270
254
  } else if (payload[payload.type] && payload.key) {
@@ -352,19 +336,27 @@ function formatPayload(payload, value, element) {
352
336
  */
353
337
  export async function getData(form) {
354
338
  const dataKeys = new Map();
355
- const formObject = forms.get(form);
356
-
357
- if (!formObject) {
358
- const selector = "[storage], [database], [array], [object]";
359
- const elementsInForm = form.querySelectorAll(selector);
360
- await init(elementsInForm);
361
- return await getData(form);
339
+ const selector = "[storage], [database], [array], [object]";
340
+
341
+ // form.elements includes controls associated through form="id", even when
342
+ // the controller lives elsewhere in the document.
343
+ for (const element of Array.from(form.elements || [])) {
344
+ if (!element.matches(selector) || element.closest("[template]")) continue;
345
+
346
+ // A dataKey identifies the document scope and intentionally excludes
347
+ // the field-level `key`. Reusing the registry entry's data here would
348
+ // therefore give every control the key of the first registered field.
349
+ const data = getDataObject(element);
350
+ registerFormElement(form, element, data);
362
351
  }
363
352
 
353
+ const formObject = forms.get(form);
354
+ if (!formObject) return [];
364
355
 
365
356
  for (const type of formObject.types.values()) {
366
357
  for (const [element, data] of type.entries()) {
367
358
  if (
359
+ element.closest("[template]") ||
368
360
  !element.hasAttribute("key") ||
369
361
  element.getAttribute("save") === "false"
370
362
  ) {
@@ -384,6 +376,17 @@ export async function getData(form) {
384
376
  const value = await element.getValue();
385
377
  if (value === undefined) continue;
386
378
 
379
+ // A form can expose multiple file sources (for example, device and
380
+ // camera) while only one source has a pending selection. An unused
381
+ // file input must not produce an empty object.create operation.
382
+ if (
383
+ element.matches('input[type="file"]') &&
384
+ Array.isArray(value) &&
385
+ value.length === 0
386
+ ) {
387
+ continue;
388
+ }
389
+
387
390
  const payload = { ...data };
388
391
  const dataKey = elements.get(element);
389
392
 
@@ -414,4 +417,4 @@ export default {
414
417
  getDataKeys,
415
418
  getData,
416
419
  getDataObject
417
- };
420
+ };
@@ -0,0 +1,104 @@
1
+ import Actions from "@cocreate/actions";
2
+ import { queryElements } from "@cocreate/utils";
3
+
4
+ const selector = "[href], [target]";
5
+
6
+ function init() {
7
+ document.link ||= { islink: "true" };
8
+ document.addEventListener("click", linkEvent, true);
9
+ }
10
+
11
+ // Delegation also handles links inserted after this feature loads.
12
+ function linkEvent(event) {
13
+ const target = event.target.closest?.(selector);
14
+ if (!target || target.hasAttribute("download") || target.closest("[actions]")) return;
15
+ open(target, event);
16
+ }
17
+
18
+ function open(target, event) {
19
+ if (!target) return;
20
+ const doc = target.ownerDocument || document;
21
+ if (doc.link?.islink === "false" || doc.link?.islink === false) return;
22
+ if (!target.getAttribute("href")) return;
23
+ event?.preventDefault();
24
+ openLink(target);
25
+ }
26
+
27
+ function openLink(link) {
28
+ const href = link.getAttribute("href");
29
+ const target = link.getAttribute("target");
30
+
31
+ if (!target || target === "_self") {
32
+ const currentUrl = new URL(location.href);
33
+ const targetUrl = new URL(href, location.href);
34
+ const currentPath = currentUrl.pathname.replace("/index.html", "/");
35
+ const targetPath = targetUrl.pathname.replace("/index.html", "/");
36
+ if (currentPath !== targetPath || currentUrl.hash !== targetUrl.hash) {
37
+ location.href = href;
38
+ }
39
+ } else if (target === "_window") {
40
+ window.open(href, "_blank", "location=yes,height=570,width=520,scrollbars=yes,status=yes");
41
+ } else {
42
+ window.open(href, target);
43
+ }
44
+ }
45
+
46
+ // Actions passes its chain element; resolve its associated link before opening it.
47
+ function href(element, context) {
48
+ const link = element.closest("[href], [target], [state_to]") ||
49
+ element.querySelector("[href], [target], [state_to]");
50
+ if (!link || link.hasAttribute("download")) return;
51
+ return open(link, context?.event);
52
+ }
53
+
54
+ function linkDocuments(element) {
55
+ const targets = element.hasAttribute("link-query")
56
+ ? queryElements({ element, prefix: "link" }) : [document];
57
+ return new Set(targets.map(target => target.nodeType === 9 ? target :
58
+ target.contentDocument || target.document || target.ownerDocument).filter(Boolean));
59
+ }
60
+
61
+ function disableHref(element) {
62
+ for (const doc of linkDocuments(element)) {
63
+ doc.link = { islink: "false" };
64
+ doc.removeEventListener("click", linkEvent, true);
65
+ doc.addEventListener("click", preventDefault, true);
66
+ }
67
+ }
68
+
69
+ function enableHref(element) {
70
+ for (const doc of linkDocuments(element)) {
71
+ doc.link = { islink: "true" };
72
+ doc.removeEventListener("click", preventDefault, true);
73
+ doc.addEventListener("click", linkEvent, true);
74
+ }
75
+ }
76
+
77
+ function preventDefault(event) {
78
+ event.preventDefault();
79
+ }
80
+
81
+ Actions.init([
82
+ {
83
+ name: "disableHref",
84
+ endEvent: "disableHref",
85
+ callback: action => {
86
+ disableHref(action.element);
87
+ action.end();
88
+ }
89
+ },
90
+ {
91
+ name: "enableHref",
92
+ endEvent: "enableHref",
93
+ callback: action => {
94
+ enableHref(action.element);
95
+ action.end();
96
+ }
97
+ }
98
+ ]);
99
+
100
+ Actions.href = href;
101
+ init();
102
+
103
+ export { open, disableHref, enableHref };
104
+ export default { open, disableHref, enableHref };
@@ -0,0 +1,90 @@
1
+ import observer from "@cocreate/observer";
2
+ import { relayEvents } from "@cocreate/utils/iframe";
3
+
4
+ export function clickedElement(frames) {
5
+ for (const frame of frames) {
6
+ try {
7
+ const frameDocument = frame.contentDocument;
8
+ if (frameDocument.clickedElement === undefined) {
9
+ frameDocument.addEventListener("click", (e) => {
10
+ frame.clickedElement = e.target;
11
+ frameDocument.clickedElement = e.target;
12
+ const ClickedElementEvent = frame.ownerDocument.defaultView.CustomEvent;
13
+ frame.dispatchEvent(new ClickedElementEvent("clickedElement", {
14
+ bubbles: true,
15
+ detail: {
16
+ cursor: { x: e.clientX, y: e.clientY },
17
+ document: frameDocument
18
+ }
19
+ }));
20
+ });
21
+ frameDocument.clickedElement = null;
22
+ }
23
+ } catch (iframeError) {
24
+ console.log(
25
+ `Cross-origin frame handling failed for: ${frame}`,
26
+ iframeError
27
+ );
28
+ }
29
+ }
30
+ }
31
+
32
+ export function hoveredElement(frames) {
33
+ for (const frame of frames) {
34
+ try {
35
+ const frameDocument = frame.contentDocument;
36
+ if (frameDocument.hoveredElement === undefined) {
37
+ frameDocument.hoveredElement = null;
38
+ frame.hoveredElement = null;
39
+
40
+ const emitHoveredElement = (event) => {
41
+ const HoveredElementEvent = frame.ownerDocument.defaultView.CustomEvent;
42
+ frame.dispatchEvent(new HoveredElementEvent("hoveredElement", {
43
+ bubbles: true,
44
+ detail: {
45
+ cursor: { x: event.clientX, y: event.clientY },
46
+ document: frameDocument
47
+ }
48
+ }));
49
+ };
50
+
51
+ frameDocument.addEventListener("mouseover", (event) => {
52
+ frame.hoveredElement = event.target;
53
+ frameDocument.hoveredElement = event.target;
54
+ emitHoveredElement(event);
55
+ });
56
+ frameDocument.addEventListener("mouseout", (event) => {
57
+ const nextElement = event.relatedTarget;
58
+ frame.hoveredElement = nextElement;
59
+ frameDocument.hoveredElement = nextElement;
60
+ emitHoveredElement(event);
61
+ });
62
+ frameDocument.addEventListener("mousemove", (event) => {
63
+ frame.hoveredElement = event.target;
64
+ frameDocument.hoveredElement = event.target;
65
+ emitHoveredElement(event);
66
+ });
67
+ }
68
+ } catch (iframeError) {
69
+ console.log(
70
+ `Cross-origin frame handling failed for: ${frame}`,
71
+ iframeError
72
+ );
73
+ }
74
+ }
75
+ }
76
+
77
+ clickedElement(document.querySelectorAll("iframe"));
78
+ hoveredElement(document.querySelectorAll("iframe"));
79
+ relayEvents(document.querySelectorAll("iframe"));
80
+
81
+ observer.init({
82
+ name: "CoCreateIframes",
83
+ types: ["addedNodes"],
84
+ selector: "iframe",
85
+ callback: (mutation) => {
86
+ clickedElement([mutation.target]);
87
+ hoveredElement([mutation.target]);
88
+ relayEvents([mutation.target]);
89
+ }
90
+ });