@widgetic/editor 4.0.9 → 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 -44
  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,15 +266,18 @@
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
283
  onMount(async () => {
@@ -277,13 +294,13 @@
277
294
  }
278
295
 
279
296
  // Load saved filter
280
- const savedFilter = localStorage.getItem(FONT_FILTER_KEY(controllerId));
297
+ const savedFilter = readEditorStorageValue(FONT_FILTER_KEY(controllerId));
281
298
  if (savedFilter) {
282
299
  currentFilter = savedFilter as FilterType;
283
300
  }
284
301
 
285
302
  // Load saved weight
286
- const savedWeight = localStorage.getItem(FONT_WEIGHT_KEY(controllerId));
303
+ const savedWeight = readEditorStorageValue(FONT_WEIGHT_KEY(controllerId));
287
304
  if (savedWeight) {
288
305
  selectedWeight = savedWeight;
289
306
  }
@@ -785,7 +802,7 @@
785
802
 
786
803
  selectedWeight = `${controllerId}-${newWeight}`;
787
804
  if (browser) {
788
- localStorage.setItem(FONT_WEIGHT_KEY(controllerId), selectedWeight);
805
+ writeEditorStorageValue(FONT_WEIGHT_KEY(controllerId), selectedWeight);
789
806
  }
790
807
 
791
808
  // Load the full font when selected
@@ -847,7 +864,7 @@
847
864
  if (!searchTerm && value) {
848
865
  lastActiveFilter = currentFilter;
849
866
  currentFilter = 'All';
850
- localStorage.setItem(FONT_FILTER_KEY(controllerId), 'All');
867
+ writeEditorStorageValue(FONT_FILTER_KEY(controllerId), 'All');
851
868
  }
852
869
 
853
870
  // Apply filtering with the new search term
@@ -1059,7 +1076,7 @@
1059
1076
  currentFilter = filter;
1060
1077
 
1061
1078
  if (browser) {
1062
- localStorage.setItem(FONT_FILTER_KEY(controllerId), filter);
1079
+ writeEditorStorageValue(FONT_FILTER_KEY(controllerId), filter);
1063
1080
  }
1064
1081
 
1065
1082
  if (fontManagerInstance) {
@@ -1477,7 +1494,8 @@
1477
1494
  * @param term Search term
1478
1495
  * @returns HTML string with highlighted match
1479
1496
  *
1480
- * 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).
1481
1499
  */
1482
1500
  function highlightMatch(name: string, term: string) {
1483
1501
  // Check if this font is the selected one
@@ -1486,8 +1504,8 @@
1486
1504
  if (!term) {
1487
1505
  // If no search term, just return the name with appropriate color
1488
1506
  return isSelected
1489
- ? `<span style="color: white">${name}</span>`
1490
- : `<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>`;
1491
1509
  }
1492
1510
 
1493
1511
  const nameLower = name.toLowerCase();
@@ -1496,9 +1514,9 @@
1496
1514
  // If the name contains the search term, highlight that part
1497
1515
  if (nameLower.includes(termLower)) {
1498
1516
  const index = nameLower.indexOf(termLower);
1499
- const beforeMatch = name.slice(0, index);
1500
- const match = name.slice(index, index + termLower.length);
1501
- 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));
1502
1520
 
1503
1521
  if (isSelected) {
1504
1522
  // For selected items: text is white, highlight is grey7
@@ -1511,8 +1529,8 @@
1511
1529
 
1512
1530
  // Default case - just return the name with appropriate color
1513
1531
  return isSelected
1514
- ? `<span style="color: white">${name}</span>`
1515
- : `<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>`;
1516
1534
  }
1517
1535
 
1518
1536
  /**
@@ -1545,28 +1563,31 @@
1545
1563
  }
1546
1564
  }
1547
1565
 
1548
- // 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
1549
1571
  onMount(() => {
1550
1572
  // Add scroll listener with passive option
1551
- window.addEventListener('scroll', handleGlobalScroll, {
1552
- capture: true,
1553
- passive: true
1554
- });
1573
+ stopListeningForScroll = listenForScroll(controllerElement, handleGlobalScroll, { passive: true });
1555
1574
  window.addEventListener('resize', handleFamilyPopoverViewportChange);
1556
1575
  window.visualViewport?.addEventListener('resize', handleFamilyPopoverViewportChange);
1557
1576
 
1558
- // Add touch listeners with passive option
1559
- window.addEventListener('touchstart', () => {}, { passive: true });
1560
- 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.)
1561
1580
  });
1562
1581
 
1563
1582
  // Update onDestroy to remove all listeners
1564
1583
  onDestroy(() => {
1565
- window.removeEventListener('scroll', handleGlobalScroll, true);
1584
+ stopListeningForScroll();
1566
1585
  window.removeEventListener('resize', handleFamilyPopoverViewportChange);
1567
1586
  window.visualViewport?.removeEventListener('resize', handleFamilyPopoverViewportChange);
1568
- window.removeEventListener('touchstart', () => {});
1569
- 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;
1570
1591
 
1571
1592
  if (fontObserver) {
1572
1593
  fontObserver.disconnect();
@@ -1648,7 +1669,7 @@
1648
1669
  function handleWeightSelect(weight: string) {
1649
1670
  selectedWeight = `${controllerId}-${weight}`;
1650
1671
  if (browser) {
1651
- localStorage.setItem(FONT_WEIGHT_KEY(controllerId), selectedWeight);
1672
+ writeEditorStorageValue(FONT_WEIGHT_KEY(controllerId), selectedWeight);
1652
1673
  }
1653
1674
  const current = coerceFontValue(value);
1654
1675
  if (onChange) {
@@ -1674,7 +1695,8 @@
1674
1695
  }
1675
1696
 
1676
1697
  // Ensure the selected item is visible
1677
- 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)}"]`);
1678
1700
  selectedElement?.scrollIntoView({ block: 'nearest' });
1679
1701
  }
1680
1702
 
@@ -1686,14 +1708,16 @@
1686
1708
 
1687
1709
  // Functions for RecentItemsSection
1688
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.
1689
1713
  return {
1690
1714
  html: `
1691
1715
  <div class="flex items-center flex-1 min-w-0">
1692
1716
  <span
1693
1717
  class="text-[24px] truncate"
1694
- 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;"
1695
1719
  >
1696
- ${font.family}
1720
+ ${escapeHtml(font.family)}
1697
1721
  </span>
1698
1722
  </div>
1699
1723
  `
@@ -1722,7 +1746,7 @@
1722
1746
  </script>
1723
1747
 
1724
1748
  <!-- Dual IC: header zone always on the left (even if label is empty); 185px inputs on the right. -->
1725
- <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}>
1726
1750
  <div class="font-family-row flex items-center justify-between w-full min-w-0 gap-2" style="min-height: 48px;">
1727
1751
  <div class="font-family-label-ct flex-1 min-w-0">
1728
1752
  <InputControllerHeader label={label || ''} />
@@ -1774,7 +1798,7 @@
1774
1798
  >
1775
1799
  <span
1776
1800
  class="truncate flex-1 text-left subtext text-grey7"
1777
- 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;"
1778
1802
  >
1779
1803
  {resolvedFont?.family || "Select font..."}
1780
1804
  </span>
@@ -1785,7 +1809,7 @@
1785
1809
  collisionPadding={12}
1786
1810
  side={familyPopoverSide}
1787
1811
  sideOffset={4}
1788
- portalProps={{ to: '#widgetic-font-picker-portal' }}
1812
+ portalProps={{ to: fontPickerPortalHost }}
1789
1813
  class="font-input-popover !p-0 widgetic-colors widgetic-radius rounded-medium modal-shadow border border-grey2"
1790
1814
  style={familyPopoverStyle}
1791
1815
  >
@@ -1830,7 +1854,7 @@
1830
1854
  // Restore the previous filter when clearing search
1831
1855
  currentFilter = lastActiveFilter;
1832
1856
  if (browser) {
1833
- localStorage.setItem(FONT_FILTER_KEY(controllerId), lastActiveFilter);
1857
+ writeEditorStorageValue(FONT_FILTER_KEY(controllerId), lastActiveFilter);
1834
1858
  }
1835
1859
 
1836
1860
  // ... rest of your onClear code ...
@@ -1932,7 +1956,7 @@
1932
1956
  <Check class="mr-2 h-6 w-6 flex-shrink-0 {isFontSelected(font) ? 'text-white' : 'text-sage-green opacity-0'}"/>
1933
1957
  <span
1934
1958
  class="text-[24px] truncate {index === activeSearchIndex && searchTerm ? 'font-medium' : ''}"
1935
- 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;"
1936
1960
  >
1937
1961
  {@html highlightMatch(font.family, searchTerm)}
1938
1962
  </span>
@@ -1991,7 +2015,7 @@
1991
2015
  </span>
1992
2016
  <IconCaretDownFilled class="w-5 h-5 shrink-0 text-grey5 group-hover:text-grey7" />
1993
2017
  </Popover.Trigger>
1994
- <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 }}>
1995
2019
  <Command.Root>
1996
2020
  <Command.List>
1997
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 === '-') {