@widgetic/editor 4.0.10 → 4.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.
Files changed (55) hide show
  1. package/README.md +118 -0
  2. package/dist/components/WidgetPreview.svelte +65 -27
  3. package/dist/components/WidgetPreview.svelte.d.ts +8 -0
  4. package/dist/components/editors/CompositionEditorApp.svelte +508 -0
  5. package/dist/components/editors/CompositionEditorApp.svelte.d.ts +75 -0
  6. package/dist/components/editors/PropsEditor.svelte +193 -792
  7. package/dist/components/editors/PropsEditor.svelte.d.ts +7 -0
  8. package/dist/components/ic/BrowserInputController.svelte +21 -2
  9. package/dist/components/ic/ColorPickerInputController.svelte +24 -6
  10. package/dist/components/ic/DateFieldInputController.svelte +7 -3
  11. package/dist/components/ic/DatePickerInputController.svelte +10 -4
  12. package/dist/components/ic/DropdownInputController.svelte +11 -4
  13. package/dist/components/ic/FontInputController.svelte +68 -49
  14. package/dist/components/ic/InputControllerHeader.svelte +11 -8
  15. package/dist/components/ic/NumberInputController.svelte +4 -2
  16. package/dist/components/ic/PositionInputController.svelte +14 -11
  17. package/dist/components/ic/RangeCalendarInputController.svelte +10 -4
  18. package/dist/components/ic/RoundedCornersInputController.svelte +17 -9
  19. package/dist/components/ic/TextAreaInputController.svelte +20 -7
  20. package/dist/components/ic/TextAreaPreviewInputController.svelte +19 -69
  21. package/dist/components/ic/TimeInputController.svelte +9 -3
  22. package/dist/components/ic/VectorInputController.svelte +80 -54
  23. package/dist/components/ic/shared/ItemGrid.svelte +5 -1
  24. package/dist/composition/compositionApi.d.ts +95 -0
  25. package/dist/composition/compositionApi.js +289 -0
  26. package/dist/composition/compositionEditorController.d.ts +254 -0
  27. package/dist/composition/compositionEditorController.js +734 -0
  28. package/dist/composition/compositionHelpers.d.ts +72 -0
  29. package/dist/composition/compositionHelpers.js +403 -0
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.js +4 -0
  32. package/dist/mediaLibraryContext.d.ts +10 -0
  33. package/dist/mediaLibraryContext.js +30 -0
  34. package/dist/utils/FontManager.d.ts +21 -5
  35. package/dist/utils/FontManager.js +62 -67
  36. package/dist/utils/VectorManager.d.ts +0 -1
  37. package/dist/utils/VectorManager.js +10 -12
  38. package/dist/utils/editorStorage.d.ts +23 -0
  39. package/dist/utils/editorStorage.js +95 -0
  40. package/dist/utils/fontUtils.js +5 -3
  41. package/dist/utils/htmlSafety.d.ts +21 -0
  42. package/dist/utils/htmlSafety.js +38 -0
  43. package/dist/utils/itemReorder.d.ts +90 -0
  44. package/dist/utils/itemReorder.js +354 -0
  45. package/dist/utils/recentColors.js +4 -2
  46. package/dist/utils/richTextPreview.d.ts +38 -0
  47. package/dist/utils/richTextPreview.js +207 -0
  48. package/dist/utils/scrollActivity.d.ts +14 -0
  49. package/dist/utils/scrollActivity.js +63 -0
  50. package/dist/utils/shadowDom.d.ts +67 -0
  51. package/dist/utils/shadowDom.js +211 -0
  52. package/dist/utils/vectorUtils.js +5 -3
  53. package/dist/utils/visibilityCondition.d.ts +40 -0
  54. package/dist/utils/visibilityCondition.js +535 -0
  55. package/package.json +6 -5
@@ -66,6 +66,9 @@
66
66
  } from '../../utils/vectorUtils';
67
67
  import type { VectorData } from '../../utils/VectorManager';
68
68
  import VectorManager from '../../utils/VectorManager';
69
+ import { escapeHtml } from '../../utils/htmlSafety';
70
+ import { listenForScroll, queryInTreeOf } from '../../utils/shadowDom';
71
+ import { readEditorStorageValue, writeEditorStorageValue } from '../../utils/editorStorage';
69
72
 
