@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
package/README.md CHANGED
@@ -38,6 +38,119 @@ dispatch('propertyChange', { schema, property, value});
38
38
  }} />
39
39
  ```
40
40
 
41
+ ## Using your own media library
42
+
43
+ By default the file pickers inside the editor (the controls that open the File Browser) list and upload to the
44
+ user's Widgetic uploads. A platform that keeps its media somewhere else passes a `mediaLibrary` and every picker uses
45
+ it instead:
46
+
47
+ ```svelte
48
+ <PropsEditor {designSchema} {contentSchema} mediaLibrary={myMediaLibrary} />
49
+ ```
50
+
51
+ `mediaLibrary` is a `MediaLibraryProvider` (`list`, and optionally `upload`, `rename`, `delete`), exactly as the File
52
+ Browser defines it: see "Using your own media library" in the
53
+ [File Browser README](../file-browser/README.md). The value the editor reports for a picked file is `{ id, file, url }`
54
+ (plus `hash` when the library gives one), taken from the library's file: `id` is the library's own id, `file` its
55
+ name and `url` its address.
56
+
57
+ With a library set, the pickers make no Widgetic upload request, not even when the page address carries `apiUrl` and
58
+ `token`, and the File Browser keeps nothing on the device. Without one, nothing changes.
59
+
60
+ Needs a `@widgetic/file-browser` that supports `mediaLibrary`; an older one ignores the prop and keeps using the
61
+ Widgetic uploads.
62
+
63
+ ## Editing a whole composition
64
+
65
+ `CompositionEditorApp` is the editor with everything around it. It loads one Widgetic composition, shows its settings
66
+ (the `PropsEditor`) beside a live preview of the widget, and saves what the user changes. A page that has a token for
67
+ the Widgetic API (a user's own, or an editor session token for a partner's managed user) only has to name the
68
+ composition:
69
+
70
+ ```svelte
71
+ <script>
72
+ import { CompositionEditorApp } from '@widgetic/editor';
73
+
74
+ // Called before every request, and again with 'expired' when the API refuses the token.
75
+ async function getAccessToken(reason) {
76
+ const response = await fetch('/my-backend/widgetic-editor-session', { method: 'POST' });
77
+ return (await response.json()).token;
78
+ }
79
+ </script>
80
+
81
+ <!-- It fills the box it is put in, so give the box a height. -->
82
+ <div style="height: 100vh">
83
+ <CompositionEditorApp
84
+ compositionId="comp_123"
85
+ apiBasePath="https://api.widgetic.com/v1"
86
+ {getAccessToken}
87
+ on:saved={(event) => console.log('saved at', event.detail.savedAt)}
88
+ />
89
+ </div>
90
+ ```
91
+
92
+ Props:
93
+
94
+ - `compositionId`: the composition to edit. One component edits one composition: for another one, make another component.
95
+ - `apiBasePath`: the API's address with its version (`https://api.widgetic.com/v1`). A blank one throws when the
96
+ component is made.
97
+ - `getAccessToken`: `(reason: 'initial' | 'expired', refusedToken?: string) => string | Promise<string>`. See "The
98
+ token" below.
99
+ - `mediaLibrary` (optional): the partner's own `MediaLibraryProvider` for the file pickers (see above). Without it the
100
+ pickers use the Widgetic library the token can see.
101
+ - `googleFontsApiKey` (optional): the key the font pickers list Google Fonts with.
102
+ - `autosaveDelayMs` (optional): how long to wait after the last change before saving by itself (default 2500); `0`
103
+ turns saving by itself off. Read once, when the component is made.
104
+ - `class`, `style`: for the box the editor fills.
105
+
106
+ **The token.** `getAccessToken` is called before every request, so a page that keeps its token fresh is always used
107
+ up to date. When the API answers 401 the function is called again with `'expired'` and the token that was refused
108
+ (`refusedToken`) and, only if the token that comes back is a different one, the request is sent once more (a save is
109
+ sent with the same `Idempotency-Key` and body, so it cannot be applied twice). Several requests that start together with
110
+ the same token are refused together, and each asks: if your function has already replaced `refusedToken`, hand out the
111
+ new token instead of asking your server for another one. If the session is really over, the editor says so and `error`
112
+ reports `failure.sessionEnded`.
113
+
114
+ **Saving.** A change is saved a moment after the last one (2.5 seconds), or at once with the "Save" button. Only one
115
+ save is on its way at a time, and a change made meanwhile is saved by the next one. A failure that may pass (no
116
+ connection, a timeout, 429, 5xx) is tried again after 5, 15 and 45 seconds, the user can press "Try again" at any time,
117
+ and a failure that cannot pass (the session ended, a refusal) is not retried.
118
+
119
+ **The size.** The size the user gives the preview, by dragging its corner or typing it in, is saved with the composition
120
+ (`embedSettings.width` and `height`) and is the size the widget gets where it is embedded. Nothing else changes it: the
121
+ desktop and mobile buttons only change how the preview is looked at, and a box smaller than the preview shrinks it to
122
+ fit without changing what is saved (so opening a composition in a small window does not edit it).
123
+
124
+ Events (the data is in `event.detail`):
125
+
126
+ - `ready` `{ name, widgetId }`: the editor is on the screen.
127
+ - `dirty` `{ dirty }`: there is, or is no longer, work that is not saved.
128
+ - `saved` `{ composition, savedAt }`: a save went through (`composition` is what the API answered).
129
+ - `error` `{ during, failure, fatal }`: `during` is `'load'`, `'schemas'` or `'save'`; `failure` is
130
+ `{ message, code, status, retryable, sessionEnded, correlationId? }`; `fatal` is true when the composition could not
131
+ be loaded at all (the editor then shows its own message with "Try again").
132
+ - `deviceView` `{ deviceView }`: the preview is looked at as `'desktop'` or `'mobile'` now, whether a button of the
133
+ preview, a size the user gave it or `setDeviceView()` did it. Told once per change.
134
+
135
+ The component also has the methods `save()`, `flush()`, `isDirty()`, `getState()` and `reload()`. **Before removing
136
+ the editor, `await flush()`**: removing it cancels whatever is on its way, and changes that were not saved are lost.
137
+
138
+ For a page that has controls of its own around the editor:
139
+
140
+ - `setDeviceView('desktop' | 'mobile')` does what the preview's desktop and mobile buttons do. It is a way of looking,
141
+ not an edit: nothing becomes unsaved.
142
+ - `adoptEmbedSettings(settings)` is for a page that saves the composition's `embedSettings` itself (its own "Embed"
143
+ screen, say). The editor sends back the embed settings it loaded with every save, so without this a later save would put
144
+ them back. Call it after `await flush()` (so no save of the editor is on its way), with the settings the API now holds.
145
+ Nothing becomes unsaved, and the preview takes the size the settings name.
146
+
147
+ What it asks of the API (all of it is on an editor session's allow-list): `GET /compositions/{id}`,
148
+ `GET /compositions/{id}/preview`, `GET /compositions/{id}/schema/design`, `GET /compositions/{id}/schema/content` and
149
+ `PUT /compositions/{id}`, plus `/uploads` for the default file library. A composition whose widget is not published
150
+ has no preview address (`WIDGET_NOT_PUBLISHED`): its settings stay editable and the editor says why there is no preview.
151
+
152
+ It is a Svelte component for a page that can import it, and it takes the page's styles and fonts as they are. A
153
+ plain-JavaScript module that works on any page, inside a shadow root, is a separate step.
41
154
 
42
155
  ## Input Controllers
43
156
 
@@ -91,6 +204,11 @@ npm run dev
91
204
  npm run dev -- --open
92
205
  ```
93
206
 
207
+ The dev server also serves three playground pages that need no sign-in:
208
+ `/ic` (an input controller on its own), `/embed` (the Embed editor) and
209
+ `/mock-widget`. They compile the sibling packages from source, so a change in
210
+ `design-system` or `file-browser` shows up there without a build.
211
+
94
212
  ## Build the project
95
213
 
96
214
  To create a production version of your app:
@@ -1,13 +1,16 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy, createEventDispatcher, tick } from 'svelte';
3
3
  import { GenerateLoader } from '@widgetic/design-system/components';
4
+ import { lockPointerStyle } from '../utils/shadowDom';
4
5
 
5
6
  const dispatch = createEventDispatcher<{
6
7
  load: { src: string | null };
7
8
  error: { src: string | null };
8
9
  refresh: void;
9
10
  openInTab: void;
10
- resize: { width: number; height: number };
11
+ // `source` says where a size came from, so a host can tell a size the user chose ('drag', 'input', 'fit',
12
+ // 'device') from one the box around the preview forced on it ('clamp': the box is smaller than the size asked for).
13
+ resize: { width: number; height: number; source?: 'drag' | 'input' | 'fit' | 'device' | 'clamp' };
11
14
  mobileViewport: { width: number; height: number };
12
15
  desktopViewport: void;
13
16
  deviceView: { mobile: boolean };
@@ -50,6 +53,17 @@
50
53
  export let debugMode: boolean = false;
51
54
  export let iframeReloadKey = 0;
52
55
 
56
+ // ── What the preview iframe may do ──────────────────────────────────────
57
+ /**
58
+ * The iframe's `sandbox` tokens. `undefined` keeps what each mode always had (controlled: no sandbox; standalone:
59
+ * scripts, popups, forms and its own origin); a string is used exactly as given; `null` means no `sandbox` at all.
60
+ * A page that embeds the editor for its own visitors passes a list without `allow-top-navigation`, so that a
61
+ * widget's code cannot send the visitor's whole tab somewhere else.
62
+ */
63
+ export let sandbox: string | null | undefined = undefined;
64
+ /** The iframe's `allow` attribute: which of the page's permissions the preview may use. */
65
+ export let allow: string = 'clipboard-read; clipboard-write';
66
+
53
67
  // ── Layout / chrome ─────────────────────────────────────────────────────
54
68
  export let width: string = '100%';
55
69
  export let height: string = '100%';
@@ -222,11 +236,19 @@
222
236
  heightInputValue = String(Math.round(resizeHeight));
223
237
  }
224
238
  if (resizeWidth != null && resizeHeight != null) {
225
- dispatch('resize', { width: resizeWidth, height: resizeHeight });
239
+ dispatch('resize', { width: resizeWidth, height: resizeHeight, source: 'input' });
226
240
  }
227
241
  console.log('[WidgetPreview] Dimension input', { dimension, value: parsed });
228
242
  }
229
243
 
244
+ /**
245
+ * While a drag or a resize lasts the pointer shows the matching cursor and nothing is selected as it moves
246
+ * (`lockPointerStyle`, in `shadowDom.ts`: set on the host of the shadow root the editor lives in, or on <body> when
247
+ * there is none, and put back afterwards). Puts back what the running drag or resize changed: the pointer style,
248
+ * and the listeners on `document`.
249
+ */
250
+ let finishPointerGesture: (() => void) | null = null;
251
+
230
252
  function startPreviewDrag(event: PointerEvent) {
231
253
  if (!enableDrag || isResizing) return;
232
254
  const target = event.target as HTMLElement | null;
@@ -252,22 +274,25 @@
252
274
 
253
275
  function endDrag() {
254
276
  isDraggingPreview = false;
255
- document.removeEventListener('pointermove', onPointerMove);
256
- document.removeEventListener('pointerup', endDrag);
257
- document.removeEventListener('pointercancel', endDrag);
258
- document.body.style.removeProperty('user-select');
259
- document.body.style.removeProperty('cursor');
277
+ finishPointerGesture?.();
260
278
  console.log('[WidgetPreview] Preview drag end', {
261
279
  left: previewShellLeft,
262
280
  top: previewShellTop,
263
281
  });
264
282
  }
265
283
 
266
- document.body.style.userSelect = 'none';
267
- document.body.style.cursor = 'move';
284
+ finishPointerGesture?.();
285
+ const unlockPointerStyle = lockPointerStyle(event.target, 'move');
268
286
  document.addEventListener('pointermove', onPointerMove);
269
287
  document.addEventListener('pointerup', endDrag);
270
288
  document.addEventListener('pointercancel', endDrag);
289
+ finishPointerGesture = () => {
290
+ document.removeEventListener('pointermove', onPointerMove);
291
+ document.removeEventListener('pointerup', endDrag);
292
+ document.removeEventListener('pointercancel', endDrag);
293
+ unlockPointerStyle();
294
+ finishPointerGesture = null;
295
+ };
271
296
  }
272
297
 
273
298
  /** Firefox/Chrome skip painting a cross-origin iframe that started at opacity 0 or inside a CSS filter until a reflow (opening DevTools). */
@@ -308,7 +333,7 @@
308
333
  }
309
334
  resizeWidth = limits.maxWidth;
310
335
  resizeHeight = limits.maxHeight;
311
- dispatch('resize', { width: limits.maxWidth, height: limits.maxHeight });
336
+ dispatch('resize', { width: limits.maxWidth, height: limits.maxHeight, source: 'fit' });
312
337
  previewPositionReady = false;
313
338
  requestAnimationFrame(() => {
314
339
  resetPreviewPosition();
@@ -575,6 +600,9 @@
575
600
  }
576
601
 
577
602
  function handleMessage(event: MessageEvent) {
603
+ // Only the preview iframe of this component speaks to it: any other window on the page (a partner's own frames
604
+ // and scripts included) can post messages to this window too.
605
+ if (!iframeEl?.contentWindow || event.source !== iframeEl.contentWindow) return;
578
606
  const type = event.data?.type;
579
607
  if (type === 'widgetic:ready' || type === 'widgetic:pong') {
580
608
  if (mode === 'standalone') {
@@ -597,7 +625,8 @@
597
625
 
598
626
  function handleOpenInTabClick() {
599
627
  const url = effectiveDisplayUrl || effectiveSrc;
600
- if (url) window.open(url, '_blank');
628
+ // noopener: the page that opens must not get a handle on the page that opened it (which can be a partner's page).
629
+ if (url) window.open(url, '_blank', 'noopener,noreferrer');
601
630
  dispatch('openInTab');
602
631
  }
603
632
 
@@ -607,7 +636,7 @@
607
636
  const h = Math.min(mobileViewportHeight, maxHeight);
608
637
  resizeWidth = w;
609
638
  resizeHeight = h;
610
- dispatch('resize', { width: w, height: h });
639
+ dispatch('resize', { width: w, height: h, source: 'device' });
611
640
  dispatch('mobileViewport', { width: w, height: h });
612
641
  }
613
642
 
@@ -629,7 +658,7 @@
629
658
  if (nextW !== resizeWidth || nextH !== resizeHeight) {
630
659
  resizeWidth = nextW;
631
660
  resizeHeight = nextH;
632
- dispatch('resize', { width: nextW, height: nextH });
661
+ dispatch('resize', { width: nextW, height: nextH, source: 'clamp' });
633
662
  }
634
663
  }
635
664
 
@@ -689,35 +718,40 @@
689
718
  }
690
719
 
691
720
  if (resizeWidth != null && resizeHeight != null) {
692
- dispatch('resize', { width: resizeWidth, height: resizeHeight });
721
+ dispatch('resize', { width: resizeWidth, height: resizeHeight, source: 'drag' });
693
722
  }
694
723
  }
695
724
 
696
725
  function endResize() {
697
726
  isResizing = false;
698
727
  // Keep orange stroke while Alt remains held (composition corner mode).
699
- document.removeEventListener('pointermove', onPointerMove);
700
- document.removeEventListener('pointerup', endResize);
701
- document.removeEventListener('pointercancel', endResize);
702
- document.body.style.removeProperty('user-select');
703
- document.body.style.removeProperty('cursor');
728
+ finishPointerGesture?.();
704
729
  if (resizeWidth != null && resizeHeight != null) {
705
- dispatch('resize', { width: resizeWidth, height: resizeHeight });
730
+ dispatch('resize', { width: resizeWidth, height: resizeHeight, source: 'drag' });
706
731
  }
707
732
  }
708
733
 
709
- document.body.style.userSelect = 'none';
710
- document.body.style.cursor =
734
+ finishPointerGesture?.();
735
+ const unlockPointerStyle = lockPointerStyle(
736
+ event.target,
711
737
  signX !== 0 && signY !== 0
712
738
  ? signX * signY > 0
713
739
  ? 'nwse-resize'
714
740
  : 'nesw-resize'
715
741
  : signX !== 0
716
742
  ? 'ew-resize'
717
- : 'ns-resize';
743
+ : 'ns-resize'
744
+ );
718
745
  document.addEventListener('pointermove', onPointerMove);
719
746
  document.addEventListener('pointerup', endResize);
720
747
  document.addEventListener('pointercancel', endResize);
748
+ finishPointerGesture = () => {
749
+ document.removeEventListener('pointermove', onPointerMove);
750
+ document.removeEventListener('pointerup', endResize);
751
+ document.removeEventListener('pointercancel', endResize);
752
+ unlockPointerStyle();
753
+ finishPointerGesture = null;
754
+ };
721
755
  }
722
756
 
723
757
  $: if (enableResize && contentContainerEl && (resizeWidth != null || resizeHeight != null)) {
@@ -762,8 +796,8 @@
762
796
  onDestroy(() => {
763
797
  window.removeEventListener('message', handleMessage);
764
798
  stageResizeObserver?.disconnect();
765
- document.body.style.removeProperty('user-select');
766
- document.body.style.removeProperty('cursor');
799
+ // A drag or resize that was still going when the editor was removed: stop listening and put the style back.
800
+ finishPointerGesture?.();
767
801
  });
768
802
  </script>
769
803
 
@@ -1057,9 +1091,13 @@
1057
1091
  src={mountedPreviewSrc}
1058
1092
  class="widget-preview-iframe h-full w-full border-0"
1059
1093
  tabindex="-1"
1060
- sandbox={mode === 'controlled' ? undefined : 'allow-scripts allow-popups allow-forms allow-same-origin'}
1094
+ sandbox={sandbox !== undefined
1095
+ ? (sandbox ?? undefined)
1096
+ : mode === 'controlled'
1097
+ ? undefined
1098
+ : 'allow-scripts allow-popups allow-forms allow-same-origin'}
1061
1099
  loading="eager"
1062
- allow="clipboard-read; clipboard-write"
1100
+ {allow}
1063
1101
  onload={handleIframeLoad}
1064
1102
  onerror={handleIframeError}
1065
1103
  ></iframe>
@@ -34,6 +34,13 @@ declare const WidgetPreview: $$__sveltets_2_IsomorphicComponent<{
34
34
  forceMockReady?: boolean;
35
35
  debugMode?: boolean;
36
36
  iframeReloadKey?: number;
37
+ /**
38
+ * The iframe's `sandbox` tokens. `undefined` keeps what each mode always had (controlled: no sandbox; standalone:
39
+ * scripts, popups, forms and its own origin); a string is used exactly as given; `null` means no `sandbox` at all.
40
+ * A page that embeds the editor for its own visitors passes a list without `allow-top-navigation`, so that a
41
+ * widget's code cannot send the visitor's whole tab somewhere else.
42
+ */ sandbox?: string | null | undefined;
43
+ /** The iframe's `allow` attribute: which of the page's permissions the preview may use. */ allow?: string;
37
44
  width?: string;
38
45
  height?: string;
39
46
  contentHeight?: string;
@@ -82,6 +89,7 @@ declare const WidgetPreview: $$__sveltets_2_IsomorphicComponent<{
82
89
  resize: CustomEvent<{
83
90
  width: number;
84
91
  height: number;
92
+ source?: "drag" | "input" | "fit" | "device" | "clamp";
85
93
  }>;
86
94
  mobileViewport: CustomEvent<{
87
95
  width: number;