@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
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
|
|
403
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
if (
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
540
|
-
|
|
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
|
-
|
|
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
|
+
}
|