70
73
  // Constants
71
74
  const VECTOR_FILTER_KEY = (id: string) => `vector-specific-filter-state-${id}`;
@@ -109,6 +112,8 @@
109
112
 
110
113
  // Vector loading optimization
111
114
  let vectorListContainer: HTMLElement;
115
+ // The control's own element: it says which tree (the page or a module's shadow root) to listen for scrolling in
116
+ let controllerElement: HTMLDivElement | undefined;
112
117
  let vectorObserver = new IntersectionObserver(() => {});
113
118
 
114
119
  // Initialize VectorManager instance
@@ -175,6 +180,15 @@
175
180
  `;
176
181
  }
177
182
 
183
+ /**
184
+ * The sticky header of the vector list. It is looked up in the tree the list lives in (inside a module that is a
185
+ * shadow root, which `document.querySelector` cannot see into), and the id is escaped so that one with dots or
186
+ * brackets in it is found too.
187
+ */
188
+ function findStickyHeader(): Element | null {
189
+ return queryInTreeOf(vectorListContainer, `#${CSS.escape(`vector-header-${controllerId}`)}`);
190
+ }
191
+
178
192
  // Function to update scroll state
179
193
  function updateScrollState() {
180
194
  if (vectorListContainer) {
@@ -183,7 +197,7 @@
183
197
 
184
198
  // If we're transitioning from scrolled to not scrolled, remove transition
185
199
  if (wasScrolled && !isScrolled) {
186
- const stickyHeader = document.querySelector(`#vector-header-${controllerId}`);
200
+ const stickyHeader = findStickyHeader();
187
201
  if (stickyHeader) {
188
202
  stickyHeader.classList.remove('transition-border');
189
203
  // Force a reflow to ensure the transition class removal takes effect
@@ -191,7 +205,7 @@
191
205
  }
192
206
  } else if (!wasScrolled && isScrolled) {
193
207
  // Add transition class when starting to scroll
194
- const stickyHeader = document.querySelector(`#vector-header-${controllerId}`);
208
+ const stickyHeader = findStickyHeader();
195
209
  if (stickyHeader) {
196
210
  stickyHeader.classList.add('transition-border');
197
211
  }
@@ -314,31 +328,14 @@
314
328
  await handleSearch(event);
315
329
  }, 300); // 300ms debounce time
316
330
 
317
- // Setup global error handler for SVG loading errors
318
- const vectorErrorHandler = (event: Event) => {
319
- if (event instanceof CustomEvent && event.detail) {
320
- const vectorName = event.detail;
321
- // Check if we can get the vector URL from allVectors
322
- const vectorData = allVectors.find(v => v.name === vectorName);
323
- const vectorUrl = vectorData?.url || 'unknown';
324
-
325
- console.error(`❌ [VectorInput] Failed to load SVG: "${vectorName}" from URL: ${vectorUrl}`);
326
- svgLoadErrors.set(vectorName, true);
327
- // Force component update
328
- svgLoadErrors = new Map(svgLoadErrors);
329
- }
330
- };
331
-
332
- // Add the event listener
333
- document.addEventListener('vector-load-error', vectorErrorHandler);
334
-
335
- // Store the handler reference for cleanup in onDestroy
336
- (window as any)['vectorErrorHandler'] = vectorErrorHandler;
331
+ // SVG loading errors are noticed by the watchVectorImageErrors action on the list of pictures. They used to be
332
+ // announced on `document` by an inline onerror handler and caught here; with the editor inside a partner's page
333
+ // that was an event on the partner's document and a handler stored on the partner's `window`.
337
334
 
338
335
  try {
339
336
  if (browser) {
340
337
  // Load saved filter
341
- const savedFilter = localStorage.getItem(VECTOR_FILTER_KEY(controllerId));
338
+ const savedFilter = readEditorStorageValue(VECTOR_FILTER_KEY(controllerId));
342
339
  if (savedFilter) {
343
340
  currentFilter = savedFilter as FilterType;
344
341
  }
@@ -493,7 +490,7 @@
493
490
  lastActiveFilter = currentFilter;
494
491
  currentFilter = 'All'; // Reset to All when starting a search
495
492
  if (browser) {
496
- localStorage.setItem(VECTOR_FILTER_KEY(controllerId), 'All');
493
+ writeEditorStorageValue(VECTOR_FILTER_KEY(controllerId), 'All');
497
494
  }
498
495
  }
499
496
 
@@ -970,16 +967,7 @@
970
967
  }
971
968
 
972
969
  onDestroy(() => {
973
- window.removeEventListener('scroll', handleGlobalScroll, true);
974
- window.removeEventListener('touchstart', () => {});
975
- window.removeEventListener('touchmove', () => {});
976
-
977
- // Remove vector load error listener using the stored reference
978
- const vectorErrorHandler = (window as any)['vectorErrorHandler'];
979
- if (vectorErrorHandler) {
980
- document.removeEventListener('vector-load-error', vectorErrorHandler);
981
- delete (window as any)['vectorErrorHandler'];
982
- }
970
+ stopListeningForScroll();
983
971
 
984
972
  if (vectorObserver) {
985
973
  vectorObserver.disconnect();
@@ -1030,7 +1018,7 @@
1030
1018
  lastActiveFilter = filter; // Remember the last active filter
1031
1019
 
1032
1020
  if (browser) {
1033
- localStorage.setItem(VECTOR_FILTER_KEY(controllerId), filter);
1021
+ writeEditorStorageValue(VECTOR_FILTER_KEY(controllerId), filter);
1034
1022
  }
1035
1023
 
1036
1024
  if (vectorManagerInstance) {
@@ -1104,25 +1092,26 @@
1104
1092
  }
1105
1093
 
1106
1094
  /**
1107
- * Highlights search term matches in vector names
1095
+ * Highlights search term matches in vector names.
1096
+ * The result is meant for {@html}, so every piece of the name is escaped as it is put in.
1108
1097
  */
1109
1098
  function highlightMatch(name: string, term: string) {
1110
- if (!term) return name;
1099
+ if (!term) return escapeHtml(name);
1111
1100
  const nameLower = name.toLowerCase();
1112
1101
  const termLower = term.toLowerCase().trim();
1113
1102
 
1114
1103
  // If the name contains the search term, highlight that part in blue
1115
1104
  if (termLower && nameLower.includes(termLower)) {
1116
1105
  const index = nameLower.indexOf(termLower);
1117
- const beforeMatch = name.slice(0, index);
1118
- const match = name.slice(index, index + termLower.length);
1119
- const afterMatch = name.slice(index + termLower.length);
1106
+ const beforeMatch = escapeHtml(name.slice(0, index));
1107
+ const match = escapeHtml(name.slice(index, index + termLower.length));
1108
+ const afterMatch = escapeHtml(name.slice(index + termLower.length));
1120
1109
 
1121
1110
  return `<span style="color: hsl(var(--grey7))">${beforeMatch}<span style="color: #45BCFE">${match}</span>${afterMatch}</span>`;
1122
1111
  }
1123
1112
 
1124
1113
  // Default case - just return the name in grey7 color
1125
- return `<span style="color: hsl(var(--grey7))">${name}</span>`;
1114
+ return `<span style="color: hsl(var(--grey7))">${escapeHtml(name)}</span>`;
1126
1115
  }
1127
1116
 
1128
1117
 
@@ -1173,15 +1162,16 @@
1173
1162
  }
1174
1163
  }
1175
1164
 
1176
- // Add scroll listener with passive option
1177
- window.addEventListener('scroll', handleGlobalScroll, {
1178
- capture: true,
1179
- passive: true
1165
+ // Add scroll listener with passive option. It listens to the page and, inside a module, to the module's own panels
1166
+ // (a scroll never leaves a shadow root, so a listener on the window alone does not hear about those).
1167
+ let stopListeningForScroll: () => void = () => {};
1168
+ onMount(() => {
1169
+ stopListeningForScroll = listenForScroll(controllerElement, handleGlobalScroll, { passive: true });
1180
1170
  });
1181
1171
 
1182
- // Add touch listeners with passive option
1183
- window.addEventListener('touchstart', () => {}, { passive: true });
1184
- window.addEventListener('touchmove', () => {}, { passive: true });
1172
+ // (Two touchstart/touchmove listeners that did nothing used to be added to the window here, and could never be
1173
+ // removed: each input left two more on the customer's page every time it was shown. They did nothing, so they are
1174
+ // gone.)
1185
1175
 
1186
1176
  // Add to VectorInputController.svelte
1187
1177
  function handleToggle() {
@@ -1299,6 +1289,37 @@
1299
1289
  removeVector(vector.name);
1300
1290
  }
1301
1291
 
1292
+ /**
1293
+ * Svelte action for the list that holds the vector pictures: notices a picture that does not load and shows the
1294
+ * "Failed to load" state for that vector. `error` events do not bubble, so it listens in the capture phase; a
1295
+ * picture is found by its data-vector-name (see renderVectorItem). It replaces an inline onerror attribute that
1296
+ * reported through `document`, which is the partner's page when the editor runs inside one.
1297
+ */
1298
+ function watchVectorImageErrors(node: HTMLElement) {
1299
+ const onImageError = (event: Event) => {
1300
+ const image = event.target;
1301
+ if (!(image instanceof HTMLImageElement)) return;
1302
+ const vectorName = image.dataset.vectorName;
1303
+ if (!vectorName) return;
1304
+ image.style.display = 'none';
1305
+ // Reported once per vector, however many pictures of it are on screen.
1306
+ if (hasSvgLoadError(vectorName)) return;
1307
+ const vectorData = allVectors.find(vector => vector.name === vectorName);
1308
+ console.error(`❌ [VectorInput] Failed to load SVG: "${vectorName}" from URL: ${vectorData?.url || image.src || 'unknown'}`);
1309
+ svgLoadErrors.set(vectorName, true);
1310
+ // Force component update
1311
+ svgLoadErrors = new Map(svgLoadErrors);
1312
+ };
1313
+
1314
+ node.addEventListener('error', onImageError, true);
1315
+
1316
+ return {
1317
+ destroy() {
1318
+ node.removeEventListener('error', onImageError, true);
1319
+ }
1320
+ };
1321
+ }
1322
+
1302
1323
  // Create a Svelte action to handle image error events
1303
1324
  function handleImageError(node: HTMLElement, vectorName: string) {
1304
1325
  const errorHandler = (event: Event) => {
@@ -1345,15 +1366,19 @@
1345
1366
  };
1346
1367
  }
1347
1368
 
1369
+ // The result is used as HTML by RecentItemsSection and ItemGrid: the name and the address of a vector (a custom
1370
+ // one is named after the person's own file) are escaped as they are put in. A picture that does not load is
1371
+ // noticed by watchVectorImageErrors, which finds it by data-vector-name; an inline onerror="..." would not run
1372
+ // under a strict Content-Security-Policy.
1348
1373
  return {
1349
1374
  html: `
1350
1375
  <div class="flex items-center justify-center w-full h-full vector-img-container">
1351
1376
  <img
1352
- src="${vector.url}"
1353
- alt="${vector.name}"
1377
+ src="${escapeHtml(vector.url)}"
1378
+ alt="${escapeHtml(vector.name)}"
1379
+ data-vector-name="${escapeHtml(vector.name)}"
1354
1380
  class="w-[72px] h-[72px] object-contain"
1355
1381
  draggable="false"
1356
- onerror="this.style.display='none'; document.dispatchEvent(new CustomEvent('vector-load-error', {detail: this.alt, bubbles: true})); console.error('❌ [VectorInput] Vector failed to load:', this.alt, 'URL:', this.src);"
1357
1382
  />
1358
1383
  </div>
1359
1384
  `
@@ -1399,7 +1424,7 @@
1399
1424
  lastActiveFilter = currentFilter;
1400
1425
  currentFilter = 'All'; // Reset to All when starting a search
1401
1426
  if (browser) {
1402
- localStorage.setItem(VECTOR_FILTER_KEY(controllerId), 'All');
1427
+ writeEditorStorageValue(VECTOR_FILTER_KEY(controllerId), 'All');
1403
1428
  }
1404
1429
  }
1405
1430
 
@@ -1472,7 +1497,7 @@
1472
1497
  }
1473
1498
  </script>
1474
1499
 
1475
- <div class="flex flex-col gap-2 widgetic-colors">
1500
+ <div bind:this={controllerElement} class="flex flex-col gap-2 widgetic-colors">
1476
1501
  <div class="flex items-center w-full relative">
1477
1502
  <div class="w-[185px] flex-shrink-0 ml-auto">
1478
1503
  <InputControllerHeader
@@ -1617,7 +1642,7 @@
1617
1642
  // Restore the previous filter when clearing search
1618
1643
  currentFilter = lastActiveFilter;
1619
1644
  if (browser) {
1620
- localStorage.setItem(VECTOR_FILTER_KEY(controllerId), lastActiveFilter);
1645
+ writeEditorStorageValue(VECTOR_FILTER_KEY(controllerId), lastActiveFilter);
1621
1646
  }
1622
1647
 
1623
1648
  // Reset batch index to start fresh
@@ -1691,6 +1716,7 @@
1691
1716
  <div
1692
1717
  class="flex-1 {filteredVectors.length > 0 ? 'overflow-y-auto' : 'overflow-hidden'} min-h-0 vector-list-container"
1693
1718
  bind:this={vectorListContainer}
1719
+ use:watchVectorImageErrors
1694
1720
  onscroll={(e: Event) => {
1695
1721
  e.stopPropagation();
1696
1722
  handleVectorListScroll(e);
@@ -8,9 +8,13 @@
8
8
  * - Handles item selection
9
9
  * - Supports custom item rendering
10
10
  * - Optimized with intersection observer
11
+ *
12
+ * `renderItem` may return a plain string (shown as text) or `{ html }`. The `html` is put on the page as it is, so the
13
+ * function that makes it must escape everything that comes from outside (names, addresses): see escapeHtml.
11
14
  -->
12
15
  <script lang="ts">
13
16
  import { Check } from '@widgetic/design-system/icons';
17
+ import { escapeHtml } from '../../../utils/htmlSafety';
14
18
 
15
19
  export let items: any[] = [];
16
20
  export let containerRef: (node: HTMLElement) => void;
@@ -41,7 +45,7 @@
41
45
  function renderContent(item: any) {
42
46
  const rendered = renderItem(item);
43
47
  if (typeof rendered === 'string') {
44
- return `<span>${rendered}</span>`;
48
+ return `<span>${escapeHtml(rendered)}</span>`;
45
49
  }
46
50
 
47
51
  if (rendered && typeof rendered === 'object' && 'html' in rendered) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The calls an embedded composition editor makes to the Widgetic API, over `fetch`.
3
+ *
4
+ * Why not the generated SDK: the editor runs in a customer's browser and may only call a short list of routes
5
+ * (those an editor session allows), the list is fixed, and it must work with whatever token the host page gives it.
6
+ * A client of its own keeps the editor small and independent of the SDK's release cycle, and it keeps the few
7
+ * rules that matter in one place:
8
+ *
9
+ * - the token is asked from the host page before each request (`getAccessToken('initial')`); when the API
10
+ * answers 401 it is asked again (`'expired'`) and the request is sent once more with a token that is new;
11
+ * - a save carries an `Idempotency-Key` (the API requires one on every PUT from a session), kept the same when
12
+ * the request is sent again after a 401, so one save is never applied twice;
13
+ * - only the fields editing changes can be sent (`SaveCompositionBody`); a session is refused any other;
14
+ * - every failure is a `CompositionApiError` with the API's own code, so the host can tell an ended session
15
+ * (`EDITOR_SESSION_EXPIRED`...) from a missing composition (`NOT_FOUND`) from a widget that was never
16
+ * published (`WIDGET_NOT_PUBLISHED`);
17
+ * - a request that gets no answer does not hang the editor: it fails with `TIMEOUT` after a set time.
18
+ */
19
+ import { type JsonRecord } from './compositionHelpers.js';
20
+ /** Why the token is being asked for. `expired` means the API just refused the previous one. */
21
+ export type TokenReason = 'initial' | 'expired';
22
+ /** The fields of a composition an editor may change. Anything else is refused by the API for an editor session. */
23
+ export interface SaveCompositionBody {
24
+ name?: string;
25
+ content?: JsonRecord;
26
+ design?: JsonRecord;
27
+ embedSettings?: JsonRecord;
28
+ }
29
+ /** Where to show a composition: the widget build the preview frame loads (`GET /compositions/{id}/preview`). */
30
+ export interface CompositionPreviewInfo {
31
+ compositionId: string;
32
+ widgetId: string;
33
+ version: number;
34
+ previewUrl: string;
35
+ }
36
+ export interface CompositionApiOptions {
37
+ /** The API's address including the version, like `https://api.widgetic.com/v1`. */
38
+ apiBasePath: string;
39
+ /**
40
+ * Gives the token to call the API with. Called before every request, so a host that keeps its token fresh is
41
+ * always used up to date; called again with `'expired'` when the API answers 401, after which the request is
42
+ * sent once more if (and only if) the token that comes back is a different one. That second call also gets the
43
+ * token that was refused, so a host can tell the refusal of its current token (get a new one) from the late
44
+ * refusal of one it has already replaced (hand out the new one) when several requests are refused together.
45
+ */
46
+ getAccessToken: (reason: TokenReason, refusedToken?: string) => Promise<string> | string;
47
+ /** The `fetch` to use. Default: the page's own. */
48
+ fetch?: typeof fetch;
49
+ /** Makes the key a save is identified by (8 to 50 letters, digits, dashes and underscores). Default: a random UUID. */
50
+ createIdempotencyKey?: () => string;
51
+ /** How long to wait for an answer before giving up with `TIMEOUT`, in milliseconds. Default: 30 seconds. */
52
+ requestTimeoutMs?: number;
53
+ }
54
+ /** Options of one call. */
55
+ export interface CompositionRequestOptions {
56
+ /** Cancels the request: the call then fails with `ABORTED`. */
57
+ signal?: AbortSignal;
58
+ }
59
+ export interface SaveCompositionOptions extends CompositionRequestOptions {
60
+ /** Identifies this save. Give the same key to send the same save again; default: a new one. */
61
+ idempotencyKey?: string;
62
+ }
63
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
64
+ /** Codes the client itself reports (the API's own codes, such as `NOT_FOUND`, are passed through as they are). */
65
+ export type ClientErrorCode = 'NETWORK' | 'TIMEOUT' | 'ABORTED' | 'TOKEN_UNAVAILABLE' | 'INVALID_RESPONSE';
66
+ /** A request to the API that did not give the answer wanted. */
67
+ export declare class CompositionApiError extends Error {
68
+ /** The HTTP status; `0` when no answer arrived (network, timeout, abort, or no token to send). */
69
+ readonly status: number;
70
+ /** The API's error code (`NOT_FOUND`, `EDITOR_SESSION_EXPIRED`...), or one of {@link ClientErrorCode}. */
71
+ readonly code: string;
72
+ /** The API's correlation id for the failed request, to quote in a support request. */
73
+ readonly correlationId?: string;
74
+ constructor(message: string, details: {
75
+ status: number;
76
+ code: string;
77
+ correlationId?: string;
78
+ });
79
+ /** True when the editor session cannot be used any more: the host page has to give a new one. */
80
+ get sessionEnded(): boolean;
81
+ /** True when sending the same request again later may work: nothing was refused, the network or the server failed. */
82
+ get retryable(): boolean;
83
+ }
84
+ /** What a client of the composition routes can do. */
85
+ export interface CompositionApi {
86
+ getComposition(compositionId: string, options?: CompositionRequestOptions): Promise<JsonRecord>;
87
+ getPreview(compositionId: string, options?: CompositionRequestOptions): Promise<CompositionPreviewInfo>;
88
+ getContentSchema(compositionId: string, options?: CompositionRequestOptions): Promise<unknown>;
89
+ getDesignSchema(compositionId: string, options?: CompositionRequestOptions): Promise<unknown>;
90
+ saveComposition(compositionId: string, body: SaveCompositionBody, options?: SaveCompositionOptions): Promise<JsonRecord>;
91
+ }
92
+ /** A key that is unique, not secret: the page's `crypto.randomUUID`, else random bytes, else the clock and `Math.random`. */
93
+ export declare function createRandomIdempotencyKey(): string;
94
+ /** Builds the client. `apiBasePath` and `getAccessToken` are required. */
95
+ export declare function createCompositionApi(options: CompositionApiOptions): CompositionApi;