@widgetic/creator 0.3.59 → 0.3.61

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.
@@ -3,30 +3,134 @@
3
3
  * Live iframe + chrome over Fabric WidgetShapes (W3).
4
4
  * Body drag is locked on the shape; move via the top drag bar so the iframe stays interactive.
5
5
  */
6
- let className = '';
7
- export { className as class };
8
- export let style = '';
9
-
10
- export let enabled = true;
11
- /** Screen-space boxes for widget shapes currently on the canvas. */
12
- export let getRects:
13
- | (() => Array<{ widgetId: string; left: number; top: number; width: number; height: number }>)
14
- | null = null;
15
- /** Resolve a live preview URL for a widget (CDN published or Worker preview). */
16
- export let resolveUrl: ((widgetId: string) => string | null) | null = null;
17
- /** Currently selected WidgetShape id (highlights chrome). */
18
- export let selectedWidgetId: string | null = null;
19
- /** Translate shape by screen-pixel delta. */
20
- export let onTranslate:
21
- | ((widgetId: string, dxScreen: number, dyScreen: number) => void)
22
- | null = null;
23
- /** Select the Fabric WidgetShape (e.g. when pressing the drag bar). */
24
- export let onSelectShape: ((widgetId: string) => void) | null = null;
25
- /** Open Widget Details / chat for this widget. */
26
- export let onOpenChat: ((widgetId: string) => void) | null = null;
27
-
28
- const DRAG_BAR_H = 28;
6
+ import { onMount } from 'svelte';
7
+ import { Checked } from '@widgetic/design-system/icons';
8
+ import { Portal } from '@widgetic/design-system';
9
+ import HoverTooltip from './HoverTooltip.svelte';
10
+ import VersionDropdownRow from './VersionDropdownRow.svelte';
11
+
12
+ interface VersionOption {
13
+ id: string;
14
+ /** Full list label, e.g. "Version 9". */
15
+ label: string;
16
+ /** Compact trigger label, e.g. "v9". */
17
+ shortLabel?: string;
18
+ /** Published date/time shown in the list. */
19
+ dateLabel?: string;
20
+ /** True when this option is the live published version. */
21
+ isLive?: boolean;
22
+ /** True when this version has never been published. */
23
+ isDraft?: boolean;
24
+ }
25
+
26
+ interface Props {
27
+ class?: string;
28
+ style?: string;
29
+ enabled?: boolean;
30
+ /** Shared host: inactive units < active body (60) < controls (70) < active chrome (80). */
31
+ portalTarget?: string;
32
+ /** Bounds park chrome; the local dimensions/matrix transform the live body. */
33
+ getRects?:
34
+ | (() => Array<Omit<ShapeRect, 'src'>>)
35
+ | null;
36
+ /** Resolve a live preview URL for a widget (CDN published or Worker preview). */
37
+ resolveUrl?: ((widgetId: string) => string | null) | null;
38
+ /** Keep chrome visible while a historical build is pending or failed. */
39
+ resolvePreviewState?: ((widgetId: string) => { loading: boolean; error: string | null } | null) | null;
40
+ /** Retry the selected historical version; never refresh shared HEAD. */
41
+ onRetryPreview?: ((widgetId: string) => void) | null;
42
+ /** Currently selected WidgetShape id (highlights chrome). */
43
+ selectedWidgetId?: string | null;
44
+ primaryWidgetId?: string | null;
45
+ onSetPrimary?: ((widgetId: string) => void) | null;
46
+ /** Shared publish gate; explicit versions must not inherit another row's error. */
47
+ resolvePublishDisabled?: ((widgetId: string, version?: number | null) => boolean) | null;
48
+ /** Translate shape by screen-pixel delta. */
49
+ onTranslate?: ((widgetId: string, dxScreen: number, dyScreen: number) => void) | null;
50
+ /** Fired once when a chrome drag ends (mark canvas dirty / history). */
51
+ onTranslateEnd?: ((widgetId: string) => void) | null;
52
+ /** Select the Fabric WidgetShape (e.g. when pressing the drag bar). */
53
+ onSelectShape?: ((widgetId: string) => void) | null;
54
+ /** Open Widget Details / chat for this widget. */
55
+ onOpenChat?: ((widgetId: string) => void) | null;
56
+ /** Resolve display title for the chrome bar. */
57
+ resolveTitle?: ((widgetId: string) => string | null) | null;
58
+ /** Current version label, e.g. "v3" or "Preview". */
59
+ resolveVersionLabel?: ((widgetId: string) => string | null) | null;
60
+ /** Available version options for the select (null/empty → show label only). */
61
+ resolveVersionOptions?: ((widgetId: string) => Array<VersionOption> | null) | null;
62
+ /** Currently selected version option id. */
63
+ resolveVersionSelectedId?: ((widgetId: string) => string | null) | null;
64
+ onVersionChange?: ((widgetId: string, versionId: string, buildPreview?: boolean) => void) | null;
65
+ /** Promote a published version to Live (same as chat Set live). */
66
+ onSetLive?: ((widgetId: string, version: number) => void) | null;
67
+ /** Version currently being set live (disables that row's button). */
68
+ settingLiveVersion?: number | null;
69
+ /** Capture callback after an explicit reload has loaded and settled. */
70
+ onRefresh?: ((widgetId: string, iframe: HTMLIFrameElement, isCurrent: () => boolean) => void | Promise<void>) | null;
71
+ /** Bump when widget names / publish state change so chrome re-renders. */
72
+ titleRevision?: number;
73
+ /** Whether this widget has unpublished codegen ahead of Live (show Publish). */
74
+ resolveCanPublish?: ((widgetId: string) => boolean) | null;
75
+ /**
76
+ * Whether a specific Draft row can be published. Drafts carry their own
77
+ * commit, so they can be promoted without regenerating first — the main
78
+ * `resolveCanPublish` gate is about HEAD vs Live and would say "no".
79
+ */
80
+ resolveCanPublishDraft?: ((widgetId: string, version: number) => boolean) | null;
81
+ /** Publish HEAD as a new live version (shape bar Publish button). */
82
+ onPublish?: ((widgetId: string) => void) | null;
83
+ /** Publish a specific Draft version (built from that version's own commit). */
84
+ onPublishDraft?: ((widgetId: string, version: number) => void) | null;
85
+ /** True while publish is in flight for this widget. */
86
+ resolveIsPublishing?: ((widgetId: string) => boolean) | null;
87
+ /** Publish-disabled reason for a specific Draft row (null = enabled). */
88
+ resolvePublishDisabledReason?: ((widgetId: string, version: number) => string | null) | null;
89
+ }
90
+
91
+ let {
92
+ class: className = '',
93
+ style = '',
94
+ enabled = true,
95
+ portalTarget = 'floating-panels-overlay',
96
+ getRects = null,
97
+ resolveUrl = null,
98
+ resolvePreviewState = null,
99
+ onRetryPreview = null,
100
+ selectedWidgetId = null,
101
+ primaryWidgetId = null,
102
+ onSetPrimary = null,
103
+ resolvePublishDisabled = null,
104
+ onTranslate = null,
105
+ onTranslateEnd = null,
106
+ onSelectShape = null,
107
+ onOpenChat = null,
108
+ resolveTitle = null,
109
+ resolveVersionLabel = null,
110
+ resolveVersionOptions = null,
111
+ resolveVersionSelectedId = null,
112
+ onVersionChange = null,
113
+ onSetLive = null,
114
+ settingLiveVersion = null,
115
+ onRefresh = null,
116
+ titleRevision = 0,
117
+ resolveCanPublish = null,
118
+ resolveCanPublishDraft = null,
119
+ onPublish = null,
120
+ onPublishDraft = null,
121
+ resolveIsPublishing = null,
122
+ /** Per-widget publish-gate reason for a Draft row (tooltip on Publish). */
123
+ resolvePublishDisabledReason = null,
124
+ }: Props = $props();
125
+
126
+ const DRAG_BAR_H = 30;
29
127
  const BAR_SHAPE_GAP = 4;
