@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/dist/react.js CHANGED
@@ -10,6 +10,11 @@ const BUILDER_STATE_EVENT_NAME = 'shoppex:builder-state';
10
10
  const BUILDER_BLOCK_SELECTOR = '[data-builder-block]';
11
11
  const BUILDER_CONTENT_SELECTOR = '[data-builder-content]';
12
12
  const BUILDER_SELECTED_SELECTOR = '[data-builder-selected="true"]';
13
+ const BUILDER_READY_HEALTH = {
14
+ reactMounted: true,
15
+ builderRuntimeProvider: true,
16
+ protocolVersion: 2,
17
+ };
13
18
  const BuilderRuntimeContext = createContext(null);
14
19
  const BuilderBlockContext = createContext(null);
15
20
  export function BuilderRuntimeProvider({ settings, children }) {
@@ -27,10 +32,12 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
27
32
  }, [settings.revision]);
28
33
  useEffect(() => {
29
34
  const currentWindow = window;
30
- const parentOrigin = getPreviewParentOrigin(document.referrer);
35
+ const parentOrigin = getPreviewParentOrigin(window.location, document.referrer);
31
36
  const isTrustedPreviewEmbed = isTrustedBuilderPreviewEmbed(window.location, parentOrigin);
32
37
  const removeInspectorStyles = isTrustedPreviewEmbed ? installBuilderPreviewInspectorStyles() : () => { };
33
38
  const removeHoverInspector = isTrustedPreviewEmbed ? installBuilderPreviewHoverInspector() : () => { };
39
+ let interactionMode = 'edit';
40
+ let removeDirectManipulation = () => { };
34
41
  const postToParent = (event, response) => {
35
42
  const target = event?.source && 'postMessage' in event.source ? event.source : window.parent;
36
43
  if (!target || target === window)
@@ -38,6 +45,13 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
38
45
  const targetOrigin = parentOrigin ?? event?.origin ?? '*';
39
46
  target.postMessage(response, targetOrigin || '*');
40
47
  };
48
+ const postReady = (event) => {
49
+ postToParent(event, {
50
+ type: 'READY',
51
+ revision: settingsRevisionRef.current,
52
+ health: BUILDER_READY_HEALTH,
53
+ });
54
+ };
41
55
  const applySettings = (input) => {
42
56
  const parsed = BuilderSettingsSchema.safeParse(input);
43
57
  if (!parsed.success)
@@ -65,7 +79,7 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
65
79
  return;
66
80
  const message = parsed.data;
67
81
  if (message.type === 'REQUEST_READY') {
68
- postToParent(event, { type: 'READY', revision: settingsRevisionRef.current });
82
+ postReady(event);
69
83
  return;
70
84
  }
71
85
  if (message.type === 'APPLY_STATE') {
@@ -81,19 +95,42 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
81
95
  }
82
96
  if (message.type === 'RELOAD') {
83
97
  postToParent(event, { type: 'APPLIED', revision: message.revision });
84
- window.setTimeout(() => window.location.reload(), 0);
98
+ const previewReloadTarget = resolvePreviewReloadTarget(window.location, currentWindow.__SHOPPEX_PREVIEW_SESSION_PATH__);
99
+ window.setTimeout(() => {
100
+ if (previewReloadTarget) {
101
+ window.location.href = previewReloadTarget;
102
+ return;
103
+ }
104
+ window.location.reload();
105
+ }, 0);
85
106
  return;
86
107
  }
87
108
  if (message.type === 'SELECT_ELEMENT') {
88
109
  selectBuilderElement(message.selection.blockId);
110
+ return;
111
+ }
112
+ if (message.type === 'SET_INTERACTION_MODE') {
113
+ interactionMode = message.mode;
114
+ document.documentElement.setAttribute('data-builder-interaction-mode', interactionMode);
115
+ return;
89
116
  }
90
117
  };
91
118
  const handleBuilderClick = (event) => {
92
119
  if (!isTrustedPreviewEmbed)
93
120
  return;
121
+ // In "preview" interaction mode let the storefront react to clicks
122
+ // naturally so the merchant can test buy-now flows or anchor links.
123
+ if (interactionMode === 'preview')
124
+ return;
94
125
  const target = event.target;
95
126
  if (!(target instanceof Element))
96
127
  return;
128
+ // Once an element is in inline-edit mode, clicks on it should
129
+ // place the caret instead of re-firing block selection. The
130
+ // mousedown handler in installBuilderDirectManipulation has
131
+ // already validated this is a legitimate edit interaction.
132
+ if (target.closest('[data-builder-inline-edit="true"]'))
133
+ return;
97
134
  const blockElement = target.closest(BUILDER_BLOCK_SELECTOR);
98
135
  if (!blockElement)
99
136
  return;
@@ -108,18 +145,51 @@ export function BuilderRuntimePreviewProvider({ initialSettings, children, }) {
108
145
  selection,
109
146
  });
110
147
  };
111
- if (isTrustedPreviewEmbed) {
112
- postToParent(null, { type: 'READY', revision: settingsRevisionRef.current });
113
- }
148
+ const postPreviewError = (source, error) => {
149
+ if (!isTrustedPreviewEmbed)
150
+ return;
151
+ const normalized = normalizePreviewRuntimeError(error);
152
+ postToParent(null, {
153
+ type: 'PREVIEW_ERROR',
154
+ revision: settingsRevisionRef.current,
155
+ message: normalized.message,
156
+ ...(normalized.stack ? { stack: normalized.stack } : {}),
157
+ source,
158
+ diagnostics: {
159
+ name: normalized.name,
160
+ href: window.location.href,
161
+ referrer: document.referrer || undefined,
162
+ parentOrigin: parentOrigin ?? undefined,
163
+ previewMode: new URLSearchParams(window.location.search).get('shoppex-preview-mode') ?? undefined,
164
+ userAgent: navigator.userAgent,
165
+ },
166
+ });
167
+ };
168
+ const handleRuntimeError = (event) => {
169
+ postPreviewError('error', event.error ?? event.message);
170
+ };
171
+ const handleUnhandledRejection = (event) => {
172
+ postPreviewError('unhandledrejection', event.reason);
173
+ };
114
174
  window.addEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
115
175
  window.addEventListener('message', handlePreviewMessage);
116
176
  window.addEventListener('click', handleBuilderClick, true);
177
+ window.addEventListener('error', handleRuntimeError);
178
+ window.addEventListener('unhandledrejection', handleUnhandledRejection);
179
+ if (isTrustedPreviewEmbed) {
180
+ window.__SHOPPEX_PREVIEW_DISABLE_BOOTSTRAP_ERROR_BRIDGE__?.();
181
+ removeDirectManipulation = installBuilderDirectManipulation((message) => postToParent(null, message), () => settingsRevisionRef.current);
182
+ postReady(null);
183
+ }
117
184
  return () => {
118
185
  removeInspectorStyles();
119
186
  removeHoverInspector();
187
+ removeDirectManipulation();
120
188
  window.removeEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
121
189
  window.removeEventListener('message', handlePreviewMessage);
122
190
  window.removeEventListener('click', handleBuilderClick, true);
191
+ window.removeEventListener('error', handleRuntimeError);
192
+ window.removeEventListener('unhandledrejection', handleUnhandledRejection);
123
193
  };
124
194
  }, []);
