@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
@@ -1,4 +1,5 @@
1
1
  import type { ContentSchemaView, DesignSchemaView } from '../../schemaViews.js';
2
+ import type { MediaLibraryProvider } from '@widgetic/file-browser';
2
3
  /**
3
4
  * STYLES from Widgetic Design System
4
5
  * Note: props-editor.css global rules (body, *) moved to app.css (standalone only).
@@ -45,6 +46,12 @@ declare const PropsEditor: $$__sveltets_2_IsomorphicComponent<{
45
46
  /** Optional layout wrapper moved by this editor's built-in drag and position API. */ positionTarget?: any;
46
47
  initialDesignValues?: Record<string, any>;
47
48
  initialContentValues?: Record<string, any>;
49
+ /**
50
+ * Where the file controls (images, video, audio, documents) get their files from. Without it they show the
51
+ * Widgetic account's uploads, as they always have (configured with `setFileBrowserUploadConfig`). With a
52
+ * `MediaLibraryProvider` they list, upload, rename and delete through its functions instead, so a partner's own
53
+ * storage (a CMS, an S3 bucket, a DAM) can be used with no Widgetic storage at all.
54
+ */ mediaLibrary?: MediaLibraryProvider | undefined;
48
55
  updateDesignValue?: (propertyId: string, value: any) => void;
49
56
  setPosition?: (left: number, top: number) => void;
