@zalify/storefront-kit 0.1.9 → 0.1.11
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/editor/frame.d.ts +6 -2
- package/dist/editor/frame.js +111 -4
- package/dist/editor/host.d.ts +5 -1
- package/dist/editor/host.js +7 -0
- package/dist/react/engine/CssVariables.js +3 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/dist/schemas/bridge.d.ts +41 -3
- package/package.json +1 -1
- package/src/editor/frame.ts +125 -6
- package/src/editor/host.ts +14 -0
- package/src/react/engine/CssVariables.tsx +3 -1
- package/src/react/index.ts +1 -0
- package/src/schemas/bridge.ts +75 -4
package/dist/editor/frame.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* later message must match it, and nothing but `bridge:ready` is ever
|
|
17
17
|
* posted before the pin.
|
|
18
18
|
*/
|
|
19
|
-
import { type Device, type SectionGroupData, type
|
|
19
|
+
import { type Device, type EditorBootstrap, type EditorCapability, type SectionGroupData, type SettingsData, type TemplateData, type ThemeEditorManifest } from '../schemas/index.ts';
|
|
20
20
|
export declare const DATA_HOVERED_ATTR = "data-z-hovered";
|
|
21
21
|
export interface FrameBridgeOptions {
|
|
22
22
|
/** Template currently rendered ("index", "product", …). */
|
|
@@ -25,6 +25,10 @@ export interface FrameBridgeOptions {
|
|
|
25
25
|
hash: string;
|
|
26
26
|
/** Serve the editor manifest (static import or dynamic build). */
|
|
27
27
|
getManifest: () => ThemeEditorManifest | Promise<ThemeEditorManifest>;
|
|
28
|
+
/** Capabilities supported by this deployed storefront runtime. */
|
|
29
|
+
capabilities?: EditorCapability[];
|
|
30
|
+
/** Serve the complete Site Editor bootstrap payload. */
|
|
31
|
+
getBootstrap?: () => EditorBootstrap | Promise<EditorBootstrap>;
|
|
28
32
|
/**
|
|
29
33
|
* Hot-apply an updated template in memory (optimistic edit). Return
|
|
30
34
|
* false (or throw) if the app cannot apply without a reload.
|
|
@@ -33,7 +37,7 @@ export interface FrameBridgeOptions {
|
|
|
33
37
|
templateName: string;
|
|
34
38
|
template: TemplateData;
|
|
35
39
|
groups?: Record<string, SectionGroupData>;
|
|
36
|
-
settingsData?:
|
|
40
|
+
settingsData?: SettingsData;
|
|
37
41
|
}) => boolean | Promise<boolean>;
|
|
38
42
|
onDeviceChange?: (device: Device) => void;
|
|
39
43
|
/** Overrides for tests / non-browser hosts. */
|
package/dist/editor/frame.js
CHANGED
|
@@ -33,6 +33,21 @@ function rectOf(element) {
|
|
|
33
33
|
const rect = element.getBoundingClientRect();
|
|
34
34
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
35
35
|
}
|
|
36
|
+
function visibleRectOf(element) {
|
|
37
|
+
const ownRect = rectOf(element);
|
|
38
|
+
if (ownRect.width > 0 && ownRect.height > 0)
|
|
39
|
+
return ownRect;
|
|
40
|
+
const childRects = [...element.children]
|
|
41
|
+
.map(rectOf)
|
|
42
|
+
.filter((rect) => rect.width > 0 && rect.height > 0);
|
|
43
|
+
if (childRects.length === 0)
|
|
44
|
+
return null;
|
|
45
|
+
const left = Math.min(...childRects.map((rect) => rect.x));
|
|
46
|
+
const top = Math.min(...childRects.map((rect) => rect.y));
|
|
47
|
+
const right = Math.max(...childRects.map((rect) => rect.x + rect.width));
|
|
48
|
+
const bottom = Math.max(...childRects.map((rect) => rect.y + rect.height));
|
|
49
|
+
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
50
|
+
}
|
|
36
51
|
function pathNodeOf(target) {
|
|
37
52
|
if (!(target instanceof Element))
|
|
38
53
|
return null;
|
|
@@ -47,6 +62,19 @@ export function mountFrameBridge(options) {
|
|
|
47
62
|
const doc = win.document;
|
|
48
63
|
let editorOrigin = null;
|
|
49
64
|
let lastHeight = 0;
|
|
65
|
+
let selectedPath = null;
|
|
66
|
+
const selectionHighlight = doc.createElement('div');
|
|
67
|
+
selectionHighlight.id = 'zalify-editor-highlight';
|
|
68
|
+
selectionHighlight.setAttribute('aria-hidden', 'true');
|
|
69
|
+
Object.assign(selectionHighlight.style, {
|
|
70
|
+
position: 'fixed',
|
|
71
|
+
display: 'none',
|
|
72
|
+
pointerEvents: 'none',
|
|
73
|
+
boxSizing: 'border-box',
|
|
74
|
+
border: '2px solid #2563eb',
|
|
75
|
+
zIndex: '2147483647',
|
|
76
|
+
});
|
|
77
|
+
(doc.body ?? doc.documentElement).append(selectionHighlight);
|
|
50
78
|
const post = (message) => {
|
|
51
79
|
// bridge:ready is the only pre-pin message; everything else waits.
|
|
52
80
|
const target = editorOrigin ?? '*';
|
|
@@ -63,6 +91,43 @@ export function mountFrameBridge(options) {
|
|
|
63
91
|
const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
|
|
64
92
|
doc.querySelector(selector)?.setAttribute(attr, '1');
|
|
65
93
|
};
|
|
94
|
+
const syncSelectionHighlight = () => {
|
|
95
|
+
if (selectedPath === null) {
|
|
96
|
+
selectionHighlight.style.display = 'none';
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const selector = `[${DATA_PATH_ATTR}="${CSS.escape(selectedPath)}"]`;
|
|
100
|
+
const node = doc.querySelector(selector);
|
|
101
|
+
const rect = node ? visibleRectOf(node) : null;
|
|
102
|
+
if (!rect) {
|
|
103
|
+
selectionHighlight.style.display = 'none';
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
Object.assign(selectionHighlight.style, {
|
|
107
|
+
display: 'block',
|
|
108
|
+
left: `${rect.x}px`,
|
|
109
|
+
top: `${rect.y}px`,
|
|
110
|
+
width: `${rect.width}px`,
|
|
111
|
+
height: `${rect.height}px`,
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
const selectionResizeObserver = new win.ResizeObserver(syncSelectionHighlight);
|
|
115
|
+
const setSelection = (path) => {
|
|
116
|
+
setUnique(DATA_SELECTED_ATTR, path);
|
|
117
|
+
selectedPath = path;
|
|
118
|
+
selectionResizeObserver.disconnect();
|
|
119
|
+
if (path !== null) {
|
|
120
|
+
const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
|
|
121
|
+
const node = doc.querySelector(selector);
|
|
122
|
+
if (node) {
|
|
123
|
+
selectionResizeObserver.observe(node);
|
|
124
|
+
for (const child of node.children) {
|
|
125
|
+
selectionResizeObserver.observe(child);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
syncSelectionHighlight();
|
|
130
|
+
};
|
|
66
131
|
const reportRects = () => {
|
|
67
132
|
const rects = [...doc.querySelectorAll(`[${DATA_PATH_ATTR}]`)].map((node) => ({
|
|
68
133
|
path: node.getAttribute(DATA_PATH_ATTR),
|
|
@@ -95,11 +160,11 @@ export function mountFrameBridge(options) {
|
|
|
95
160
|
editorOrigin = event.origin;
|
|
96
161
|
}
|
|
97
162
|
options.onDeviceChange?.(message.payload.device);
|
|
98
|
-
|
|
163
|
+
setSelection(message.payload.selectedPath);
|
|
99
164
|
break;
|
|
100
165
|
}
|
|
101
166
|
case 'block:select':
|
|
102
|
-
|
|
167
|
+
setSelection(message.payload.path);
|
|
103
168
|
// Rects let the host scroll its full-height canvas to the
|
|
104
169
|
// selection (the frame itself has no scrollbar in that mode).
|
|
105
170
|
reportRects();
|
|
@@ -149,6 +214,21 @@ export function mountFrameBridge(options) {
|
|
|
149
214
|
post({ type: 'manifest:response', payload: { manifest } });
|
|
150
215
|
break;
|
|
151
216
|
}
|
|
217
|
+
case 'editor:bootstrap:request': {
|
|
218
|
+
if (!options.getBootstrap) {
|
|
219
|
+
post({
|
|
220
|
+
type: 'bridge:error',
|
|
221
|
+
payload: {
|
|
222
|
+
code: 'bootstrap-unsupported',
|
|
223
|
+
message: 'frame cannot provide Site Editor bootstrap data',
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
const bootstrap = await options.getBootstrap();
|
|
229
|
+
post({ type: 'editor:bootstrap:response', payload: { bootstrap } });
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
152
232
|
}
|
|
153
233
|
};
|
|
154
234
|
const handleClick = (event) => {
|
|
@@ -159,9 +239,26 @@ export function mountFrameBridge(options) {
|
|
|
159
239
|
if (!node)
|
|
160
240
|
return;
|
|
161
241
|
const path = node.getAttribute(DATA_PATH_ATTR);
|
|
162
|
-
|
|
242
|
+
setSelection(path);
|
|
163
243
|
post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
|
|
164
244
|
};
|
|
245
|
+
const handleWheel = (event) => {
|
|
246
|
+
// Both previews are fixed device viewports. Let ordinary wheel gestures
|
|
247
|
+
// scroll the storefront; pinch/Ctrl+wheel remains a canvas zoom gesture.
|
|
248
|
+
if (!event.ctrlKey)
|
|
249
|
+
return;
|
|
250
|
+
event.preventDefault();
|
|
251
|
+
post({
|
|
252
|
+
type: 'viewport:wheel',
|
|
253
|
+
payload: {
|
|
254
|
+
deltaX: event.deltaX,
|
|
255
|
+
deltaY: event.deltaY,
|
|
256
|
+
ctrlKey: event.ctrlKey,
|
|
257
|
+
clientX: event.clientX,
|
|
258
|
+
clientY: event.clientY,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
};
|
|
165
262
|
/** Suppress the page's own interactivity (capture phase, doc root). */
|
|
166
263
|
const suppress = (event) => {
|
|
167
264
|
event.preventDefault();
|
|
@@ -197,6 +294,7 @@ export function mountFrameBridge(options) {
|
|
|
197
294
|
if (height === lastHeight || height <= 0)
|
|
198
295
|
return;
|
|
199
296
|
lastHeight = height;
|
|
297
|
+
syncSelectionHighlight();
|
|
200
298
|
post({ type: 'height:changed', payload: { height } });
|
|
201
299
|
};
|
|
202
300
|
const resizeObserver = new win.ResizeObserver(measure);
|
|
@@ -204,8 +302,11 @@ export function mountFrameBridge(options) {
|
|
|
204
302
|
if (doc.body)
|
|
205
303
|
resizeObserver.observe(doc.body);
|
|
206
304
|
win.addEventListener('message', handleMessage);
|
|
305
|
+
win.addEventListener('resize', syncSelectionHighlight);
|
|
306
|
+
win.addEventListener('scroll', syncSelectionHighlight, true);
|
|
207
307
|
doc.addEventListener('click', handleClick, true);
|
|
208
308
|
doc.addEventListener('mousemove', handleMouseMove, true);
|
|
309
|
+
doc.addEventListener('wheel', handleWheel, { capture: true, passive: false });
|
|
209
310
|
for (const type of SUPPRESSED_EVENTS) {
|
|
210
311
|
doc.addEventListener(type, suppress, true);
|
|
211
312
|
}
|
|
@@ -215,20 +316,26 @@ export function mountFrameBridge(options) {
|
|
|
215
316
|
contractVersion: CONTRACT_VERSION,
|
|
216
317
|
templateName: options.templateName,
|
|
217
318
|
hash: options.hash,
|
|
319
|
+
capabilities: options.capabilities ?? [],
|
|
218
320
|
},
|
|
219
321
|
});
|
|
220
322
|
measure();
|
|
221
323
|
return {
|
|
222
324
|
unmount: () => {
|
|
223
325
|
win.removeEventListener('message', handleMessage);
|
|
326
|
+
win.removeEventListener('resize', syncSelectionHighlight);
|
|
327
|
+
win.removeEventListener('scroll', syncSelectionHighlight, true);
|
|
224
328
|
doc.removeEventListener('click', handleClick, true);
|
|
225
329
|
doc.removeEventListener('mousemove', handleMouseMove, true);
|
|
330
|
+
doc.removeEventListener('wheel', handleWheel, true);
|
|
226
331
|
for (const type of SUPPRESSED_EVENTS) {
|
|
227
332
|
doc.removeEventListener(type, suppress, true);
|
|
228
333
|
}
|
|
229
334
|
resizeObserver.disconnect();
|
|
230
|
-
|
|
335
|
+
selectionResizeObserver.disconnect();
|
|
336
|
+
setSelection(null);
|
|
231
337
|
setUnique(DATA_HOVERED_ATTR, null);
|
|
338
|
+
selectionHighlight.remove();
|
|
232
339
|
},
|
|
233
340
|
notifySynced: (hash) => {
|
|
234
341
|
post({ type: 'template:synced', payload: { hash } });
|
package/dist/editor/host.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* when the iframe reloads (sandbox HMR hard-refresh, crash recovery),
|
|
6
6
|
* and exposes typed send/subscribe surfaces.
|
|
7
7
|
*/
|
|
8
|
-
import { type Device, type FrameMessage, type HostMessage } from '../schemas/index.ts';
|
|
8
|
+
import { type Device, type EditorCapability, type FrameMessage, type HostMessage } from '../schemas/index.ts';
|
|
9
9
|
export interface HostBridgeOptions {
|
|
10
10
|
iframe: HTMLIFrameElement;
|
|
11
11
|
/** Origin the preview is served from (the sandbox preview URL). */
|
|
@@ -16,6 +16,10 @@ export interface HostBridgeOptions {
|
|
|
16
16
|
onMessage?: (message: FrameMessage) => void;
|
|
17
17
|
/** Called when the frame speaks an incompatible contract version. */
|
|
18
18
|
onIncompatible?: (frameVersion: string) => void;
|
|
19
|
+
/** Capabilities that must be present before the frame becomes writable. */
|
|
20
|
+
requiredCapabilities?: EditorCapability[];
|
|
21
|
+
/** Called with the required capabilities missing from bridge:ready. */
|
|
22
|
+
onMissingCapabilities?: (capabilities: EditorCapability[]) => void;
|
|
19
23
|
/** Overrides for tests / non-browser hosts. */
|
|
20
24
|
window?: Window;
|
|
21
25
|
}
|
package/dist/editor/host.js
CHANGED
|
@@ -38,6 +38,13 @@ export function createHostBridge(options) {
|
|
|
38
38
|
}
|
|
39
39
|
const message = data;
|
|
40
40
|
if (message.type === 'bridge:ready') {
|
|
41
|
+
const offered = new Set(message.payload.capabilities ?? []);
|
|
42
|
+
const missing = (options.requiredCapabilities ?? []).filter((capability) => !offered.has(capability));
|
|
43
|
+
if (missing.length) {
|
|
44
|
+
ready = false;
|
|
45
|
+
options.onMissingCapabilities?.(missing);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
41
48
|
// First load and every iframe reload land here: (re)init.
|
|
42
49
|
ready = true;
|
|
43
50
|
post({
|
|
@@ -10,6 +10,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
10
10
|
*/
|
|
11
11
|
import { useMemo } from 'react';
|
|
12
12
|
import { colorSchemes, themeSettings } from './settings';
|
|
13
|
+
import { getThemeStoreVersion } from './store';
|
|
13
14
|
import { googleFontsHrefsFromSettings, parseFontHandle, } from "../../commerce/google-fonts.js";
|
|
14
15
|
function buildCss() {
|
|
15
16
|
const s = themeSettings;
|
|
@@ -106,7 +107,8 @@ const FONT_CSS_LOADER = [
|
|
|
106
107
|
'})(l[i])}',
|
|
107
108
|
].join('');
|
|
108
109
|
export function CssVariables({ nonce, fonts, } = {}) {
|
|
109
|
-
const
|
|
110
|
+
const themeVersion = getThemeStoreVersion();
|
|
111
|
+
const { css, links } = useMemo(() => ({ css: buildCss(), links: googleFontsHrefs() }), [themeVersion]);
|
|
110
112
|
// Server-resolved fonts (the next/font pattern): @font-face rules are
|
|
111
113
|
// inlined and the latin woff2 files preloaded, so the font downloads
|
|
112
114
|
// with the HTML and usually beats first paint — no async stylesheet,
|
package/dist/react/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Server loaders: import {loadSectionData} from '@zalify/storefront-kit/react/server'.
|
|
14
14
|
*/
|
|
15
15
|
export * from './engine/types';
|
|
16
|
-
export { installTheme, getTemplate, getSectionGroup, parseThemeJson, } from './engine/store';
|
|
16
|
+
export { installTheme, getTemplate, getSectionGroup, getThemeStoreVersion, parseThemeJson, } from './engine/store';
|
|
17
17
|
export type { ThemeSchema, InstallThemeOptions } from './engine/store';
|
|
18
18
|
export { themeSettings, colorSchemes, getColorScheme } from './engine/settings';
|
|
19
19
|
export { t } from './engine/translate';
|
package/dist/react/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
// Engine
|
|
16
16
|
export * from './engine/types';
|
|
17
|
-
export { installTheme, getTemplate, getSectionGroup, parseThemeJson, } from './engine/store';
|
|
17
|
+
export { installTheme, getTemplate, getSectionGroup, getThemeStoreVersion, parseThemeJson, } from './engine/store';
|
|
18
18
|
export { themeSettings, colorSchemes, getColorScheme } from './engine/settings';
|
|
19
19
|
export { t } from './engine/translate';
|
|
20
20
|
export { ThemeTemplate, SectionGroup, RenderSection, RenderSections, } from './engine/render';
|
package/dist/schemas/bridge.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Both sides validate `event.origin` and the message envelope before
|
|
7
7
|
* acting; versions negotiate on CONTRACT_VERSION's major component.
|
|
8
8
|
*/
|
|
9
|
-
import type { SectionGroupData,
|
|
9
|
+
import type { SectionGroupData, SettingsData, TemplateData } from './data.ts';
|
|
10
10
|
import type { ThemeEditorManifest } from './manifest.ts';
|
|
11
11
|
export declare const BRIDGE_NAMESPACE = "zalify-editor-bridge";
|
|
12
12
|
/** Query param that switches a theme preview into editor mode. */
|
|
@@ -16,6 +16,35 @@ export declare const DATA_PATH_ATTR = "data-z-path";
|
|
|
16
16
|
/** DOM attribute the bridge sets on the selected node. */
|
|
17
17
|
export declare const DATA_SELECTED_ATTR = "data-z-selected";
|
|
18
18
|
export type Device = 'desktop' | 'mobile';
|
|
19
|
+
export type EditorCapability = 'editor-bootstrap-v1' | 'apply-template-v1' | 'apply-groups-v1' | 'apply-settings-v1' | 'preview-navigation-v1';
|
|
20
|
+
export type PreviewResourceType = 'index' | 'product' | 'collection' | 'page' | 'blog' | 'article' | 'cart' | 'search' | 'list-collections' | '404';
|
|
21
|
+
export interface PreviewContext {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
url: string;
|
|
25
|
+
resourceType: PreviewResourceType;
|
|
26
|
+
}
|
|
27
|
+
export interface EditorBootstrap {
|
|
28
|
+
revision: string;
|
|
29
|
+
manifest: ThemeEditorManifest;
|
|
30
|
+
templates: Array<{
|
|
31
|
+
name: string;
|
|
32
|
+
writePath: string;
|
|
33
|
+
data: TemplateData;
|
|
34
|
+
preview?: PreviewContext;
|
|
35
|
+
}>;
|
|
36
|
+
groups: Array<{
|
|
37
|
+
name: string;
|
|
38
|
+
writePath: string;
|
|
39
|
+
data: SectionGroupData;
|
|
40
|
+
}>;
|
|
41
|
+
settings: {
|
|
42
|
+
writePath: string;
|
|
43
|
+
schema: ThemeEditorManifest['settingsSchema'];
|
|
44
|
+
resolvedData: SettingsData;
|
|
45
|
+
};
|
|
46
|
+
previewContexts: Partial<Record<'product' | 'collection' | 'page' | 'blog' | 'article', PreviewContext>>;
|
|
47
|
+
}
|
|
19
48
|
export interface DOMRectLike {
|
|
20
49
|
x: number;
|
|
21
50
|
y: number;
|
|
@@ -60,14 +89,15 @@ export type HostMessage = BridgeEnvelope<'bridge:init', {
|
|
|
60
89
|
templateName: string;
|
|
61
90
|
template: TemplateData;
|
|
62
91
|
groups?: Record<string, SectionGroupData>;
|
|
63
|
-
settingsData?:
|
|
92
|
+
settingsData?: SettingsData;
|
|
64
93
|
}> | BridgeEnvelope<'device:set', {
|
|
65
94
|
device: Device;
|
|
66
|
-
}> | BridgeEnvelope<'manifest:request', Record<string, never>>;
|
|
95
|
+
}> | BridgeEnvelope<'manifest:request', Record<string, never>> | BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
|
|
67
96
|
export type FrameMessage = BridgeEnvelope<'bridge:ready', {
|
|
68
97
|
contractVersion: string;
|
|
69
98
|
templateName: string;
|
|
70
99
|
hash: string;
|
|
100
|
+
capabilities: EditorCapability[];
|
|
71
101
|
}> | BridgeEnvelope<'block:clicked', {
|
|
72
102
|
path: string;
|
|
73
103
|
rect: DOMRectLike;
|
|
@@ -81,10 +111,18 @@ export type FrameMessage = BridgeEnvelope<'bridge:ready', {
|
|
|
81
111
|
}>;
|
|
82
112
|
}> | BridgeEnvelope<'manifest:response', {
|
|
83
113
|
manifest: ThemeEditorManifest;
|
|
114
|
+
}> | BridgeEnvelope<'editor:bootstrap:response', {
|
|
115
|
+
bootstrap: EditorBootstrap;
|
|
84
116
|
}> | BridgeEnvelope<'template:synced', {
|
|
85
117
|
hash: string;
|
|
86
118
|
}> | BridgeEnvelope<'height:changed', {
|
|
87
119
|
height: number;
|
|
120
|
+
}> | BridgeEnvelope<'viewport:wheel', {
|
|
121
|
+
deltaX: number;
|
|
122
|
+
deltaY: number;
|
|
123
|
+
ctrlKey: boolean;
|
|
124
|
+
clientX: number;
|
|
125
|
+
clientY: number;
|
|
88
126
|
}> | BridgeEnvelope<'navigation', {
|
|
89
127
|
templateName: string;
|
|
90
128
|
url: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalify/storefront-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Zalify storefront SDK: framework-agnostic commerce logic (/commerce), the theme contract types and validators (/schemas), the canvas-editor bridge (/editor), and the React theme engine + shared components (/ui, /react/server). Consumed as TypeScript source inside the zalify-storefronts monorepo; published as compiled ESM + d.ts.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/editor/frame.ts
CHANGED
|
@@ -26,10 +26,12 @@ import {
|
|
|
26
26
|
isCompatibleVersion,
|
|
27
27
|
type Device,
|
|
28
28
|
type DOMRectLike,
|
|
29
|
+
type EditorBootstrap,
|
|
30
|
+
type EditorCapability,
|
|
29
31
|
type FrameMessage,
|
|
30
32
|
type HostMessage,
|
|
31
33
|
type SectionGroupData,
|
|
32
|
-
type
|
|
34
|
+
type SettingsData,
|
|
33
35
|
type TemplateData,
|
|
34
36
|
type ThemeEditorManifest,
|
|
35
37
|
} from '../schemas/index.ts';
|
|
@@ -45,6 +47,10 @@ export interface FrameBridgeOptions {
|
|
|
45
47
|
getManifest: () =>
|
|
46
48
|
| ThemeEditorManifest
|
|
47
49
|
| Promise<ThemeEditorManifest>;
|
|
50
|
+
/** Capabilities supported by this deployed storefront runtime. */
|
|
51
|
+
capabilities?: EditorCapability[];
|
|
52
|
+
/** Serve the complete Site Editor bootstrap payload. */
|
|
53
|
+
getBootstrap?: () => EditorBootstrap | Promise<EditorBootstrap>;
|
|
48
54
|
/**
|
|
49
55
|
* Hot-apply an updated template in memory (optimistic edit). Return
|
|
50
56
|
* false (or throw) if the app cannot apply without a reload.
|
|
@@ -53,7 +59,7 @@ export interface FrameBridgeOptions {
|
|
|
53
59
|
templateName: string;
|
|
54
60
|
template: TemplateData;
|
|
55
61
|
groups?: Record<string, SectionGroupData>;
|
|
56
|
-
settingsData?:
|
|
62
|
+
settingsData?: SettingsData;
|
|
57
63
|
}) => boolean | Promise<boolean>;
|
|
58
64
|
onDeviceChange?: (device: Device) => void;
|
|
59
65
|
/** Overrides for tests / non-browser hosts. */
|
|
@@ -77,6 +83,22 @@ function rectOf(element: Element): DOMRectLike {
|
|
|
77
83
|
return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
function visibleRectOf(element: Element): DOMRectLike | null {
|
|
87
|
+
const ownRect = rectOf(element);
|
|
88
|
+
if (ownRect.width > 0 && ownRect.height > 0) return ownRect;
|
|
89
|
+
|
|
90
|
+
const childRects = [...element.children]
|
|
91
|
+
.map(rectOf)
|
|
92
|
+
.filter((rect) => rect.width > 0 && rect.height > 0);
|
|
93
|
+
if (childRects.length === 0) return null;
|
|
94
|
+
|
|
95
|
+
const left = Math.min(...childRects.map((rect) => rect.x));
|
|
96
|
+
const top = Math.min(...childRects.map((rect) => rect.y));
|
|
97
|
+
const right = Math.max(...childRects.map((rect) => rect.x + rect.width));
|
|
98
|
+
const bottom = Math.max(...childRects.map((rect) => rect.y + rect.height));
|
|
99
|
+
return {x: left, y: top, width: right - left, height: bottom - top};
|
|
100
|
+
}
|
|
101
|
+
|
|
80
102
|
function pathNodeOf(target: EventTarget | null): HTMLElement | null {
|
|
81
103
|
if (!(target instanceof Element)) return null;
|
|
82
104
|
return target.closest<HTMLElement>(`[${DATA_PATH_ATTR}]`);
|
|
@@ -103,6 +125,20 @@ export function mountFrameBridge(
|
|
|
103
125
|
const doc = win.document;
|
|
104
126
|
let editorOrigin: string | null = null;
|
|
105
127
|
let lastHeight = 0;
|
|
128
|
+
let selectedPath: string | null = null;
|
|
129
|
+
|
|
130
|
+
const selectionHighlight = doc.createElement('div');
|
|
131
|
+
selectionHighlight.id = 'zalify-editor-highlight';
|
|
132
|
+
selectionHighlight.setAttribute('aria-hidden', 'true');
|
|
133
|
+
Object.assign(selectionHighlight.style, {
|
|
134
|
+
position: 'fixed',
|
|
135
|
+
display: 'none',
|
|
136
|
+
pointerEvents: 'none',
|
|
137
|
+
boxSizing: 'border-box',
|
|
138
|
+
border: '2px solid #2563eb',
|
|
139
|
+
zIndex: '2147483647',
|
|
140
|
+
});
|
|
141
|
+
(doc.body ?? doc.documentElement).append(selectionHighlight);
|
|
106
142
|
|
|
107
143
|
const post = (message: Omit<FrameMessage, 'z' | 'v'>): void => {
|
|
108
144
|
// bridge:ready is the only pre-pin message; everything else waits.
|
|
@@ -123,6 +159,47 @@ export function mountFrameBridge(
|
|
|
123
159
|
doc.querySelector(selector)?.setAttribute(attr, '1');
|
|
124
160
|
};
|
|
125
161
|
|
|
162
|
+
const syncSelectionHighlight = (): void => {
|
|
163
|
+
if (selectedPath === null) {
|
|
164
|
+
selectionHighlight.style.display = 'none';
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const selector = `[${DATA_PATH_ATTR}="${CSS.escape(selectedPath)}"]`;
|
|
168
|
+
const node = doc.querySelector(selector);
|
|
169
|
+
const rect = node ? visibleRectOf(node) : null;
|
|
170
|
+
if (!rect) {
|
|
171
|
+
selectionHighlight.style.display = 'none';
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
Object.assign(selectionHighlight.style, {
|
|
175
|
+
display: 'block',
|
|
176
|
+
left: `${rect.x}px`,
|
|
177
|
+
top: `${rect.y}px`,
|
|
178
|
+
width: `${rect.width}px`,
|
|
179
|
+
height: `${rect.height}px`,
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const selectionResizeObserver = new win.ResizeObserver(
|
|
184
|
+
syncSelectionHighlight,
|
|
185
|
+
);
|
|
186
|
+
const setSelection = (path: string | null): void => {
|
|
187
|
+
setUnique(DATA_SELECTED_ATTR, path);
|
|
188
|
+
selectedPath = path;
|
|
189
|
+
selectionResizeObserver.disconnect();
|
|
190
|
+
if (path !== null) {
|
|
191
|
+
const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
|
|
192
|
+
const node = doc.querySelector(selector);
|
|
193
|
+
if (node) {
|
|
194
|
+
selectionResizeObserver.observe(node);
|
|
195
|
+
for (const child of node.children) {
|
|
196
|
+
selectionResizeObserver.observe(child);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
syncSelectionHighlight();
|
|
201
|
+
};
|
|
202
|
+
|
|
126
203
|
const reportRects = (): void => {
|
|
127
204
|
const rects = [...doc.querySelectorAll(`[${DATA_PATH_ATTR}]`)].map(
|
|
128
205
|
(node) => ({
|
|
@@ -155,11 +232,11 @@ export function mountFrameBridge(
|
|
|
155
232
|
editorOrigin = event.origin;
|
|
156
233
|
}
|
|
157
234
|
options.onDeviceChange?.(message.payload.device);
|
|
158
|
-
|
|
235
|
+
setSelection(message.payload.selectedPath);
|
|
159
236
|
break;
|
|
160
237
|
}
|
|
161
238
|
case 'block:select':
|
|
162
|
-
|
|
239
|
+
setSelection(message.payload.path);
|
|
163
240
|
// Rects let the host scroll its full-height canvas to the
|
|
164
241
|
// selection (the frame itself has no scrollbar in that mode).
|
|
165
242
|
reportRects();
|
|
@@ -208,6 +285,21 @@ export function mountFrameBridge(
|
|
|
208
285
|
post({type: 'manifest:response', payload: {manifest}});
|
|
209
286
|
break;
|
|
210
287
|
}
|
|
288
|
+
case 'editor:bootstrap:request': {
|
|
289
|
+
if (!options.getBootstrap) {
|
|
290
|
+
post({
|
|
291
|
+
type: 'bridge:error',
|
|
292
|
+
payload: {
|
|
293
|
+
code: 'bootstrap-unsupported',
|
|
294
|
+
message: 'frame cannot provide Site Editor bootstrap data',
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
const bootstrap = await options.getBootstrap();
|
|
300
|
+
post({type: 'editor:bootstrap:response', payload: {bootstrap}});
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
211
303
|
}
|
|
212
304
|
};
|
|
213
305
|
|
|
@@ -218,10 +310,27 @@ export function mountFrameBridge(
|
|
|
218
310
|
event.stopPropagation();
|
|
219
311
|
if (!node) return;
|
|
220
312
|
const path = node.getAttribute(DATA_PATH_ATTR)!;
|
|
221
|
-
|
|
313
|
+
setSelection(path);
|
|
222
314
|
post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
|
|
223
315
|
};
|
|
224
316
|
|
|
317
|
+
const handleWheel = (event: WheelEvent): void => {
|
|
318
|
+
// Both previews are fixed device viewports. Let ordinary wheel gestures
|
|
319
|
+
// scroll the storefront; pinch/Ctrl+wheel remains a canvas zoom gesture.
|
|
320
|
+
if (!event.ctrlKey) return;
|
|
321
|
+
event.preventDefault();
|
|
322
|
+
post({
|
|
323
|
+
type: 'viewport:wheel',
|
|
324
|
+
payload: {
|
|
325
|
+
deltaX: event.deltaX,
|
|
326
|
+
deltaY: event.deltaY,
|
|
327
|
+
ctrlKey: event.ctrlKey,
|
|
328
|
+
clientX: event.clientX,
|
|
329
|
+
clientY: event.clientY,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
|
|
225
334
|
/** Suppress the page's own interactivity (capture phase, doc root). */
|
|
226
335
|
const suppress = (event: Event): void => {
|
|
227
336
|
event.preventDefault();
|
|
@@ -259,6 +368,7 @@ export function mountFrameBridge(
|
|
|
259
368
|
);
|
|
260
369
|
if (height === lastHeight || height <= 0) return;
|
|
261
370
|
lastHeight = height;
|
|
371
|
+
syncSelectionHighlight();
|
|
262
372
|
post({type: 'height:changed', payload: {height}});
|
|
263
373
|
};
|
|
264
374
|
const resizeObserver = new win.ResizeObserver(measure);
|
|
@@ -266,8 +376,11 @@ export function mountFrameBridge(
|
|
|
266
376
|
if (doc.body) resizeObserver.observe(doc.body);
|
|
267
377
|
|
|
268
378
|
win.addEventListener('message', handleMessage);
|
|
379
|
+
win.addEventListener('resize', syncSelectionHighlight);
|
|
380
|
+
win.addEventListener('scroll', syncSelectionHighlight, true);
|
|
269
381
|
doc.addEventListener('click', handleClick, true);
|
|
270
382
|
doc.addEventListener('mousemove', handleMouseMove, true);
|
|
383
|
+
doc.addEventListener('wheel', handleWheel, {capture: true, passive: false});
|
|
271
384
|
for (const type of SUPPRESSED_EVENTS) {
|
|
272
385
|
doc.addEventListener(type, suppress, true);
|
|
273
386
|
}
|
|
@@ -278,6 +391,7 @@ export function mountFrameBridge(
|
|
|
278
391
|
contractVersion: CONTRACT_VERSION,
|
|
279
392
|
templateName: options.templateName,
|
|
280
393
|
hash: options.hash,
|
|
394
|
+
capabilities: options.capabilities ?? [],
|
|
281
395
|
},
|
|
282
396
|
});
|
|
283
397
|
measure();
|
|
@@ -285,14 +399,19 @@ export function mountFrameBridge(
|
|
|
285
399
|
return {
|
|
286
400
|
unmount: () => {
|
|
287
401
|
win.removeEventListener('message', handleMessage);
|
|
402
|
+
win.removeEventListener('resize', syncSelectionHighlight);
|
|
403
|
+
win.removeEventListener('scroll', syncSelectionHighlight, true);
|
|
288
404
|
doc.removeEventListener('click', handleClick, true);
|
|
289
405
|
doc.removeEventListener('mousemove', handleMouseMove, true);
|
|
406
|
+
doc.removeEventListener('wheel', handleWheel, true);
|
|
290
407
|
for (const type of SUPPRESSED_EVENTS) {
|
|
291
408
|
doc.removeEventListener(type, suppress, true);
|
|
292
409
|
}
|
|
293
410
|
resizeObserver.disconnect();
|
|
294
|
-
|
|
411
|
+
selectionResizeObserver.disconnect();
|
|
412
|
+
setSelection(null);
|
|
295
413
|
setUnique(DATA_HOVERED_ATTR, null);
|
|
414
|
+
selectionHighlight.remove();
|
|
296
415
|
},
|
|
297
416
|
notifySynced: (hash) => {
|
|
298
417
|
post({type: 'template:synced', payload: {hash}});
|
package/src/editor/host.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isBridgeMessage,
|
|
13
13
|
isCompatibleVersion,
|
|
14
14
|
type Device,
|
|
15
|
+
type EditorCapability,
|
|
15
16
|
type FrameMessage,
|
|
16
17
|
type HostMessage,
|
|
17
18
|
} from '../schemas/index.ts';
|
|
@@ -26,6 +27,10 @@ export interface HostBridgeOptions {
|
|
|
26
27
|
onMessage?: (message: FrameMessage) => void;
|
|
27
28
|
/** Called when the frame speaks an incompatible contract version. */
|
|
28
29
|
onIncompatible?: (frameVersion: string) => void;
|
|
30
|
+
/** Capabilities that must be present before the frame becomes writable. */
|
|
31
|
+
requiredCapabilities?: EditorCapability[];
|
|
32
|
+
/** Called with the required capabilities missing from bridge:ready. */
|
|
33
|
+
onMissingCapabilities?: (capabilities: EditorCapability[]) => void;
|
|
29
34
|
/** Overrides for tests / non-browser hosts. */
|
|
30
35
|
window?: Window;
|
|
31
36
|
}
|
|
@@ -71,6 +76,15 @@ export function createHostBridge(options: HostBridgeOptions): HostBridge {
|
|
|
71
76
|
}
|
|
72
77
|
const message = data as FrameMessage;
|
|
73
78
|
if (message.type === 'bridge:ready') {
|
|
79
|
+
const offered = new Set(message.payload.capabilities ?? []);
|
|
80
|
+
const missing = (options.requiredCapabilities ?? []).filter(
|
|
81
|
+
(capability) => !offered.has(capability),
|
|
82
|
+
);
|
|
83
|
+
if (missing.length) {
|
|
84
|
+
ready = false;
|
|
85
|
+
options.onMissingCapabilities?.(missing);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
74
88
|
// First load and every iframe reload land here: (re)init.
|
|
75
89
|
ready = true;
|
|
76
90
|
post({
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import {useMemo} from 'react';
|
|
11
11
|
import {colorSchemes, themeSettings} from './settings';
|
|
12
|
+
import {getThemeStoreVersion} from './store';
|
|
12
13
|
import {
|
|
13
14
|
googleFontsHrefsFromSettings,
|
|
14
15
|
parseFontHandle,
|
|
@@ -136,9 +137,10 @@ export function CssVariables({
|
|
|
136
137
|
nonce,
|
|
137
138
|
fonts,
|
|
138
139
|
}: {nonce?: string; fonts?: ResolvedGoogleFontCss | null} = {}) {
|
|
140
|
+
const themeVersion = getThemeStoreVersion();
|
|
139
141
|
const {css, links} = useMemo(
|
|
140
142
|
() => ({css: buildCss(), links: googleFontsHrefs()}),
|
|
141
|
-
[],
|
|
143
|
+
[themeVersion],
|
|
142
144
|
);
|
|
143
145
|
|
|
144
146
|
// Server-resolved fonts (the next/font pattern): @font-face rules are
|
package/src/react/index.ts
CHANGED
package/src/schemas/bridge.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
* Both sides validate `event.origin` and the message envelope before
|
|
7
7
|
* acting; versions negotiate on CONTRACT_VERSION's major component.
|
|
8
8
|
*/
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
SectionGroupData,
|
|
11
|
+
SettingsData,
|
|
12
|
+
TemplateData,
|
|
13
|
+
} from './data.ts';
|
|
10
14
|
import type {ThemeEditorManifest} from './manifest.ts';
|
|
11
15
|
|
|
12
16
|
export const BRIDGE_NAMESPACE = 'zalify-editor-bridge';
|
|
@@ -21,6 +25,56 @@ export const DATA_SELECTED_ATTR = 'data-z-selected';
|
|
|
21
25
|
|
|
22
26
|
export type Device = 'desktop' | 'mobile';
|
|
23
27
|
|
|
28
|
+
export type EditorCapability =
|
|
29
|
+
| 'editor-bootstrap-v1'
|
|
30
|
+
| 'apply-template-v1'
|
|
31
|
+
| 'apply-groups-v1'
|
|
32
|
+
| 'apply-settings-v1'
|
|
33
|
+
| 'preview-navigation-v1';
|
|
34
|
+
|
|
35
|
+
export type PreviewResourceType =
|
|
36
|
+
| 'index'
|
|
37
|
+
| 'product'
|
|
38
|
+
| 'collection'
|
|
39
|
+
| 'page'
|
|
40
|
+
| 'blog'
|
|
41
|
+
| 'article'
|
|
42
|
+
| 'cart'
|
|
43
|
+
| 'search'
|
|
44
|
+
| 'list-collections'
|
|
45
|
+
| '404';
|
|
46
|
+
|
|
47
|
+
export interface PreviewContext {
|
|
48
|
+
id: string;
|
|
49
|
+
title: string;
|
|
50
|
+
url: string;
|
|
51
|
+
resourceType: PreviewResourceType;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface EditorBootstrap {
|
|
55
|
+
revision: string;
|
|
56
|
+
manifest: ThemeEditorManifest;
|
|
57
|
+
templates: Array<{
|
|
58
|
+
name: string;
|
|
59
|
+
writePath: string;
|
|
60
|
+
data: TemplateData;
|
|
61
|
+
preview?: PreviewContext;
|
|
62
|
+
}>;
|
|
63
|
+
groups: Array<{
|
|
64
|
+
name: string;
|
|
65
|
+
writePath: string;
|
|
66
|
+
data: SectionGroupData;
|
|
67
|
+
}>;
|
|
68
|
+
settings: {
|
|
69
|
+
writePath: string;
|
|
70
|
+
schema: ThemeEditorManifest['settingsSchema'];
|
|
71
|
+
resolvedData: SettingsData;
|
|
72
|
+
};
|
|
73
|
+
previewContexts: Partial<
|
|
74
|
+
Record<'product' | 'collection' | 'page' | 'blog' | 'article', PreviewContext>
|
|
75
|
+
>;
|
|
76
|
+
}
|
|
77
|
+
|
|
24
78
|
export interface DOMRectLike {
|
|
25
79
|
x: number;
|
|
26
80
|
y: number;
|
|
@@ -96,18 +150,24 @@ export type HostMessage =
|
|
|
96
150
|
templateName: string;
|
|
97
151
|
template: TemplateData;
|
|
98
152
|
groups?: Record<string, SectionGroupData>;
|
|
99
|
-
settingsData?:
|
|
153
|
+
settingsData?: SettingsData;
|
|
100
154
|
}
|
|
101
155
|
>
|
|
102
156
|
| BridgeEnvelope<'device:set', {device: Device}>
|
|
103
|
-
| BridgeEnvelope<'manifest:request', Record<string, never
|
|
157
|
+
| BridgeEnvelope<'manifest:request', Record<string, never>>
|
|
158
|
+
| BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
|
|
104
159
|
|
|
105
160
|
/* ------------------------- frame -> host ---------------------------- */
|
|
106
161
|
|
|
107
162
|
export type FrameMessage =
|
|
108
163
|
| BridgeEnvelope<
|
|
109
164
|
'bridge:ready',
|
|
110
|
-
{
|
|
165
|
+
{
|
|
166
|
+
contractVersion: string;
|
|
167
|
+
templateName: string;
|
|
168
|
+
hash: string;
|
|
169
|
+
capabilities: EditorCapability[];
|
|
170
|
+
}
|
|
111
171
|
>
|
|
112
172
|
| BridgeEnvelope<'block:clicked', {path: string; rect: DOMRectLike}>
|
|
113
173
|
| BridgeEnvelope<'block:hovered', {path: string | null; rect?: DOMRectLike}>
|
|
@@ -116,8 +176,19 @@ export type FrameMessage =
|
|
|
116
176
|
{rects: Array<{path: string; rect: DOMRectLike}>}
|
|
117
177
|
>
|
|
118
178
|
| BridgeEnvelope<'manifest:response', {manifest: ThemeEditorManifest}>
|
|
179
|
+
| BridgeEnvelope<'editor:bootstrap:response', {bootstrap: EditorBootstrap}>
|
|
119
180
|
| BridgeEnvelope<'template:synced', {hash: string}>
|
|
120
181
|
| BridgeEnvelope<'height:changed', {height: number}>
|
|
182
|
+
| BridgeEnvelope<
|
|
183
|
+
'viewport:wheel',
|
|
184
|
+
{
|
|
185
|
+
deltaX: number;
|
|
186
|
+
deltaY: number;
|
|
187
|
+
ctrlKey: boolean;
|
|
188
|
+
clientX: number;
|
|
189
|
+
clientY: number;
|
|
190
|
+
}
|
|
191
|
+
>
|
|
121
192
|
| BridgeEnvelope<'navigation', {templateName: string; url: string}>
|
|
122
193
|
| BridgeEnvelope<'bridge:error', {code: string; message: string}>;
|
|
123
194
|
|