@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/css-vars.d.ts.map +1 -1
- package/dist/css-vars.js +24 -6
- package/dist/layout.d.ts +9 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +10 -0
- package/dist/react.d.ts +5 -222
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +506 -27
- package/package.json +1 -1
- package/src/builder-runtime.test.ts +24 -0
- package/src/css-vars.ts +29 -8
- package/src/layout.ts +23 -0
- package/src/react-runtime.test.tsx +173 -3
- package/src/react.tsx +579 -24
- package/dist/builder-runtime.test.d.ts +0 -2
- package/dist/builder-runtime.test.d.ts.map +0 -1
- package/dist/builder-runtime.test.js +0 -115
- package/dist/react-runtime.test.d.ts +0 -2
- package/dist/react-runtime.test.d.ts.map +0 -1
- package/dist/react-runtime.test.js +0 -292
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
274
|
-
if (!
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
hovered
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
+
}
|