50
57
  getPosition?: () => {
@@ -35,12 +35,14 @@
35
35
  import { Portal, Tooltip } from '@widgetic/design-system/components';
36
36
  import { ThumbHashImage } from "@widgetic/design-system/components";
37
37
  import { FileBrowser, setFileBrowserUploadConfig, persistentSelectStore } from '@widgetic/file-browser';
38
+ import { useEditorMediaLibrary } from '../../mediaLibraryContext';
38
39
  import X from '../icons/x.svelte';
39
40
  // console.log("BrowserInputController - Tooltip:", Tooltip);
40
41
 
41
42
 
42
43
  // utils imports
43
44
  import MediaManager from '../../utils/MediaManager';
45
+ import { queryInTreeOf } from '../../utils/shadowDom';
44
46
 
45
47
  // Static asset imports (bundled via Vite, works when imported as library)
46
48
  import icAudio from '../../assets/icons/ic-browser/audio.svg?url';
@@ -65,10 +67,17 @@
65
67
  (portalTarget instanceof HTMLElement &&
66
68
  portalTarget.id === 'props-editor-file-browser-portal');
67
69
  export let config: BrowserInputControllerProps['config'] = {};
70
+
71
+ // The media library the editor above was given (a partner's own storage, for instance), read through a getter
72
+ // because it reaches this component through context.
73
+ const getEditorMediaLibrary = useEditorMediaLibrary();
68
74
 
69
75
  // Extract allowed file types from config or default to all files
70
76
  const fileTypes = config.fileTypes || ['*'];
71
77
 
78
+ // The control's own element, to find the editor's panel from (see getPortalHost)
79
+ let controllerElement: HTMLDivElement | undefined;
80
+
72
81
  // Media element references for audio/video playback
73
82
  let audioElement: HTMLAudioElement | null = null;
74
83
  let videoElement: HTMLVideoElement | null = null;
@@ -166,6 +175,9 @@
166
175
  /** Only embedded iframe mode (URL params) sets upload config here; standalone uses parent +page sync. */
167
176
  function syncFileBrowserUploadConfigIfEmbedded() {
168
177
  if (typeof window === 'undefined') return;
178
+ // With a media library the File Browser takes its files from it and never reads the upload configuration, and the
179
+ // address of the page this editor sits in (which can be a partner's) is none of the editor's business.
180
+ if (getEditorMediaLibrary()) return;
169
181
  const params = new URLSearchParams(window.location.search);
170
182
  const apiUrl = params.get('apiUrl');
171
183
  const token = params.get('token');
@@ -187,7 +199,9 @@
187
199
  function getPortalHost(): HTMLElement | null {
188
200
  if (portalTarget instanceof HTMLElement) return portalTarget;
189
201
  if (typeof document === 'undefined') return null;
190
- return document.getElementById('props-editor-file-browser-portal');
202
+ // Looked for in the tree this control is in: inside a module that is a shadow root, which `document` cannot see into
203
+ const portalHost = queryInTreeOf(controllerElement, '#props-editor-file-browser-portal');
204
+ return portalHost instanceof HTMLElement ? portalHost : null;
191
205
  }
192
206
 
193
207
  function setPortalHostInteractive(active: boolean) {
@@ -461,7 +475,7 @@
461
475
  </script>
462
476
 
463
477
  <!-- Main container with updated styling -->
464
- <div class="flex flex-col gap-2 widgetic-colors">
478
+ <div bind:this={controllerElement} class="flex flex-col gap-2 widgetic-colors">
465
479
  <div class="flex items-center w-full relative">
466
480
  <div class="flex-1 min-w-0">
467
481
  <InputControllerHeader label={header?.label || label} helpText={header?.helpText} />
@@ -699,12 +713,17 @@
699
713
  </div>
700
714
 
701
715
  <div class="browser-input-file-browser-body flex min-h-0 flex-1 flex-col overflow-hidden">
716
+ <!-- With a media library the File Browser keeps nothing about this visit (the filter it was left on, the
717
+ folder it was left in) in the page's localStorage: that storage belongs to the page this editor sits in,
718
+ which can be a partner's. Without one (the Widgetic site) it remembers them, as it always did. -->
702
719
  <FileBrowser
703
720
  class="h-full min-h-0 flex-1"
704
721
  instanceId={`props-editor-browser-input-${fileBrowserFilter}`}
705
722
  initialFilter={fileBrowserFilter}
706
723
  restrictUpload={fileBrowserRestrictUpload}
707
724
  useDemoContent={false}
725
+ mediaLibrary={getEditorMediaLibrary()}
726
+ persistHistory={!getEditorMediaLibrary()}
708
727
  allowSelecting={true}
709
728
  showAddToEditor={true}
710
729
  maxSelections={1}
@@ -69,6 +69,7 @@
69
69
  import { colorNames } from '../../utils/colorNames';
70
70
  import { getColorSuggestions, getNextSuggestion, type AutocompleteResult } from '../../utils/colorAutocomplete';
71
71
  import { recentColorsUtil } from '../../utils/recentColors';
72
+ import { listenForScroll, lockPointerStyle } from '../../utils/shadowDom';
72
73
 
73
74
 
74
75
 
@@ -544,8 +545,10 @@
544
545
  * @param event - Scroll event
545
546
  */
546
547
  function handleScroll(event: Event) {
547
- // Don't close if scrolling happens within allowed elements
548
- if ((event.target as HTMLElement).closest('[data-allow-scroll="true"]')) {
548
+ // Don't close if scrolling happens within allowed elements. The page itself scrolling has the document as its
549
+ // target (it has no closest()), so only an element can be one of the allowed ones.
550
+ const scrollTarget = event.target;
551
+ if (scrollTarget instanceof Element && scrollTarget.closest('[data-allow-scroll="true"]')) {
549
552
  return;
550
553
  }
551
554
 
@@ -557,6 +560,9 @@
557
560
  }
558
561
  }
559
562
 
563
+ // Puts the pointer back after an opacity drag, however the drag ends (the picker can be removed in the middle of one).
564
+ let releaseOpacityPointerStyle: (() => void) | null = null;
565
+
560
566
  /**
561
567
  * Manages opacity slider interactions
562
568
  * - Handles mouse drag events
@@ -567,7 +573,12 @@
567
573
  */
568
574
  function handleOpacityMouseDown(event: MouseEvent) {
569
575
  isDragging = true;
570
- document.body.style.cursor = 'grabbing';
576
+ // The cursor is shown on the host of the shadow root the picker is in, and on <body> only on a normal page: the
577
+ // <body> of a customer's page is not the editor's to change.
578
+ releaseOpacityPointerStyle?.();
579
+ releaseOpacityPointerStyle = lockPointerStyle(event.currentTarget ?? event.target, 'grabbing', {
580
+ blockTextSelection: false
581
+ });
571
582
  updateOpacity(event);
572
583
 
573
584
  const handleMouseMove = (e: MouseEvent) => {
@@ -578,7 +589,8 @@
578
589
 
579
590
  const handleMouseUp = () => {
580
591
  isDragging = false;
581
- document.body.style.cursor = '';
592
+ releaseOpacityPointerStyle?.();
593
+ releaseOpacityPointerStyle = null;
582
594
  window.removeEventListener('mousemove', handleMouseMove);
583
595
  window.removeEventListener('mouseup', handleMouseUp);
584
596
  };
@@ -947,6 +959,10 @@
947
959
  }
948
960
  }
949
961
 
962
+ // Stops listening for scrolling (the page and, inside a module, the module's own panels: a scroll never leaves a
963
+ // shadow root, so a listener on the window alone does not hear about those)
964
+ let stopListeningForScroll: () => void = () => {};
965
+
950
966
  // Sets up event listeners when the component mounts
951
967
  onMount(() => {
952
968
  initializeColor();
@@ -954,17 +970,19 @@
954
970
  window.addEventListener('mousemove', handleGlobalMouseMove);
955
971
  window.addEventListener('keydown', handlePopupKeyDown);
956
972
  window.addEventListener('keyup', handlePopupKeyUp);
957
- window.addEventListener('scroll', handleScroll, true);
973
+ stopListeningForScroll = listenForScroll(inputRef, handleScroll);
958
974
  window.addEventListener('widgetic:close-color-pickers', onCloseOtherPickers as EventListener);
959
975
  });
960
976
 
961
977
  // Cleans up event listeners when the component is destroyed
962
978
  onDestroy(() => {
979
+ releaseOpacityPointerStyle?.();
980
+ releaseOpacityPointerStyle = null;
963
981
  window.removeEventListener('mouseup', handleGlobalMouseUp);
964
982
  window.removeEventListener('mousemove', handleGlobalMouseMove);
965
983
  window.removeEventListener('keydown', handlePopupKeyDown);
966
984
  window.removeEventListener('keyup', handlePopupKeyUp);
967
- window.removeEventListener('scroll', handleScroll, true);
985
+ stopListeningForScroll();
968
986
  window.removeEventListener('widgetic:close-color-pickers', onCloseOtherPickers as EventListener);
969
987
  });
970
988
 
@@ -29,6 +29,7 @@
29
29
  <script lang="ts">
30
30
  import { onMount, tick } from 'svelte';
31
31
  import InputControllerHeader from './InputControllerHeader.svelte';
32
+ import { activeElementIn } from '../../utils/shadowDom';
32
33
 
33
34
  // Constants
34
35
  const MIN_DAY = 1;
@@ -311,9 +312,12 @@
311
312
 
312
313
  // Reset focused field after a short delay to allow for focus changes
313
314
  setTimeout(() => {
314
- if (!document.activeElement ||
315
- !(document.activeElement instanceof HTMLInputElement) ||
316
- !Object.values(inputRefs).includes(document.activeElement)) {
315
+ // The focused element of the tree this control is in: inside a module `document.activeElement` is the module's
316
+ // host element, which is never one of the fields
317
+ const focusedElement = activeElementIn(input);
318
+ if (!focusedElement ||
319
+ !(focusedElement instanceof HTMLInputElement) ||
320
+ !Object.values(inputRefs).includes(focusedElement)) {
317
321
  focusedField = null;
318
322
  }
319
323
  }, 100);
@@ -37,6 +37,7 @@
37
37
  <script lang="ts">
38
38
  // Svelte imports
39
39
  import { onMount, onDestroy } from 'svelte';
40
+ import { listenForScroll } from '../../utils/shadowDom';
40
41
 
41
42
  // Shadcn UI Components
42
43
  import { Button, Select } from '@widgetic/design-system/components';
@@ -209,19 +210,24 @@
209
210
  }
210
211
  }
211
212
 
212
- // Setup scroll event listener on mount
213
+ // The control's own element, which says which tree (the page or a module's shadow root) to listen for scrolling in
214
+ let controllerElement: HTMLDivElement | undefined;
215
+ let stopListeningForScroll: () => void = () => {};
216
+
217
+ // Setup scroll event listener on mount (the page and, inside a module, the module's own panels: a scroll never
218
+ // leaves a shadow root, so a listener on the window alone does not hear about those)
213
219
  onMount(() => {
214
- window.addEventListener('scroll', handleScroll, { capture: true });
220
+ stopListeningForScroll = listenForScroll(controllerElement, handleScroll);
215
221
  });
216
222
 
217
223
  // Cleanup scroll event listener on destroy
218
224
  onDestroy(() => {
219
- window.removeEventListener('scroll', handleScroll, true);
225
+ stopListeningForScroll();
220
226
  });
221
227
  </script>
222
228
 
223
229
  <!-- Date Picker Container -->
224
- <div class="flex flex-col gap-2 widgetic-colors">
230
+ <div bind:this={controllerElement} class="flex flex-col gap-2 widgetic-colors">
225
231
  <div class="flex items-center w-full relative">
226
232
  <div class="flex-1 min-w-0">
227
233
  <InputControllerHeader {label} />
@@ -31,6 +31,7 @@
31
31
  * Required for component setup and cleanup
32
32
  */
33
33
  import { onMount, onDestroy } from 'svelte';
34
+ import { listenForScroll } from '../../utils/shadowDom';
34
35
 
35
36
  // Local Components imports
36
37
  import { InputControllerHeader } from './index';
@@ -113,13 +114,18 @@
113
114
  * Lifecycle Hooks
114
115
  * Setup and cleanup of scroll event listeners
115
116
  */
117
+ // The control's own element, which says which tree (the page or a module's shadow root) to listen for scrolling in
118
+ // (a scroll never leaves a shadow root, so a listener on the window alone only hears about the page scrolling)
119
+ let controllerElement: HTMLDivElement | undefined;
120
+ let stopListeningForScroll: () => void = () => {};
121
+
116
122
  onMount(() => {
117
123
  // console.log('DropdownInputController: onMount:', value);
118
- window.addEventListener('scroll', handleScroll, true);
124
+ stopListeningForScroll = listenForScroll(controllerElement, handleScroll);
119
125
  });
120
126
 
121
127
  onDestroy(() => {
122
- window.removeEventListener('scroll', handleScroll, true);
128
+ stopListeningForScroll();
123
129
  });
124
130
 
125
131
  /**
@@ -147,14 +153,15 @@
147
153
  /**
148
154
  * The option label for the current selection. `value` accepts a bare string
149
155
  * or an option object; normalize once instead of re-narrowing at each read.
156
+ * A missing selection is the empty string, as the select expects.
150
157
  */
151
- $: selectedValue = typeof value === 'string' ? value : value?.value;
158
+ $: selectedValue = typeof value === 'string' ? value : (value?.value ?? '');
152
159
  $: triggerContent =
153
160
  options.find((o) => o.value === selectedValue)?.label ?? defaultSelectedLabel;
154
161
  </script>
155
162
 
156
163
  <!-- Dropdown Container -->
157
- <div class="dropdown-ic flex flex-col gap-2 widgetic-colors">
164
+ <div bind:this={controllerElement} class="dropdown-ic flex flex-col gap-2 widgetic-colors">
158
165
  <div class="flex items-center w-full relative">
159
166
  <div class="flex-1 min-w-0">
160
167
  <InputControllerHeader {label} />
@@ -63,6 +63,9 @@
63
63
  import FontManager, { mapGoogleVariantIdsToWeightNames } from '../../utils/FontManager';
64
64
 
65
65
  import { shouldSkipFont } from '../../utils/skipFonts';
66
+ import { escapeHtml, cssQuotedString } from '../../utils/htmlSafety';
67
+ import { acquirePortalHost, releasePortalHost, listenForScroll, queryInTreeOf } from '../../utils/shadowDom';
68
+ import { readEditorStorageValue, writeEditorStorageValue } from '../../utils/editorStorage';
66
69
  import type { Writable } from 'svelte/store';
67
70
 
68
71
  import { InputControllerHeader } from './index';
@@ -125,6 +128,8 @@
125
128
 
126
129
  // Font loading optimization
127
130
  let fontListContainer: HTMLElement;
131
+ // The control's own element: it says which tree (the page or a module's shadow root) to listen for scrolling in
132
+ let controllerElement: HTMLDivElement | undefined;
128
133
  let familyTriggerEl: HTMLDivElement | undefined;
129
134
  let familyPopoverStyle = '';
130
135
  let familyPopoverSide: 'top' | 'bottom' = 'bottom';
@@ -191,6 +196,15 @@
191
196
  // Add a timeout to track long-running initial loads
192
197
  let initialLoadTimeout: ReturnType<typeof setTimeout> | null = null;
193
198
 
199
+ /**
200
+ * The sticky header of the font list. It is looked up in the tree the list lives in (inside a module that is a
201
+ * shadow root, which `document.querySelector` cannot see into), and the id is escaped so that one with dots or
202
+ * brackets in it is found too.
203
+ */
204
+ function findStickyHeader(): Element | null {
205
+ return queryInTreeOf(fontListContainer, `#${CSS.escape(`font-header-${controllerId}`)}`);
206
+ }
207
+
194
208
  // Function to update scroll state
195
209
  function updateScrollState() {
196
210
  if (fontListContainer) {
@@ -199,7 +213,7 @@
199
213
 
200
214
  // If we're transitioning from scrolled to not scrolled, remove transition
201
215
  if (wasScrolled && !isScrolled) {
202
- const stickyHeader = document.querySelector(`#font-header-${controllerId}`);
216
+ const stickyHeader = findStickyHeader();
203
217
  if (stickyHeader) {
204
218
  stickyHeader.classList.remove('transition-border');
205
219
  // Force a reflow to ensure the transition class removal takes effect
@@ -207,7 +221,7 @@
207
221
  }
208
222
  } else if (!wasScrolled && isScrolled) {
209
223
  // Add transition class when starting to scroll
210
- const stickyHeader = document.querySelector(`#font-header-${controllerId}`);
224
+ const stickyHeader = findStickyHeader();
211
225
  if (stickyHeader) {
212
226
  stickyHeader.classList.add('transition-border');
213
227
  }
@@ -252,22 +266,20 @@
252
266
 
253
267
  let fontPickerReadyAt = 0;
254
268
 
269
+ /**
270
+ * The layer the font popovers are drawn in: one of their own, above everything else, in the tree this input lives
271
+ * in. On a page that is a node at the end of the body; inside a module it is a node in the module's portal layer,
272
+ * because a popover drawn in the customer's body would lose the module's styles and leave a node behind in the
273
+ * customer's page. The input has to be on the page to tell which tree it is in, so until it is (a short moment,
274
+ * and a popover is never open by then) the popovers go where the design system's portal configuration sends them.
275
+ */
276
+ let fontPickerPortalHost: HTMLElement | undefined;
277
+
255
278
  function ensureFontPickerPortal() {
256
- if (!browser) return;
257
- const portalId = 'widgetic-font-picker-portal';
258
- if (document.getElementById(portalId)) return;
259
- const host = document.createElement('div');
260
- host.id = portalId;
261
- host.style.position = 'relative';
262
- host.style.zIndex = '100000000';
263
- document.body.appendChild(host);
279
+ if (!browser || fontPickerPortalHost) return;
280
+ fontPickerPortalHost = acquirePortalHost(familyTriggerEl, 'widgetic-font-picker-portal') ?? undefined;
264
281
  }
265
282
 
266
- // The font popover looks up this node while it is created. onMount is too
267
- // late: the child portal mounts first and throws if the host is missing,
268
- // which takes the whole details panel (including chat) down with it.
269
- ensureFontPickerPortal();
270
-
271
283
  onMount(async () => {
272
284
  try {
273
285
  if (browser) {
@@ -282,13 +294,13 @@
282
294
  }
283
295
 
284
296
  // Load saved filter
285
- const savedFilter = localStorage.getItem(FONT_FILTER_KEY(controllerId));
297
+ const savedFilter = readEditorStorageValue(FONT_FILTER_KEY(controllerId));
286
298
  if (savedFilter) {
287
299
  currentFilter = savedFilter as FilterType;
288
300
  }
289
301
 
290
302
  // Load saved weight
291
- const savedWeight = localStorage.getItem(FONT_WEIGHT_KEY(controllerId));
303
+ const savedWeight = readEditorStorageValue(FONT_WEIGHT_KEY(controllerId));
292
304
  if (savedWeight) {
293
305
  selectedWeight = savedWeight;
294
306
  }
@@ -790,7 +802,7 @@
790
802
 
791
803
  selectedWeight = `${controllerId}-${newWeight}`;
792
804
  if (browser) {
793
- localStorage.setItem(FONT_WEIGHT_KEY(controllerId), selectedWeight);
805
+ writeEditorStorageValue(FONT_WEIGHT_KEY(controllerId), selectedWeight);
794
806
  }
795
807
 
796
808
  // Load the full font when selected
@@ -852,7 +864,7 @@
852
864
  if (!searchTerm && value) {
853
865
  lastActiveFilter = currentFilter;
854
866
  currentFilter = 'All';
855
- localStorage.setItem(FONT_FILTER_KEY(controllerId), 'All');
867
+ writeEditorStorageValue(FONT_FILTER_KEY(controllerId), 'All');
856
868
  }
857
869
 
858
870
  // Apply filtering with the new search term
@@ -1064,7 +1076,7 @@
1064
1076
  currentFilter = filter;
1065
1077
 
1066
1078
  if (browser) {
1067
- localStorage.setItem(FONT_FILTER_KEY(controllerId), filter);
1079
+ writeEditorStorageValue(FONT_FILTER_KEY(controllerId), filter);
1068
1080
  }
1069
1081
 
1070
1082
  if (fontManagerInstance) {
@@ -1482,7 +1494,8 @@
1482
1494
  * @param term Search term
1483
1495
  * @returns HTML string with highlighted match
1484
1496
  *
1485
- * Note: Returns safe HTML as we're only wrapping with <strong>
1497
+ * Note: The result goes to {@html}, so every piece of the font name is escaped as it is put in (a name such as
1498
+ * `<img src=x onerror=...>` must show as text, not run in the page the editor is on).
1486
1499
  */
1487
1500
  function highlightMatch(name: string, term: string) {
1488
1501
  // Check if this font is the selected one
@@ -1491,8 +1504,8 @@
1491
1504
  if (!term) {
1492
1505
  // If no search term, just return the name with appropriate color
1493
1506
  return isSelected
1494
- ? `<span style="color: white">${name}</span>`
1495
- : `<span style="color: hsl(var(--grey7))">${name}</span>`;
1507
+ ? `<span style="color: white">${escapeHtml(name)}</span>`
1508
+ : `<span style="color: hsl(var(--grey7))">${escapeHtml(name)}</span>`;
1496
1509
  }
1497
1510
 
1498
1511
  const nameLower = name.toLowerCase();
@@ -1501,9 +1514,9 @@
1501
1514
  // If the name contains the search term, highlight that part
1502
1515
  if (nameLower.includes(termLower)) {
1503
1516
  const index = nameLower.indexOf(termLower);
1504
- const beforeMatch = name.slice(0, index);
1505
- const match = name.slice(index, index + termLower.length);
1506
- const afterMatch = name.slice(index + termLower.length);
1517
+ const beforeMatch = escapeHtml(name.slice(0, index));
1518
+ const match = escapeHtml(name.slice(index, index + termLower.length));
1519
+ const afterMatch = escapeHtml(name.slice(index + termLower.length));
1507
1520
 
1508
1521
  if (isSelected) {
1509
1522
  // For selected items: text is white, highlight is grey7
@@ -1516,8 +1529,8 @@
1516
1529
 
1517
1530
  // Default case - just return the name with appropriate color
1518
1531
  return isSelected
1519
- ? `<span style="color: white">${name}</span>`
1520
- : `<span style="color: hsl(var(--grey7))">${name}</span>`;
1532
+ ? `<span style="color: white">${escapeHtml(name)}</span>`
1533
+ : `<span style="color: hsl(var(--grey7))">${escapeHtml(name)}</span>`;
1521
1534
  }
1522
1535
 
1523
1536
  /**
@@ -1550,28 +1563,31 @@
1550
1563
  }
1551
1564
  }
1552
1565
 
1553
- // Update onMount to add scroll and touch listeners with passive option
1566
+ // Stops listening for scrolling (the page and, inside a module, the module's own panels: a scroll never leaves a
1567
+ // shadow root, so a listener on the window alone does not hear about those)
1568
+ let stopListeningForScroll: () => void = () => {};
1569
+
1570
+ // Update onMount to add the scroll listener with passive option
1554
1571
  onMount(() => {
1555
1572
  // Add scroll listener with passive option
1556
- window.addEventListener('scroll', handleGlobalScroll, {
1557
- capture: true,
1558
- passive: true
1559
- });
1573
+ stopListeningForScroll = listenForScroll(controllerElement, handleGlobalScroll, { passive: true });
1560
1574
  window.addEventListener('resize', handleFamilyPopoverViewportChange);
1561
1575
  window.visualViewport?.addEventListener('resize', handleFamilyPopoverViewportChange);
1562
1576
 
1563
- // Add touch listeners with passive option
1564
- window.addEventListener('touchstart', () => {}, { passive: true });
1565
- window.addEventListener('touchmove', () => {}, { passive: true });
1577
+ // (Two touchstart/touchmove listeners that did nothing used to be added to the window here, and could never be
1578
+ // removed: each input left two more on the customer's page every time it was shown. They did nothing, so they are
1579
+ // gone.)
1566
1580
  });
1567
1581
 
1568
1582
  // Update onDestroy to remove all listeners
1569
1583
  onDestroy(() => {
1570
- window.removeEventListener('scroll', handleGlobalScroll, true);
1584
+ stopListeningForScroll();
1571
1585
  window.removeEventListener('resize', handleFamilyPopoverViewportChange);
1572
1586
  window.visualViewport?.removeEventListener('resize', handleFamilyPopoverViewportChange);
1573
- window.removeEventListener('touchstart', () => {});
1574
- window.removeEventListener('touchmove', () => {});
1587
+
1588
+ // Give the popover layer back (the last input to leave removes it from the page).
1589
+ releasePortalHost(fontPickerPortalHost);
1590
+ fontPickerPortalHost = undefined;
1575
1591
 
1576
1592
  if (fontObserver) {
1577
1593
  fontObserver.disconnect();
@@ -1653,7 +1669,7 @@
1653
1669
  function handleWeightSelect(weight: string) {
1654
1670
  selectedWeight = `${controllerId}-${weight}`;
1655
1671
  if (browser) {
1656
- localStorage.setItem(FONT_WEIGHT_KEY(controllerId), selectedWeight);
1672
+ writeEditorStorageValue(FONT_WEIGHT_KEY(controllerId), selectedWeight);
1657
1673
  }
1658
1674
  const current = coerceFontValue(value);
1659
1675
  if (onChange) {
@@ -1679,7 +1695,8 @@
1679
1695
  }
1680
1696
 
1681
1697
  // Ensure the selected item is visible
1682
- const selectedElement = fontListContainer?.querySelector(`[data-font="${filteredFonts[activeSearchIndex].family}"]`);
1698
+ // (The family goes through CSS.escape: a name with a quote in it must not end the selector.)
1699
+ const selectedElement = fontListContainer?.querySelector(`[data-font="${CSS.escape(filteredFonts[activeSearchIndex].family)}"]`);
1683
1700
  selectedElement?.scrollIntoView({ block: 'nearest' });
1684
1701
  }
1685
1702
 
@@ -1691,14 +1708,16 @@
1691
1708
 
1692
1709
  // Functions for RecentItemsSection
1693
1710
  function renderFontItem(font: FontItem) {
1711
+ // The result is used as HTML by RecentItemsSection: the family is quoted for CSS and escaped for HTML where it is
1712
+ // a rule, and escaped for HTML where it is the text.
1694
1713
  return {
1695
1714
  html: `
1696
1715
  <div class="flex items-center flex-1 min-w-0">
1697
1716
  <span
1698
1717
  class="text-[24px] truncate"
1699
- style="font-family: '${font.family}', sans-serif; font-display: swap; color: inherit; -webkit-text-fill-color: currentColor;"
1718
+ style="font-family: ${escapeHtml(cssQuotedString(font.family))}, sans-serif; font-display: swap; color: inherit; -webkit-text-fill-color: currentColor;"
1700
1719
  >
1701
- ${font.family}
1720
+ ${escapeHtml(font.family)}
1702
1721
  </span>
1703
1722
  </div>
1704
1723
  `
@@ -1727,7 +1746,7 @@
1727
1746
  </script>
1728
1747
 
1729
1748
  <!-- Dual IC: header zone always on the left (even if label is empty); 185px inputs on the right. -->
1730
- <div class="font-ic flex flex-col gap-2 w-full min-w-0 widgetic-colors {className}" style={style}>
1749
+ <div bind:this={controllerElement} class="font-ic flex flex-col gap-2 w-full min-w-0 widgetic-colors {className}" style={style}>
1731
1750
  <div class="font-family-row flex items-center justify-between w-full min-w-0 gap-2" style="min-height: 48px;">
1732
1751
  <div class="font-family-label-ct flex-1 min-w-0">
1733
1752
  <InputControllerHeader label={label || ''} />
@@ -1779,7 +1798,7 @@
1779
1798
  >
1780
1799
  <span
1781
1800
  class="truncate flex-1 text-left subtext text-grey7"
1782
- style="font-family: '{resolvedFont?.family || 'system-ui'}', sans-serif; font-display: swap;"
1801
+ style="font-family: {cssQuotedString(resolvedFont?.family || 'system-ui')}, sans-serif; font-display: swap;"
1783
1802
  >
1784
1803
  {resolvedFont?.family || "Select font..."}
1785
1804
  </span>
@@ -1790,7 +1809,7 @@
1790
1809
  collisionPadding={12}
1791
1810
  side={familyPopoverSide}
1792
1811
  sideOffset={4}
1793
- portalProps={{ to: '#widgetic-font-picker-portal' }}
1812
+ portalProps={{ to: fontPickerPortalHost }}
1794
1813
  class="font-input-popover !p-0 widgetic-colors widgetic-radius rounded-medium modal-shadow border border-grey2"
1795
1814
  style={familyPopoverStyle}
1796
1815
  >
@@ -1835,7 +1854,7 @@
1835
1854
  // Restore the previous filter when clearing search
1836
1855
  currentFilter = lastActiveFilter;
1837
1856
  if (browser) {
1838
- localStorage.setItem(FONT_FILTER_KEY(controllerId), lastActiveFilter);
1857
+ writeEditorStorageValue(FONT_FILTER_KEY(controllerId), lastActiveFilter);
1839
1858
  }
1840
1859
 
1841
1860
  // ... rest of your onClear code ...
@@ -1937,7 +1956,7 @@
1937
1956
  <Check class="mr-2 h-6 w-6 flex-shrink-0 {isFontSelected(font) ? 'text-white' : 'text-sage-green opacity-0'}"/>
1938
1957
  <span
1939
1958
  class="text-[24px] truncate {index === activeSearchIndex && searchTerm ? 'font-medium' : ''}"
1940
- style="font-family: '{font.family}', sans-serif; font-display: swap; color: inherit; -webkit-text-fill-color: currentColor;"
1959
+ style="font-family: {cssQuotedString(font.family)}, sans-serif; font-display: swap; color: inherit; -webkit-text-fill-color: currentColor;"
1941
1960
  >
1942
1961
  {@html highlightMatch(font.family, searchTerm)}
1943
1962
  </span>
@@ -1996,7 +2015,7 @@
1996
2015
  </span>
1997
2016
  <IconCaretDownFilled class="w-5 h-5 shrink-0 text-grey5 group-hover:text-grey7" />
1998
2017
  </Popover.Trigger>
1999
- <Popover.Content class="font-input-popover p-0 w-[150px]" portalProps={{ to: '#widgetic-font-picker-portal' }}>
2018
+ <Popover.Content class="font-input-popover p-0 w-[150px]" portalProps={{ to: fontPickerPortalHost }}>
2000
2019
  <Command.Root>
2001
2020
  <Command.List>
2002
2021
  <Command.Group>
@@ -2,6 +2,9 @@
2
2
  // Svelte imports
3
3
  import { onDestroy, onMount } from 'svelte';
4
4
 
5
+ // Shared listener for "the user is scrolling" (one per tree, not one per header)
6
+ import { onScrollActivity } from '../../utils/scrollActivity';
7
+
5
8
  // Shadcn UI Components from design-system
6
9
  import { Tooltip, Portal } from '@widgetic/design-system/components';
7
10
  // console.log("InputControllerHeader - Tooltip:", Tooltip);
@@ -58,23 +61,23 @@ function handleMouseLeave() {
58
61
  clearTimeout(tooltipTimeout);
59
62
  }
60
63
 
64
+ let headerElement: HTMLElement | undefined;
65
+ let stopListeningForScrolling: () => void = () => {};
66
+
61
67
  onMount(() => {
62
- // Add listeners to both window and all scrollable parents
63
- window.addEventListener('scroll', handleScroll, { capture: true, passive: true });
64
- document.addEventListener('wheel', handleScroll, { capture: true, passive: true });
65
- document.addEventListener('touchmove', handleScroll, { capture: true, passive: true });
68
+ // The listeners for scrolling are shared by all the headers in the same tree (the page or the module's shadow
69
+ // root): every header used to add three of its own to the window and the document.
70
+ stopListeningForScrolling = onScrollActivity(headerElement, handleScroll);
66
71
  });
67
72
 
68
73
  onDestroy(() => {
69
- window.removeEventListener('scroll', handleScroll, { capture: true });
70
- document.removeEventListener('wheel', handleScroll, { capture: true });
71
- document.removeEventListener('touchmove', handleScroll, { capture: true });
74
+ stopListeningForScrolling();
72
75
  clearTimeout(scrollTimeout);
73
76
  clearTimeout(tooltipTimeout);
74
77
  });
75
78
  </script>
76
79
 
77
- <div class="input-controller-header widgetic-colors {className ? className + ' ' : ''}flex group" {style}>
80
+ <div bind:this={headerElement} class="input-controller-header widgetic-colors {className ? className + ' ' : ''}flex group" {style}>
78
81
  <div class="header-label-ct flex-1 min-w-0 flex items-center h-full">
79
82
  <div class="tooltip-group flex items-center max-w-full" style="gap: 8px;">
80
83
  <span class="subtext cursor-default select-none truncate !text-base" style="color: hsl(var(--grey5));">
@@ -203,11 +203,13 @@
203
203
  * Blur Event Handler
204
204
  * Validates and normalizes input when focus is lost
205
205
  */
206
- function handleBlur() {
206
+ function handleBlur(event: FocusEvent) {
207
207
  // console.log('handleBlur:', { value, isEditing, min, max });
208
208
 
209
209
  isEditing = false;
210
- const input = document.getElementById('number-input') as HTMLInputElement;
210
+ // The input that lost the focus. (It used to be looked up by the id "number-input" in the whole document, which is
211
+ // another input when there are several, and nothing inside a module's shadow root.)
212
+ const input = event.currentTarget as HTMLInputElement;
211
213
 
212
214
  // Handle empty or invalid input
213
215
  if (input.value === '' || input.value === '-') {