128
+ const SHOW_TITLE_MIN_WIDTH = 160;
129
+ /** Minimum chrome width when expanded so version + Live/Publish controls fit. */
130
+ const MIN_EXPANDED_CHROME_WIDTH = 220;
131
+ const IFRAME_INSET_PX = 2;
132
+ /** ~5 version rows before the menu scrolls (matches chat history panel). */
133
+ const VERSION_MENU_MAX_H = '12.5rem';
30
134
 
31
135
  type ShapeRect = {
32
136
  widgetId: string;
@@ -34,45 +138,100 @@
34
138
  top: number;
35
139
  width: number;
36
140
  height: number;
141
+ localWidth?: number;
142
+ localHeight?: number;
143
+ matrix?: number[];
144
+ /** Pointer is over the shape (Fabric mouse:over); keeps chrome visible. */
145
+ hovered?: boolean;
37
146
  src: string;
38
147
  };
39
148
 
40
- let overlays: ShapeRect[] = [];
41
- let draggingWidgetId: string | null = null;
149
+ let overlays = $state<ShapeRect[]>([]);
150
+ // Pass the existing element to DS Portal: its string-target ancestor guard
151
+ // otherwise creates a second floating-panels-overlay on document.body.
152
+ let portalHost = $state<HTMLElement | null>(null);
153
+ let draggingWidgetId = $state<string | null>(null);
154
+ let resizingWidgetId = $state<string | null>(null);
155
+ /** Per-widget cache-bust tokens for Refresh (iframe reload only). */
156
+ let reloadTokens = $state<Record<string, number>>({});
157
+ /**
158
+ * Explicit refresh intent: widgetId → token. The parent capture callback
159
+ * (onRefresh) fires only after THAT reload's load event has settled, never
160
+ * for a stale frame, and exactly once per explicit Refresh click.
161
+ */
162
+ const pendingRefreshTokens = new Map<string, number>();
163
+ /** Settle timers for post-load captures so unmount/sync can cancel them. */
164
+ const refreshSettleTimers = new Map<string, ReturnType<typeof setTimeout>>();
165
+
166
+ function cancelPendingRefresh(widgetId: string) {
167
+ const timer = refreshSettleTimers.get(widgetId);
168
+ if (timer) {
169
+ clearTimeout(timer);
170
+ refreshSettleTimers.delete(widgetId);
171
+ }
172
+ pendingRefreshTokens.delete(widgetId);
173
+ }
174
+ /** Which shape chrome version menu is open. */
175
+ let openVersionMenuWidgetId = $state<string | null>(null);
176
+ /** Hide Fabric placeholder flash — show loader until iframe fires load. */
177
+ let iframeLoadedByWidgetId = $state<Record<string, boolean>>({});
42
178
 
43
179
  export function sync(): void {
44
180
  if (!enabled || !getRects || !resolveUrl) {
181
+ for (const widgetId of [...pendingRefreshTokens.keys()]) cancelPendingRefresh(widgetId);
45
182
  overlays = [];
46
183
  return;
47
184
  }
48
185
  const next: ShapeRect[] = [];
186
+ const nextLoaded = { ...iframeLoadedByWidgetId };
49
187
  for (const rect of getRects() || []) {
50
188
  if (!rect?.widgetId) continue;
51
- const src = resolveUrl(rect.widgetId);
52
- if (!src) continue;
53
- next.push({
54
- widgetId: rect.widgetId,
55
- left: rect.left,
56
- top: rect.top,
57
- width: rect.width,
58
- height: rect.height,
59
- src,
60
- });
189
+ const src = resolveUrl(rect.widgetId) || '';
190
+ const previewState = resolvePreviewState?.(rect.widgetId);
191
+ if (!src && !previewState?.loading && !previewState?.error) continue;
192
+ const prev = overlays.find((o) => o.widgetId === rect.widgetId);
193
+ if (!prev || prev.src !== src) {
194
+ cancelPendingRefresh(rect.widgetId);
195
+ nextLoaded[rect.widgetId] = false;
196
+ }
197
+ next.push({ ...rect, src });
198
+ }
199
+ iframeLoadedByWidgetId = nextLoaded;
200
+ // Drop refresh intents for shapes that left the canvas — their iframes
201
+ // will never fire the load event the capture is waiting on.
202
+ for (const pendingWidgetId of [...pendingRefreshTokens.keys()]) {
203
+ if (!next.some((overlay) => overlay.widgetId === pendingWidgetId)) {
204
+ cancelPendingRefresh(pendingWidgetId);
205
+ }
61
206
  }
62
207
  overlays = next;
63
208
  }
