@shoppexio/builder-runtime 0.1.0 → 0.1.1

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.
package/src/react.tsx CHANGED
@@ -37,6 +37,11 @@ const BUILDER_STATE_EVENT_NAME = 'shoppex:builder-state';
37
37
  const BUILDER_BLOCK_SELECTOR = '[data-builder-block]';
38
38
  const BUILDER_CONTENT_SELECTOR = '[data-builder-content]';
39
39
  const BUILDER_SELECTED_SELECTOR = '[data-builder-selected="true"]';
40
+ const BUILDER_READY_HEALTH = {
41
+ reactMounted: true,
42
+ builderRuntimeProvider: true,
43
+ protocolVersion: 2,
44
+ } as const;
40
45
 
41
46
  const BuilderRuntimeContext = createContext<BuilderRuntimeContextValue | null>(null);
42
47
  const BuilderBlockContext = createContext<BlockInstance | null>(null);
@@ -67,11 +72,14 @@ export function BuilderRuntimePreviewProvider({
67
72
  useEffect(() => {
68
73
  const currentWindow = window as Window & {
69
74
  __SHOPPEX_BUILDER_SETTINGS__?: BuilderSettings;
75
+ __SHOPPEX_PREVIEW_SESSION_PATH__?: string;
70
76
  };
71
- const parentOrigin = getPreviewParentOrigin(document.referrer);
77
+ const parentOrigin = getPreviewParentOrigin(window.location, document.referrer);
72
78
  const isTrustedPreviewEmbed = isTrustedBuilderPreviewEmbed(window.location, parentOrigin);
73
79
  const removeInspectorStyles = isTrustedPreviewEmbed ? installBuilderPreviewInspectorStyles() : () => {};
74
80
  const removeHoverInspector = isTrustedPreviewEmbed ? installBuilderPreviewHoverInspector() : () => {};
81
+ let interactionMode: 'edit' | 'preview' = 'edit';
82
+ let removeDirectManipulation: () => void = () => {};
75
83
 
76
84
  const postToParent = (event: MessageEvent<unknown> | null, response: unknown) => {
77
85
  const target = event?.source && 'postMessage' in event.source ? event.source : window.parent;
@@ -82,6 +90,13 @@ export function BuilderRuntimePreviewProvider({
82
90
  targetOrigin || '*',
83
91
  );
84
92
  };
93
+ const postReady = (event: MessageEvent<unknown> | null) => {
94
+ postToParent(event, {
95
+ type: 'READY',
96
+ revision: settingsRevisionRef.current,
97
+ health: BUILDER_READY_HEALTH,
98
+ });
99
+ };
85
100
 
86
101
  const applySettings = (input: unknown): { status: 'applied'; settings: BuilderSettings } | { status: 'invalid' | 'stale' } => {
87
102
  const parsed = BuilderSettingsSchema.safeParse(input);
@@ -110,7 +125,7 @@ export function BuilderRuntimePreviewProvider({
110
125
  const message = parsed.data;
111
126
 
112
127
  if (message.type === 'REQUEST_READY') {
113
- postToParent(event, { type: 'READY', revision: settingsRevisionRef.current });
128
+ postReady(event);
114
129
  return;
115
130
  }
116
131
 
@@ -131,20 +146,49 @@ export function BuilderRuntimePreviewProvider({
131
146
 
132
147
  if (message.type === 'RELOAD') {
133
148
  postToParent(event, { type: 'APPLIED', revision: message.revision });
134
- window.setTimeout(() => window.location.reload(), 0);
149
+ const previewReloadTarget = resolvePreviewReloadTarget(
150
+ window.location,
151
+ currentWindow.__SHOPPEX_PREVIEW_SESSION_PATH__,
152
+ );
153
+ window.setTimeout(() => {
154
+ if (previewReloadTarget) {
155
+ window.location.href = previewReloadTarget;
156
+ return;
157
+ }
158
+ window.location.reload();
159
+ }, 0);
135
160
  return;
136
161
  }
137
162
 
138
163
  if (message.type === 'SELECT_ELEMENT') {
139
164
  selectBuilderElement(message.selection.blockId);
165
+ return;
166
+ }
167
+
168
+ if (message.type === 'SET_INTERACTION_MODE') {
169
+ interactionMode = message.mode;
170
+ document.documentElement.setAttribute(
171
+ 'data-builder-interaction-mode',
172
+ interactionMode,
173
+ );
174
+ return;
140
175
  }
141
176
  };
142
177
 
143
178
  const handleBuilderClick = (event: MouseEvent) => {
144
179
  if (!isTrustedPreviewEmbed) return;
180
+ // In "preview" interaction mode let the storefront react to clicks
181
+ // naturally so the merchant can test buy-now flows or anchor links.
182
+ if (interactionMode === 'preview') return;
145
183
  const target = event.target;
146
184
  if (!(target instanceof Element)) return;
147
185
 
186
+ // Once an element is in inline-edit mode, clicks on it should
187
+ // place the caret instead of re-firing block selection. The
188
+ // mousedown handler in installBuilderDirectManipulation has
189
+ // already validated this is a legitimate edit interaction.
190
+ if (target.closest('[data-builder-inline-edit="true"]')) return;
191
+
148
192
  const blockElement = target.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
149
193
  if (!blockElement) return;
150
194
 
@@ -161,19 +205,58 @@ export function BuilderRuntimePreviewProvider({
161
205
  });
162
206
  };
163
207
 
164
- if (isTrustedPreviewEmbed) {
165
- postToParent(null, { type: 'READY', revision: settingsRevisionRef.current });
166
- }
208
+ const postPreviewError = (source: 'error' | 'unhandledrejection', error: unknown) => {
209
+ if (!isTrustedPreviewEmbed) return;
210
+ const normalized = normalizePreviewRuntimeError(error);
211
+ postToParent(null, {
212
+ type: 'PREVIEW_ERROR',
213
+ revision: settingsRevisionRef.current,
214
+ message: normalized.message,
215
+ ...(normalized.stack ? { stack: normalized.stack } : {}),
216
+ source,
217
+ diagnostics: {
218
+ name: normalized.name,
219
+ href: window.location.href,
220
+ referrer: document.referrer || undefined,
221
+ parentOrigin: parentOrigin ?? undefined,
222
+ previewMode: new URLSearchParams(window.location.search).get('shoppex-preview-mode') ?? undefined,
223
+ userAgent: navigator.userAgent,
224
+ },
225
+ });
226
+ };
227
+
228
+ const handleRuntimeError = (event: ErrorEvent) => {
229
+ postPreviewError('error', event.error ?? event.message);
230
+ };
231
+
232
+ const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
233
+ postPreviewError('unhandledrejection', event.reason);
234
+ };
167
235
 
168
236
  window.addEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
169
237
  window.addEventListener('message', handlePreviewMessage);
170
238
  window.addEventListener('click', handleBuilderClick, true);
239
+ window.addEventListener('error', handleRuntimeError);
240
+ window.addEventListener('unhandledrejection', handleUnhandledRejection);
241
+ if (isTrustedPreviewEmbed) {
242
+ (window as Window & {
243
+ __SHOPPEX_PREVIEW_DISABLE_BOOTSTRAP_ERROR_BRIDGE__?: () => void;
244
+ }).__SHOPPEX_PREVIEW_DISABLE_BOOTSTRAP_ERROR_BRIDGE__?.();
245
+ removeDirectManipulation = installBuilderDirectManipulation(
246
+ (message) => postToParent(null, message),
247
+ () => settingsRevisionRef.current,
248
+ );
249
+ postReady(null);
250
+ }
171
251
  return () => {
172
252
  removeInspectorStyles();
173
253
  removeHoverInspector();
254
+ removeDirectManipulation();
174
255
  window.removeEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
175
256
  window.removeEventListener('message', handlePreviewMessage);
176
257
  window.removeEventListener('click', handleBuilderClick, true);
258
+ window.removeEventListener('error', handleRuntimeError);
259
+ window.removeEventListener('unhandledrejection', handleUnhandledRejection);
177
260
  };
178
261
  }, []);
179
262
 
@@ -242,7 +325,11 @@ export function BuilderPage<TContext = unknown>({
242
325
  {pageBlocks.map((block) => {
243
326
  const Component = registry[block.type];
244
327
  if (!Component) {
245
- return fallback;
328
+ return (
329
+ <BuilderBlockProvider key={block.id} block={block}>
330
+ {fallback ?? renderMissingBuilderBlock(pageId, block)}
331
+ </BuilderBlockProvider>
332
+ );
246
333
  }
247
334
 
248
335
  return (
@@ -255,6 +342,33 @@ export function BuilderPage<TContext = unknown>({
255
342
  );
256
343
  }
257
344
 
345
+ function renderMissingBuilderBlock(pageId: string, block: BlockInstance): ReactNode {
346
+ if (!isBuilderPreviewRuntime()) {
347
+ return null;
348
+ }
349
+
350
+ return createElement(
351
+ 'div',
352
+ {
353
+ 'data-page-id': pageId,
354
+ 'data-builder-block': block.id,
355
+ 'data-builder-block-type': block.type,
356
+ 'data-builder-runtime-error': 'missing-block-component',
357
+ style: {
358
+ margin: '12px 0',
359
+ border: '1px solid #dc2626',
360
+ borderRadius: '8px',
361
+ background: '#fef2f2',
362
+ color: '#7f1d1d',
363
+ padding: '12px',
364
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
365
+ fontSize: '13px',
366
+ },
367
+ },
368
+ `Missing Builder component for block "${block.type}".`,
369
+ );
370
+ }
371
+
258
372
  export function useBuilderRuntime(): BuilderRuntimeContextValue {
259
373
  const context = useContext(BuilderRuntimeContext);
260
374
  if (!context) {
@@ -319,6 +433,19 @@ export function useVisibleBuilderPageBlocks(pageId: string) {
319
433
  return getVisiblePageBlocks(useBuilderRuntime().settings, pageId);
320
434
  }
321
435
 
436
+ export function useThemePageBlocks(pageId: string, defaultOrder: string[]): BlockInstance[] {
437
+ const { settings } = useBuilderRuntime();
438
+
439
+ return useMemo(() => {
440
+ const page = settings.theme.layout[pageId];
441
+ if (page) {
442
+ return page.blocks.filter((block) => block.visible);
443
+ }
444
+
445
+ return defaultOrder.map((type) => ({ id: type, type, visible: true, settings: {} }));
446
+ }, [defaultOrder, pageId, settings.theme.layout]);
447
+ }
448
+
322
449
  export function useBuilderStyleSlot(
323
450
  slotId: StyleSlotId,
324
451
  input: { breakpoint?: Breakpoint; fallback?: unknown } = {},
@@ -358,6 +485,14 @@ function getNestedBuilderSetting(record: Record<string, unknown>, path: string):
358
485
  return current;
359
486
  }
360
487
 
488
+ function isBuilderPreviewRuntime(): boolean {
489
+ if (typeof window === 'undefined') {
490
+ return false;
491
+ }
492
+
493
+ return window.location.search.includes('shoppex-preview-mode=theme');
494
+ }
495
+
361
496
  function parseInitialBuilderSettings(input: unknown): BuilderSettings {
362
497
  const parsed = BuilderSettingsSchema.safeParse(input);
363
498
  if (parsed.success) return parsed.data;
@@ -399,15 +534,56 @@ function isRecord(value: unknown): value is Record<string, unknown> {
399
534
  return typeof value === 'object' && value !== null && !Array.isArray(value);
400
535
  }
401
536
 
402
- function getPreviewParentOrigin(referrer: string): string | null {
403
- if (!referrer) return null;
537
+ export function resolvePreviewReloadTarget(
538
+ location: Pick<Location, 'search' | 'hash'>,
539
+ sessionPath: unknown,
540
+ ): string | null {
541
+ if (typeof sessionPath !== 'string' || !sessionPath.startsWith('/s/')) {
542
+ return null;
543
+ }
544
+
545
+ return `${sessionPath}${location.search}${location.hash}`;
546
+ }
547
+
548
+ function normalizePreviewRuntimeError(error: unknown): { message: string; stack?: string; name?: string } {
549
+ if (error instanceof Error) {
550
+ return {
551
+ message: error.message || error.name || 'Preview runtime error',
552
+ name: error.name,
553
+ ...(typeof error.stack === 'string' && error.stack ? { stack: error.stack } : {}),
554
+ };
555
+ }
556
+
557
+ if (typeof error === 'string') {
558
+ return { message: error || 'Preview runtime error' };
559
+ }
560
+
561
+ if (isRecord(error)) {
562
+ const message = typeof error.message === 'string' && error.message
563
+ ? error.message
564
+ : 'Preview runtime error';
565
+ const stack = typeof error.stack === 'string' && error.stack ? error.stack : undefined;
566
+ const name = typeof error.name === 'string' && error.name ? error.name : undefined;
567
+ return { message, ...(stack ? { stack } : {}), ...(name ? { name } : {}) };
568
+ }
569
+
570
+ return { message: 'Preview runtime error' };
571
+ }
572
+
573
+ function parseOrigin(value: string | null | undefined): string | null {
574
+ if (!value) return null;
404
575
  try {
405
- return new URL(referrer).origin;
576
+ return new URL(value).origin;
406
577
  } catch {
407
578
  return null;
408
579
  }
409
580
  }
410
581
 
582
+ function getPreviewParentOrigin(location: Location, referrer: string): string | null {
583
+ const explicitOrigin = new URLSearchParams(location.search).get('shoppex-preview-parent-origin');
584
+ return parseOrigin(explicitOrigin) ?? parseOrigin(referrer);
585
+ }
586
+
411
587
  function isTrustedBuilderPreviewEmbed(location: Location, parentOrigin: string | null): boolean {
412
588
  if (window.parent === window || !parentOrigin) return false;
413
589
  if (!hasBuilderPreviewMode(location)) return false;
@@ -502,49 +678,428 @@ function installBuilderPreviewInspectorStyles(): () => void {
502
678
  position: relative;
503
679
  }
504
680
  [data-builder-block][data-builder-selected="true"] {
505
- outline: 2px solid #2563eb;
681
+ outline: 1px solid rgba(124, 58, 237, 0.7);
506
682
  outline-offset: 4px;
507
683
  }
508
684
  [data-builder-block][data-builder-hovered="true"] {
509
- outline: 1px dashed #2563eb;
685
+ outline: 1px dashed rgba(124, 58, 237, 0.5);
510
686
  outline-offset: 4px;
511
687
  cursor: pointer;
512
688
  }
689
+ /* Block-name tooltip in the upper-left corner of the hovered block.
690
+ Driven by a data-builder-block-label attribute the runtime sets
691
+ from manifest.blocks[type].label when available, falling back to
692
+ the block-type slug. */
693
+ [data-builder-block][data-builder-hovered="true"]::before {
694
+ content: attr(data-builder-block-label);
695
+ position: absolute;
696
+ top: -22px;
697
+ left: 0;
698
+ padding: 2px 6px;
699
+ font-size: 11px;
700
+ font-weight: 500;
701
+ line-height: 1.3;
702
+ color: #ffffff;
703
+ background: #7c3aed;
704
+ border-radius: 4px;
705
+ pointer-events: none;
706
+ white-space: nowrap;
707
+ z-index: 999;
708
+ }
709
+ /* Sub-element hover: thinner outline so a hover on a text or image
710
+ inside a block highlights only that target instead of the whole
711
+ block. Suppressed while the parent block is selected so the
712
+ merchant doesn't see double outlines. */
713
+ [data-builder-content][data-builder-content-hovered="true"] {
714
+ outline: 1px dashed rgba(124, 58, 237, 0.5);
715
+ outline-offset: 2px;
716
+ cursor: pointer;
717
+ }
718
+ [data-builder-block][data-builder-selected="true"] [data-builder-content][data-builder-content-hovered="true"] {
719
+ outline: none;
720
+ }
721
+ /* Inline-edit affordance: hover over editable text content reveals a
722
+ text cursor + subtle brand underline so the merchant can see what
723
+ a double-click would target. Images, links, and buttons stay
724
+ outside this rule — those have their own picker affordances. */
725
+ [data-builder-content][data-builder-content-hovered="true"]:not(img):not(a[href]):not(:has(a[href])):not(:has(button)) {
726
+ text-decoration: underline;
727
+ text-decoration-color: rgba(124, 58, 237, 0.55);
728
+ text-decoration-thickness: 2px;
729
+ text-underline-offset: 4px;
730
+ cursor: text;
731
+ }
732
+ /* Active inline-edit state: replaces the dashed outline with a
733
+ brand-tinted ring so the merchant gets unambiguous focus
734
+ feedback while typing. */
735
+ [data-builder-content][data-builder-inline-edit="true"] {
736
+ outline: 2px solid #7c3aed !important;
737
+ outline-offset: 4px !important;
738
+ border-radius: 4px;
739
+ background: rgba(124, 58, 237, 0.06);
740
+ text-decoration: none !important;
741
+ cursor: text !important;
742
+ caret-color: #7c3aed;
743
+ }
744
+ [data-builder-content][data-builder-inline-edit="true"]:focus {
745
+ outline: 2px solid #7c3aed !important;
746
+ outline-offset: 4px !important;
747
+ }
513
748
  `;
514
749
  document.head.appendChild(style);
515
750
  return () => style.remove();
516
751
  }
517
752
 
518
753
  function installBuilderPreviewHoverInspector(): () => void {
519
- let hovered: HTMLElement | null = null;
754
+ let hoveredBlock: HTMLElement | null = null;
755
+ let hoveredContent: HTMLElement | null = null;
756
+
757
+ const clearHoveredBlock = () => {
758
+ hoveredBlock?.removeAttribute('data-builder-hovered');
759
+ hoveredBlock = null;
760
+ };
520
761
 
521
- const clearHovered = () => {
522
- hovered?.removeAttribute('data-builder-hovered');
523
- hovered = null;
762
+ const clearHoveredContent = () => {
763
+ hoveredContent?.removeAttribute('data-builder-content-hovered');
764
+ hoveredContent = null;
524
765
  };
525
766
 
526
767
  const handleMouseOver = (event: MouseEvent) => {
527
768
  const target = event.target;
528
769
  if (!(target instanceof Element)) return;
529
770
  const blockElement = target.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
530
- if (blockElement === hovered) return;
531
- clearHovered();
532
- if (!blockElement) return;
533
- hovered = blockElement;
534
- hovered.setAttribute('data-builder-hovered', 'true');
771
+ const contentElement = target.closest<HTMLElement>(BUILDER_CONTENT_SELECTOR);
772
+
773
+ if (blockElement !== hoveredBlock) {
774
+ clearHoveredBlock();
775
+ if (blockElement) {
776
+ hoveredBlock = blockElement;
777
+ // Drive the ::before tooltip via a label attribute. Derive
778
+ // it from the block-type slug — pretty manifests already use
779
+ // capitalised labels, but we can't read manifest from here so
780
+ // we humanise the slug ("hero" → "Hero").
781
+ if (!blockElement.hasAttribute('data-builder-block-label')) {
782
+ const type = blockElement.getAttribute('data-builder-block-type') ?? '';
783
+ // Slugs whose Title-Cased form ("Custom Html") doesn't match the
784
+ // manifest's display label. We can't read manifest from here so
785
+ // we keep a small override table for these.
786
+ const SLUG_LABEL_OVERRIDES: Record<string, string> = {
787
+ 'custom-html': 'Custom Embed',
788
+ };
789
+ const override = SLUG_LABEL_OVERRIDES[type];
790
+ const label = override ?? type
791
+ .replace(/[-_]/g, ' ')
792
+ .replace(/\b\w/g, (c) => c.toUpperCase());
793
+ if (label) blockElement.setAttribute('data-builder-block-label', label);
794
+ }
795
+ hoveredBlock.setAttribute('data-builder-hovered', 'true');
796
+ }
797
+ }
798
+
799
+ if (contentElement !== hoveredContent) {
800
+ clearHoveredContent();
801
+ if (contentElement && contentElement !== blockElement) {
802
+ hoveredContent = contentElement;
803
+ hoveredContent.setAttribute('data-builder-content-hovered', 'true');
804
+ }
805
+ }
535
806
  };
536
807
 
537
808
  const handleMouseOut = (event: MouseEvent) => {
538
809
  const relatedTarget = event.relatedTarget;
539
- if (relatedTarget instanceof Node && hovered?.contains(relatedTarget)) return;
540
- clearHovered();
810
+ if (relatedTarget instanceof Node) {
811
+ if (hoveredBlock?.contains(relatedTarget) && hoveredContent?.contains(relatedTarget)) return;
812
+ if (hoveredBlock?.contains(relatedTarget) && !hoveredContent) return;
813
+ }
814
+ clearHoveredBlock();
815
+ clearHoveredContent();
541
816
  };
542
817
 
543
818
  window.addEventListener('mouseover', handleMouseOver, true);
544
819
  window.addEventListener('mouseout', handleMouseOut, true);
545
820
  return () => {
546
- clearHovered();
821
+ clearHoveredBlock();
822
+ clearHoveredContent();
547
823
  window.removeEventListener('mouseover', handleMouseOver, true);
548
824
  window.removeEventListener('mouseout', handleMouseOut, true);
549
825
  };
550
826
  }
827
+
828
+ const INSERTER_HOVER_BAND_PX = 16;
829
+
830
+ /**
831
+ * Direct-manipulation bridge: tracks the bounding rect of the selected
832
+ * block (for the floating toolbar), the inter-block gap currently
833
+ * hovered (for the "+" inserter), and inline-text-edit commits. All
834
+ * coordinates are reported in the iframe's viewport space; the
835
+ * dashboard maps them to its overlay layer.
836
+ */
837
+ function installBuilderDirectManipulation(
838
+ postMessage: (message: unknown) => void,
839
+ getRevision: () => number,
840
+ ): () => void {
841
+ // Defensive: test environments may stub document/window without the
842
+ // observer APIs we rely on. In that case we silently no-op so the
843
+ // existing READY/APPLY handshake remains testable.
844
+ if (
845
+ typeof MutationObserver === 'undefined' ||
846
+ typeof window === 'undefined' ||
847
+ typeof document === 'undefined'
848
+ ) {
849
+ return () => {};
850
+ }
851
+
852
+ let trackedBlock: HTMLElement | null = null;
853
+ let lastRectKey: string | null = null;
854
+ let lastInserterKey: string | null = null;
855
+
856
+ const postBlockRect = () => {
857
+ if (!trackedBlock || !trackedBlock.isConnected) {
858
+ if (trackedBlock) {
859
+ postMessage({
860
+ type: 'BLOCK_RECT',
861
+ revision: getRevision(),
862
+ blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
863
+ rect: null,
864
+ });
865
+ }
866
+ trackedBlock = null;
867
+ lastRectKey = null;
868
+ return;
869
+ }
870
+ const rect = trackedBlock.getBoundingClientRect();
871
+ const blockId = trackedBlock.getAttribute('data-builder-block') ?? '';
872
+ const next = `${blockId}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
873
+ if (next === lastRectKey) return;
874
+ lastRectKey = next;
875
+ postMessage({
876
+ type: 'BLOCK_RECT',
877
+ revision: getRevision(),
878
+ blockId,
879
+ rect: {
880
+ top: rect.top,
881
+ left: rect.left,
882
+ width: rect.width,
883
+ height: rect.height,
884
+ },
885
+ });
886
+ };
887
+
888
+ const selectionObserver = new MutationObserver(() => {
889
+ const selected = document.querySelector(BUILDER_SELECTED_SELECTOR);
890
+ if (selected instanceof HTMLElement) {
891
+ trackedBlock = selected;
892
+ postBlockRect();
893
+ } else if (trackedBlock) {
894
+ // Selection cleared.
895
+ postMessage({
896
+ type: 'BLOCK_RECT',
897
+ revision: getRevision(),
898
+ blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
899
+ rect: null,
900
+ });
901
+ trackedBlock = null;
902
+ lastRectKey = null;
903
+ }
904
+ });
905
+ selectionObserver.observe(document.body, {
906
+ attributes: true,
907
+ attributeFilter: ['data-builder-selected'],
908
+ subtree: true,
909
+ });
910
+
911
+ let rectFrame = 0;
912
+ const scheduleRectUpdate = () => {
913
+ if (rectFrame) return;
914
+ rectFrame = window.requestAnimationFrame(() => {
915
+ rectFrame = 0;
916
+ postBlockRect();
917
+ });
918
+ };
919
+
920
+ window.addEventListener('scroll', scheduleRectUpdate, true);
921
+ window.addEventListener('resize', scheduleRectUpdate);
922
+
923
+ // Inter-block gap inserter: detect mouse near the top/bottom edge of
924
+ // a tracked block stack. We don't try to be clever about which gap;
925
+ // every direct ancestor with a list of `[data-builder-block]` children
926
+ // contributes one gap per pair.
927
+ const postInserter = (
928
+ index: number | null,
929
+ rect: DOMRect | null,
930
+ ) => {
931
+ const key =
932
+ index === null || !rect
933
+ ? '__none__'
934
+ : `${index}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
935
+ if (key === lastInserterKey) return;
936
+ lastInserterKey = key;
937
+ postMessage({
938
+ type: 'INSERTER_HOVER',
939
+ revision: getRevision(),
940
+ index,
941
+ rect:
942
+ rect === null
943
+ ? null
944
+ : {
945
+ top: rect.top,
946
+ left: rect.left,
947
+ width: rect.width,
948
+ height: rect.height,
949
+ },
950
+ });
951
+ };
952
+
953
+ const handleMouseMove = (event: MouseEvent) => {
954
+ const blocks = Array.from(
955
+ document.querySelectorAll<HTMLElement>(BUILDER_BLOCK_SELECTOR),
956
+ ).filter((el) => {
957
+ // Only consider top-level builder blocks inside the same parent.
958
+ const parent = el.parentElement;
959
+ return parent
960
+ ? Array.from(parent.children).some(
961
+ (child) =>
962
+ child instanceof HTMLElement &&
963
+ child.hasAttribute('data-builder-block'),
964
+ )
965
+ : false;
966
+ });
967
+
968
+ for (let i = 0; i < blocks.length - 1; i++) {
969
+ const top = blocks[i]!.getBoundingClientRect();
970
+ const bottom = blocks[i + 1]!.getBoundingClientRect();
971
+ const gapTop = top.bottom;
972
+ const gapBottom = bottom.top;
973
+ if (
974
+ event.clientY >= gapTop - INSERTER_HOVER_BAND_PX / 2 &&
975
+ event.clientY <= gapBottom + INSERTER_HOVER_BAND_PX / 2 &&
976
+ event.clientX >= top.left &&
977
+ event.clientX <= top.right
978
+ ) {
979
+ const rect = new DOMRect(
980
+ top.left,
981
+ gapTop,
982
+ top.width,
983
+ Math.max(gapBottom - gapTop, INSERTER_HOVER_BAND_PX),
984
+ );
985
+ postInserter(i + 1, rect);
986
+ return;
987
+ }
988
+ }
989
+ postInserter(null, null);
990
+ };
991
+ window.addEventListener('mousemove', handleMouseMove);
992
+
993
+ // Inline edit: double-click on an editable text content element flips
994
+ // it into contenteditable and gives the caret to the merchant.
995
+ //
996
+ // We don't bind the browser's native `dblclick` event because the
997
+ // dashboard's APPLY_STATE round-trip can rerender the iframe between
998
+ // the two clicks and the browser silently drops the dblclick.
999
+ // Instead we track two consecutive `mousedown` events on the same
1000
+ // content element within a 500ms window — same behaviour as the
1001
+ // native event but resilient to React re-mounts in between.
1002
+ let lastDownContent: HTMLElement | null = null;
1003
+ let lastDownAt = 0;
1004
+ const DOUBLE_DOWN_WINDOW_MS = 500;
1005
+
1006
+ const beginInlineEdit = (contentElement: HTMLElement) => {
1007
+ const blockElement = contentElement.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
1008
+ const blockId = blockElement?.getAttribute('data-builder-block');
1009
+ const contentPath = contentElement.getAttribute('data-builder-content');
1010
+ if (!blockId || !contentPath) return false;
1011
+
1012
+ // Skip inline edit for images/links/buttons — those are picked
1013
+ // separately in the inspector. Only run on plain text containers.
1014
+ if (
1015
+ contentElement instanceof HTMLImageElement ||
1016
+ contentElement.closest('a[href], button')
1017
+ ) {
1018
+ return false;
1019
+ }
1020
+
1021
+ contentElement.setAttribute('contenteditable', 'plaintext-only');
1022
+ contentElement.setAttribute('data-builder-inline-edit', 'true');
1023
+ contentElement.focus();
1024
+ const range = document.createRange();
1025
+ range.selectNodeContents(contentElement);
1026
+ const selection = window.getSelection();
1027
+ selection?.removeAllRanges();
1028
+ selection?.addRange(range);
1029
+
1030
+ const finish = (commit: boolean) => {
1031
+ contentElement.removeAttribute('contenteditable');
1032
+ contentElement.removeAttribute('data-builder-inline-edit');
1033
+ contentElement.removeEventListener('blur', onBlur);
1034
+ contentElement.removeEventListener('keydown', onKey);
1035
+ if (commit) {
1036
+ const value = contentElement.textContent ?? '';
1037
+ postMessage({
1038
+ type: 'INLINE_EDIT_COMMIT',
1039
+ revision: getRevision(),
1040
+ blockId,
1041
+ contentPath,
1042
+ value,
1043
+ });
1044
+ }
1045
+ };
1046
+
1047
+ const onBlur = () => finish(true);
1048
+ const onKey = (kev: KeyboardEvent) => {
1049
+ if (kev.key === 'Enter' && !kev.shiftKey) {
1050
+ kev.preventDefault();
1051
+ contentElement.blur();
1052
+ } else if (kev.key === 'Escape') {
1053
+ kev.preventDefault();
1054
+ finish(false);
1055
+ contentElement.blur();
1056
+ }
1057
+ };
1058
+ contentElement.addEventListener('blur', onBlur, { once: true });
1059
+ contentElement.addEventListener('keydown', onKey);
1060
+ return true;
1061
+ };
1062
+
1063
+ const handleMouseDown = (event: MouseEvent) => {
1064
+ const target = event.target;
1065
+ if (!(target instanceof HTMLElement)) {
1066
+ lastDownContent = null;
1067
+ return;
1068
+ }
1069
+ // If we're already in an inline edit on this element, let the
1070
+ // browser handle caret placement normally — no special handling.
1071
+ if (target.closest('[data-builder-inline-edit="true"]')) return;
1072
+
1073
+ const contentElement = target.closest<HTMLElement>(BUILDER_CONTENT_SELECTOR);
1074
+ if (!contentElement) {
1075
+ lastDownContent = null;
1076
+ lastDownAt = 0;
1077
+ return;
1078
+ }
1079
+ const now = Date.now();
1080
+ const isSecondDown =
1081
+ lastDownContent === contentElement && now - lastDownAt < DOUBLE_DOWN_WINDOW_MS;
1082
+ if (!isSecondDown) {
1083
+ lastDownContent = contentElement;
1084
+ lastDownAt = now;
1085
+ return;
1086
+ }
1087
+ // Second mousedown on the same content element within the window —
1088
+ // treat as the user asking to edit inline.
1089
+ lastDownContent = null;
1090
+ lastDownAt = 0;
1091
+ if (!beginInlineEdit(contentElement)) return;
1092
+ event.preventDefault();
1093
+ event.stopPropagation();
1094
+ };
1095
+ window.addEventListener('mousedown', handleMouseDown, true);
1096
+
1097
+ return () => {
1098
+ selectionObserver.disconnect();
1099
+ if (rectFrame) window.cancelAnimationFrame(rectFrame);
1100
+ window.removeEventListener('scroll', scheduleRectUpdate, true);
1101
+ window.removeEventListener('resize', scheduleRectUpdate);
1102
+ window.removeEventListener('mousemove', handleMouseMove);
1103
+ window.removeEventListener('mousedown', handleMouseDown, true);
1104
+ };
1105
+ }