@zalify/storefront-kit 0.2.0 → 0.3.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/editor/frame.d.ts +38 -5
- package/dist/editor/frame.js +139 -18
- package/dist/schemas/bridge.d.ts +10 -0
- package/package.json +1 -1
- package/src/editor/frame.ts +149 -18
- package/src/schemas/bridge.ts +10 -0
package/dist/editor/frame.d.ts
CHANGED
|
@@ -6,11 +6,24 @@
|
|
|
6
6
|
* sync — and hands app-specific concerns (template hot-apply, device
|
|
7
7
|
* emulation) to callbacks.
|
|
8
8
|
*
|
|
9
|
-
* Editor mode
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Editor mode has two interaction modes, switched by the host:
|
|
10
|
+
*
|
|
11
|
+
* - `select` (default): a click selects the block under it and nothing
|
|
12
|
+
* else happens — links and buttons do not fire, so a merchant can
|
|
13
|
+
* reach for a nav link to edit it without leaving the page. Form
|
|
14
|
+
* controls (inputs, selects, labels, buttons inside a form) still
|
|
15
|
+
* work, so a search box or a sign-up form can be exercised.
|
|
16
|
+
* - `interact`: the storefront is fully live — drawers, variant
|
|
17
|
+
* pickers, add to cart — and the bridge only polices navigation:
|
|
18
|
+
* same-origin links and GET forms become `location.replace`
|
|
19
|
+
* navigations that keep the editor-mode query param (so the next
|
|
20
|
+
* document mounts the bridge again) and add no browser-history
|
|
21
|
+
* entries; off-site links, new-tab links, modifier-clicks and
|
|
22
|
+
* off-site paths (checkout, account) are blocked.
|
|
23
|
+
*
|
|
24
|
+
* Every click selects in both modes. Double/middle clicks and context
|
|
25
|
+
* menus stay suppressed. Each mount reports its URL as a `navigation`,
|
|
26
|
+
* which is how the editor follows in-preview browsing.
|
|
14
27
|
*
|
|
15
28
|
* Security: the first `bridge:init` pins the editor origin; every
|
|
16
29
|
* later message must match it, and nothing but `bridge:ready` is ever
|
|
@@ -47,6 +60,26 @@ export interface FrameBridgeOptions {
|
|
|
47
60
|
}
|
|
48
61
|
/** True when this document should mount the editor bridge. */
|
|
49
62
|
export declare function isEditorMode(win?: Window): boolean;
|
|
63
|
+
export type NavigationDecision = {
|
|
64
|
+
kind: 'allow';
|
|
65
|
+
url: string;
|
|
66
|
+
} | {
|
|
67
|
+
kind: 'block';
|
|
68
|
+
reason: 'off-origin' | 'new-tab' | 'modifier' | 'off-site' | 'download';
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Where a click on `anchor` may take the preview. Same-origin page loads are
|
|
72
|
+
* allowed (with the editor-mode param re-attached); anything that would leave
|
|
73
|
+
* the sandbox or open another window is blocked.
|
|
74
|
+
*/
|
|
75
|
+
export declare function decideNavigation(href: string, base: string, flags: {
|
|
76
|
+
target?: string | null;
|
|
77
|
+
download?: boolean;
|
|
78
|
+
modifier?: boolean;
|
|
79
|
+
}): NavigationDecision;
|
|
80
|
+
/** The storefront path the editor should remember: no editor-mode param. */
|
|
81
|
+
export declare function previewPathOf(href: string): string;
|
|
82
|
+
export declare function isFormControl(target: EventTarget | null): boolean;
|
|
50
83
|
export interface FrameBridgeController {
|
|
51
84
|
unmount: () => void;
|
|
52
85
|
/** Report that a persisted write round-tripped (HMR applied `hash`). */
|
package/dist/editor/frame.js
CHANGED
|
@@ -6,11 +6,24 @@
|
|
|
6
6
|
* sync — and hands app-specific concerns (template hot-apply, device
|
|
7
7
|
* emulation) to callbacks.
|
|
8
8
|
*
|
|
9
|
-
* Editor mode
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Editor mode has two interaction modes, switched by the host:
|
|
10
|
+
*
|
|
11
|
+
* - `select` (default): a click selects the block under it and nothing
|
|
12
|
+
* else happens — links and buttons do not fire, so a merchant can
|
|
13
|
+
* reach for a nav link to edit it without leaving the page. Form
|
|
14
|
+
* controls (inputs, selects, labels, buttons inside a form) still
|
|
15
|
+
* work, so a search box or a sign-up form can be exercised.
|
|
16
|
+
* - `interact`: the storefront is fully live — drawers, variant
|
|
17
|
+
* pickers, add to cart — and the bridge only polices navigation:
|
|
18
|
+
* same-origin links and GET forms become `location.replace`
|
|
19
|
+
* navigations that keep the editor-mode query param (so the next
|
|
20
|
+
* document mounts the bridge again) and add no browser-history
|
|
21
|
+
* entries; off-site links, new-tab links, modifier-clicks and
|
|
22
|
+
* off-site paths (checkout, account) are blocked.
|
|
23
|
+
*
|
|
24
|
+
* Every click selects in both modes. Double/middle clicks and context
|
|
25
|
+
* menus stay suppressed. Each mount reports its URL as a `navigation`,
|
|
26
|
+
* which is how the editor follows in-preview browsing.
|
|
14
27
|
*
|
|
15
28
|
* Security: the first `bridge:init` pins the editor origin; every
|
|
16
29
|
* later message must match it, and nothing but `bridge:ready` is ever
|
|
@@ -49,10 +62,70 @@ function visibleRectOf(element) {
|
|
|
49
62
|
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
50
63
|
}
|
|
51
64
|
function pathNodeOf(target) {
|
|
52
|
-
if (!(target
|
|
65
|
+
if (!isElement(target))
|
|
53
66
|
return null;
|
|
54
67
|
return target.closest(`[${DATA_PATH_ATTR}]`);
|
|
55
68
|
}
|
|
69
|
+
function isElement(target) {
|
|
70
|
+
return (typeof Element !== 'undefined' && target instanceof Element);
|
|
71
|
+
}
|
|
72
|
+
/** Storefront paths that hand off to Shopify-hosted pages; never previewable. */
|
|
73
|
+
const OFF_SITE_PATHS = [/^\/checkout(?:s)?(?:\/|$)/, /^\/cart\/c\//, /^\/account(?:\/|$)/, /^\/admin(?:\/|$)/];
|
|
74
|
+
/**
|
|
75
|
+
* Where a click on `anchor` may take the preview. Same-origin page loads are
|
|
76
|
+
* allowed (with the editor-mode param re-attached); anything that would leave
|
|
77
|
+
* the sandbox or open another window is blocked.
|
|
78
|
+
*/
|
|
79
|
+
export function decideNavigation(href, base, flags) {
|
|
80
|
+
if (flags.modifier)
|
|
81
|
+
return { kind: 'block', reason: 'modifier' };
|
|
82
|
+
if (flags.download)
|
|
83
|
+
return { kind: 'block', reason: 'download' };
|
|
84
|
+
if (flags.target && flags.target !== '_self')
|
|
85
|
+
return { kind: 'block', reason: 'new-tab' };
|
|
86
|
+
let url;
|
|
87
|
+
try {
|
|
88
|
+
url = new URL(href, base);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { kind: 'block', reason: 'off-origin' };
|
|
92
|
+
}
|
|
93
|
+
if (url.origin !== new URL(base).origin)
|
|
94
|
+
return { kind: 'block', reason: 'off-origin' };
|
|
95
|
+
if (OFF_SITE_PATHS.some((pattern) => pattern.test(url.pathname))) {
|
|
96
|
+
return { kind: 'block', reason: 'off-site' };
|
|
97
|
+
}
|
|
98
|
+
url.searchParams.set(EDITOR_MODE_PARAM, '1');
|
|
99
|
+
return { kind: 'allow', url: url.toString() };
|
|
100
|
+
}
|
|
101
|
+
/** The storefront path the editor should remember: no editor-mode param. */
|
|
102
|
+
export function previewPathOf(href) {
|
|
103
|
+
const url = new URL(href);
|
|
104
|
+
url.searchParams.delete(EDITOR_MODE_PARAM);
|
|
105
|
+
return url.pathname + url.search;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Controls that keep their native behaviour in `select` mode: everything a
|
|
109
|
+
* form is made of, plus standalone inputs. Links and other buttons are the
|
|
110
|
+
* things a merchant reaches for to edit, so those stay selection-only.
|
|
111
|
+
*/
|
|
112
|
+
const FORM_CONTROL_SELECTOR = [
|
|
113
|
+
'input',
|
|
114
|
+
'textarea',
|
|
115
|
+
'select',
|
|
116
|
+
'option',
|
|
117
|
+
'label',
|
|
118
|
+
'[contenteditable=""]',
|
|
119
|
+
'[contenteditable="true"]',
|
|
120
|
+
'form button',
|
|
121
|
+
'form [role="button"]',
|
|
122
|
+
].join(',');
|
|
123
|
+
export function isFormControl(target) {
|
|
124
|
+
return isElement(target) && target.closest(FORM_CONTROL_SELECTOR) !== null;
|
|
125
|
+
}
|
|
126
|
+
function anchorOf(target) {
|
|
127
|
+
return isElement(target) ? target.closest('a[href]') : null;
|
|
128
|
+
}
|
|
56
129
|
/**
|
|
57
130
|
* Mount the frame bridge. Returns a controller with an `unmount`
|
|
58
131
|
* function. Call only when `isEditorMode()` is true.
|
|
@@ -61,6 +134,7 @@ export function mountFrameBridge(options) {
|
|
|
61
134
|
const win = options.window ?? window;
|
|
62
135
|
const doc = win.document;
|
|
63
136
|
let editorOrigin = null;
|
|
137
|
+
let interaction = 'select';
|
|
64
138
|
let lastHeight = 0;
|
|
65
139
|
let selectedPath = null;
|
|
66
140
|
const selectionHighlight = doc.createElement('div');
|
|
@@ -82,6 +156,15 @@ export function mountFrameBridge(options) {
|
|
|
82
156
|
return;
|
|
83
157
|
win.parent.postMessage({ z: BRIDGE_NAMESPACE, v: CONTRACT_VERSION, ...message }, target);
|
|
84
158
|
};
|
|
159
|
+
// Every mount tells the editor where the preview is now: after an
|
|
160
|
+
// in-preview link or search the host would otherwise keep showing the
|
|
161
|
+
// page it originally asked for. Sent once the editor origin is pinned.
|
|
162
|
+
const reportNavigation = () => {
|
|
163
|
+
post({
|
|
164
|
+
type: 'navigation',
|
|
165
|
+
payload: { templateName: options.templateName, url: previewPathOf(win.location.href) },
|
|
166
|
+
});
|
|
167
|
+
};
|
|
85
168
|
const setUnique = (attr, path) => {
|
|
86
169
|
for (const node of doc.querySelectorAll(`[${attr}]`)) {
|
|
87
170
|
node.removeAttribute(attr);
|
|
@@ -164,6 +247,7 @@ export function mountFrameBridge(options) {
|
|
|
164
247
|
editorOrigin = event.origin;
|
|
165
248
|
}
|
|
166
249
|
options.onDeviceChange?.(message.payload.device);
|
|
250
|
+
interaction = message.payload.interaction ?? 'select';
|
|
167
251
|
setSelection(message.payload.selectedPath);
|
|
168
252
|
// The first measurement can run before bridge:init arrives. It is
|
|
169
253
|
// intentionally not posted until the editor origin is pinned, so
|
|
@@ -171,6 +255,7 @@ export function mountFrameBridge(options) {
|
|
|
171
255
|
queueMicrotask(() => {
|
|
172
256
|
lastHeight = 0;
|
|
173
257
|
measure();
|
|
258
|
+
reportNavigation();
|
|
174
259
|
});
|
|
175
260
|
break;
|
|
176
261
|
}
|
|
@@ -220,6 +305,9 @@ export function mountFrameBridge(options) {
|
|
|
220
305
|
case 'device:set':
|
|
221
306
|
options.onDeviceChange?.(message.payload.device);
|
|
222
307
|
break;
|
|
308
|
+
case 'interaction:set':
|
|
309
|
+
interaction = message.payload.mode;
|
|
310
|
+
break;
|
|
223
311
|
case 'manifest:request': {
|
|
224
312
|
const manifest = await options.getManifest();
|
|
225
313
|
post({ type: 'manifest:response', payload: { manifest } });
|
|
@@ -244,14 +332,50 @@ export function mountFrameBridge(options) {
|
|
|
244
332
|
};
|
|
245
333
|
const handleClick = (event) => {
|
|
246
334
|
const node = pathNodeOf(event.target);
|
|
247
|
-
|
|
335
|
+
if (node) {
|
|
336
|
+
const path = node.getAttribute(DATA_PATH_ATTR);
|
|
337
|
+
setSelection(path);
|
|
338
|
+
post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
|
|
339
|
+
}
|
|
340
|
+
if (interaction === 'select') {
|
|
341
|
+
// Selection only: nothing fires except form controls.
|
|
342
|
+
if (!isFormControl(event.target)) {
|
|
343
|
+
event.preventDefault();
|
|
344
|
+
event.stopPropagation();
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// Interact mode: the page keeps its interactivity; only navigation is
|
|
349
|
+
// policed.
|
|
350
|
+
const anchor = anchorOf(event.target);
|
|
351
|
+
if (!anchor)
|
|
352
|
+
return;
|
|
353
|
+
const decision = decideNavigation(anchor.getAttribute('href') ?? '', win.location.href, {
|
|
354
|
+
target: anchor.getAttribute('target'),
|
|
355
|
+
download: anchor.hasAttribute('download'),
|
|
356
|
+
modifier: event.metaKey || event.ctrlKey || event.shiftKey || event.altKey,
|
|
357
|
+
});
|
|
248
358
|
event.preventDefault();
|
|
249
|
-
|
|
250
|
-
|
|
359
|
+
if (decision.kind === 'allow')
|
|
360
|
+
win.location.replace(decision.url);
|
|
361
|
+
};
|
|
362
|
+
const handleSubmit = (event) => {
|
|
363
|
+
const form = event.target;
|
|
364
|
+
if (!(form instanceof win.HTMLFormElement))
|
|
251
365
|
return;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
366
|
+
if ((form.getAttribute('method') ?? 'get').toLowerCase() !== 'get')
|
|
367
|
+
return;
|
|
368
|
+
// A GET form is a navigation: route it like a link so the editor-mode
|
|
369
|
+
// param survives and history stays clean. POST forms run as they are.
|
|
370
|
+
const action = new URL(form.getAttribute('action') || win.location.pathname, win.location.href);
|
|
371
|
+
for (const [key, value] of new FormData(form)) {
|
|
372
|
+
if (typeof value === 'string')
|
|
373
|
+
action.searchParams.set(key, value);
|
|
374
|
+
}
|
|
375
|
+
const decision = decideNavigation(action.toString(), win.location.href, {});
|
|
376
|
+
event.preventDefault();
|
|
377
|
+
if (decision.kind === 'allow')
|
|
378
|
+
win.location.replace(decision.url);
|
|
255
379
|
};
|
|
256
380
|
const handleWheel = (event) => {
|
|
257
381
|
// Cross-origin iframe wheel events never reach the host canvas. Forward
|
|
@@ -274,12 +398,7 @@ export function mountFrameBridge(options) {
|
|
|
274
398
|
event.preventDefault();
|
|
275
399
|
event.stopPropagation();
|
|
276
400
|
};
|
|
277
|
-
const SUPPRESSED_EVENTS = [
|
|
278
|
-
'dblclick',
|
|
279
|
-
'auxclick',
|
|
280
|
-
'submit',
|
|
281
|
-
'contextmenu',
|
|
282
|
-
];
|
|
401
|
+
const SUPPRESSED_EVENTS = ['dblclick', 'auxclick', 'contextmenu'];
|
|
283
402
|
const handleMouseMove = (() => {
|
|
284
403
|
let lastPath = null;
|
|
285
404
|
return (event) => {
|
|
@@ -315,6 +434,7 @@ export function mountFrameBridge(options) {
|
|
|
315
434
|
win.addEventListener('resize', syncSelectionHighlight);
|
|
316
435
|
win.addEventListener('scroll', syncSelectionHighlight, true);
|
|
317
436
|
doc.addEventListener('click', handleClick, true);
|
|
437
|
+
doc.addEventListener('submit', handleSubmit, true);
|
|
318
438
|
doc.addEventListener('mousemove', handleMouseMove, true);
|
|
319
439
|
doc.addEventListener('wheel', handleWheel, { capture: true, passive: false });
|
|
320
440
|
for (const type of SUPPRESSED_EVENTS) {
|
|
@@ -336,6 +456,7 @@ export function mountFrameBridge(options) {
|
|
|
336
456
|
win.removeEventListener('resize', syncSelectionHighlight);
|
|
337
457
|
win.removeEventListener('scroll', syncSelectionHighlight, true);
|
|
338
458
|
doc.removeEventListener('click', handleClick, true);
|
|
459
|
+
doc.removeEventListener('submit', handleSubmit, true);
|
|
339
460
|
doc.removeEventListener('mousemove', handleMouseMove, true);
|
|
340
461
|
doc.removeEventListener('wheel', handleWheel, true);
|
|
341
462
|
for (const type of SUPPRESSED_EVENTS) {
|
package/dist/schemas/bridge.d.ts
CHANGED
|
@@ -16,6 +16,12 @@ 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
|
+
/**
|
|
20
|
+
* How clicks in the preview behave. `select` (default): a click selects the
|
|
21
|
+
* block; form controls still work but links and buttons do not fire.
|
|
22
|
+
* `interact`: the storefront is fully live and the editor follows navigation.
|
|
23
|
+
*/
|
|
24
|
+
export type InteractionMode = 'select' | 'interact';
|
|
19
25
|
export type EditorCapability = 'editor-bootstrap-v1' | 'apply-template-v1' | 'apply-groups-v1' | 'apply-settings-v1' | 'preview-navigation-v1';
|
|
20
26
|
export type PreviewResourceType = 'index' | 'product' | 'collection' | 'page' | 'blog' | 'article' | 'cart' | 'search' | 'list-collections' | '404';
|
|
21
27
|
export interface PreviewContext {
|
|
@@ -79,6 +85,8 @@ export type HostMessage = BridgeEnvelope<'bridge:init', {
|
|
|
79
85
|
editorOrigin: string;
|
|
80
86
|
device: Device;
|
|
81
87
|
selectedPath: string | null;
|
|
88
|
+
/** Omitted by older hosts: behaves as `select`. */
|
|
89
|
+
interaction?: InteractionMode;
|
|
82
90
|
}> | BridgeEnvelope<'block:select', {
|
|
83
91
|
path: string | null;
|
|
84
92
|
}> | BridgeEnvelope<'block:hover', {
|
|
@@ -92,6 +100,8 @@ export type HostMessage = BridgeEnvelope<'bridge:init', {
|
|
|
92
100
|
settingsData?: SettingsData;
|
|
93
101
|
}> | BridgeEnvelope<'device:set', {
|
|
94
102
|
device: Device;
|
|
103
|
+
}> | BridgeEnvelope<'interaction:set', {
|
|
104
|
+
mode: InteractionMode;
|
|
95
105
|
}> | BridgeEnvelope<'manifest:request', Record<string, never>> | BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
|
|
96
106
|
export type FrameMessage = BridgeEnvelope<'bridge:ready', {
|
|
97
107
|
contractVersion: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalify/storefront-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
@@ -6,11 +6,24 @@
|
|
|
6
6
|
* sync — and hands app-specific concerns (template hot-apply, device
|
|
7
7
|
* emulation) to callbacks.
|
|
8
8
|
*
|
|
9
|
-
* Editor mode
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Editor mode has two interaction modes, switched by the host:
|
|
10
|
+
*
|
|
11
|
+
* - `select` (default): a click selects the block under it and nothing
|
|
12
|
+
* else happens — links and buttons do not fire, so a merchant can
|
|
13
|
+
* reach for a nav link to edit it without leaving the page. Form
|
|
14
|
+
* controls (inputs, selects, labels, buttons inside a form) still
|
|
15
|
+
* work, so a search box or a sign-up form can be exercised.
|
|
16
|
+
* - `interact`: the storefront is fully live — drawers, variant
|
|
17
|
+
* pickers, add to cart — and the bridge only polices navigation:
|
|
18
|
+
* same-origin links and GET forms become `location.replace`
|
|
19
|
+
* navigations that keep the editor-mode query param (so the next
|
|
20
|
+
* document mounts the bridge again) and add no browser-history
|
|
21
|
+
* entries; off-site links, new-tab links, modifier-clicks and
|
|
22
|
+
* off-site paths (checkout, account) are blocked.
|
|
23
|
+
*
|
|
24
|
+
* Every click selects in both modes. Double/middle clicks and context
|
|
25
|
+
* menus stay suppressed. Each mount reports its URL as a `navigation`,
|
|
26
|
+
* which is how the editor follows in-preview browsing.
|
|
14
27
|
*
|
|
15
28
|
* Security: the first `bridge:init` pins the editor origin; every
|
|
16
29
|
* later message must match it, and nothing but `bridge:ready` is ever
|
|
@@ -28,6 +41,7 @@ import {
|
|
|
28
41
|
type DOMRectLike,
|
|
29
42
|
type EditorBootstrap,
|
|
30
43
|
type EditorCapability,
|
|
44
|
+
type InteractionMode,
|
|
31
45
|
type FrameMessage,
|
|
32
46
|
type HostMessage,
|
|
33
47
|
type SectionGroupData,
|
|
@@ -102,10 +116,82 @@ function visibleRectOf(element: Element): DOMRectLike | null {
|
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
function pathNodeOf(target: EventTarget | null): HTMLElement | null {
|
|
105
|
-
if (!(target
|
|
119
|
+
if (!isElement(target)) return null;
|
|
106
120
|
return target.closest<HTMLElement>(`[${DATA_PATH_ATTR}]`);
|
|
107
121
|
}
|
|
108
122
|
|
|
123
|
+
function isElement(target: EventTarget | null): target is Element {
|
|
124
|
+
return (
|
|
125
|
+
typeof Element !== 'undefined' && target instanceof Element
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Storefront paths that hand off to Shopify-hosted pages; never previewable. */
|
|
130
|
+
const OFF_SITE_PATHS = [/^\/checkout(?:s)?(?:\/|$)/, /^\/cart\/c\//, /^\/account(?:\/|$)/, /^\/admin(?:\/|$)/];
|
|
131
|
+
|
|
132
|
+
export type NavigationDecision =
|
|
133
|
+
| {kind: 'allow'; url: string}
|
|
134
|
+
| {kind: 'block'; reason: 'off-origin' | 'new-tab' | 'modifier' | 'off-site' | 'download'};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Where a click on `anchor` may take the preview. Same-origin page loads are
|
|
138
|
+
* allowed (with the editor-mode param re-attached); anything that would leave
|
|
139
|
+
* the sandbox or open another window is blocked.
|
|
140
|
+
*/
|
|
141
|
+
export function decideNavigation(
|
|
142
|
+
href: string,
|
|
143
|
+
base: string,
|
|
144
|
+
flags: {target?: string | null; download?: boolean; modifier?: boolean},
|
|
145
|
+
): NavigationDecision {
|
|
146
|
+
if (flags.modifier) return {kind: 'block', reason: 'modifier'};
|
|
147
|
+
if (flags.download) return {kind: 'block', reason: 'download'};
|
|
148
|
+
if (flags.target && flags.target !== '_self') return {kind: 'block', reason: 'new-tab'};
|
|
149
|
+
let url: URL;
|
|
150
|
+
try {
|
|
151
|
+
url = new URL(href, base);
|
|
152
|
+
} catch {
|
|
153
|
+
return {kind: 'block', reason: 'off-origin'};
|
|
154
|
+
}
|
|
155
|
+
if (url.origin !== new URL(base).origin) return {kind: 'block', reason: 'off-origin'};
|
|
156
|
+
if (OFF_SITE_PATHS.some((pattern) => pattern.test(url.pathname))) {
|
|
157
|
+
return {kind: 'block', reason: 'off-site'};
|
|
158
|
+
}
|
|
159
|
+
url.searchParams.set(EDITOR_MODE_PARAM, '1');
|
|
160
|
+
return {kind: 'allow', url: url.toString()};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The storefront path the editor should remember: no editor-mode param. */
|
|
164
|
+
export function previewPathOf(href: string): string {
|
|
165
|
+
const url = new URL(href);
|
|
166
|
+
url.searchParams.delete(EDITOR_MODE_PARAM);
|
|
167
|
+
return url.pathname + url.search;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Controls that keep their native behaviour in `select` mode: everything a
|
|
172
|
+
* form is made of, plus standalone inputs. Links and other buttons are the
|
|
173
|
+
* things a merchant reaches for to edit, so those stay selection-only.
|
|
174
|
+
*/
|
|
175
|
+
const FORM_CONTROL_SELECTOR = [
|
|
176
|
+
'input',
|
|
177
|
+
'textarea',
|
|
178
|
+
'select',
|
|
179
|
+
'option',
|
|
180
|
+
'label',
|
|
181
|
+
'[contenteditable=""]',
|
|
182
|
+
'[contenteditable="true"]',
|
|
183
|
+
'form button',
|
|
184
|
+
'form [role="button"]',
|
|
185
|
+
].join(',');
|
|
186
|
+
|
|
187
|
+
export function isFormControl(target: EventTarget | null): boolean {
|
|
188
|
+
return isElement(target) && target.closest(FORM_CONTROL_SELECTOR) !== null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function anchorOf(target: EventTarget | null): HTMLAnchorElement | null {
|
|
192
|
+
return isElement(target) ? target.closest<HTMLAnchorElement>('a[href]') : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
109
195
|
export interface FrameBridgeController {
|
|
110
196
|
unmount: () => void;
|
|
111
197
|
/** Report that a persisted write round-tripped (HMR applied `hash`). */
|
|
@@ -126,6 +212,7 @@ export function mountFrameBridge(
|
|
|
126
212
|
const win = options.window ?? window;
|
|
127
213
|
const doc = win.document;
|
|
128
214
|
let editorOrigin: string | null = null;
|
|
215
|
+
let interaction: InteractionMode = 'select';
|
|
129
216
|
let lastHeight = 0;
|
|
130
217
|
let selectedPath: string | null = null;
|
|
131
218
|
|
|
@@ -152,6 +239,16 @@ export function mountFrameBridge(
|
|
|
152
239
|
);
|
|
153
240
|
};
|
|
154
241
|
|
|
242
|
+
// Every mount tells the editor where the preview is now: after an
|
|
243
|
+
// in-preview link or search the host would otherwise keep showing the
|
|
244
|
+
// page it originally asked for. Sent once the editor origin is pinned.
|
|
245
|
+
const reportNavigation = (): void => {
|
|
246
|
+
post({
|
|
247
|
+
type: 'navigation',
|
|
248
|
+
payload: {templateName: options.templateName, url: previewPathOf(win.location.href)},
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
155
252
|
const setUnique = (attr: string, path: string | null): void => {
|
|
156
253
|
for (const node of doc.querySelectorAll(`[${attr}]`)) {
|
|
157
254
|
node.removeAttribute(attr);
|
|
@@ -236,6 +333,7 @@ export function mountFrameBridge(
|
|
|
236
333
|
editorOrigin = event.origin;
|
|
237
334
|
}
|
|
238
335
|
options.onDeviceChange?.(message.payload.device);
|
|
336
|
+
interaction = message.payload.interaction ?? 'select';
|
|
239
337
|
setSelection(message.payload.selectedPath);
|
|
240
338
|
// The first measurement can run before bridge:init arrives. It is
|
|
241
339
|
// intentionally not posted until the editor origin is pinned, so
|
|
@@ -243,6 +341,7 @@ export function mountFrameBridge(
|
|
|
243
341
|
queueMicrotask(() => {
|
|
244
342
|
lastHeight = 0;
|
|
245
343
|
measure();
|
|
344
|
+
reportNavigation();
|
|
246
345
|
});
|
|
247
346
|
break;
|
|
248
347
|
}
|
|
@@ -291,6 +390,9 @@ export function mountFrameBridge(
|
|
|
291
390
|
case 'device:set':
|
|
292
391
|
options.onDeviceChange?.(message.payload.device);
|
|
293
392
|
break;
|
|
393
|
+
case 'interaction:set':
|
|
394
|
+
interaction = message.payload.mode;
|
|
395
|
+
break;
|
|
294
396
|
case 'manifest:request': {
|
|
295
397
|
const manifest = await options.getManifest();
|
|
296
398
|
post({type: 'manifest:response', payload: {manifest}});
|
|
@@ -316,13 +418,45 @@ export function mountFrameBridge(
|
|
|
316
418
|
|
|
317
419
|
const handleClick = (event: MouseEvent): void => {
|
|
318
420
|
const node = pathNodeOf(event.target);
|
|
319
|
-
|
|
421
|
+
if (node) {
|
|
422
|
+
const path = node.getAttribute(DATA_PATH_ATTR)!;
|
|
423
|
+
setSelection(path);
|
|
424
|
+
post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
|
|
425
|
+
}
|
|
426
|
+
if (interaction === 'select') {
|
|
427
|
+
// Selection only: nothing fires except form controls.
|
|
428
|
+
if (!isFormControl(event.target)) {
|
|
429
|
+
event.preventDefault();
|
|
430
|
+
event.stopPropagation();
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
// Interact mode: the page keeps its interactivity; only navigation is
|
|
435
|
+
// policed.
|
|
436
|
+
const anchor = anchorOf(event.target);
|
|
437
|
+
if (!anchor) return;
|
|
438
|
+
const decision = decideNavigation(anchor.getAttribute('href') ?? '', win.location.href, {
|
|
439
|
+
target: anchor.getAttribute('target'),
|
|
440
|
+
download: anchor.hasAttribute('download'),
|
|
441
|
+
modifier: event.metaKey || event.ctrlKey || event.shiftKey || event.altKey,
|
|
442
|
+
});
|
|
320
443
|
event.preventDefault();
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
444
|
+
if (decision.kind === 'allow') win.location.replace(decision.url);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const handleSubmit = (event: Event): void => {
|
|
448
|
+
const form = event.target;
|
|
449
|
+
if (!(form instanceof win.HTMLFormElement)) return;
|
|
450
|
+
if ((form.getAttribute('method') ?? 'get').toLowerCase() !== 'get') return;
|
|
451
|
+
// A GET form is a navigation: route it like a link so the editor-mode
|
|
452
|
+
// param survives and history stays clean. POST forms run as they are.
|
|
453
|
+
const action = new URL(form.getAttribute('action') || win.location.pathname, win.location.href);
|
|
454
|
+
for (const [key, value] of new FormData(form)) {
|
|
455
|
+
if (typeof value === 'string') action.searchParams.set(key, value);
|
|
456
|
+
}
|
|
457
|
+
const decision = decideNavigation(action.toString(), win.location.href, {});
|
|
458
|
+
event.preventDefault();
|
|
459
|
+
if (decision.kind === 'allow') win.location.replace(decision.url);
|
|
326
460
|
};
|
|
327
461
|
|
|
328
462
|
const handleWheel = (event: WheelEvent): void => {
|
|
@@ -347,12 +481,7 @@ export function mountFrameBridge(
|
|
|
347
481
|
event.preventDefault();
|
|
348
482
|
event.stopPropagation();
|
|
349
483
|
};
|
|
350
|
-
const SUPPRESSED_EVENTS = [
|
|
351
|
-
'dblclick',
|
|
352
|
-
'auxclick',
|
|
353
|
-
'submit',
|
|
354
|
-
'contextmenu',
|
|
355
|
-
] as const;
|
|
484
|
+
const SUPPRESSED_EVENTS = ['dblclick', 'auxclick', 'contextmenu'] as const;
|
|
356
485
|
|
|
357
486
|
const handleMouseMove = (() => {
|
|
358
487
|
let lastPath: string | null = null;
|
|
@@ -390,6 +519,7 @@ export function mountFrameBridge(
|
|
|
390
519
|
win.addEventListener('resize', syncSelectionHighlight);
|
|
391
520
|
win.addEventListener('scroll', syncSelectionHighlight, true);
|
|
392
521
|
doc.addEventListener('click', handleClick, true);
|
|
522
|
+
doc.addEventListener('submit', handleSubmit, true);
|
|
393
523
|
doc.addEventListener('mousemove', handleMouseMove, true);
|
|
394
524
|
doc.addEventListener('wheel', handleWheel, {capture: true, passive: false});
|
|
395
525
|
for (const type of SUPPRESSED_EVENTS) {
|
|
@@ -413,6 +543,7 @@ export function mountFrameBridge(
|
|
|
413
543
|
win.removeEventListener('resize', syncSelectionHighlight);
|
|
414
544
|
win.removeEventListener('scroll', syncSelectionHighlight, true);
|
|
415
545
|
doc.removeEventListener('click', handleClick, true);
|
|
546
|
+
doc.removeEventListener('submit', handleSubmit, true);
|
|
416
547
|
doc.removeEventListener('mousemove', handleMouseMove, true);
|
|
417
548
|
doc.removeEventListener('wheel', handleWheel, true);
|
|
418
549
|
for (const type of SUPPRESSED_EVENTS) {
|
package/src/schemas/bridge.ts
CHANGED
|
@@ -25,6 +25,13 @@ export const DATA_SELECTED_ATTR = 'data-z-selected';
|
|
|
25
25
|
|
|
26
26
|
export type Device = 'desktop' | 'mobile';
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* How clicks in the preview behave. `select` (default): a click selects the
|
|
30
|
+
* block; form controls still work but links and buttons do not fire.
|
|
31
|
+
* `interact`: the storefront is fully live and the editor follows navigation.
|
|
32
|
+
*/
|
|
33
|
+
export type InteractionMode = 'select' | 'interact';
|
|
34
|
+
|
|
28
35
|
export type EditorCapability =
|
|
29
36
|
| 'editor-bootstrap-v1'
|
|
30
37
|
| 'apply-template-v1'
|
|
@@ -139,6 +146,8 @@ export type HostMessage =
|
|
|
139
146
|
editorOrigin: string;
|
|
140
147
|
device: Device;
|
|
141
148
|
selectedPath: string | null;
|
|
149
|
+
/** Omitted by older hosts: behaves as `select`. */
|
|
150
|
+
interaction?: InteractionMode;
|
|
142
151
|
}
|
|
143
152
|
>
|
|
144
153
|
| BridgeEnvelope<'block:select', {path: string | null}>
|
|
@@ -154,6 +163,7 @@ export type HostMessage =
|
|
|
154
163
|
}
|
|
155
164
|
>
|
|
156
165
|
| BridgeEnvelope<'device:set', {device: Device}>
|
|
166
|
+
| BridgeEnvelope<'interaction:set', {mode: InteractionMode}>
|
|
157
167
|
| BridgeEnvelope<'manifest:request', Record<string, never>>
|
|
158
168
|
| BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
|
|
159
169
|
|