@akinon/pz-theme 2.0.49-rc.0 → 2.0.50-beta.0
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/CHANGELOG.md +20 -31
- package/package.json +4 -4
- package/src/__tests__/browse-mode.test.tsx +313 -0
- package/src/browse-mode.ts +87 -0
- package/src/chrome/chrome-fallback.tsx +16 -5
- package/src/chrome/code-owned-region.tsx +6 -1
- package/src/chrome/composable-region-client.tsx +5 -0
- package/src/index.ts +4 -0
- package/src/theme-placeholder-client.tsx +8 -3
- package/src/theme-placeholder-wrapper.tsx +26 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,25 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
- Updated dependencies [95b139dc1]
|
|
24
|
-
- Updated dependencies [1d00f2d0]
|
|
25
|
-
- Updated dependencies [4ac7b2a1]
|
|
26
|
-
- Updated dependencies [e9598c7]
|
|
27
|
-
- Updated dependencies [4998a963]
|
|
28
|
-
- Updated dependencies [804d2bd6]
|
|
29
|
-
- Updated dependencies [3909d3224]
|
|
30
|
-
- Updated dependencies [6a3d8a63]
|
|
31
|
-
- Updated dependencies [e18836b20]
|
|
32
|
-
- @akinon/next@2.0.49-rc.0
|
|
33
|
-
- @akinon/theme-contract@2.0.49-rc.0
|
|
3
|
+
## 2.0.50-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd75]
|
|
8
|
+
- @akinon/next@2.0.50-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.50-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.49
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 8b5f0b6: ZERO-4744: Let the theme editor preview behave like the live site on demand
|
|
16
|
+
|
|
17
|
+
The preview now honours `SET_PREVIEW_INTERACTION_MODE`, so the editor's "Use preview" toggle turns the canvas back into a storefront: links navigate, sliders and menus work, overlays trap focus, project-owned chrome stops swallowing the pointer, and publish windows and visibility rules apply as they do for a visitor. Editing affordances (selection chrome, click-to-select, pinned part states) come back when the designer returns.
|
|
18
|
+
|
|
19
|
+
The editor stays attached throughout — the selected breakpoint, sampled data sources and page-context discovery are unaffected — and the checkout payment internals remain sealed in both modes.
|
|
20
|
+
|
|
21
|
+
- @akinon/next@2.0.49
|
|
22
|
+
- @akinon/theme-contract@2.0.49
|
|
34
23
|
|
|
35
24
|
## 2.0.48
|
|
36
25
|
|
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.50-beta.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.
|
|
11
|
+
"@akinon/next": "2.0.50-beta.0",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.
|
|
16
|
+
"@akinon/theme-contract": "2.0.50-beta.0",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"tailwind-merge": "^2.5.4",
|
|
20
20
|
"zod": "4.4.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@akinon/next": "2.0.
|
|
23
|
+
"@akinon/next": "2.0.50-beta.0",
|
|
24
24
|
"@testing-library/jest-dom": "5.16.4",
|
|
25
25
|
"@testing-library/react": "16.3.2",
|
|
26
26
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent, act } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import { __resetBrowseModeForTests, useBrowseMode } from '../browse-mode';
|
|
5
|
+
import { __resetEditorBridgeForTests } from '../editor-bridge';
|
|
6
|
+
import { ChromeFallback } from '../chrome/chrome-fallback';
|
|
7
|
+
import { CodeOwnedRegion } from '../chrome/code-owned-region';
|
|
8
|
+
import { ComposableRegionClient } from '../chrome/composable-region-client';
|
|
9
|
+
import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Browse mode is the designer asking the canvas to stop being a canvas.
|
|
13
|
+
*
|
|
14
|
+
* Everything the editor needs — the forced breakpoint, the selection it is
|
|
15
|
+
* holding, the placeholder registration — has to survive that request, and
|
|
16
|
+
* everything the SHOPPER would not see has to go. The two halves are easy to
|
|
17
|
+
* conflate (they were one flag), so both directions are pinned here, plus the
|
|
18
|
+
* one thing browse mode must never do: unseal the payment internals.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const EDITOR_ORIGIN = 'http://localhost:5174';
|
|
22
|
+
|
|
23
|
+
const frameDocument = () =>
|
|
24
|
+
Object.defineProperty(window, 'top', {
|
|
25
|
+
configurable: true,
|
|
26
|
+
value: { name: 'designer' }
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const setBrowseMode = (enabled: boolean, origin = EDITOR_ORIGIN) =>
|
|
30
|
+
act(() => {
|
|
31
|
+
fireEvent(
|
|
32
|
+
window,
|
|
33
|
+
new MessageEvent('message', {
|
|
34
|
+
origin,
|
|
35
|
+
data: {
|
|
36
|
+
type: 'SET_PREVIEW_INTERACTION_MODE',
|
|
37
|
+
data: { enabled }
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const changeBreakpoint = (breakpoint: string) =>
|
|
44
|
+
act(() => {
|
|
45
|
+
fireEvent(
|
|
46
|
+
window,
|
|
47
|
+
new MessageEvent('message', {
|
|
48
|
+
origin: EDITOR_ORIGIN,
|
|
49
|
+
data: { type: 'CHANGE_BREAKPOINT', data: { breakpoint } }
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const Probe = () => <span data-testid="probe">{String(useBrowseMode())}</span>;
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
__resetBrowseModeForTests();
|
|
58
|
+
__resetEditorBridgeForTests();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('the browse-mode flag', () => {
|
|
62
|
+
beforeAll(frameDocument);
|
|
63
|
+
|
|
64
|
+
it('follows the editor and ignores anyone else', () => {
|
|
65
|
+
render(<Probe />);
|
|
66
|
+
expect(screen.getByTestId('probe')).toHaveTextContent('false');
|
|
67
|
+
|
|
68
|
+
setBrowseMode(true);
|
|
69
|
+
expect(screen.getByTestId('probe')).toHaveTextContent('true');
|
|
70
|
+
|
|
71
|
+
// A page that framed the preview without proving itself must not be able
|
|
72
|
+
// to strip the designer's chrome — or, read the other way, to make a
|
|
73
|
+
// production embed think it is being edited.
|
|
74
|
+
setBrowseMode(false, 'https://evil.example');
|
|
75
|
+
expect(screen.getByTestId('probe')).toHaveTextContent('true');
|
|
76
|
+
|
|
77
|
+
setBrowseMode(false);
|
|
78
|
+
expect(screen.getByTestId('probe')).toHaveTextContent('false');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('survives a remount, because the editor only says it once', () => {
|
|
82
|
+
const first = render(<Probe />);
|
|
83
|
+
setBrowseMode(true);
|
|
84
|
+
first.unmount();
|
|
85
|
+
|
|
86
|
+
// An in-iframe navigation remounts every placeholder; the mode frame is not
|
|
87
|
+
// repeated for each one, so a fresh mount has to read the standing answer.
|
|
88
|
+
render(<Probe />);
|
|
89
|
+
expect(screen.getByTestId('probe')).toHaveTextContent('true');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('a composed placeholder in browse mode', () => {
|
|
94
|
+
const section = {
|
|
95
|
+
id: 's1',
|
|
96
|
+
type: 'generic-section',
|
|
97
|
+
name: 'promo',
|
|
98
|
+
label: 'Promo Section',
|
|
99
|
+
order: 0,
|
|
100
|
+
hidden: false,
|
|
101
|
+
properties: {},
|
|
102
|
+
// Deliberately different per breakpoint: this is what proves the canvas
|
|
103
|
+
// still renders at the breakpoint the editor picked.
|
|
104
|
+
styles: { 'background-color': { desktop: 'red', mobile: 'blue' } },
|
|
105
|
+
blocks: [
|
|
106
|
+
{
|
|
107
|
+
id: 'b1',
|
|
108
|
+
type: 'group',
|
|
109
|
+
label: 'Promo Card',
|
|
110
|
+
order: 0,
|
|
111
|
+
hidden: false,
|
|
112
|
+
properties: {},
|
|
113
|
+
styles: {},
|
|
114
|
+
blocks: []
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
} as any;
|
|
118
|
+
|
|
119
|
+
const renderCanvas = () => {
|
|
120
|
+
const postMessage = jest.fn();
|
|
121
|
+
Object.defineProperty(window, 'parent', {
|
|
122
|
+
configurable: true,
|
|
123
|
+
value: { postMessage }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const view = render(
|
|
127
|
+
<ThemePlaceholderWrapper
|
|
128
|
+
slug="home-body"
|
|
129
|
+
initialSections={[section]}
|
|
130
|
+
initialPlaceholderId="home-body"
|
|
131
|
+
isDesignMode
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return { ...view, postMessage };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
beforeAll(frameDocument);
|
|
139
|
+
|
|
140
|
+
it('drops the editing chrome and stops claiming clicks', () => {
|
|
141
|
+
const { container, postMessage } = renderCanvas();
|
|
142
|
+
|
|
143
|
+
const blockHost = () =>
|
|
144
|
+
container.querySelector('[data-block-id="b1"]') as HTMLElement;
|
|
145
|
+
const sectionHost = () =>
|
|
146
|
+
container.querySelector('[data-section-id="s1"]') as HTMLElement;
|
|
147
|
+
|
|
148
|
+
// A frame from the editor is what proves its origin to the bridge; without
|
|
149
|
+
// one there is no trusted target and every message is dropped by design.
|
|
150
|
+
changeBreakpoint('desktop');
|
|
151
|
+
|
|
152
|
+
// Editing: both hosts advertise themselves as selectable and a click is
|
|
153
|
+
// the editor's selection, not the page's.
|
|
154
|
+
expect(blockHost().className).toContain('cursor-pointer');
|
|
155
|
+
expect(sectionHost().className).toContain('cursor-pointer');
|
|
156
|
+
|
|
157
|
+
fireEvent.click(blockHost());
|
|
158
|
+
expect(
|
|
159
|
+
postMessage.mock.calls.some(([envelope]) => envelope?.type === 'SELECT_BLOCK')
|
|
160
|
+
).toBe(true);
|
|
161
|
+
|
|
162
|
+
postMessage.mockClear();
|
|
163
|
+
setBrowseMode(true);
|
|
164
|
+
|
|
165
|
+
expect(blockHost().className).not.toContain('cursor-pointer');
|
|
166
|
+
expect(sectionHost().className).not.toContain('cursor-pointer');
|
|
167
|
+
expect(container.querySelector('[data-part-state-preview]')).toBeNull();
|
|
168
|
+
|
|
169
|
+
// The click belongs to the page now — no selection message at all.
|
|
170
|
+
fireEvent.click(blockHost());
|
|
171
|
+
expect(
|
|
172
|
+
postMessage.mock.calls.some(([envelope]) => envelope?.type === 'SELECT_BLOCK')
|
|
173
|
+
).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('keeps rendering at the breakpoint the editor selected', () => {
|
|
177
|
+
// The trap this pins: binding the forced breakpoint to the editing flag
|
|
178
|
+
// instead of "is the editor attached" repaints the desktop layout inside a
|
|
179
|
+
// mobile-width frame, so every toggle jumps.
|
|
180
|
+
const { container } = renderCanvas();
|
|
181
|
+
changeBreakpoint('mobile');
|
|
182
|
+
|
|
183
|
+
const css = () =>
|
|
184
|
+
Array.from(container.querySelectorAll('style'))
|
|
185
|
+
.map((tag) => tag.textContent || '')
|
|
186
|
+
.join('');
|
|
187
|
+
|
|
188
|
+
const mobileCss = css();
|
|
189
|
+
expect(mobileCss).toContain('blue');
|
|
190
|
+
expect(mobileCss).not.toContain('red');
|
|
191
|
+
|
|
192
|
+
setBrowseMode(true);
|
|
193
|
+
expect(css()).toBe(mobileCss);
|
|
194
|
+
|
|
195
|
+
setBrowseMode(false);
|
|
196
|
+
expect(css()).toBe(mobileCss);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('gives the chrome back when the designer returns', () => {
|
|
200
|
+
const { container } = renderCanvas();
|
|
201
|
+
|
|
202
|
+
setBrowseMode(true);
|
|
203
|
+
setBrowseMode(false);
|
|
204
|
+
|
|
205
|
+
expect(
|
|
206
|
+
(container.querySelector('[data-block-id="b1"]') as HTMLElement).className
|
|
207
|
+
).toContain('cursor-pointer');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('project-owned chrome in browse mode', () => {
|
|
212
|
+
beforeAll(frameDocument);
|
|
213
|
+
|
|
214
|
+
it('hands the brand markup back its pointer', () => {
|
|
215
|
+
// This wrapper is why an uncomposed header cannot be clicked in the editor
|
|
216
|
+
// at all: its markup is wrapped in a pointer-swallowing div under a
|
|
217
|
+
// takeover scrim. Browse mode is the whole answer to that.
|
|
218
|
+
const { container } = render(
|
|
219
|
+
<ChromeFallback slug="header">
|
|
220
|
+
<a href="/women" data-testid="menu-link">
|
|
221
|
+
Women
|
|
222
|
+
</a>
|
|
223
|
+
</ChromeFallback>
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const swallowsPointer = () =>
|
|
227
|
+
Array.from(container.querySelectorAll<HTMLElement>('div')).some(
|
|
228
|
+
(node) => node.style.pointerEvents === 'none'
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(swallowsPointer()).toBe(true);
|
|
232
|
+
expect(container.querySelector('[data-chrome-managed-by]')).not.toBeNull();
|
|
233
|
+
|
|
234
|
+
setBrowseMode(true);
|
|
235
|
+
|
|
236
|
+
expect(swallowsPointer()).toBe(false);
|
|
237
|
+
expect(container.querySelector('[data-chrome-managed-by]')).toBeNull();
|
|
238
|
+
expect(screen.getByTestId('menu-link')).toBeInTheDocument();
|
|
239
|
+
|
|
240
|
+
// And no takeover affordance on hover — a visitor is never offered one.
|
|
241
|
+
fireEvent.mouseEnter(screen.getByTestId('menu-link').parentElement!);
|
|
242
|
+
expect(
|
|
243
|
+
screen.queryByRole('button', { name: /edit in theme editor/i })
|
|
244
|
+
).toBeNull();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('renders nothing for an empty zone, exactly like the live site', () => {
|
|
248
|
+
const { container } = render(
|
|
249
|
+
<ChromeFallback slug="checkout-bottom-body" emptyZone />
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(container.querySelector('[data-chrome-empty-zone]')).not.toBeNull();
|
|
253
|
+
|
|
254
|
+
setBrowseMode(true);
|
|
255
|
+
expect(container.querySelector('[data-chrome-empty-zone]')).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('stops explaining a code-owned region while browsing', () => {
|
|
259
|
+
const { container } = render(
|
|
260
|
+
<CodeOwnedRegion label="checkout flow">
|
|
261
|
+
<div data-testid="flow">payment steps</div>
|
|
262
|
+
</CodeOwnedRegion>
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const region = container.querySelector(
|
|
266
|
+
'[data-code-owned="checkout flow"]'
|
|
267
|
+
) as HTMLElement;
|
|
268
|
+
|
|
269
|
+
fireEvent.mouseEnter(region);
|
|
270
|
+
expect(screen.getByText(/code-owned/i)).toBeInTheDocument();
|
|
271
|
+
|
|
272
|
+
setBrowseMode(true);
|
|
273
|
+
expect(screen.queryByText(/code-owned/i)).toBeNull();
|
|
274
|
+
// The wrapper itself never leaves — the flow below it must not remount.
|
|
275
|
+
expect(screen.getByTestId('flow')).toBeInTheDocument();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('the checkout body in browse mode', () => {
|
|
280
|
+
beforeAll(() => {
|
|
281
|
+
frameDocument();
|
|
282
|
+
window.__themeEditorDesigner = true;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
afterAll(() => {
|
|
286
|
+
delete window.__themeEditorDesigner;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('stays sealed — browse mode is not a licence to place an order', () => {
|
|
290
|
+
// Browse mode restores navigation and UI interaction. It deliberately does
|
|
291
|
+
// NOT reach into the payment internals: a designer walking the preview must
|
|
292
|
+
// not be able to submit a real order from inside the editor.
|
|
293
|
+
const { container } = render(
|
|
294
|
+
<ComposableRegionClient slug="checkout-page-body">
|
|
295
|
+
<button type="button" data-testid="place-order">
|
|
296
|
+
Place order
|
|
297
|
+
</button>
|
|
298
|
+
</ComposableRegionClient>
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const region = () =>
|
|
302
|
+
container.querySelector(
|
|
303
|
+
'[data-chrome-region="checkout-page-body"]'
|
|
304
|
+
) as HTMLElement;
|
|
305
|
+
|
|
306
|
+
expect(region().style.pointerEvents).toBe('none');
|
|
307
|
+
|
|
308
|
+
setBrowseMode(true);
|
|
309
|
+
|
|
310
|
+
expect(region().style.pointerEvents).toBe('none');
|
|
311
|
+
expect(screen.getByTestId('place-order')).toBeInTheDocument();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
import { isTrustedEditorEvent } from './editor-bridge';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Browse mode — the designer asking the canvas to behave like the live site.
|
|
9
|
+
*
|
|
10
|
+
* Editing needs the opposite of a working storefront: a click has to mean
|
|
11
|
+
* "select this block", so links resolve to `#`, sliders lose their arrows,
|
|
12
|
+
* menus never open and project-owned chrome swallows the pointer. That leaves
|
|
13
|
+
* no way to walk the page as a shopper — open a menu, follow a link, step
|
|
14
|
+
* through a flow — without leaving the editor, which is what the editor's
|
|
15
|
+
* "Use preview" toggle asks for over `SET_PREVIEW_INTERACTION_MODE`.
|
|
16
|
+
*
|
|
17
|
+
* Consumers treat this as the second half of a pair: `isDesigner` answers "is
|
|
18
|
+
* the editor attached" (it drives the forced breakpoint, data sampling and
|
|
19
|
+
* page-context discovery, all of which must survive the toggle), while
|
|
20
|
+
* `isDesigner && !browseMode` answers "are the editing affordances on" and is
|
|
21
|
+
* what sections and blocks receive.
|
|
22
|
+
*
|
|
23
|
+
* The flag lives on `window` rather than in a module variable for the same
|
|
24
|
+
* reason `__themeEditorDesigner` does: the editor sends its state once per
|
|
25
|
+
* document, so a component mounting later — an in-iframe route change
|
|
26
|
+
* remounts every placeholder — has to read the current answer instead of
|
|
27
|
+
* resetting it to the default.
|
|
28
|
+
*/
|
|
29
|
+
declare global {
|
|
30
|
+
interface Window {
|
|
31
|
+
__themeEditorBrowseMode?: boolean;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const listeners = new Set<() => void>();
|
|
36
|
+
let listening = false;
|
|
37
|
+
|
|
38
|
+
const readFlag = (): boolean =>
|
|
39
|
+
typeof window !== 'undefined' && window.__themeEditorBrowseMode === true;
|
|
40
|
+
|
|
41
|
+
/** Server render is never the designer's canvas, so it never browses. */
|
|
42
|
+
const readServerFlag = (): boolean => false;
|
|
43
|
+
|
|
44
|
+
const handleMessage = (event: MessageEvent) => {
|
|
45
|
+
if (!isTrustedEditorEvent(event)) return;
|
|
46
|
+
if (event.data?.type !== 'SET_PREVIEW_INTERACTION_MODE') return;
|
|
47
|
+
|
|
48
|
+
const next = Boolean(event.data?.data?.enabled);
|
|
49
|
+
if (next === readFlag()) return;
|
|
50
|
+
|
|
51
|
+
window.__themeEditorBrowseMode = next;
|
|
52
|
+
listeners.forEach((listener) => listener());
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The window listener is attached on first use and then kept for the life of
|
|
57
|
+
* the document — deliberately never removed. Placeholders unmount in bursts
|
|
58
|
+
* during an in-iframe navigation, and a mode frame arriving in that gap would
|
|
59
|
+
* otherwise leave the flag stale for the page that mounts next.
|
|
60
|
+
*/
|
|
61
|
+
const subscribe = (listener: () => void): (() => void) => {
|
|
62
|
+
listeners.add(listener);
|
|
63
|
+
|
|
64
|
+
if (!listening && typeof window !== 'undefined') {
|
|
65
|
+
window.addEventListener('message', handleMessage);
|
|
66
|
+
listening = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
listeners.delete(listener);
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Imperative read, for code that is not a component. */
|
|
75
|
+
export const isBrowseMode = readFlag;
|
|
76
|
+
|
|
77
|
+
export function useBrowseMode(): boolean {
|
|
78
|
+
return useSyncExternalStore(subscribe, readFlag, readServerFlag);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Test-only: forget the flag and every subscriber. */
|
|
82
|
+
export function __resetBrowseModeForTests(): void {
|
|
83
|
+
if (typeof window !== 'undefined') {
|
|
84
|
+
delete window.__themeEditorBrowseMode;
|
|
85
|
+
}
|
|
86
|
+
listeners.clear();
|
|
87
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useEffect, useState } from 'react';
|
|
4
4
|
|
|
5
|
+
import { useBrowseMode } from '../browse-mode';
|
|
5
6
|
import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
|
|
6
7
|
import { isSlugComposedInFrame } from './composition-state';
|
|
7
8
|
|
|
@@ -48,6 +49,7 @@ export function ChromeFallback({
|
|
|
48
49
|
const [inDesigner, setInDesigner] = useState(false);
|
|
49
50
|
const [isHovered, setIsHovered] = useState(false);
|
|
50
51
|
const [isOverridden, setIsOverridden] = useState(initiallyOverridden);
|
|
52
|
+
const isBrowseMode = useBrowseMode();
|
|
51
53
|
|
|
52
54
|
useEffect(() => {
|
|
53
55
|
if (typeof window !== 'undefined' && window.self !== window.top) {
|
|
@@ -80,11 +82,20 @@ export function ChromeFallback({
|
|
|
80
82
|
return () => window.removeEventListener('message', handleMessage);
|
|
81
83
|
}, [slug]);
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// Browse mode takes the same path as the live site, which matters most
|
|
86
|
+
// here: this wrapper is what makes an uncomposed header or footer inert in
|
|
87
|
+
// the designer, so its menus and links are exactly what a designer cannot
|
|
88
|
+
// reach while editing. No pointer swallowing, no takeover affordance, no
|
|
89
|
+
// dashed band — only what a visitor would get.
|
|
90
|
+
//
|
|
91
|
+
// `isOverridden` rather than the prop: a takeover that happened in this
|
|
92
|
+
// session has already pushed a composition, and the brand markup must stay
|
|
93
|
+
// out of its way. On the live site no theme frames arrive, so the two agree.
|
|
94
|
+
if (!inDesigner || isBrowseMode) {
|
|
95
|
+
// Composed: the composition owns the area, the brand markup must never
|
|
96
|
+
// double-render under it. An empty zone has nothing to show a visitor
|
|
97
|
+
// either way.
|
|
98
|
+
if (isOverridden || emptyZone) return null;
|
|
88
99
|
|
|
89
100
|
return (
|
|
90
101
|
<div data-chrome-fallback={slug} className={wrapperClassName}>
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useEffect, useState } from 'react';
|
|
4
4
|
|
|
5
|
+
import { useBrowseMode } from '../browse-mode';
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Wraps markup that is deliberately not editable at all — no takeover, no
|
|
7
9
|
* placement, nothing but an explanation. Without this wrapper, hovering the
|
|
@@ -36,6 +38,9 @@ export function CodeOwnedRegion({
|
|
|
36
38
|
}) {
|
|
37
39
|
const [inDesigner, setInDesigner] = useState(false);
|
|
38
40
|
const [isHovered, setIsHovered] = useState(false);
|
|
41
|
+
// Browse mode wants the live site, and the note is editor chrome — the
|
|
42
|
+
// wrapper itself stays either way, so nothing remounts on the toggle.
|
|
43
|
+
const isBrowseMode = useBrowseMode();
|
|
39
44
|
|
|
40
45
|
useEffect(() => {
|
|
41
46
|
if (typeof window !== 'undefined' && window.self !== window.top) {
|
|
@@ -56,7 +61,7 @@ export function CodeOwnedRegion({
|
|
|
56
61
|
>
|
|
57
62
|
{children}
|
|
58
63
|
|
|
59
|
-
{inDesigner && isHovered && (
|
|
64
|
+
{inDesigner && isHovered && !isBrowseMode && (
|
|
60
65
|
<div
|
|
61
66
|
style={{
|
|
62
67
|
position: 'absolute',
|
|
@@ -113,6 +113,11 @@ export function ComposableRegionClient({
|
|
|
113
113
|
// target, which only exists once a composition places one. Without
|
|
114
114
|
// this, a composed-but-unplaced flow would leave a working "place
|
|
115
115
|
// order" button on the canvas. The live site emits no value at all.
|
|
116
|
+
//
|
|
117
|
+
// Browse mode does NOT lift this, on purpose. It restores navigation
|
|
118
|
+
// and UI interaction across the canvas, but the payment internals stay
|
|
119
|
+
// sealed — a designer walking the preview must not be able to place a
|
|
120
|
+
// real order from inside the editor. Pinned by test.
|
|
116
121
|
pointerEvents: inDesigner ? 'none' : undefined
|
|
117
122
|
}}
|
|
118
123
|
>
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,10 @@ export * from './utils';
|
|
|
15
15
|
export * from './hooks/use-designer-features';
|
|
16
16
|
export * from './hooks/use-visibility-context';
|
|
17
17
|
|
|
18
|
+
// Brand code that renders its own designer-aware markup needs the same answer
|
|
19
|
+
// the package uses internally: is the editor attached, and is it editing.
|
|
20
|
+
export { isBrowseMode, useBrowseMode } from './browse-mode';
|
|
21
|
+
|
|
18
22
|
export { WithDesignerFeatures } from './components/with-designer-features';
|
|
19
23
|
|
|
20
24
|
export type { Section } from './theme-section';
|
|
@@ -13,6 +13,7 @@ import { isPublishWindowVisible } from './utils/publish-window';
|
|
|
13
13
|
import { useThemePageContext } from './theme-page-context';
|
|
14
14
|
|
|
15
15
|
import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
|
|
16
|
+
import { useBrowseMode } from './browse-mode';
|
|
16
17
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
17
18
|
// Global flags that must survive component remounts (e.g. in-iframe route
|
|
18
19
|
// changes): IFRAME_READY is sent once per document, and the designer flag
|
|
@@ -47,12 +48,16 @@ export default function ThemePlaceholderClient({
|
|
|
47
48
|
const [isDesigner, setIsDesigner] = useState(
|
|
48
49
|
() => typeof window !== 'undefined' && window.__themeEditorDesigner === true
|
|
49
50
|
);
|
|
51
|
+
// Browse mode keeps the editor attached but hands the canvas back to the
|
|
52
|
+
// storefront; see browse-mode.ts for why the two flags are separate.
|
|
53
|
+
const isBrowseMode = useBrowseMode();
|
|
54
|
+
const isEditing = isDesigner && !isBrowseMode;
|
|
50
55
|
// Null unless a ThemePageContextProvider sits above this placeholder; data
|
|
51
56
|
// conditions then have nothing to read and resolve the same way on both sides.
|
|
52
57
|
const pageContext = useThemePageContext();
|
|
53
58
|
const visibilityContext = useVisibilityContext(currentBreakpoint, {
|
|
54
59
|
pageContext,
|
|
55
|
-
isDesigner
|
|
60
|
+
isDesigner: isEditing
|
|
56
61
|
});
|
|
57
62
|
const renderedSections = applyVisibilityRulesToSections(
|
|
58
63
|
sections,
|
|
@@ -174,7 +179,7 @@ export default function ThemePlaceholderClient({
|
|
|
174
179
|
.filter(
|
|
175
180
|
(section) =>
|
|
176
181
|
!section.hidden &&
|
|
177
|
-
(
|
|
182
|
+
(isEditing ||
|
|
178
183
|
isPublishWindowVisible(section.properties?.['publish-window']))
|
|
179
184
|
)
|
|
180
185
|
.map((section) => (
|
|
@@ -186,7 +191,7 @@ export default function ThemePlaceholderClient({
|
|
|
186
191
|
// all: the context is read for visibility rules and then dropped
|
|
187
192
|
// before the section that needs it.
|
|
188
193
|
pageContext={pageContext}
|
|
189
|
-
isDesigner={
|
|
194
|
+
isDesigner={isEditing}
|
|
190
195
|
isSelected={selectedSectionId === section.id}
|
|
191
196
|
selectedBlockId={selectedBlockId}
|
|
192
197
|
currentBreakpoint={currentBreakpoint}
|
|
@@ -21,6 +21,7 @@ import { isPublishWindowVisible } from './utils/publish-window';
|
|
|
21
21
|
import { buildClientRequestUrl } from '@akinon/next/utils';
|
|
22
22
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
23
23
|
import { parseEditorMessage, postToEditor } from './editor-bridge';
|
|
24
|
+
import { useBrowseMode } from './browse-mode';
|
|
24
25
|
|
|
25
26
|
// Helper function to inject theme colors as CSS custom properties
|
|
26
27
|
const injectThemeColors = (settings: Record<string, unknown>) => {
|
|
@@ -310,6 +311,18 @@ export default function ThemePlaceholderWrapper({
|
|
|
310
311
|
isDesignMode ||
|
|
311
312
|
(typeof window !== 'undefined' && window.__themeEditorDesigner === true)
|
|
312
313
|
);
|
|
314
|
+
// Two different questions, and they used to share one answer:
|
|
315
|
+
//
|
|
316
|
+
// `isDesigner` — is the editor attached. It drives the plumbing that has to
|
|
317
|
+
// keep working while the designer browses: the forced breakpoint, sampled
|
|
318
|
+
// data sources, page-context discovery.
|
|
319
|
+
//
|
|
320
|
+
// `isEditing` — are the editing affordances on. This is what sections and
|
|
321
|
+
// blocks receive, so browse mode turns the canvas back into a storefront
|
|
322
|
+
// (links navigate, sliders slide, overlays trap focus, publish windows and
|
|
323
|
+
// visibility rules apply) without disturbing anything above.
|
|
324
|
+
const isBrowseMode = useBrowseMode();
|
|
325
|
+
const isEditing = isDesigner && !isBrowseMode;
|
|
313
326
|
const [dataSources, setDataSources] = useState<any[]>(initialDataSources);
|
|
314
327
|
const [themeSettings, setThemeSettings] = useState<Record<
|
|
315
328
|
string,
|
|
@@ -719,6 +732,10 @@ export default function ThemePlaceholderWrapper({
|
|
|
719
732
|
postToEditor({ type: action, data });
|
|
720
733
|
};
|
|
721
734
|
|
|
735
|
+
// Bound to `isDesigner`, NOT to `isEditing`: the canvas renders at the
|
|
736
|
+
// breakpoint the editor selected, and dropping back to real media queries
|
|
737
|
+
// when the designer starts browsing would repaint a desktop layout inside a
|
|
738
|
+
// mobile-width frame — a layout jump on every toggle.
|
|
722
739
|
const dynamicCSS = useMemo(
|
|
723
740
|
() =>
|
|
724
741
|
generateThemeCSS(sections, isDesigner ? currentBreakpoint : undefined),
|
|
@@ -726,7 +743,7 @@ export default function ThemePlaceholderWrapper({
|
|
|
726
743
|
);
|
|
727
744
|
const visibilityContext = useVisibilityContext(currentBreakpoint, {
|
|
728
745
|
pageContext: resolvedPageContext,
|
|
729
|
-
isDesigner
|
|
746
|
+
isDesigner: isEditing
|
|
730
747
|
});
|
|
731
748
|
const renderedSections = useMemo(
|
|
732
749
|
() => applyVisibilityRulesToSections(sections, visibilityContext),
|
|
@@ -739,7 +756,12 @@ export default function ThemePlaceholderWrapper({
|
|
|
739
756
|
// falls back to its empty state.
|
|
740
757
|
<ThemePageContextProvider value={resolvedPageContext}>
|
|
741
758
|
<ThemeSettingsProvider value={themeSettings}>
|
|
742
|
-
|
|
759
|
+
{/*
|
|
760
|
+
Pins are released while browsing — a button frozen in its hover state
|
|
761
|
+
is not what the live site looks like. The editor re-sends the pinned
|
|
762
|
+
state when the designer comes back, so the panel and the canvas agree.
|
|
763
|
+
*/}
|
|
764
|
+
<PartStatePreview isDesigner={isEditing} />
|
|
743
765
|
{dynamicCSS && <style dangerouslySetInnerHTML={{ __html: dynamicCSS }} />}
|
|
744
766
|
<div
|
|
745
767
|
data-placeholder={slug}
|
|
@@ -751,7 +773,7 @@ export default function ThemePlaceholderWrapper({
|
|
|
751
773
|
.filter(
|
|
752
774
|
(section) =>
|
|
753
775
|
!section.hidden &&
|
|
754
|
-
(
|
|
776
|
+
(isEditing ||
|
|
755
777
|
isPublishWindowVisible(section.properties?.['publish-window']))
|
|
756
778
|
)
|
|
757
779
|
.map((section) => (
|
|
@@ -760,7 +782,7 @@ export default function ThemePlaceholderWrapper({
|
|
|
760
782
|
section={section}
|
|
761
783
|
placeholderId={placeholderSlug}
|
|
762
784
|
pageContext={resolvedPageContext}
|
|
763
|
-
isDesigner={
|
|
785
|
+
isDesigner={isEditing}
|
|
764
786
|
isSelected={selectedSectionId === section.id}
|
|
765
787
|
selectedBlockId={selectedBlockId}
|
|
766
788
|
currentBreakpoint={currentBreakpoint}
|