64
209
 
210
+ function buildIframeSrc(baseSrc: string, widgetId: string): string {
211
+ const token = reloadTokens[widgetId];
212
+ if (!token) return baseSrc;
213
+ try {
214
+ const url = new URL(baseSrc, typeof window !== 'undefined' ? window.location.href : 'http://localhost');
215
+ url.searchParams.set('_t', String(token));
216
+ return url.toString();
217
+ } catch (err) {
218
+ console.warn('[WidgetShapeLivePreviews] buildIframeSrc failed', err);
219
+ const cleaned = baseSrc.replace(/([?&])_t=\d+/g, '$1').replace(/[?&]$/, '');
220
+ const sep = cleaned.includes('?') ? '&' : '?';
221
+ return `${cleaned}${sep}_t=${token}`;
222
+ }
223
+ }
224
+
65
225
  function startShapeDrag(event: PointerEvent, widgetId: string) {
226
+ if (event.button !== 0 || draggingWidgetId || resizingWidgetId) return;
66
227
  const target = event.target as HTMLElement | null;
67
- if (target?.closest('button, a, input')) return;
228
+ if (target?.closest('button, a, input, select, label')) return;
68
229
  event.preventDefault();
69
230
  event.stopPropagation();
70
231
  onSelectShape?.(widgetId);
71
232
  draggingWidgetId = widgetId;
72
- const startX = event.clientX;
73
- const startY = event.clientY;
74
- let lastX = startX;
75
- let lastY = startY;
233
+ let lastX = event.clientX;
234
+ let lastY = event.clientY;
76
235
 
77
236
  function onMove(e: PointerEvent) {
78
237
  const dx = e.clientX - lastX;
@@ -90,6 +249,7 @@
90
249
  document.removeEventListener('pointercancel', endDrag);
91
250
  document.body.style.removeProperty('user-select');
92
251
  document.body.style.removeProperty('cursor');
252
+ onTranslateEnd?.(widgetId);
93
253
  console.log('[WidgetShapeLivePreviews] Drag end', widgetId.substring(0, 8));
94
254
  }
95
255
 
@@ -107,64 +267,548 @@
107
267
  onSelectShape?.(widgetId);
108
268
  onOpenChat?.(widgetId);
109
269
  }
