@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.
@@ -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 neutralizes the page's own interactivity: clicks,
10
- * double/middle clicks, form submits, and context menus are captured
11
- * at the document root and suppressed, so selecting a node never
12
- * navigates a link, opens a drawer, or submits a form — a click is
13
- * only ever a selection. (Hover stays live for highlight tracking.)
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`). */
@@ -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 neutralizes the page's own interactivity: clicks,
10
- * double/middle clicks, form submits, and context menus are captured
11
- * at the document root and suppressed, so selecting a node never
12
- * navigates a link, opens a drawer, or submits a form — a click is
13
- * only ever a selection. (Hover stays live for highlight tracking.)
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 instanceof Element))
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
- // Editor mode owns clicks: no navigation, no dialogs.
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
- event.stopPropagation();
250
- if (!node)
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
- const path = node.getAttribute(DATA_PATH_ATTR);
253
- setSelection(path);
254
- post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
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) {
@@ -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.2.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",
@@ -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 neutralizes the page's own interactivity: clicks,
10
- * double/middle clicks, form submits, and context menus are captured
11
- * at the document root and suppressed, so selecting a node never
12
- * navigates a link, opens a drawer, or submits a form — a click is
13
- * only ever a selection. (Hover stays live for highlight tracking.)
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 instanceof Element)) return null;
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
- // Editor mode owns clicks: no navigation, no dialogs.
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
- event.stopPropagation();
322
- if (!node) return;
323
- const path = node.getAttribute(DATA_PATH_ATTR)!;
324
- setSelection(path);
325
- post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
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) {
@@ -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