@shoppexio/builder-runtime 0.1.0 → 0.1.2
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/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/layout.d.ts +5 -1
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +2 -0
- package/dist/preview-fixtures.d.ts +16 -0
- package/dist/preview-fixtures.d.ts.map +1 -0
- package/dist/preview-fixtures.js +40 -0
- package/dist/product-page.d.ts +13 -0
- package/dist/product-page.d.ts.map +1 -0
- package/dist/product-page.js +18 -0
- package/dist/react.d.ts +36 -224
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +606 -47
- package/dist/search-bar-settings.d.ts +33 -0
- package/dist/search-bar-settings.d.ts.map +1 -0
- package/dist/search-bar-settings.js +99 -0
- package/dist/standard-product-blocks.d.ts +48 -0
- package/dist/standard-product-blocks.d.ts.map +1 -0
- package/dist/standard-product-blocks.js +45 -0
- package/dist/standard-product-page.d.ts +69 -0
- package/dist/standard-product-page.d.ts.map +1 -0
- package/dist/standard-product-page.js +89 -0
- package/dist/storefront-google-fonts.d.ts +2 -0
- package/dist/storefront-google-fonts.d.ts.map +1 -0
- package/dist/storefront-google-fonts.js +28 -0
- package/package.json +3 -3
- package/src/builder-runtime.test.ts +57 -0
- package/src/css-vars.ts +29 -8
- package/src/index.ts +4 -0
- package/src/layout.ts +14 -1
- package/src/preview-fixtures.ts +56 -0
- package/src/product-page.test.ts +37 -0
- package/src/product-page.ts +32 -0
- package/src/react-runtime.test.tsx +215 -3
- package/src/react.tsx +769 -45
- package/src/search-bar-settings.test.ts +72 -0
- package/src/search-bar-settings.ts +176 -0
- package/src/standard-product-blocks.test.tsx +93 -0
- package/src/standard-product-blocks.tsx +121 -0
- package/src/standard-product-page.test.ts +171 -0
- package/src/standard-product-page.ts +169 -0
- package/src/storefront-google-fonts.test.ts +31 -0
- package/src/storefront-google-fonts.ts +43 -0
- 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
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
BuilderSettingsSchema,
|
|
4
4
|
PreviewMessageSchema,
|
|
5
5
|
createEmptyBuilderSettings,
|
|
6
|
+
isTrustedPreviewParentOrigin,
|
|
6
7
|
migrateLegacyBuilderSettings,
|
|
8
|
+
normalizeDedicatedPageId,
|
|
7
9
|
} from '@shoppex/builder-contracts';
|
|
8
10
|
import {
|
|
9
11
|
createElement,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
useMemo,
|
|
14
16
|
useRef,
|
|
15
17
|
useState,
|
|
18
|
+
type ComponentPropsWithoutRef,
|
|
16
19
|
type ComponentType,
|
|
17
20
|
type ElementType,
|
|
18
21
|
type ReactNode,
|
|
@@ -24,8 +27,14 @@ import {
|
|
|
24
27
|
getBuilderContentString,
|
|
25
28
|
getBuilderContentValue,
|
|
26
29
|
} from './content.js';
|
|
30
|
+
import { BUILDER_PREVIEW_REVIEWS } from './preview-fixtures.js';
|
|
27
31
|
import { createBuilderCss } from './css-vars.js';
|
|
32
|
+
import { builderBlock, type BuilderAttributeMap } from './attributes.js';
|
|
28
33
|
import { getPageBlocks, getPageLayout, getVisiblePageBlocks } from './layout.js';
|
|
34
|
+
import {
|
|
35
|
+
getNavigationHeaderSettings,
|
|
36
|
+
resolveSearchBarSettings,
|
|
37
|
+
} from './search-bar-settings.js';
|
|
29
38
|
import { resolveStyleSlotValue } from './style-slots.js';
|
|
30
39
|
|
|
31
40
|
type BuilderRuntimeContextValue = {
|
|
@@ -37,6 +46,11 @@ const BUILDER_STATE_EVENT_NAME = 'shoppex:builder-state';
|
|
|
37
46
|
const BUILDER_BLOCK_SELECTOR = '[data-builder-block]';
|
|
38
47
|
const BUILDER_CONTENT_SELECTOR = '[data-builder-content]';
|
|
39
48
|
const BUILDER_SELECTED_SELECTOR = '[data-builder-selected="true"]';
|
|
49
|
+
const BUILDER_READY_HEALTH = {
|
|
50
|
+
reactMounted: true,
|
|
51
|
+
builderRuntimeProvider: true,
|
|
52
|
+
protocolVersion: 2,
|
|
53
|
+
} as const;
|
|
40
54
|
|
|
41
55
|
const BuilderRuntimeContext = createContext<BuilderRuntimeContextValue | null>(null);
|
|
42
56
|
const BuilderBlockContext = createContext<BlockInstance | null>(null);
|
|
@@ -67,11 +81,14 @@ export function BuilderRuntimePreviewProvider({
|
|
|
67
81
|
useEffect(() => {
|
|
68
82
|
const currentWindow = window as Window & {
|
|
69
83
|
__SHOPPEX_BUILDER_SETTINGS__?: BuilderSettings;
|
|
84
|
+
__SHOPPEX_PREVIEW_SESSION_PATH__?: string;
|
|
70
85
|
};
|
|
71
|
-
const parentOrigin = getPreviewParentOrigin(document.referrer);
|
|
86
|
+
const parentOrigin = getPreviewParentOrigin(window.location, document.referrer);
|
|
72
87
|
const isTrustedPreviewEmbed = isTrustedBuilderPreviewEmbed(window.location, parentOrigin);
|
|
73
88
|
const removeInspectorStyles = isTrustedPreviewEmbed ? installBuilderPreviewInspectorStyles() : () => {};
|
|
74
89
|
const removeHoverInspector = isTrustedPreviewEmbed ? installBuilderPreviewHoverInspector() : () => {};
|
|
90
|
+
let interactionMode: 'edit' | 'preview' = 'edit';
|
|
91
|
+
let removeDirectManipulation: () => void = () => {};
|
|
75
92
|
|
|
76
93
|
const postToParent = (event: MessageEvent<unknown> | null, response: unknown) => {
|
|
77
94
|
const target = event?.source && 'postMessage' in event.source ? event.source : window.parent;
|
|
@@ -82,6 +99,13 @@ export function BuilderRuntimePreviewProvider({
|
|
|
82
99
|
targetOrigin || '*',
|
|
83
100
|
);
|
|
84
101
|
};
|
|
102
|
+
const postReady = (event: MessageEvent<unknown> | null) => {
|
|
103
|
+
postToParent(event, {
|
|
104
|
+
type: 'READY',
|
|
105
|
+
revision: settingsRevisionRef.current,
|
|
106
|
+
health: BUILDER_READY_HEALTH,
|
|
107
|
+
});
|
|
108
|
+
};
|
|
85
109
|
|
|
86
110
|
const applySettings = (input: unknown): { status: 'applied'; settings: BuilderSettings } | { status: 'invalid' | 'stale' } => {
|
|
87
111
|
const parsed = BuilderSettingsSchema.safeParse(input);
|
|
@@ -110,7 +134,7 @@ export function BuilderRuntimePreviewProvider({
|
|
|
110
134
|
const message = parsed.data;
|
|
111
135
|
|
|
112
136
|
if (message.type === 'REQUEST_READY') {
|
|
113
|
-
|
|
137
|
+
postReady(event);
|
|
114
138
|
return;
|
|
115
139
|
}
|
|
116
140
|
|
|
@@ -131,20 +155,49 @@ export function BuilderRuntimePreviewProvider({
|
|
|
131
155
|
|
|
132
156
|
if (message.type === 'RELOAD') {
|
|
133
157
|
postToParent(event, { type: 'APPLIED', revision: message.revision });
|
|
134
|
-
|
|
158
|
+
const previewReloadTarget = resolvePreviewReloadTarget(
|
|
159
|
+
window.location,
|
|
160
|
+
currentWindow.__SHOPPEX_PREVIEW_SESSION_PATH__,
|
|
161
|
+
);
|
|
162
|
+
window.setTimeout(() => {
|
|
163
|
+
if (previewReloadTarget) {
|
|
164
|
+
window.location.href = previewReloadTarget;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
window.location.reload();
|
|
168
|
+
}, 0);
|
|
135
169
|
return;
|
|
136
170
|
}
|
|
137
171
|
|
|
138
172
|
if (message.type === 'SELECT_ELEMENT') {
|
|
139
173
|
selectBuilderElement(message.selection.blockId);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (message.type === 'SET_INTERACTION_MODE') {
|
|
178
|
+
interactionMode = message.mode;
|
|
179
|
+
document.documentElement.setAttribute(
|
|
180
|
+
'data-builder-interaction-mode',
|
|
181
|
+
interactionMode,
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
140
184
|
}
|
|
141
185
|
};
|
|
142
186
|
|
|
143
187
|
const handleBuilderClick = (event: MouseEvent) => {
|
|
144
188
|
if (!isTrustedPreviewEmbed) return;
|
|
189
|
+
// In "preview" interaction mode let the storefront react to clicks
|
|
190
|
+
// naturally so the merchant can test buy-now flows or anchor links.
|
|
191
|
+
if (interactionMode === 'preview') return;
|
|
145
192
|
const target = event.target;
|
|
146
193
|
if (!(target instanceof Element)) return;
|
|
147
194
|
|
|
195
|
+
// Once an element is in inline-edit mode, clicks on it should
|
|
196
|
+
// place the caret instead of re-firing block selection. The
|
|
197
|
+
// mousedown handler in installBuilderDirectManipulation has
|
|
198
|
+
// already validated this is a legitimate edit interaction.
|
|
199
|
+
if (target.closest('[data-builder-inline-edit="true"]')) return;
|
|
200
|
+
|
|
148
201
|
const blockElement = target.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
|
|
149
202
|
if (!blockElement) return;
|
|
150
203
|
|
|
@@ -161,19 +214,59 @@ export function BuilderRuntimePreviewProvider({
|
|
|
161
214
|
});
|
|
162
215
|
};
|
|
163
216
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
217
|
+
const postPreviewError = (source: 'error' | 'unhandledrejection', error: unknown) => {
|
|
218
|
+
if (!isTrustedPreviewEmbed) return;
|
|
219
|
+
const normalized = normalizePreviewRuntimeError(error);
|
|
220
|
+
postToParent(null, {
|
|
221
|
+
type: 'PREVIEW_ERROR',
|
|
222
|
+
revision: settingsRevisionRef.current,
|
|
223
|
+
message: normalized.message,
|
|
224
|
+
...(normalized.stack ? { stack: normalized.stack } : {}),
|
|
225
|
+
source,
|
|
226
|
+
phase: 'runtime',
|
|
227
|
+
diagnostics: {
|
|
228
|
+
name: normalized.name,
|
|
229
|
+
href: window.location.href,
|
|
230
|
+
referrer: document.referrer || undefined,
|
|
231
|
+
parentOrigin: parentOrigin ?? undefined,
|
|
232
|
+
previewMode: new URLSearchParams(window.location.search).get('shoppex-preview-mode') ?? undefined,
|
|
233
|
+
userAgent: navigator.userAgent,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const handleRuntimeError = (event: ErrorEvent) => {
|
|
239
|
+
postPreviewError('error', event.error ?? event.message);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
|
243
|
+
postPreviewError('unhandledrejection', event.reason);
|
|
244
|
+
};
|
|
167
245
|
|
|
168
246
|
window.addEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
|
|
169
247
|
window.addEventListener('message', handlePreviewMessage);
|
|
170
248
|
window.addEventListener('click', handleBuilderClick, true);
|
|
249
|
+
window.addEventListener('error', handleRuntimeError);
|
|
250
|
+
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
|
251
|
+
if (isTrustedPreviewEmbed) {
|
|
252
|
+
(window as Window & {
|
|
253
|
+
__SHOPPEX_PREVIEW_DISABLE_BOOTSTRAP_ERROR_BRIDGE__?: () => void;
|
|
254
|
+
}).__SHOPPEX_PREVIEW_DISABLE_BOOTSTRAP_ERROR_BRIDGE__?.();
|
|
255
|
+
removeDirectManipulation = installBuilderDirectManipulation(
|
|
256
|
+
(message) => postToParent(null, message),
|
|
257
|
+
() => settingsRevisionRef.current,
|
|
258
|
+
);
|
|
259
|
+
postReady(null);
|
|
260
|
+
}
|
|
171
261
|
return () => {
|
|
172
262
|
removeInspectorStyles();
|
|
173
263
|
removeHoverInspector();
|
|
264
|
+
removeDirectManipulation();
|
|
174
265
|
window.removeEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
|
|
175
266
|
window.removeEventListener('message', handlePreviewMessage);
|
|
176
267
|
window.removeEventListener('click', handleBuilderClick, true);
|
|
268
|
+
window.removeEventListener('error', handleRuntimeError);
|
|
269
|
+
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
|
177
270
|
};
|
|
178
271
|
}, []);
|
|
179
272
|
|
|
@@ -242,7 +335,11 @@ export function BuilderPage<TContext = unknown>({
|
|
|
242
335
|
{pageBlocks.map((block) => {
|
|
243
336
|
const Component = registry[block.type];
|
|
244
337
|
if (!Component) {
|
|
245
|
-
return
|
|
338
|
+
return (
|
|
339
|
+
<BuilderBlockProvider key={block.id} block={block}>
|
|
340
|
+
{fallback ?? renderMissingBuilderBlock(pageId, block)}
|
|
341
|
+
</BuilderBlockProvider>
|
|
342
|
+
);
|
|
246
343
|
}
|
|
247
344
|
|
|
248
345
|
return (
|
|
@@ -255,6 +352,33 @@ export function BuilderPage<TContext = unknown>({
|
|
|
255
352
|
);
|
|
256
353
|
}
|
|
257
354
|
|
|
355
|
+
function renderMissingBuilderBlock(pageId: string, block: BlockInstance): ReactNode {
|
|
356
|
+
if (!isBuilderPreviewRuntime()) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return createElement(
|
|
361
|
+
'div',
|
|
362
|
+
{
|
|
363
|
+
'data-page-id': pageId,
|
|
364
|
+
'data-builder-block': block.id,
|
|
365
|
+
'data-builder-block-type': block.type,
|
|
366
|
+
'data-builder-runtime-error': 'missing-block-component',
|
|
367
|
+
style: {
|
|
368
|
+
margin: '12px 0',
|
|
369
|
+
border: '1px solid #dc2626',
|
|
370
|
+
borderRadius: '8px',
|
|
371
|
+
background: '#fef2f2',
|
|
372
|
+
color: '#7f1d1d',
|
|
373
|
+
padding: '12px',
|
|
374
|
+
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
|
375
|
+
fontSize: '13px',
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
`Missing Builder component for block "${block.type}".`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
258
382
|
export function useBuilderRuntime(): BuilderRuntimeContextValue {
|
|
259
383
|
const context = useContext(BuilderRuntimeContext);
|
|
260
384
|
if (!context) {
|
|
@@ -319,6 +443,90 @@ export function useVisibleBuilderPageBlocks(pageId: string) {
|
|
|
319
443
|
return getVisiblePageBlocks(useBuilderRuntime().settings, pageId);
|
|
320
444
|
}
|
|
321
445
|
|
|
446
|
+
export function useThemePageBlocks(pageId: string, defaultOrder: string[]): BlockInstance[] {
|
|
447
|
+
const { settings } = useBuilderRuntime();
|
|
448
|
+
const canonicalPageId = normalizeDedicatedPageId(pageId);
|
|
449
|
+
|
|
450
|
+
return useMemo(() => {
|
|
451
|
+
const page = settings.theme.layout[canonicalPageId];
|
|
452
|
+
if (page) {
|
|
453
|
+
return page.blocks.filter((block) => block.visible);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return defaultOrder.map((type) => ({ id: type, type, visible: true, settings: {} }));
|
|
457
|
+
}, [canonicalPageId, defaultOrder, settings.theme.layout]);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function useThemePageBlockAttributes(
|
|
461
|
+
pageId: string,
|
|
462
|
+
defaultOrder: string[],
|
|
463
|
+
): BuilderAttributeMap {
|
|
464
|
+
const canonicalPageId = normalizeDedicatedPageId(pageId);
|
|
465
|
+
const blocks = useThemePageBlocks(pageId, defaultOrder);
|
|
466
|
+
const block = blocks[0];
|
|
467
|
+
|
|
468
|
+
return useMemo(
|
|
469
|
+
() => ({
|
|
470
|
+
'data-page-id': canonicalPageId,
|
|
471
|
+
...(block ? builderBlock(block.id, block.type) : {}),
|
|
472
|
+
}),
|
|
473
|
+
[block, canonicalPageId],
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
type DedicatedBuilderPageProps<T extends ElementType> = {
|
|
478
|
+
pageId: string;
|
|
479
|
+
defaultBlockOrder: string[];
|
|
480
|
+
as?: T;
|
|
481
|
+
children: ReactNode;
|
|
482
|
+
} & Omit<ComponentPropsWithoutRef<T>, 'as' | 'children'>;
|
|
483
|
+
|
|
484
|
+
export function DedicatedBuilderPage<T extends ElementType = 'section'>({
|
|
485
|
+
pageId,
|
|
486
|
+
defaultBlockOrder,
|
|
487
|
+
as,
|
|
488
|
+
children,
|
|
489
|
+
...rest
|
|
490
|
+
}: DedicatedBuilderPageProps<T>) {
|
|
491
|
+
const attrs = useThemePageBlockAttributes(pageId, defaultBlockOrder);
|
|
492
|
+
const Component = (as ?? 'section') as ElementType;
|
|
493
|
+
|
|
494
|
+
return createElement(Component, { ...attrs, ...rest }, children);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function useBuilderPreviewReviews<T>(input: {
|
|
498
|
+
reviews: T[];
|
|
499
|
+
isLoading: boolean;
|
|
500
|
+
error: unknown;
|
|
501
|
+
fixtures?: T[];
|
|
502
|
+
}): {
|
|
503
|
+
reviews: T[];
|
|
504
|
+
isLoading: boolean;
|
|
505
|
+
error: unknown;
|
|
506
|
+
isUsingFixtures: boolean;
|
|
507
|
+
} {
|
|
508
|
+
const fixtures = input.fixtures ?? (BUILDER_PREVIEW_REVIEWS as T[]);
|
|
509
|
+
const shouldUseFixtures = isBuilderPreviewRuntime() && Boolean(input.error);
|
|
510
|
+
|
|
511
|
+
return useMemo(() => {
|
|
512
|
+
if (!shouldUseFixtures) {
|
|
513
|
+
return {
|
|
514
|
+
reviews: input.reviews,
|
|
515
|
+
isLoading: input.isLoading,
|
|
516
|
+
error: input.error,
|
|
517
|
+
isUsingFixtures: false,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
reviews: fixtures,
|
|
523
|
+
isLoading: false,
|
|
524
|
+
error: null,
|
|
525
|
+
isUsingFixtures: true,
|
|
526
|
+
};
|
|
527
|
+
}, [fixtures, input.error, input.isLoading, input.reviews, shouldUseFixtures]);
|
|
528
|
+
}
|
|
529
|
+
|
|
322
530
|
export function useBuilderStyleSlot(
|
|
323
531
|
slotId: StyleSlotId,
|
|
324
532
|
input: { breakpoint?: Breakpoint; fallback?: unknown } = {},
|
|
@@ -358,6 +566,67 @@ function getNestedBuilderSetting(record: Record<string, unknown>, path: string):
|
|
|
358
566
|
return current;
|
|
359
567
|
}
|
|
360
568
|
|
|
569
|
+
export function useSearchBarSettings(input: {
|
|
570
|
+
variant: 'hero' | 'navigation';
|
|
571
|
+
headerSettings?: Record<string, unknown>;
|
|
572
|
+
}) {
|
|
573
|
+
const runtime = useBuilderRuntime();
|
|
574
|
+
const headerSettings = useMemo(
|
|
575
|
+
() => input.headerSettings ?? getNavigationHeaderSettings(runtime.settings),
|
|
576
|
+
[input.headerSettings, runtime.settings],
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
const heroPlaceholder = useBuilderContentValue('hero.search.placeholder');
|
|
580
|
+
const heroBackground = useBuilderContentValue('hero.search.background');
|
|
581
|
+
const heroBorderColor = useBuilderContentValue('hero.search.borderColor');
|
|
582
|
+
const heroBorderRadius = useBuilderContentValue('hero.search.borderRadius');
|
|
583
|
+
const heroMaxWidth = useBuilderContentValue('hero.search.maxWidth');
|
|
584
|
+
const heroShowShortcut = useBuilderContentValue('hero.search.showShortcut');
|
|
585
|
+
const heroShow = useBuilderContentValue('hero.search.show');
|
|
586
|
+
|
|
587
|
+
return useMemo(
|
|
588
|
+
() =>
|
|
589
|
+
resolveSearchBarSettings({
|
|
590
|
+
variant: input.variant,
|
|
591
|
+
headerSettings,
|
|
592
|
+
heroValues: {
|
|
593
|
+
'hero.search.placeholder': heroPlaceholder,
|
|
594
|
+
'hero.search.background': heroBackground,
|
|
595
|
+
'hero.search.borderColor': heroBorderColor,
|
|
596
|
+
'hero.search.borderRadius': heroBorderRadius,
|
|
597
|
+
'hero.search.maxWidth': heroMaxWidth,
|
|
598
|
+
'hero.search.showShortcut': heroShowShortcut,
|
|
599
|
+
'hero.search.show': heroShow,
|
|
600
|
+
},
|
|
601
|
+
}),
|
|
602
|
+
[
|
|
603
|
+
headerSettings,
|
|
604
|
+
heroBackground,
|
|
605
|
+
heroBorderColor,
|
|
606
|
+
heroBorderRadius,
|
|
607
|
+
heroMaxWidth,
|
|
608
|
+
heroPlaceholder,
|
|
609
|
+
heroShow,
|
|
610
|
+
heroShowShortcut,
|
|
611
|
+
input.variant,
|
|
612
|
+
],
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export {
|
|
617
|
+
buildSearchShellStyle,
|
|
618
|
+
getNavigationHeaderSettings,
|
|
619
|
+
resolveSearchBarSettings,
|
|
620
|
+
} from './search-bar-settings.js';
|
|
621
|
+
|
|
622
|
+
export function isBuilderPreviewRuntime(): boolean {
|
|
623
|
+
if (typeof window === 'undefined') {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return window.location.search.includes('shoppex-preview-mode=theme');
|
|
628
|
+
}
|
|
629
|
+
|
|
361
630
|
function parseInitialBuilderSettings(input: unknown): BuilderSettings {
|
|
362
631
|
const parsed = BuilderSettingsSchema.safeParse(input);
|
|
363
632
|
if (parsed.success) return parsed.data;
|
|
@@ -399,19 +668,60 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
399
668
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
400
669
|
}
|
|
401
670
|
|
|
402
|
-
function
|
|
403
|
-
|
|
671
|
+
export function resolvePreviewReloadTarget(
|
|
672
|
+
location: Pick<Location, 'search' | 'hash'>,
|
|
673
|
+
sessionPath: unknown,
|
|
674
|
+
): string | null {
|
|
675
|
+
if (typeof sessionPath !== 'string' || !sessionPath.startsWith('/s/')) {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return `${sessionPath}${location.search}${location.hash}`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function normalizePreviewRuntimeError(error: unknown): { message: string; stack?: string; name?: string } {
|
|
683
|
+
if (error instanceof Error) {
|
|
684
|
+
return {
|
|
685
|
+
message: error.message || error.name || 'Preview runtime error',
|
|
686
|
+
name: error.name,
|
|
687
|
+
...(typeof error.stack === 'string' && error.stack ? { stack: error.stack } : {}),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (typeof error === 'string') {
|
|
692
|
+
return { message: error || 'Preview runtime error' };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (isRecord(error)) {
|
|
696
|
+
const message = typeof error.message === 'string' && error.message
|
|
697
|
+
? error.message
|
|
698
|
+
: 'Preview runtime error';
|
|
699
|
+
const stack = typeof error.stack === 'string' && error.stack ? error.stack : undefined;
|
|
700
|
+
const name = typeof error.name === 'string' && error.name ? error.name : undefined;
|
|
701
|
+
return { message, ...(stack ? { stack } : {}), ...(name ? { name } : {}) };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return { message: 'Preview runtime error' };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function parseOrigin(value: string | null | undefined): string | null {
|
|
708
|
+
if (!value) return null;
|
|
404
709
|
try {
|
|
405
|
-
return new URL(
|
|
710
|
+
return new URL(value).origin;
|
|
406
711
|
} catch {
|
|
407
712
|
return null;
|
|
408
713
|
}
|
|
409
714
|
}
|
|
410
715
|
|
|
716
|
+
function getPreviewParentOrigin(location: Location, referrer: string): string | null {
|
|
717
|
+
const explicitOrigin = new URLSearchParams(location.search).get('shoppex-preview-parent-origin');
|
|
718
|
+
return parseOrigin(explicitOrigin) ?? parseOrigin(referrer);
|
|
719
|
+
}
|
|
720
|
+
|
|
411
721
|
function isTrustedBuilderPreviewEmbed(location: Location, parentOrigin: string | null): boolean {
|
|
412
722
|
if (window.parent === window || !parentOrigin) return false;
|
|
413
723
|
if (!hasBuilderPreviewMode(location)) return false;
|
|
414
|
-
return
|
|
724
|
+
return isTrustedPreviewParentOrigin(parentOrigin);
|
|
415
725
|
}
|
|
416
726
|
|
|
417
727
|
function hasBuilderPreviewMode(location: Location): boolean {
|
|
@@ -423,25 +733,6 @@ function hasBuilderPreviewMode(location: Location): boolean {
|
|
|
423
733
|
);
|
|
424
734
|
}
|
|
425
735
|
|
|
426
|
-
function isTrustedBuilderPreviewParentOrigin(origin: string): boolean {
|
|
427
|
-
try {
|
|
428
|
-
const parsed = new URL(origin);
|
|
429
|
-
const hostname = parsed.hostname.toLowerCase();
|
|
430
|
-
return (
|
|
431
|
-
hostname === 'dashboard.shoppex.io'
|
|
432
|
-
|| hostname === 'dashboard.shoppex.test'
|
|
433
|
-
|| hostname === 'localhost'
|
|
434
|
-
|| hostname === '127.0.0.1'
|
|
435
|
-
|| hostname === '::1'
|
|
436
|
-
|| hostname.endsWith('.localhost')
|
|
437
|
-
|| hostname.endsWith('.vercel.app')
|
|
438
|
-
|| hostname.endsWith('.vercel.run')
|
|
439
|
-
);
|
|
440
|
-
} catch {
|
|
441
|
-
return false;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
736
|
function selectBuilderElement(blockId: string | undefined): void {
|
|
446
737
|
document
|
|
447
738
|
.querySelectorAll<HTMLElement>(BUILDER_SELECTED_SELECTOR)
|
|
@@ -455,7 +746,10 @@ function selectBuilderElement(blockId: string | undefined): void {
|
|
|
455
746
|
if (!target) return;
|
|
456
747
|
|
|
457
748
|
target.setAttribute('data-builder-selected', 'true');
|
|
458
|
-
target.
|
|
749
|
+
const pageId = target.getAttribute('data-page-id');
|
|
750
|
+
const scrollBlock =
|
|
751
|
+
pageId === 'footer' ? 'end' : pageId === 'navigation' ? 'start' : 'nearest';
|
|
752
|
+
target.scrollIntoView({ behavior: 'smooth', block: scrollBlock });
|
|
459
753
|
}
|
|
460
754
|
|
|
461
755
|
function createBuilderSelection(blockElement: HTMLElement, target: Element): BuilderSelection {
|
|
@@ -502,49 +796,479 @@ function installBuilderPreviewInspectorStyles(): () => void {
|
|
|
502
796
|
position: relative;
|
|
503
797
|
}
|
|
504
798
|
[data-builder-block][data-builder-selected="true"] {
|
|
505
|
-
outline:
|
|
799
|
+
outline: 1px solid rgba(124, 58, 237, 0.7);
|
|
506
800
|
outline-offset: 4px;
|
|
507
801
|
}
|
|
508
802
|
[data-builder-block][data-builder-hovered="true"] {
|
|
509
|
-
outline: 1px dashed
|
|
803
|
+
outline: 1px dashed rgba(124, 58, 237, 0.5);
|
|
510
804
|
outline-offset: 4px;
|
|
511
805
|
cursor: pointer;
|
|
512
806
|
}
|
|
807
|
+
/* Block-name tooltip in the upper-left corner of the hovered block.
|
|
808
|
+
Driven by a data-builder-block-label attribute the runtime sets
|
|
809
|
+
from manifest.blocks[type].label when available, falling back to
|
|
810
|
+
the block-type slug. */
|
|
811
|
+
[data-builder-block][data-builder-hovered="true"]::before {
|
|
812
|
+
content: attr(data-builder-block-label);
|
|
813
|
+
position: absolute;
|
|
814
|
+
top: -22px;
|
|
815
|
+
left: 0;
|
|
816
|
+
padding: 2px 6px;
|
|
817
|
+
font-size: 11px;
|
|
818
|
+
font-weight: 500;
|
|
819
|
+
line-height: 1.3;
|
|
820
|
+
color: #ffffff;
|
|
821
|
+
background: #7c3aed;
|
|
822
|
+
border-radius: 4px;
|
|
823
|
+
pointer-events: none;
|
|
824
|
+
white-space: nowrap;
|
|
825
|
+
z-index: 999;
|
|
826
|
+
}
|
|
827
|
+
/* Sub-element hover: thinner outline so a hover on a text or image
|
|
828
|
+
inside a block highlights only that target instead of the whole
|
|
829
|
+
block. Suppressed while the parent block is selected so the
|
|
830
|
+
merchant doesn't see double outlines. */
|
|
831
|
+
[data-builder-content][data-builder-content-hovered="true"] {
|
|
832
|
+
outline: 1px dashed rgba(124, 58, 237, 0.5);
|
|
833
|
+
outline-offset: 2px;
|
|
834
|
+
cursor: pointer;
|
|
835
|
+
}
|
|
836
|
+
[data-builder-block][data-builder-selected="true"] [data-builder-content][data-builder-content-hovered="true"] {
|
|
837
|
+
outline: none;
|
|
838
|
+
}
|
|
839
|
+
/* Inline-edit affordance: hover over editable text content reveals a
|
|
840
|
+
text cursor + subtle brand underline so the merchant can see what
|
|
841
|
+
a double-click would target. Images, links, and buttons stay
|
|
842
|
+
outside this rule — those have their own picker affordances. */
|
|
843
|
+
[data-builder-content][data-builder-content-hovered="true"]:not(img):not(a[href]):not(:has(a[href])):not(:has(button)) {
|
|
844
|
+
text-decoration: underline;
|
|
845
|
+
text-decoration-color: rgba(124, 58, 237, 0.55);
|
|
846
|
+
text-decoration-thickness: 2px;
|
|
847
|
+
text-underline-offset: 4px;
|
|
848
|
+
cursor: text;
|
|
849
|
+
}
|
|
850
|
+
/* Active inline-edit state: replaces the dashed outline with a
|
|
851
|
+
brand-tinted ring so the merchant gets unambiguous focus
|
|
852
|
+
feedback while typing. */
|
|
853
|
+
[data-builder-content][data-builder-inline-edit="true"] {
|
|
854
|
+
outline: 2px solid #7c3aed !important;
|
|
855
|
+
outline-offset: 4px !important;
|
|
856
|
+
border-radius: 4px;
|
|
857
|
+
background: rgba(124, 58, 237, 0.06);
|
|
858
|
+
text-decoration: none !important;
|
|
859
|
+
cursor: text !important;
|
|
860
|
+
caret-color: #7c3aed;
|
|
861
|
+
}
|
|
862
|
+
[data-builder-content][data-builder-inline-edit="true"]:focus {
|
|
863
|
+
outline: 2px solid #7c3aed !important;
|
|
864
|
+
outline-offset: 4px !important;
|
|
865
|
+
}
|
|
513
866
|
`;
|
|
514
867
|
document.head.appendChild(style);
|
|
515
868
|
return () => style.remove();
|
|
516
869
|
}
|
|
517
870
|
|
|
518
871
|
function installBuilderPreviewHoverInspector(): () => void {
|
|
519
|
-
let
|
|
872
|
+
let hoveredBlock: HTMLElement | null = null;
|
|
873
|
+
let hoveredContent: HTMLElement | null = null;
|
|
874
|
+
|
|
875
|
+
const clearHoveredBlock = () => {
|
|
876
|
+
hoveredBlock?.removeAttribute('data-builder-hovered');
|
|
877
|
+
hoveredBlock = null;
|
|
878
|
+
};
|
|
520
879
|
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
880
|
+
const clearHoveredContent = () => {
|
|
881
|
+
hoveredContent?.removeAttribute('data-builder-content-hovered');
|
|
882
|
+
hoveredContent = null;
|
|
524
883
|
};
|
|
525
884
|
|
|
526
885
|
const handleMouseOver = (event: MouseEvent) => {
|
|
527
886
|
const target = event.target;
|
|
528
887
|
if (!(target instanceof Element)) return;
|
|
529
888
|
const blockElement = target.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if (
|
|
533
|
-
|
|
534
|
-
|
|
889
|
+
const contentElement = target.closest<HTMLElement>(BUILDER_CONTENT_SELECTOR);
|
|
890
|
+
|
|
891
|
+
if (blockElement !== hoveredBlock) {
|
|
892
|
+
clearHoveredBlock();
|
|
893
|
+
if (blockElement) {
|
|
894
|
+
hoveredBlock = blockElement;
|
|
895
|
+
// Drive the ::before tooltip via a label attribute. Derive
|
|
896
|
+
// it from the block-type slug — pretty manifests already use
|
|
897
|
+
// capitalised labels, but we can't read manifest from here so
|
|
898
|
+
// we humanise the slug ("hero" → "Hero").
|
|
899
|
+
if (!blockElement.hasAttribute('data-builder-block-label')) {
|
|
900
|
+
const type = blockElement.getAttribute('data-builder-block-type') ?? '';
|
|
901
|
+
// Slugs whose Title-Cased form ("Custom Html") doesn't match the
|
|
902
|
+
// manifest's display label. We can't read manifest from here so
|
|
903
|
+
// we keep a small override table for these.
|
|
904
|
+
const SLUG_LABEL_OVERRIDES: Record<string, string> = {
|
|
905
|
+
'custom-html': 'Custom Embed',
|
|
906
|
+
};
|
|
907
|
+
const override = SLUG_LABEL_OVERRIDES[type];
|
|
908
|
+
const label = override ?? type
|
|
909
|
+
.replace(/[-_]/g, ' ')
|
|
910
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
911
|
+
if (label) blockElement.setAttribute('data-builder-block-label', label);
|
|
912
|
+
}
|
|
913
|
+
hoveredBlock.setAttribute('data-builder-hovered', 'true');
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (contentElement !== hoveredContent) {
|
|
918
|
+
clearHoveredContent();
|
|
919
|
+
if (contentElement && contentElement !== blockElement) {
|
|
920
|
+
hoveredContent = contentElement;
|
|
921
|
+
hoveredContent.setAttribute('data-builder-content-hovered', 'true');
|
|
922
|
+
}
|
|
923
|
+
}
|
|
535
924
|
};
|
|
536
925
|
|
|
537
926
|
const handleMouseOut = (event: MouseEvent) => {
|
|
538
927
|
const relatedTarget = event.relatedTarget;
|
|
539
|
-
if (relatedTarget instanceof Node
|
|
540
|
-
|
|
928
|
+
if (relatedTarget instanceof Node) {
|
|
929
|
+
if (hoveredBlock?.contains(relatedTarget) && hoveredContent?.contains(relatedTarget)) return;
|
|
930
|
+
if (hoveredBlock?.contains(relatedTarget) && !hoveredContent) return;
|
|
931
|
+
}
|
|
932
|
+
clearHoveredBlock();
|
|
933
|
+
clearHoveredContent();
|
|
541
934
|
};
|
|
542
935
|
|
|
543
936
|
window.addEventListener('mouseover', handleMouseOver, true);
|
|
544
937
|
window.addEventListener('mouseout', handleMouseOut, true);
|
|
545
938
|
return () => {
|
|
546
|
-
|
|
939
|
+
clearHoveredBlock();
|
|
940
|
+
clearHoveredContent();
|
|
547
941
|
window.removeEventListener('mouseover', handleMouseOver, true);
|
|
548
942
|
window.removeEventListener('mouseout', handleMouseOut, true);
|
|
549
943
|
};
|
|
550
944
|
}
|
|
945
|
+
|
|
946
|
+
const INSERTER_HOVER_BAND_PX = 28;
|
|
947
|
+
const INSERTER_CLEAR_DELAY_MS = 150;
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Direct-manipulation bridge: tracks the bounding rect of the selected
|
|
951
|
+
* block (for the floating toolbar), the inter-block gap currently
|
|
952
|
+
* hovered (for the "+" inserter), and inline-text-edit commits. All
|
|
953
|
+
* coordinates are reported in the iframe's viewport space; the
|
|
954
|
+
* dashboard maps them to its overlay layer.
|
|
955
|
+
*/
|
|
956
|
+
function installBuilderDirectManipulation(
|
|
957
|
+
postMessage: (message: unknown) => void,
|
|
958
|
+
getRevision: () => number,
|
|
959
|
+
): () => void {
|
|
960
|
+
// Defensive: test environments may stub document/window without the
|
|
961
|
+
// observer APIs we rely on. In that case we silently no-op so the
|
|
962
|
+
// existing READY/APPLY handshake remains testable.
|
|
963
|
+
if (
|
|
964
|
+
typeof MutationObserver === 'undefined' ||
|
|
965
|
+
typeof window === 'undefined' ||
|
|
966
|
+
typeof document === 'undefined'
|
|
967
|
+
) {
|
|
968
|
+
return () => {};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
let trackedBlock: HTMLElement | null = null;
|
|
972
|
+
let lastRectKey: string | null = null;
|
|
973
|
+
let lastInserterKey: string | null = null;
|
|
974
|
+
|
|
975
|
+
const postBlockRect = () => {
|
|
976
|
+
if (!trackedBlock || !trackedBlock.isConnected) {
|
|
977
|
+
if (trackedBlock) {
|
|
978
|
+
postMessage({
|
|
979
|
+
type: 'BLOCK_RECT',
|
|
980
|
+
revision: getRevision(),
|
|
981
|
+
blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
|
|
982
|
+
rect: null,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
trackedBlock = null;
|
|
986
|
+
lastRectKey = null;
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const rect = trackedBlock.getBoundingClientRect();
|
|
990
|
+
const blockId = trackedBlock.getAttribute('data-builder-block') ?? '';
|
|
991
|
+
const next = `${blockId}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
|
|
992
|
+
if (next === lastRectKey) return;
|
|
993
|
+
lastRectKey = next;
|
|
994
|
+
postMessage({
|
|
995
|
+
type: 'BLOCK_RECT',
|
|
996
|
+
revision: getRevision(),
|
|
997
|
+
blockId,
|
|
998
|
+
rect: {
|
|
999
|
+
top: rect.top,
|
|
1000
|
+
left: rect.left,
|
|
1001
|
+
width: rect.width,
|
|
1002
|
+
height: rect.height,
|
|
1003
|
+
},
|
|
1004
|
+
});
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
const selectionObserver = new MutationObserver(() => {
|
|
1008
|
+
const selected = document.querySelector(BUILDER_SELECTED_SELECTOR);
|
|
1009
|
+
if (selected instanceof HTMLElement) {
|
|
1010
|
+
trackedBlock = selected;
|
|
1011
|
+
postBlockRect();
|
|
1012
|
+
} else if (trackedBlock) {
|
|
1013
|
+
// Selection cleared.
|
|
1014
|
+
postMessage({
|
|
1015
|
+
type: 'BLOCK_RECT',
|
|
1016
|
+
revision: getRevision(),
|
|
1017
|
+
blockId: trackedBlock.getAttribute('data-builder-block') ?? '',
|
|
1018
|
+
rect: null,
|
|
1019
|
+
});
|
|
1020
|
+
trackedBlock = null;
|
|
1021
|
+
lastRectKey = null;
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
selectionObserver.observe(document.body, {
|
|
1025
|
+
attributes: true,
|
|
1026
|
+
attributeFilter: ['data-builder-selected'],
|
|
1027
|
+
subtree: true,
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
let rectFrame = 0;
|
|
1031
|
+
const scheduleRectUpdate = () => {
|
|
1032
|
+
if (rectFrame) return;
|
|
1033
|
+
rectFrame = window.requestAnimationFrame(() => {
|
|
1034
|
+
rectFrame = 0;
|
|
1035
|
+
postBlockRect();
|
|
1036
|
+
});
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
window.addEventListener('scroll', scheduleRectUpdate, true);
|
|
1040
|
+
window.addEventListener('resize', scheduleRectUpdate);
|
|
1041
|
+
|
|
1042
|
+
// Inter-block gap inserter: detect mouse near the top/bottom edge of
|
|
1043
|
+
// a tracked block stack. We don't try to be clever about which gap;
|
|
1044
|
+
// every direct ancestor with a list of `[data-builder-block]` children
|
|
1045
|
+
// contributes one gap per pair.
|
|
1046
|
+
let inserterClearTimer: number | null = null;
|
|
1047
|
+
|
|
1048
|
+
const postInserter = (
|
|
1049
|
+
index: number | null,
|
|
1050
|
+
rect: DOMRect | null,
|
|
1051
|
+
) => {
|
|
1052
|
+
const emitInserter = () => {
|
|
1053
|
+
const key =
|
|
1054
|
+
index === null || !rect
|
|
1055
|
+
? '__none__'
|
|
1056
|
+
: `${index}|${rect.top}|${rect.left}|${rect.width}|${rect.height}`;
|
|
1057
|
+
if (key === lastInserterKey) return;
|
|
1058
|
+
lastInserterKey = key;
|
|
1059
|
+
postMessage({
|
|
1060
|
+
type: 'INSERTER_HOVER',
|
|
1061
|
+
revision: getRevision(),
|
|
1062
|
+
index,
|
|
1063
|
+
rect:
|
|
1064
|
+
rect === null
|
|
1065
|
+
? null
|
|
1066
|
+
: {
|
|
1067
|
+
top: rect.top,
|
|
1068
|
+
left: rect.left,
|
|
1069
|
+
width: rect.width,
|
|
1070
|
+
height: rect.height,
|
|
1071
|
+
},
|
|
1072
|
+
});
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
if (index !== null && rect !== null) {
|
|
1076
|
+
if (inserterClearTimer !== null) {
|
|
1077
|
+
window.clearTimeout(inserterClearTimer);
|
|
1078
|
+
inserterClearTimer = null;
|
|
1079
|
+
}
|
|
1080
|
+
emitInserter();
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (inserterClearTimer !== null) return;
|
|
1085
|
+
inserterClearTimer = window.setTimeout(() => {
|
|
1086
|
+
inserterClearTimer = null;
|
|
1087
|
+
emitInserter();
|
|
1088
|
+
}, INSERTER_CLEAR_DELAY_MS);
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
const handleMouseMove = (event: MouseEvent) => {
|
|
1092
|
+
const blocks = Array.from(
|
|
1093
|
+
document.querySelectorAll<HTMLElement>(BUILDER_BLOCK_SELECTOR),
|
|
1094
|
+
).filter((el) => {
|
|
1095
|
+
// Only consider top-level builder blocks inside the same parent.
|
|
1096
|
+
const parent = el.parentElement;
|
|
1097
|
+
return parent
|
|
1098
|
+
? Array.from(parent.children).some(
|
|
1099
|
+
(child) =>
|
|
1100
|
+
child instanceof HTMLElement &&
|
|
1101
|
+
child.hasAttribute('data-builder-block'),
|
|
1102
|
+
)
|
|
1103
|
+
: false;
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
for (let i = 0; i < blocks.length - 1; i++) {
|
|
1107
|
+
const top = blocks[i]!.getBoundingClientRect();
|
|
1108
|
+
const bottom = blocks[i + 1]!.getBoundingClientRect();
|
|
1109
|
+
const gapTop = top.bottom;
|
|
1110
|
+
const gapBottom = bottom.top;
|
|
1111
|
+
if (
|
|
1112
|
+
event.clientY >= gapTop - INSERTER_HOVER_BAND_PX / 2 &&
|
|
1113
|
+
event.clientY <= gapBottom + INSERTER_HOVER_BAND_PX / 2 &&
|
|
1114
|
+
event.clientX >= top.left &&
|
|
1115
|
+
event.clientX <= top.right
|
|
1116
|
+
) {
|
|
1117
|
+
const rect = new DOMRect(
|
|
1118
|
+
top.left,
|
|
1119
|
+
gapTop,
|
|
1120
|
+
top.width,
|
|
1121
|
+
Math.max(gapBottom - gapTop, INSERTER_HOVER_BAND_PX),
|
|
1122
|
+
);
|
|
1123
|
+
postInserter(i + 1, rect);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
postInserter(null, null);
|
|
1128
|
+
};
|
|
1129
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
1130
|
+
|
|
1131
|
+
// Inline edit: double-click on an editable text content element flips
|
|
1132
|
+
// it into contenteditable and gives the caret to the merchant.
|
|
1133
|
+
//
|
|
1134
|
+
// We don't bind the browser's native `dblclick` event because the
|
|
1135
|
+
// dashboard's APPLY_STATE round-trip can rerender the iframe between
|
|
1136
|
+
// the two clicks and the browser silently drops the dblclick.
|
|
1137
|
+
// Instead we track two consecutive `mousedown` events on the same
|
|
1138
|
+
// content element within a 500ms window — same behaviour as the
|
|
1139
|
+
// native event but resilient to React re-mounts in between.
|
|
1140
|
+
let lastDownContent: HTMLElement | null = null;
|
|
1141
|
+
let lastDownAt = 0;
|
|
1142
|
+
const DOUBLE_DOWN_WINDOW_MS = 500;
|
|
1143
|
+
|
|
1144
|
+
const beginInlineEdit = (contentElement: HTMLElement) => {
|
|
1145
|
+
const blockElement = contentElement.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
|
|
1146
|
+
const blockId = blockElement?.getAttribute('data-builder-block');
|
|
1147
|
+
const contentPath = contentElement.getAttribute('data-builder-content');
|
|
1148
|
+
if (!blockId || !contentPath) return false;
|
|
1149
|
+
|
|
1150
|
+
// Skip inline edit for images/links/buttons — those are picked
|
|
1151
|
+
// separately in the inspector. Only run on plain text containers.
|
|
1152
|
+
if (
|
|
1153
|
+
contentElement instanceof HTMLImageElement ||
|
|
1154
|
+
contentElement.closest('a[href], button')
|
|
1155
|
+
) {
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
contentElement.setAttribute('contenteditable', 'plaintext-only');
|
|
1160
|
+
contentElement.setAttribute('data-builder-inline-edit', 'true');
|
|
1161
|
+
contentElement.focus();
|
|
1162
|
+
const range = document.createRange();
|
|
1163
|
+
range.selectNodeContents(contentElement);
|
|
1164
|
+
const selection = window.getSelection();
|
|
1165
|
+
selection?.removeAllRanges();
|
|
1166
|
+
selection?.addRange(range);
|
|
1167
|
+
|
|
1168
|
+
const finish = (commit: boolean) => {
|
|
1169
|
+
contentElement.removeAttribute('contenteditable');
|
|
1170
|
+
contentElement.removeAttribute('data-builder-inline-edit');
|
|
1171
|
+
contentElement.removeEventListener('blur', onBlur);
|
|
1172
|
+
contentElement.removeEventListener('keydown', onKey);
|
|
1173
|
+
if (commit) {
|
|
1174
|
+
const value = contentElement.textContent ?? '';
|
|
1175
|
+
postMessage({
|
|
1176
|
+
type: 'INLINE_EDIT_COMMIT',
|
|
1177
|
+
revision: getRevision(),
|
|
1178
|
+
blockId,
|
|
1179
|
+
contentPath,
|
|
1180
|
+
value,
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
const onBlur = () => finish(true);
|
|
1186
|
+
const onKey = (kev: KeyboardEvent) => {
|
|
1187
|
+
if (kev.key === 'Enter' && !kev.shiftKey) {
|
|
1188
|
+
kev.preventDefault();
|
|
1189
|
+
contentElement.blur();
|
|
1190
|
+
} else if (kev.key === 'Escape') {
|
|
1191
|
+
kev.preventDefault();
|
|
1192
|
+
finish(false);
|
|
1193
|
+
contentElement.blur();
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
contentElement.addEventListener('blur', onBlur, { once: true });
|
|
1197
|
+
contentElement.addEventListener('keydown', onKey);
|
|
1198
|
+
return true;
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
1202
|
+
const target = event.target;
|
|
1203
|
+
if (!(target instanceof HTMLElement)) {
|
|
1204
|
+
lastDownContent = null;
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
// If we're already in an inline edit on this element, let the
|
|
1208
|
+
// browser handle caret placement normally — no special handling.
|
|
1209
|
+
if (target.closest('[data-builder-inline-edit="true"]')) return;
|
|
1210
|
+
|
|
1211
|
+
const contentElement = target.closest<HTMLElement>(BUILDER_CONTENT_SELECTOR);
|
|
1212
|
+
if (!contentElement) {
|
|
1213
|
+
lastDownContent = null;
|
|
1214
|
+
lastDownAt = 0;
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const now = Date.now();
|
|
1218
|
+
const isSecondDown =
|
|
1219
|
+
lastDownContent === contentElement && now - lastDownAt < DOUBLE_DOWN_WINDOW_MS;
|
|
1220
|
+
if (!isSecondDown) {
|
|
1221
|
+
lastDownContent = contentElement;
|
|
1222
|
+
lastDownAt = now;
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
// Second mousedown on the same content element within the window —
|
|
1226
|
+
// treat as the user asking to edit inline.
|
|
1227
|
+
lastDownContent = null;
|
|
1228
|
+
lastDownAt = 0;
|
|
1229
|
+
if (!beginInlineEdit(contentElement)) return;
|
|
1230
|
+
event.preventDefault();
|
|
1231
|
+
event.stopPropagation();
|
|
1232
|
+
};
|
|
1233
|
+
window.addEventListener('mousedown', handleMouseDown, true);
|
|
1234
|
+
|
|
1235
|
+
return () => {
|
|
1236
|
+
selectionObserver.disconnect();
|
|
1237
|
+
if (rectFrame) window.cancelAnimationFrame(rectFrame);
|
|
1238
|
+
window.removeEventListener('scroll', scheduleRectUpdate, true);
|
|
1239
|
+
window.removeEventListener('resize', scheduleRectUpdate);
|
|
1240
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
1241
|
+
window.removeEventListener('mousedown', handleMouseDown, true);
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
export {
|
|
1246
|
+
getBuilderBlockSettingText,
|
|
1247
|
+
getBuilderProductBlockAttributes,
|
|
1248
|
+
getLayoutPageBlockAttributes,
|
|
1249
|
+
getProductBlockText,
|
|
1250
|
+
getProductPageBlockAttributes,
|
|
1251
|
+
} from './product-page.js';
|
|
1252
|
+
export {
|
|
1253
|
+
createStandardProductBlockRegistry,
|
|
1254
|
+
splitStandardProductPageBlocks,
|
|
1255
|
+
buildStandardProductInfoTabs,
|
|
1256
|
+
resolveStandardBuyBoxLabels,
|
|
1257
|
+
resolveStandardDetailsLabels,
|
|
1258
|
+
resolveStandardRelatedProductsTitle,
|
|
1259
|
+
resolveScopedBlockSettingText,
|
|
1260
|
+
STANDARD_PRODUCT_BLOCK_TYPES,
|
|
1261
|
+
STANDARD_PRODUCT_PRIMARY_BLOCK_TYPES,
|
|
1262
|
+
STANDARD_PRODUCT_SECONDARY_BLOCK_TYPES,
|
|
1263
|
+
} from './standard-product-blocks.js';
|
|
1264
|
+
export type {
|
|
1265
|
+
StandardBuyBoxLabels,
|
|
1266
|
+
StandardDetailsLabels,
|
|
1267
|
+
StandardProductBlockRegistryData,
|
|
1268
|
+
StandardProductBlockRegistryOptions,
|
|
1269
|
+
StandardProductBlockRegistrySlots,
|
|
1270
|
+
StandardProductBlockType,
|
|
1271
|
+
StandardProductSettingScope,
|
|
1272
|
+
StandardProductTabSpec,
|
|
1273
|
+
} from './standard-product-blocks.js';
|
|
1274
|
+
export { getBuilderPreviewReviewFixtures } from './preview-fixtures.js';
|