270
+
271
+ function handleRefreshClick(event: MouseEvent, widgetId: string) {
272
+ event.preventDefault();
273
+ event.stopPropagation();
274
+ onSelectShape?.(widgetId);
275
+ cancelPendingRefresh(widgetId);
276
+ const token = Math.max(Date.now(), (reloadTokens[widgetId] || 0) + 1);
277
+ pendingRefreshTokens.set(widgetId, token);
278
+ iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: false };
279
+ reloadTokens = { ...reloadTokens, [widgetId]: token };
280
+ console.log('[WidgetShapeLivePreviews] Refresh iframe', widgetId.substring(0, 8));
281
+ }
282
+
283
+ function markIframeLoaded(widgetId: string, iframe: EventTarget | null) {
284
+ if (!(iframe instanceof HTMLIFrameElement)) return;
285
+ const loadedSource = iframe.src;
286
+ const matchesFrame = () => enabled && iframe.isConnected
287
+ && iframe.dataset.widgetId === widgetId && iframe.src === loadedSource
288
+ && overlays.some((overlay) => overlay.widgetId === widgetId
289
+ && new URL(buildIframeSrc(overlay.src, widgetId), window.location.href).href === loadedSource);
290
+ // An old load event must not mark the replacement loaded or claim its timer.
291
+ if (!matchesFrame()) return;
292
+ iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: true };
293
+ const token = pendingRefreshTokens.get(widgetId);
294
+ if (!token || refreshSettleTimers.has(widgetId)) return;
295
+ const isCurrent = () => matchesFrame()
296
+ && pendingRefreshTokens.get(widgetId) === token && reloadTokens[widgetId] === token;
297
+ // Match the existing screenshot settling delay, but only after this frame's load.
298
+ refreshSettleTimers.set(widgetId, setTimeout(async () => {
299
+ // Retain the intent and timer marker throughout capture: cancellation must
300
+ // invalidate in-flight work, and duplicate load events must not recapture.
301
+ try {
302
+ if (!isCurrent()) return;
303
+ await onRefresh?.(widgetId, iframe, isCurrent);
304
+ } catch (error) {
305
+ console.warn('[WidgetShapeLivePreviews] Refresh capture failed', widgetId, error);
306
+ } finally {
307
+ // An older completion must never consume a newer Refresh click.
308
+ if (pendingRefreshTokens.get(widgetId) === token) cancelPendingRefresh(widgetId);
309
+ }
310
+ }, 800));
311
+ }
312
+
313
+ function getTitle(widgetId: string): string {
314
+ return resolveTitle?.(widgetId)?.trim() || 'Widget';
315
+ }
316
+
317
+ function getVersionOptions(widgetId: string): VersionOption[] {
318
+ return resolveVersionOptions?.(widgetId) || [];
319
+ }
320
+
321
+ function resolveSelectedVersionId(widgetId: string, options: VersionOption[]): string {
322
+ const fromParent = resolveVersionSelectedId?.(widgetId)?.trim();
323
+ if (fromParent && options.some((o) => o.id === fromParent)) return fromParent;
324
+ const liveOpt = options.find((o) => o.isLive);
325
+ return liveOpt?.id || options[0]?.id || '';
326
+ }
327
+
328
+ /**
329
+ * True for a version that has not been published yet.
330
+ *
331
+ * The history is a single sequence of versions; a Draft is one that has no
332
+ * `publishedAt`. It must never offer "Set live" — there is no artifact to
333
+ * promote, and publishing goes through the normal Publish flow.
334
+ */
335
+ function isDraftVersion(option: { id: string; isDraft?: boolean }): boolean {
336
+ return option.isDraft === true;
337
+ }
338
+
339
+ function getSelectedOption(widgetId: string, options: VersionOption[]): VersionOption | null {
340
+ if (!options.length) return null;
341
+ const id = resolveSelectedVersionId(widgetId, options);
342
+ return options.find((o) => o.id === id) || options[0] || null;
343
+ }
344
+
345
+ function triggerLabelForOption(option: VersionOption | null, fallback: string): string {
346
+ if (!option) return fallback;
347
+ return (option.shortLabel || option.label || fallback).trim();
348
+ }
349
+
350
+ function toggleVersionMenu(event: MouseEvent, widgetId: string) {
351
+ event.preventDefault();
352
+ event.stopPropagation();
353
+ onSelectShape?.(widgetId);
354
+ openVersionMenuWidgetId = openVersionMenuWidgetId === widgetId ? null : widgetId;
355
+ }
356
+
357
+ function selectVersion(event: MouseEvent, widgetId: string, versionId: string) {
358
+ event.preventDefault();
359
+ event.stopPropagation();
360
+ openVersionMenuWidgetId = null;
361
+ if (!versionId) return;
362
+ onVersionChange?.(widgetId, versionId);
363
+ // The parent logs the selected version and resolved commit once the build completes.
364
+ }
365
+
366
+ function handleSetLiveClick(event: MouseEvent, widgetId: string, versionId: string) {
367
+ event.preventDefault();
368
+ event.stopPropagation();
369
+ const version = versionId.startsWith('v:') ? parseInt(versionId.slice(2), 10) : NaN;
370
+ if (!Number.isFinite(version)) return;
371
+ onSetLive?.(widgetId, version);
372
+ openVersionMenuWidgetId = null;
373
+ console.log('[WidgetShapeLivePreviews] Set live', widgetId.substring(0, 8), version);
374
+ }
375
+
376
+ /** Publish a Draft row: select/preview that version first, then promote exactly it. */
377
+ function handlePublishDraftClick(event: MouseEvent, widgetId: string, versionId: string) {
378
+ event.preventDefault();
379
+ event.stopPropagation();
380
+ openVersionMenuWidgetId = null;
381
+ const version = versionId.startsWith('v:') ? parseInt(versionId.slice(2), 10) : NaN;
382
+ if (!Number.isFinite(version)) return;
383
+ if (!resolveCanPublishDraft?.(widgetId, version) || resolvePublishDisabled?.(widgetId, version)) return;
384
+ // Select exactly this draft, without launching a competing preview build.
385
+ onVersionChange?.(widgetId, versionId, false);
386
+ onPublishDraft?.(widgetId, version);
387
+ console.log('[WidgetShapeLivePreviews] Publish draft', widgetId.substring(0, 8), version);
388
+ }
389
+
390
+ /** Row-select from the shared VersionDropdownRow (no event to stop). */
391
+ function selectVersionFromMenu(widgetId: string, versionId: string) {
392
+ openVersionMenuWidgetId = null;
393
+ if (!versionId) return;
394
+ onVersionChange?.(widgetId, versionId);
395
+ }
396
+
397
+ /** Publish-Draft from the shared VersionDropdownRow; guards mirror handlePublishDraftClick. */
398
+ function handlePublishDraftClickFromMenu(widgetId: string, versionId: string) {
399
+ openVersionMenuWidgetId = null;
400
+ const version = versionId.startsWith('v:') ? parseInt(versionId.slice(2), 10) : NaN;
401
+ if (!Number.isFinite(version)) return;
402
+ if (!onPublishDraft) return;
403
+ if (!resolveCanPublishDraft?.(widgetId, version) || resolvePublishDisabled?.(widgetId, version)) return;
404
+ // Select exactly this draft, without launching a competing preview build.
405
+ onVersionChange?.(widgetId, versionId, false);
406
+ onPublishDraft?.(widgetId, version);
407
+ console.log('[WidgetShapeLivePreviews] Publish draft', widgetId.substring(0, 8), version);
408
+ }
409
+
410
+ /** Set-live from the shared VersionDropdownRow. */
411
+ function handleSetLiveClickFromMenu(widgetId: string, versionId: string) {
412
+ const version = versionId.startsWith('v:') ? parseInt(versionId.slice(2), 10) : NaN;
413
+ if (!Number.isFinite(version)) return;
414
+ onSetLive?.(widgetId, version);
415
+ openVersionMenuWidgetId = null;
416
+ console.log('[WidgetShapeLivePreviews] Set live', widgetId.substring(0, 8), version);
417
+ }
418
+
419
+ /**
420
+ * Parent-side iframe selection works across origins without reading child DOM.
421
+ * pointerenter selects on hover and never cancels the child's click. Browsers
422
+ * focus the iframe element in the parent document when its child gains focus;
423
+ * window blur is deferred one task so activeElement has already been updated.
424
+ * No focus() call or transparent click catcher: rating/audio keep the gesture.
425
+ */
426
+ function trackIframeSelection(iframe, widgetId) {
427
+ let focusTimer;
428
+ function select() {
429
+ if (!enabled || draggingWidgetId || resizingWidgetId || !iframe.isConnected) return;
430
+ // Keep the menu owner selected while its dropdown is being used.
431
+ if (openVersionMenuWidgetId && openVersionMenuWidgetId !== widgetId) return;
432
+ if (selectedWidgetId !== widgetId) onSelectShape?.(widgetId);
433
+ }
434
+ function checkFocus() {
435
+ clearTimeout(focusTimer);
436
+ focusTimer = setTimeout(() => {
437
+ if (document.activeElement === iframe) select();
438
+ }, 0);
439
+ }
440
+ iframe.addEventListener('pointerenter', select);
441
+ iframe.addEventListener('focus', select);
442
+ window.addEventListener('blur', checkFocus);
443
+ return {
444
+ update(nextWidgetId) { widgetId = nextWidgetId; },
445
+ destroy() {
446
+ clearTimeout(focusTimer);
447
+ iframe.removeEventListener('pointerenter', select);
448
+ iframe.removeEventListener('focus', select);
449
+ window.removeEventListener('blur', checkFocus);
450
+ },
451
+ };
452
+ }
453
+
454
+ function handleDocumentPointerDown(event: PointerEvent) {
455
+ if (!openVersionMenuWidgetId) return;
456
+ const target = event.target as HTMLElement | null;
457
+ if (target?.closest?.(`[data-version-menu-for="${openVersionMenuWidgetId}"]`)) return;
458
+ openVersionMenuWidgetId = null;
459
+ }
460
+
461
+ function handleDocumentKeydown(event: KeyboardEvent) {
462
+ if (event.key === 'Escape' && openVersionMenuWidgetId) {
463
+ openVersionMenuWidgetId = null;
464
+ }
465
+ }
466
+
467
+ onMount(() => {
468
+ const existingHost = document.getElementsByClassName(portalTarget.replace(/^#/, ''))[0]
469
+ || document.getElementById(portalTarget.replace(/^#/, ''));
470
+ portalHost = existingHost instanceof HTMLElement ? existingHost : null;
471
+ // A child-only HMR remount does not trigger Fabric after:render. Bootstrap
472
+ // once after parent bindings/layout settle; subsequent updates stay event-driven.
473
+ const initialSyncFrame = requestAnimationFrame(() => {
474
+ try { sync(); }
475
+ catch (error) { console.warn('[WidgetShapeLivePreviews] Initial sync failed', error); }
476
+ });
477
+ document.addEventListener('pointerdown', handleDocumentPointerDown, true);
478
+ document.addEventListener('keydown', handleDocumentKeydown);
479
+ const onShapePointerBlock = (event: Event) => {
480
+ const detail = (event as CustomEvent<{ widgetId?: string | null; active?: boolean }>).detail;
481
+ if (!detail) return;
482
+ resizingWidgetId = detail.active && detail.widgetId ? detail.widgetId : null;
483
+ };
484
+ window.addEventListener('widgetic:widget-shape-pointer-block', onShapePointerBlock);
485
+ return () => {
486
+ cancelAnimationFrame(initialSyncFrame);
487
+ for (const widgetId of [...pendingRefreshTokens.keys()]) cancelPendingRefresh(widgetId);
488
+ document.removeEventListener('pointerdown', handleDocumentPointerDown, true);
489
+ document.removeEventListener('keydown', handleDocumentKeydown);
490
+ window.removeEventListener('widgetic:widget-shape-pointer-block', onShapePointerBlock);
491
+ };
492
+ });
110
493
  </script>
111
494
 
112
- {#if enabled && overlays.length > 0}
495
+ {#if enabled && overlays.length > 0 && portalHost}
496
+ <Portal target={portalHost} class="widget-shape-units-portal" style="display: contents;">
113
497
  <div class="widget-shape-live-previews-ct pointer-events-none {className}" {style}>
114
498
  {#each overlays as overlay (overlay.widgetId)}
115
499
  {@const isSelected = selectedWidgetId === overlay.widgetId}
116
500
  {@const isDragging = draggingWidgetId === overlay.widgetId}
501
+ {@const isResizing = resizingWidgetId === overlay.widgetId}
502
+ {@const blockIframePointer = isDragging || isResizing}
117
503
  {@const chromeTop = overlay.top - DRAG_BAR_H - BAR_SHAPE_GAP}
504
+ {@const bodyWidth = overlay.localWidth ?? overlay.width}
505
+ {@const bodyHeight = overlay.localHeight ?? overlay.height}
506
+ {@const bodyMatrix = overlay.matrix ?? [1, 0, 0, 1, overlay.left, overlay.top]}
507
+ {@const showExpandedChrome = isSelected || overlay.width >= SHOW_TITLE_MIN_WIDTH}
508
+ {@const chromeWidth = showExpandedChrome
509
+ ? Math.max(overlay.width, MIN_EXPANDED_CHROME_WIDTH)
510
+ : overlay.width}
511
+ {@const titleText = (void titleRevision, getTitle(overlay.widgetId))}
512
+ {@const versionMenuOpen = openVersionMenuWidgetId === overlay.widgetId}
513
+ {@const canPublish = (void titleRevision, !!resolveCanPublish?.(overlay.widgetId))}
514
+ {@const isPublishing = (void titleRevision, !!resolveIsPublishing?.(overlay.widgetId))}
515
+ {@const versionOptions = (void titleRevision, getVersionOptions(overlay.widgetId))}
516
+ {@const selectedVersion = getSelectedOption(overlay.widgetId, versionOptions)}
517
+ {@const versionTriggerText = triggerLabelForOption(
518
+ selectedVersion,
519
+ resolveVersionLabel?.(overlay.widgetId)?.trim() || '—',
520
+ )}
521
+ <!-- Inactive widgets are whole stacking units, ordered by Fabric's array.
522
+ Active wrapper is transparent to stacking: body 60 < controls 70 < bar 80. -->
523
+ {@const chromeZIndex = isSelected ? 80 : 2}
524
+ {@const isPrimary = primaryWidgetId === overlay.widgetId}
525
+ {@const previewState = (void titleRevision, resolvePreviewState?.(overlay.widgetId))}
526
+ {@const selectedPreviewBlocked = !!(previewState?.loading || previewState?.error) || !iframeLoadedByWidgetId[overlay.widgetId]}
527
+ <!-- Chrome shows on hover or selection; an open menu pins it while the pointer is inside the menu. -->
528
+ {@const chromeVisible = isSelected || overlay.hovered || versionMenuOpen}
529
+ <div class="widget-shape-unit-ct pointer-events-none" data-widget-id={overlay.widgetId}
530
+ style={isSelected ? 'display: contents;' : 'position: absolute; inset: 0; z-index: 1; pointer-events: none;'}>
118
531
  <div
119
- class="widget-shape-live-chrome-ct fixed"
120
- style="left: {overlay.left}px; top: {chromeTop}px; width: {overlay.width}px; height: {DRAG_BAR_H + BAR_SHAPE_GAP + overlay.height}px; z-index: 1;"
532
+ class="widget-shape-live-chrome-ct fixed pointer-events-none"
533
+ data-widget-id={overlay.widgetId}
534
+ class:opacity-0={!chromeVisible}
535
+ style="left: {overlay.left}px; top: {chromeTop}px; width: {chromeWidth}px; height: {DRAG_BAR_H}px; z-index: {chromeZIndex}; pointer-events: none; transition: opacity 120ms ease;"
121
536
  >
122
- <!-- Drag bar sits between rotation control and the shape top stroke -->
537
+ <!-- Attached horizontal chrome wins hits where its own rotation stem overlaps. -->
123
538
  <!-- svelte-ignore a11y_no_static_element_interactions -->
124
539
  <div
125
- class="widget-shape-drag-bar pointer-events-auto absolute left-0 right-0 top-0 flex h-7 items-center gap-1 rounded-t-md border border-b-0 bg-white/95 px-1.5 shadow-sm select-none
126
- {isSelected ? 'border-blue-400' : 'border-gray-300'}"
127
- style="height: {DRAG_BAR_H}px; cursor: grab;"
540
+ class="widget-shape-drag-bar pointer-events-auto absolute left-0 right-0 top-0 z-10 flex items-center gap-1 rounded-t-md border border-b-0 px-1.5 shadow-sm select-none [&_button:focus]:outline-none [&_button:focus]:shadow-none [&_button:focus-visible]:outline-none [&_button:focus-visible]:shadow-none
541
+ {isPrimary ? 'bg-red-100' : 'bg-white'} {isSelected ? 'border-blue-400' : 'border-gray-300'}"
542
+ style="height: {DRAG_BAR_H}px; cursor: grab; min-width: {showExpandedChrome ? MIN_EXPANDED_CHROME_WIDTH : 0}px;"
128
543
  title="Drag widget"
129
544
  onpointerdown={(e) => startShapeDrag(e, overlay.widgetId)}
130
545
  >
131
546
  <span class="widget-shape-drag-grip shrink-0 text-xs leading-none text-gray-400" aria-hidden="true">⠿</span>
132
- {#if isSelected}
547
+
548
+ {#if showExpandedChrome}
549
+ <span
550
+ class="widget-shape-title-el min-w-0 flex-1 truncate text-[11px] font-medium text-gray-700"
551
+ title={titleText}
552
+ >{titleText}</span>
553
+
554
+ <HoverTooltip text={isPrimary ? 'Primary widget' : 'Set primary'} class="inline-flex shrink-0">
555
+ <button
556
+ type="button"
557
+ class="widget-shape-set-primary-btn flex h-6 w-6 shrink-0 items-center justify-center rounded text-red-700 enabled:hover:bg-red-200 enabled:hover:text-red-800 disabled:text-red-700/40 disabled:pointer-events-none disabled:cursor-default cursor-pointer"
558
+ aria-label={isPrimary ? 'Primary widget' : 'Set primary'}
559
+ disabled={isPrimary || !onSetPrimary}
560
+ onpointerdown={(event) => event.stopPropagation()}
561
+ onclick={(event) => { event.stopPropagation(); onSetPrimary?.(overlay.widgetId); }}
562
+ ><Checked class="widget-shape-primary-icon h-4 w-4" /></button>
563
+ </HoverTooltip>
564
+ {#if versionOptions.length > 0}
565
+ <div
566
+ class="widget-shape-version-ct relative flex shrink-0 items-center gap-0.5"
567
+ data-version-menu-for={overlay.widgetId}
568
+ >
569
+ <HoverTooltip
570
+ text="Switch version"
571
+ maxWidth={120}
572
+ class="inline-flex shrink-0"
573
+ >
574
+ <button
575
+ type="button"
576
+ class="widget-shape-version-trigger flex min-w-[2.25rem] max-w-[4rem] cursor-pointer items-center gap-0.5 rounded border border-emerald-300 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-emerald-800 hover:bg-emerald-100"
577
+ aria-haspopup="listbox"
578
+ aria-expanded={versionMenuOpen}
579
+ onclick={(e) => toggleVersionMenu(e, overlay.widgetId)}
580
+ onpointerdown={(e) => e.stopPropagation()}
581
+ >
582
+ {versionTriggerText}
583
+ <span class="widget-shape-version-caret text-emerald-500" aria-hidden="true">{versionMenuOpen ? '▲' : '▼'}</span>
584
+ </button>
585
+ </HoverTooltip>
586
+ {#if canPublish && !selectedVersion}
587
+ <HoverTooltip
588
+ text="Publish latest"
589
+ maxWidth={120}
590
+ class="inline-flex shrink-0"
591
+ >
592
+ <button
593
+ type="button"
594
+ class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
595
+ disabled={isPublishing || selectedPreviewBlocked || !!resolvePublishDisabled?.(overlay.widgetId, selectedVersion ? Number(selectedVersion.id.slice(2)) : null)}
596
+ onclick={(e) => {
597
+ e.stopPropagation();
598
+ onPublish?.(overlay.widgetId);
599
+ }}
600
+ onpointerdown={(e) => e.stopPropagation()}
601
+ >{isPublishing ? '...' : 'Publish'}</button>
602
+ </HoverTooltip>
603
+ {:else if selectedVersion?.isLive}
604
+ <HoverTooltip
605
+ text="Currently live"
606
+ maxWidth={120}
607
+ class="inline-flex shrink-0"
608
+ >
609
+ <span
610
+ class="widget-shape-live-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded bg-emerald-100 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-emerald-600 opacity-80"
611
+ >Live</span>
612
+ </HoverTooltip>
613
+ {:else if selectedVersion && onSetLive && !isDraftVersion(selectedVersion)}
614
+ <HoverTooltip
615
+ text="Make this version live"
616
+ maxWidth={130}
617
+ class="inline-flex shrink-0"
618
+ >
619
+ <button
620
+ type="button"
621
+ class="widget-shape-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded border border-emerald-300 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 cursor-pointer"
622
+ disabled={settingLiveVersion === Number(selectedVersion.id.slice(2))}
623
+ onclick={(e) => handleSetLiveClick(e, overlay.widgetId, selectedVersion.id)}
624
+ onpointerdown={(e) => e.stopPropagation()}
625
+ >{settingLiveVersion === Number(selectedVersion.id.slice(2)) ? '...' : 'Set live'}</button>
626
+ </HoverTooltip>
627
+ {:else if selectedVersion && isDraftVersion(selectedVersion)}
628
+ {#if onPublishDraft}
629
+ <HoverTooltip
630
+ text="Publish this draft"
631
+ maxWidth={130}
632
+ class="inline-flex shrink-0"
633
+ >
634
+ <button
635
+ type="button"
636
+ class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
637
+ disabled={isPublishing || selectedPreviewBlocked || !resolveCanPublishDraft?.(overlay.widgetId, Number(selectedVersion.id.slice(2))) || !!resolvePublishDisabled?.(overlay.widgetId, Number(selectedVersion.id.slice(2)))}
638
+ onclick={(e) => handlePublishDraftClick(e, overlay.widgetId, selectedVersion.id)}
639
+ onpointerdown={(e) => e.stopPropagation()}
640
+ >{isPublishing ? '...' : 'Publish'}</button>
641
+ </HoverTooltip>
642
+ {:else}
643
+ <HoverTooltip
644
+ text="No commit to publish"
645
+ maxWidth={130}
646
+ class="inline-flex shrink-0"
647
+ >
648
+ <span
649
+ class="widget-shape-draft-badge shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded bg-amber-100 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-amber-700"
650
+ >Draft</span>
651
+ </HoverTooltip>
652
+ {/if}
653
+ {/if}
654
+ {#if versionMenuOpen}
655
+ <div
656
+ class="widget-shape-version-menu absolute right-0 top-full z-30 mt-0.5 w-max max-w-[min(22rem,calc(100vw-16px))] overflow-y-auto overflow-x-hidden rounded-md border border-emerald-200 bg-white py-0.5 shadow-lg"
657
+ style="max-height: {VERSION_MENU_MAX_H}; scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.2) transparent;"
658
+ role="listbox"
659
+ >
660
+ {#each versionOptions as option (option.id)}
661
+ {@const optionVersion = Number(option.id.slice(2))}
662
+ {@const optionBusy = (void titleRevision, !!resolveIsPublishing?.(overlay.widgetId))}
663
+ {@const optionPublishDisabled = !resolveCanPublishDraft?.(overlay.widgetId, optionVersion)
664
+ || !!resolvePublishDisabled?.(overlay.widgetId, optionVersion)
665
+ || (selectedVersion?.id === option.id && selectedPreviewBlocked)}
666
+ {@const optionPublishReason = (void titleRevision, resolvePublishDisabledReason?.(overlay.widgetId, optionVersion))}
667
+ <VersionDropdownRow
668
+ versionLabel={option.shortLabel || option.label}
669
+ dateLabel={option.dateLabel}
670
+ isLive={option.isLive === true}
671
+ isDraft={isDraftVersion(option)}
672
+ selected={selectedVersion?.id === option.id}
673
+ busy={optionBusy}
674
+ publishDisabled={optionPublishDisabled}
675
+ publishDisabledReason={optionPublishReason || 'Build this draft version and make it the Live version'}
676
+ setLiveDisabled={settingLiveVersion === optionVersion}
677
+ onselect={() => selectVersionFromMenu(overlay.widgetId, option.id)}
678
+ onpublish={() => handlePublishDraftClickFromMenu(overlay.widgetId, option.id)}
679
+ onsetlive={() => handleSetLiveClickFromMenu(overlay.widgetId, option.id)}
680
+ />
681
+ {/each}
682
+ </div>
683
+ {/if}
684
+ </div>
685
+ {:else if versionTriggerText && versionTriggerText !== '—'}
686
+ <span
687
+ class="widget-shape-version-label shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500"
688
+ title="Current version"
689
+ >{versionTriggerText}</span>
690
+ {/if}
691
+ {:else if isSelected}
133
692
  <span
134
693
  class="widget-shape-edit-mode-badge shrink-0 rounded-full bg-amber-200 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-900"
135
694
  >Edit mode</span>
136
695
  {/if}
137
- <div class="widget-shape-drag-bar-actions ml-auto flex items-center gap-0.5">
696
+
697
+ <div class="widget-shape-drag-bar-actions ml-auto flex shrink-0 items-center gap-0.5">
698
+ <HoverTooltip text="Refresh preview" class="inline-flex shrink-0">
699
+ <button
700
+ type="button"
701
+ class="widget-shape-refresh-btn flex h-6 w-6 items-center justify-center rounded text-gray-500 hover:bg-gray-100 hover:text-forest-green cursor-pointer"
702
+ aria-label="Refresh preview"
703
+ onclick={(e) => handleRefreshClick(e, overlay.widgetId)}
704
+ onpointerdown={(e) => e.stopPropagation()}
705
+ >
706
+ <svg class="widget-shape-refresh-icon h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
707
+ <polyline points="23 4 23 10 17 10"/>
708
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
709
+ </svg>
710
+ <span class="sr-only">Refresh</span>
711
+ </button>
712
+ </HoverTooltip>
713
+ <HoverTooltip text="Open chat" maxWidth={100} class="inline-flex shrink-0">
138
714
  <button
139
715
  type="button"
140
- class="widget-shape-open-chat-bt flex h-6 items-center gap-1 rounded px-1.5 text-[10px] font-medium text-gray-600 hover:bg-gray-100 hover:text-forest-green cursor-pointer"
141
- title="Open chat"
716
+ class="widget-shape-open-chat-btn flex h-6 w-6 items-center justify-center rounded text-gray-500 hover:bg-gray-100 hover:text-forest-green cursor-pointer"
717
+ aria-label="Open chat"
142
718
  onclick={(e) => handleOpenChat(e, overlay.widgetId)}
143
719
  onpointerdown={(e) => e.stopPropagation()}
144
720
  >
145
- Chat
721
+ <svg
722
+ class="widget-shape-chat-icon h-4 w-4"
723
+ viewBox="0 0 24 24"
724
+ fill="none"
725
+ stroke="currentColor"
726
+ stroke-width="2"
727
+ stroke-linecap="round"
728
+ stroke-linejoin="round"
729
+ aria-hidden="true"
730
+ >
731
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
732
+ </svg>
733
+ <span class="sr-only">Chat</span>
146
734
  </button>
735
+ </HoverTooltip>
147
736
  </div>
148
737
  </div>
149
738
 
739
+ </div>
740
+
150
741
  <div
151
- class="widget-shape-live-preview-frame pointer-events-none absolute left-0 right-0 overflow-visible
152
- {isSelected ? 'ring-2 ring-blue-400/80 rounded-sm' : ''}"
153
- style="top: {DRAG_BAR_H + BAR_SHAPE_GAP}px; height: {overlay.height}px;"
742
+ class="widget-shape-live-preview-frame pointer-events-none fixed left-0 top-0 origin-top-left overflow-visible"
743
+ data-widget-id={overlay.widgetId}
744
+ style="height: {bodyHeight}px; width: {bodyWidth}px; transform: matrix({bodyMatrix.join(',')}); z-index: {isSelected ? 60 : 1};"
154
745
  >
155
- <!-- Inset so Fabric corner/rotate handles stay clickable around the edges -->
746
+ <!-- A new reload gets its own window so old load/screenshot replies cannot win. -->
747
+ {#if overlay.src}
748
+ {#key buildIframeSrc(overlay.src, overlay.widgetId)}
749
+ <!-- Inset so Fabric corner/rotate handles stay clickable around the edges.
750
+ First click inside the widget selects the shape (ring + chrome) via
751
+ focus, while the iframe keeps receiving the pointer events themselves. -->
156
752
  <iframe
157
753
  class="widget-shape-live-preview-iframe pointer-events-auto absolute border-0 bg-white"
158
- class:pointer-events-none={isDragging}
159
- style="left: 8px; top: 8px; right: 8px; bottom: 8px; width: calc(100% - 16px); height: calc(100% - 16px);"
754
+ class:pointer-events-none={blockIframePointer || selectedPreviewBlocked}
755
+ class:opacity-0={selectedPreviewBlocked}
756
+ style="left: {IFRAME_INSET_PX}px; top: {IFRAME_INSET_PX}px; right: {IFRAME_INSET_PX}px; bottom: {IFRAME_INSET_PX}px; width: calc(100% - {IFRAME_INSET_PX * 2}px); height: calc(100% - {IFRAME_INSET_PX * 2}px);"
160
757
  title="Live widget preview"
161
- src={overlay.src}
758
+ src={buildIframeSrc(overlay.src, overlay.widgetId)}
759
+ data-widget-id={overlay.widgetId}
162
760
  tabindex="-1"
163
- loading="lazy"
761
+ loading="eager"
164
762
  sandbox="allow-scripts allow-same-origin allow-forms"
763
+ use:trackIframeSelection={overlay.widgetId}
764
+ onload={(event) => markIframeLoaded(overlay.widgetId, event.currentTarget)}
165
765
  ></iframe>
766
+ {/key}
767
+ {/if}
768
+ {#if previewState?.error && !previewState.loading}
769
+ <div class="widget-shape-preview-error pointer-events-auto absolute inset-0 flex flex-col items-center justify-center gap-2 overflow-auto bg-white p-4 text-center text-xs text-red-600 [scrollbar-color:rgba(0,0,0,0.2)_transparent]" role="alert">
770
+ <span>{previewState.error}</span>
771
+ {#if onRetryPreview}
772
+ <button type="button" class="widget-shape-preview-retry pointer-events-auto cursor-pointer rounded border border-gray-300 bg-white px-3 py-1.5 text-gray-700 hover:bg-gray-100 focus:outline-none focus-visible:outline-none focus-visible:bg-gray-100"
773
+ onpointerdown={(event) => event.stopPropagation()}
774
+ onclick={(event) => { event.stopPropagation(); onRetryPreview?.(overlay.widgetId); }}>Retry</button>
775
+ {/if}
776
+ </div>
777
+ {:else if selectedPreviewBlocked}
778
+ <div
779
+ class="widget-shape-preview-loader pointer-events-none absolute inset-0 z-[1] flex flex-col items-center justify-center gap-2 rounded-sm bg-white"
780
+ role="status"
781
+ aria-live="polite"
782
+ >
783
+ <div class="widget-shape-preview-spinner h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-emerald-600" aria-hidden="true"></div>
784
+ <span class="widget-shape-preview-loading-label text-xs text-gray-600">{previewState?.loading ? 'Building...' : 'Loading...'}</span>
785
+ </div>
786
+ {/if}
787
+ {#if blockIframePointer}
788
+ <!-- Transparent blue shield so drag/resize isn't stolen by the iframe -->
789
+ <div
790
+ class="widget-shape-iframe-pointer-shield pointer-events-auto absolute inset-0 z-[2] rounded-sm bg-blue-400/20"
791
+ aria-hidden="true"
792
+ ></div>
793
+ {/if}
166
794
  </div>
167
795
  </div>
168
796
  {/each}
169
797
  </div>
798
+ </Portal>
170
799
  {/if}
800
+
801
+ <style>
802
+ .widget-shape-version-menu::-webkit-scrollbar {
803
+ width: 8px;
804
+ height: 8px;
805
+ background: transparent;
806
+ }
807
+ .widget-shape-version-menu::-webkit-scrollbar-track {
808
+ background: transparent;
809
+ }
810
+ .widget-shape-version-menu::-webkit-scrollbar-thumb {
811
+ background: rgba(0, 0, 0, 0.2);
812
+ border-radius: 4px;
813
+ }
814
+ </style>