125
195
  return _jsx(BuilderRuntimeProvider, { settings: settings, children: children });
@@ -145,11 +215,32 @@ export function BuilderPage({ pageId, blocks, registry, context, fallback = null
145
215
  return (_jsx(_Fragment, { children: pageBlocks.map((block) => {
146
216
  const Component = registry[block.type];
147
217
  if (!Component) {
148
- return fallback;
218
+ return (_jsx(BuilderBlockProvider, { block: block, children: fallback ?? renderMissingBuilderBlock(pageId, block) }, block.id));
149
219
  }
150
220
  return (_jsx(BuilderBlockProvider, { block: block, children: _jsx(Component, { block: block, context: context }) }, block.id));
151
221
  }) }));
152
222
  }
223
+ function renderMissingBuilderBlock(pageId, block) {
224
+ if (!isBuilderPreviewRuntime()) {
225
+ return null;
226
+ }
227
+ return createElement('div', {
228
+ 'data-page-id': pageId,
229
+ 'data-builder-block': block.id,
230
+ 'data-builder-block-type': block.type,
231
+ 'data-builder-runtime-error': 'missing-block-component',
232
+ style: {
233
+ margin: '12px 0',
234
+ border: '1px solid #dc2626',
235
+ borderRadius: '8px',
236
+ background: '#fef2f2',
237
+ color: '#7f1d1d',
238
+ padding: '12px',
239
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
240
+ fontSize: '13px',
241
+ },
242
+ }, `Missing Builder component for block "${block.type}".`);
243
+ }
153
244
  export function useBuilderRuntime() {
154
245
  const context = useContext(BuilderRuntimeContext);
155
246
  if (!context) {
@@ -202,6 +293,16 @@ export function useBuilderPageBlocks(pageId) {
202
293
  export function useVisibleBuilderPageBlocks(pageId) {
203
294
  return getVisiblePageBlocks(useBuilderRuntime().settings, pageId);
204
295
  }
296
+ export function useThemePageBlocks(pageId, defaultOrder) {
297
+ const { settings } = useBuilderRuntime();
298
+ return useMemo(() => {
299
+ const page = settings.theme.layout[pageId];
300
+ if (page) {
301
+ return page.blocks.filter((block) => block.visible);
302
+ }
303
+ return defaultOrder.map((type) => ({ id: type, type, visible: true, settings: {} }));
304
+ }, [defaultOrder, pageId, settings.theme.layout]);
305
+ }
205
306
  export function useBuilderStyleSlot(slotId, input = {}) {
206
307
  return resolveStyleSlotValue(useBuilderRuntime().settings, slotId, input);
207
308
  }
@@ -232,6 +333,12 @@ function getNestedBuilderSetting(record, path) {
232
333
  }
233
334
  return current;
234
335
  }
336
+ function isBuilderPreviewRuntime() {
337
+ if (typeof window === 'undefined') {
338
+ return false;
339
+ }
340
+ return window.location.search.includes('shoppex-preview-mode=theme');
341
+ }
235
342
  function parseInitialBuilderSettings(input) {
236
343
  const parsed = BuilderSettingsSchema.safeParse(input);
237
344
  if (parsed.success)
@@ -270,16 +377,47 @@ function parseBuilderRevision(input) {
270
377
  function isRecord(value) {
271
378
  return typeof value === 'object' && value !== null && !Array.isArray(value);
272
379
  }
273
- function getPreviewParentOrigin(referrer) {
274
- if (!referrer)
380
+ export function resolvePreviewReloadTarget(location, sessionPath) {
381
+ if (typeof sessionPath !== 'string' || !sessionPath.startsWith('/s/')) {
382
+ return null;
383
+ }
384
+ return `${sessionPath}${location.search}${location.hash}`;
385
+ }
386
+ function normalizePreviewRuntimeError(error) {
387
+ if (error instanceof Error) {
388
+ return {
389
+ message: error.message || error.name || 'Preview runtime error',
390
+ name: error.name,
391
+ ...(typeof error.stack === 'string' && error.stack ? { stack: error.stack } : {}),
392
+ };
393
+ }
394
+ if (typeof error === 'string') {
395
+ return { message: error || 'Preview runtime error' };
396
+ }
397
+ if (isRecord(error)) {
398
+ const message = typeof error.message === 'string' && error.message
399
+ ? error.message
400
+ : 'Preview runtime error';
401
+ const stack = typeof error.stack === 'string' && error.stack ? error.stack : undefined;
402
+ const name = typeof error.name === 'string' && error.name ? error.name : undefined;
403
+ return { message, ...(stack ? { stack } : {}), ...(name ? { name } : {}) };
404
+ }
405
+ return { message: 'Preview runtime error' };
406
+ }
407
+ function parseOrigin(value) {
408
+ if (!value)
275
409
  return null;
276
410
  try {
277
- return new URL(referrer).origin;
411
+ return new URL(value).origin;
278
412
  }
279
413
  catch {
280
414
  return null;
281
415
  }
282
416
  }
417
+ function getPreviewParentOrigin(location, referrer) {
418
+ const explicitOrigin = new URLSearchParams(location.search).get('shoppex-preview-parent-origin');
419
+ return parseOrigin(explicitOrigin) ?? parseOrigin(referrer);
420
+ }
283
421
  function isTrustedBuilderPreviewEmbed(location, parentOrigin) {
284
422
  if (window.parent === window || !parentOrigin)
285
423
  return false;
@@ -361,48 +499,389 @@ function installBuilderPreviewInspectorStyles() {
361
499
  position: relative;
362
500
  }
363
501
  [data-builder-block][data-builder-selected="true"] {
364
- outline: 2px solid #2563eb;
502
+ outline: 1px solid rgba(124, 58, 237, 0.7);
365
503
  outline-offset: 4px;
366
504
  }
367
505
  [data-builder-block][data-builder-hovered="true"] {
368
- outline: 1px dashed #2563eb;
506
+ outline: 1px dashed rgba(124, 58, 237, 0.5);
369
507
  outline-offset: 4px;
370
508
  cursor: pointer;
371
509
  }
510
+ /* Block-name tooltip in the upper-left corner of the hovered block.
511
+ Driven by a data-builder-block-label attribute the runtime sets
512
+ from manifest.blocks[type].label when available, falling back to
513
+ the block-type slug. */
514
+ [data-builder-block][data-builder-hovered="true"]::before {
515
+ content: attr(data-builder-block-label);
516
+ position: absolute;
517
+ top: -22px;
518
+ left: 0;
519
+ padding: 2px 6px;
520
+ font-size: 11px;
521
+ font-weight: 500;
522
+ line-height: 1.3;
523
+ color: #ffffff;
524
+ background: #7c3aed;
525
+ border-radius: 4px;
526
+ pointer-events: none;
527
+ white-space: nowrap;
528
+ z-index: 999;
529
+ }
530
+ /* Sub-element hover: thinner outline so a hover on a text or image
531
+ inside a block highlights only that target instead of the whole
532
+ block. Suppressed while the parent block is selected so the
533
+ merchant doesn't see double outlines. */
534
+ [data-builder-content][data-builder-content-hovered="true"] {
535
+ outline: 1px dashed rgba(124, 58, 237, 0.5);
536
+ outline-offset: 2px;
537
+ cursor: pointer;
538
+ }
539
+ [data-builder-block][data-builder-selected="true"] [data-builder-content][data-builder-content-hovered="true"] {
540
+ outline: none;
541
+ }
542
+ /* Inline-edit affordance: hover over editable text content reveals a
543
+ text cursor + subtle brand underline so the merchant can see what
544
+ a double-click would target. Images, links, and buttons stay
545
+ outside this rule — those have their own picker affordances. */
546
+ [data-builder-content][data-builder-content-hovered="true"]:not(img):not(a[href]):not(:has(a[href])):not(:has(button)) {
547
+ text-decoration: underline;
548
+ text-decoration-color: rgba(124, 58, 237, 0.55);
549
+ text-decoration-thickness: 2px;
550
+ text-underline-offset: 4px;
551
+ cursor: text;
552
+ }
553
+ /* Active inline-edit state: replaces the dashed outline with a
554
+ brand-tinted ring so the merchant gets unambiguous focus
555
+ feedback while typing. */
556
+ [data-builder-content][data-builder-inline-edit="true"] {
557
+ outline: 2px solid #7c3aed !important;
558
+ outline-offset: 4px !important;
559
+ border-radius: 4px;
560
+ background: rgba(124, 58, 237, 0.06);
561
+ text-decoration: none !important;
562
+ cursor: text !important;
563
+ caret-color: #7c3aed;
564
+ }
565
+ [data-builder-content][data-builder-inline-edit="true"]:focus {
566
+ outline: 2px solid #7c3aed !important;
567
+ outline-offset: 4px !important;
568
+ }
372
569
  `;
373
570
  document.head.appendChild(style);
374
571
  return () => style.remove();
375
572
  }
376
573
  function installBuilderPreviewHoverInspector() {
377
- let hovered = null;
378
- const clearHovered = () => {
379
- hovered?.removeAttribute('data-builder-hovered');
380
- hovered = null;
574
+ let hoveredBlock = null;
575
+ let hoveredContent = null;
576
+ const clearHoveredBlock = () => {
577
+ hoveredBlock?.removeAttribute('data-builder-hovered');
578
+ hoveredBlock = null;
579
+ };
580
+ const clearHoveredContent = () => {
581
+ hoveredContent?.removeAttribute('data-builder-content-hovered');
582
+ hoveredContent = null;
381
583
  };
382
584
  const handleMouseOver = (event) => {
383
585
  const target = event.target;
384
586
  if (!(target instanceof Element))
385
587
  return;
386
588
  const blockElement = target.closest(BUILDER_BLOCK_SELECTOR);
387
- if (blockElement === hovered)
388
- return;
389
- clearHovered();
390
- if (!blockElement)
391
- return;
392
- hovered = blockElement;
393
- hovered.setAttribute('data-builder-hovered', 'true');
589
+ const contentElement = target.closest(BUILDER_CONTENT_SELECTOR);
590
+ if (blockElement !== hoveredBlock) {
591
+ clearHoveredBlock();
592
+ if (blockElement) {
593
+ hoveredBlock = blockElement;
594
+ // Drive the ::before tooltip via a label attribute. Derive
595
+ // it from the block-type slug — pretty manifests already use
596
+ // capitalised labels, but we can't read manifest from here so
597
+ // we humanise the slug ("hero" → "Hero").
598
+ if (!blockElement.hasAttribute('data-builder-block-label')) {
599
+ const type = blockElement.getAttribute('data-builder-block-type') ?? '';
600
+ // Slugs whose Title-Cased form ("Custom Html") doesn't match the
601
+ // manifest's display label. We can't read manifest from here so
602
+ // we keep a small override table for these.
603
+ const SLUG_LABEL_OVERRIDES = {
604
+ 'custom-html': 'Custom Embed',
605
+ };
606
+ const override = SLUG_LABEL_OVERRIDES[type];
607
+ const label = override ?? type
608
+ .replace(/[-_]/g, ' ')
609
+ .replace(/\b\w/g, (c) => c.toUpperCase());
610
+ if (label)
611
+ blockElement.setAttribute('data-builder-block-label', label);
612
+ }
613
+ hoveredBlock.setAttribute('data-builder-hovered', 'true');
614
+ }
615
+ }
616
+ if (contentElement !== hoveredContent) {
617
+ clearHoveredContent();
618
+ if (contentElement && contentElement !== blockElement) {
619
+ hoveredContent = contentElement;
620
+ hoveredContent.setAttribute('data-builder-content-hovered', 'true');
621
+ }
622
+ }
394
623
  };
395
624
  const handleMouseOut = (event) => {
396
625
  const relatedTarget = event.relatedTarget;
397
- if (relatedTarget instanceof Node && hovered?.contains(relatedTarget))
398
- return;
399
- clearHovered();
626
+ if (relatedTarget instanceof Node) {
627
+ if (hoveredBlock?.contains(relatedTarget) && hoveredContent?.contains(relatedTarget))
628
+ return;
629
+ if (hoveredBlock?.contains(relatedTarget) && !hoveredContent)
630
+ return;
631
+ }
632
+ clearHoveredBlock();
633
+ clearHoveredContent();
400
634
  };
401
635
  window.addEventListener('mouseover', handleMouseOver, true);
402
636
  window.addEventListener('mouseout', handleMouseOut, true);
403
637
  return () => {
404
- clearHovered();
638
+ clearHoveredBlock();
639
+ clearHoveredContent();
405
640
  window.removeEventListener('mouseover', handleMouseOver, true);
406
641
  window.removeEventListener('mouseout', handleMouseOut, true);
407
642
  };
408
643
  }
644
+ const INSERTER_HOVER_BAND_PX = 16;
645
+ /**
646
+ * Direct-manipulation bridge: tracks the bounding rect of the selected
647
+ * block (for the floating toolbar), the inter-block gap currently
648
+ * hovered (for the "+" inserter), and inline-text-edit commits. All
649
+ * coordinates are reported in the iframe's viewport space; the
650
+ * dashboard maps them to its overlay layer.
651
+ */
652
+ function installBuilderDirectManipulation(postMessage, getRevision) {
653
+ // Defensive: test environments may stub document/window without the
654
+ // observer APIs we rely on. In that case we silently no-op so the
655
+ // existing READY/APPLY handshake remains testable.
656
+ if (typeof MutationObserver === 'undefined' ||
657
+ typeof window === 'undefined' ||
658
+ typeof document === 'undefined') {
659
+ return () => { };
660
+ }
661
+ let trackedBlock = null;
662
+ let lastRectKey = null;
663
+ let lastInserterKey = null;
664
+ const postBlockRect = () => {
665
+ if (!trackedBlock || !trackedBlock.isConnected) {
666
+ if (trackedBlock) {
667
+ postMessage({
668
+ type: 'BLOCK_RECT',
669
+ revision: getRevision(),
670
+ blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
671
+ rect: null,
672
+ });
673
+ }
674
+ trackedBlock = null;
675
+ lastRectKey = null;
676
+ return;
677
+ }
678
+ const rect = trackedBlock.getBoundingClientRect();
679
+ const blockId = trackedBlock.getAttribute('data-builder-block') ?? '';
680
+ const next = `${blockId}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
681
+ if (next === lastRectKey)
682
+ return;
683
+ lastRectKey = next;
684
+ postMessage({
685
+ type: 'BLOCK_RECT',
686
+ revision: getRevision(),
687
+ blockId,
688
+ rect: {
689
+ top: rect.top,
690
+ left: rect.left,
691
+ width: rect.width,
692
+ height: rect.height,
693
+ },
694
+ });
695
+ };
696
+ const selectionObserver = new MutationObserver(() => {
697
+ const selected = document.querySelector(BUILDER_SELECTED_SELECTOR);
698
+ if (selected instanceof HTMLElement) {
699
+ trackedBlock = selected;
700
+ postBlockRect();
701
+ }
702
+ else if (trackedBlock) {
703
+ // Selection cleared.
704
+ postMessage({
705
+ type: 'BLOCK_RECT',
706
+ revision: getRevision(),
707
+ blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
708
+ rect: null,
709
+ });
710
+ trackedBlock = null;
711
+ lastRectKey = null;
712
+ }
713
+ });
714
+ selectionObserver.observe(document.body, {
715
+ attributes: true,
716
+ attributeFilter: ['data-builder-selected'],
717
+ subtree: true,
718
+ });
719
+ let rectFrame = 0;
720
+ const scheduleRectUpdate = () => {
721
+ if (rectFrame)
722
+ return;
723
+ rectFrame = window.requestAnimationFrame(() => {
724
+ rectFrame = 0;
725
+ postBlockRect();
726
+ });
727
+ };
728
+ window.addEventListener('scroll', scheduleRectUpdate, true);
729
+ window.addEventListener('resize', scheduleRectUpdate);
730
+ // Inter-block gap inserter: detect mouse near the top/bottom edge of
731
+ // a tracked block stack. We don't try to be clever about which gap;
732
+ // every direct ancestor with a list of `[data-builder-block]` children
733
+ // contributes one gap per pair.
734
+ const postInserter = (index, rect) => {
735
+ const key = index === null || !rect
736
+ ? '__none__'
737
+ : `${index}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
738
+ if (key === lastInserterKey)
739
+ return;
740
+ lastInserterKey = key;
741
+ postMessage({
742
+ type: 'INSERTER_HOVER',
743
+ revision: getRevision(),
744
+ index,
745
+ rect: rect === null
746
+ ? null
747
+ : {
748
+ top: rect.top,
749
+ left: rect.left,
750
+ width: rect.width,
751
+ height: rect.height,
752
+ },
753
+ });
754
+ };
755
+ const handleMouseMove = (event) => {
756
+ const blocks = Array.from(document.querySelectorAll(BUILDER_BLOCK_SELECTOR)).filter((el) => {
757
+ // Only consider top-level builder blocks inside the same parent.
758
+ const parent = el.parentElement;
759
+ return parent
760
+ ? Array.from(parent.children).some((child) => child instanceof HTMLElement &&
761
+ child.hasAttribute('data-builder-block'))
762
+ : false;
763
+ });
764
+ for (let i = 0; i < blocks.length - 1; i++) {
765
+ const top = blocks[i].getBoundingClientRect();
766
+ const bottom = blocks[i + 1].getBoundingClientRect();
767
+ const gapTop = top.bottom;
768
+ const gapBottom = bottom.top;
769
+ if (event.clientY >= gapTop - INSERTER_HOVER_BAND_PX / 2 &&
770
+ event.clientY <= gapBottom + INSERTER_HOVER_BAND_PX / 2 &&
771
+ event.clientX >= top.left &&
772
+ event.clientX <= top.right) {
773
+ const rect = new DOMRect(top.left, gapTop, top.width, Math.max(gapBottom - gapTop, INSERTER_HOVER_BAND_PX));
774
+ postInserter(i + 1, rect);
775
+ return;
776
+ }
777
+ }
778
+ postInserter(null, null);
779
+ };
780
+ window.addEventListener('mousemove', handleMouseMove);
781
+ // Inline edit: double-click on an editable text content element flips
782
+ // it into contenteditable and gives the caret to the merchant.
783
+ //
784
+ // We don't bind the browser's native `dblclick` event because the
785
+ // dashboard's APPLY_STATE round-trip can rerender the iframe between
786
+ // the two clicks and the browser silently drops the dblclick.
787
+ // Instead we track two consecutive `mousedown` events on the same
788
+ // content element within a 500ms window — same behaviour as the
789
+ // native event but resilient to React re-mounts in between.
790
+ let lastDownContent = null;
791
+ let lastDownAt = 0;
792
+ const DOUBLE_DOWN_WINDOW_MS = 500;
793
+ const beginInlineEdit = (contentElement) => {
794
+ const blockElement = contentElement.closest(BUILDER_BLOCK_SELECTOR);
795
+ const blockId = blockElement?.getAttribute('data-builder-block');
796
+ const contentPath = contentElement.getAttribute('data-builder-content');
797
+ if (!blockId || !contentPath)
798
+ return false;
799
+ // Skip inline edit for images/links/buttons — those are picked
800
+ // separately in the inspector. Only run on plain text containers.
801
+ if (contentElement instanceof HTMLImageElement ||
802
+ contentElement.closest('a[href], button')) {
803
+ return false;
804
+ }
805
+ contentElement.setAttribute('contenteditable', 'plaintext-only');
806
+ contentElement.setAttribute('data-builder-inline-edit', 'true');
807
+ contentElement.focus();
808
+ const range = document.createRange();
809
+ range.selectNodeContents(contentElement);
810
+ const selection = window.getSelection();
811
+ selection?.removeAllRanges();
812
+ selection?.addRange(range);
813
+ const finish = (commit) => {
814
+ contentElement.removeAttribute('contenteditable');
815
+ contentElement.removeAttribute('data-builder-inline-edit');
816
+ contentElement.removeEventListener('blur', onBlur);
817
+ contentElement.removeEventListener('keydown', onKey);
818
+ if (commit) {
819
+ const value = contentElement.textContent ?? '';
820
+ postMessage({
821
+ type: 'INLINE_EDIT_COMMIT',
822
+ revision: getRevision(),
823
+ blockId,
824
+ contentPath,
825
+ value,
826
+ });
827
+ }
828
+ };
829
+ const onBlur = () => finish(true);
830
+ const onKey = (kev) => {
831
+ if (kev.key === 'Enter' && !kev.shiftKey) {
832
+ kev.preventDefault();
833
+ contentElement.blur();
834
+ }
835
+ else if (kev.key === 'Escape') {
836
+ kev.preventDefault();
837
+ finish(false);
838
+ contentElement.blur();
839
+ }
840
+ };
841
+ contentElement.addEventListener('blur', onBlur, { once: true });
842
+ contentElement.addEventListener('keydown', onKey);
843
+ return true;
844
+ };
845
+ const handleMouseDown = (event) => {
846
+ const target = event.target;
847
+ if (!(target instanceof HTMLElement)) {
848
+ lastDownContent = null;
849
+ return;
850
+ }
851
+ // If we're already in an inline edit on this element, let the
852
+ // browser handle caret placement normally — no special handling.
853
+ if (target.closest('[data-builder-inline-edit="true"]'))
854
+ return;
855
+ const contentElement = target.closest(BUILDER_CONTENT_SELECTOR);
856
+ if (!contentElement) {
857
+ lastDownContent = null;
858
+ lastDownAt = 0;
859
+ return;
860
+ }
861
+ const now = Date.now();
862
+ const isSecondDown = lastDownContent === contentElement && now - lastDownAt < DOUBLE_DOWN_WINDOW_MS;
863
+ if (!isSecondDown) {
864
+ lastDownContent = contentElement;
865
+ lastDownAt = now;
866
+ return;
867
+ }
868
+ // Second mousedown on the same content element within the window —
869
+ // treat as the user asking to edit inline.
870
+ lastDownContent = null;
871
+ lastDownAt = 0;
872
+ if (!beginInlineEdit(contentElement))
873
+ return;
874
+ event.preventDefault();
875
+ event.stopPropagation();
876
+ };
877
+ window.addEventListener('mousedown', handleMouseDown, true);
878
+ return () => {
879
+ selectionObserver.disconnect();
880
+ if (rectFrame)
881
+ window.cancelAnimationFrame(rectFrame);
882
+ window.removeEventListener('scroll', scheduleRectUpdate, true);
883
+ window.removeEventListener('resize', scheduleRectUpdate);
884
+ window.removeEventListener('mousemove', handleMouseMove);
885
+ window.removeEventListener('mousedown', handleMouseDown, true);
886
+ };
887
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shoppexio/builder-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Theme-side Builder v2 runtime helpers for Shoppex storefront themes",
5
5
  "type": "module",
6
6
  "repository": {