@akinon/pz-theme 2.0.71-beta.0 → 2.0.71
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 +7 -4
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +276 -0
- package/src/blocks/group-block.tsx +9 -57
- package/src/blocks/render-composed-children.tsx +104 -0
- package/src/custom/README.md +249 -0
- package/src/custom/__tests__/define.test.tsx +619 -0
- package/src/custom/__tests__/module-boundaries.test.ts +156 -0
- package/src/custom/__tests__/public-api.test.tsx +427 -0
- package/src/custom/__tests__/slot-node-wire.test.tsx +309 -0
- package/src/custom/__tests__/slot-nodes.test.ts +144 -0
- package/src/custom/__tests__/theme-editable.test.tsx +486 -0
- package/src/custom/__tests__/theme-slot.test.tsx +387 -0
- package/src/custom/__tests__/use-theme-setting.test.tsx +260 -0
- package/src/custom/block-context.ts +64 -0
- package/src/custom/block-host.tsx +162 -0
- package/src/custom/catalog.ts +97 -0
- package/src/custom/collect.ts +342 -0
- package/src/custom/define.tsx +172 -0
- package/src/custom/editable-tags.ts +120 -0
- package/src/custom/index.ts +105 -0
- package/src/custom/parts.ts +108 -0
- package/src/custom/property-types.ts +70 -0
- package/src/custom/register.ts +545 -0
- package/src/custom/slot-nodes.ts +119 -0
- package/src/custom/theme-editable.tsx +342 -0
- package/src/custom/theme-slot.tsx +210 -0
- package/src/custom/types.ts +251 -0
- package/src/custom/use-theme-setting.ts +225 -0
- package/src/index.ts +66 -0
- package/src/placeholder-registry.ts +168 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.71
|
|
3
|
+
## 2.0.71
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
- 4f3f143: ZERO-4852: Add the custom block authoring API (defineCustomBlock, registerCustomBlocks, ThemeEditable, ThemeSlot, useThemeSetting)
|
|
8
|
+
- c84da01: ZERO-4852: Refuse a custom block's slot accepts/max at declare time, and keep runtime part collection under the catalog cap
|
|
9
|
+
- 3e1306b: ZERO-4852: Announce the custom block catalog to the theme editor on the placeholder frame
|
|
10
|
+
- c84da01: ZERO-4852: Let a custom block open several named slots and declare its own styling parts with states
|
|
11
|
+
- @akinon/next@2.0.71
|
|
12
|
+
- @akinon/theme-contract@2.0.71
|
|
10
13
|
|
|
11
14
|
## 2.0.70
|
|
12
15
|
|
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.71
|
|
4
|
+
"version": "2.0.71",
|
|
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.71
|
|
11
|
+
"@akinon/next": "2.0.71",
|
|
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.71
|
|
16
|
+
"@akinon/theme-contract": "2.0.71",
|
|
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.71
|
|
23
|
+
"@akinon/next": "2.0.71",
|
|
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,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the storefront announces to the editor, and what it refuses to.
|
|
3
|
+
*
|
|
4
|
+
* `getCustomCatalog()` had no caller: a brand could register a custom block
|
|
5
|
+
* and the editor could never learn the type existed. The announce is the only
|
|
6
|
+
* channel, and it is shared with the placeholder slugs — which is what makes
|
|
7
|
+
* the gating interesting. Three answers have to hold at once:
|
|
8
|
+
*
|
|
9
|
+
* - no placeholders, no frame (the editor's subscriber reloads the theme
|
|
10
|
+
* against whatever slugs a frame carries, so an empty one is destructive)
|
|
11
|
+
* - no proven designer, no catalog (a storefront is framed in production by
|
|
12
|
+
* payment providers, and the catalog is brand IP)
|
|
13
|
+
* - every frame carries the catalog whole, so the receiver can replace
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const EDITOR_ORIGIN = 'http://editor.example';
|
|
17
|
+
|
|
18
|
+
type Registry = typeof import('../placeholder-registry');
|
|
19
|
+
type Register = typeof import('../custom/register');
|
|
20
|
+
|
|
21
|
+
interface Loaded {
|
|
22
|
+
registry: Registry;
|
|
23
|
+
register: Register;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let loadedModules: Loaded[] = [];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A fresh copy of both modules, sharing one isolated graph.
|
|
30
|
+
*
|
|
31
|
+
* The registry, the catalog and the renderer registry are all module state,
|
|
32
|
+
* and `registerCustomBlocks` refuses to register a type twice — so a second
|
|
33
|
+
* test reusing the first one's modules would throw rather than start clean.
|
|
34
|
+
* Every copy is remembered so teardown can undo its window listener.
|
|
35
|
+
*/
|
|
36
|
+
const load = (): Loaded => {
|
|
37
|
+
let loaded: Loaded;
|
|
38
|
+
jest.isolateModules(() => {
|
|
39
|
+
loaded = {
|
|
40
|
+
registry: require('../placeholder-registry'),
|
|
41
|
+
register: require('../custom/register')
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
loadedModules.push(loaded!);
|
|
45
|
+
return loaded!;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const Promo = () => null;
|
|
49
|
+
|
|
50
|
+
describe('PLACEHOLDERS_AVAILABLE announce', () => {
|
|
51
|
+
const postMessage = jest.fn();
|
|
52
|
+
|
|
53
|
+
const frames = () =>
|
|
54
|
+
postMessage.mock.calls.map(([message]) => message.data ?? {});
|
|
55
|
+
|
|
56
|
+
beforeAll(() => {
|
|
57
|
+
// Framed: `postToEditor` drops everything when window.self === window.top.
|
|
58
|
+
Object.defineProperty(window, 'top', {
|
|
59
|
+
configurable: true,
|
|
60
|
+
value: { name: 'designer' }
|
|
61
|
+
});
|
|
62
|
+
Object.defineProperty(window, 'parent', {
|
|
63
|
+
configurable: true,
|
|
64
|
+
value: { postMessage }
|
|
65
|
+
});
|
|
66
|
+
Object.defineProperty(document, 'referrer', {
|
|
67
|
+
configurable: true,
|
|
68
|
+
value: `${EDITOR_ORIGIN}/builder`
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
jest.useFakeTimers();
|
|
74
|
+
postMessage.mockClear();
|
|
75
|
+
process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = EDITOR_ORIGIN;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
// Every copy attached its designer listener to the ONE jsdom window, and
|
|
80
|
+
// each copy still holds its own placeholder set — so without this the next
|
|
81
|
+
// test announces for all of its predecessors too.
|
|
82
|
+
loadedModules.forEach(({ registry }) =>
|
|
83
|
+
registry.__resetPlaceholderRegistryForTests()
|
|
84
|
+
);
|
|
85
|
+
loadedModules = [];
|
|
86
|
+
jest.useRealTimers();
|
|
87
|
+
delete process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
|
|
88
|
+
delete (window as { __themeEditorDesigner?: boolean })
|
|
89
|
+
.__themeEditorDesigner;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('posts nothing when a build registers blocks but mounts no placeholder', () => {
|
|
93
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
94
|
+
true;
|
|
95
|
+
const { register } = load();
|
|
96
|
+
|
|
97
|
+
register.registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
98
|
+
jest.advanceTimersByTime(500);
|
|
99
|
+
|
|
100
|
+
// The re-arm alone must never produce a frame: the editor's subscriber
|
|
101
|
+
// clears its cache and swaps the active placeholder set the moment one
|
|
102
|
+
// arrives, and this one would carry no slugs at all.
|
|
103
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('carries placeholders and the catalog in one frame', () => {
|
|
107
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
108
|
+
true;
|
|
109
|
+
const { registry, register } = load();
|
|
110
|
+
|
|
111
|
+
registry.registerPlaceholder('header');
|
|
112
|
+
// Re-arms the placeholder debounce rather than starting a second timer.
|
|
113
|
+
register.registerCustomBlocks('acme', {
|
|
114
|
+
'promo-strip': Promo
|
|
115
|
+
});
|
|
116
|
+
jest.advanceTimersByTime(100);
|
|
117
|
+
|
|
118
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
119
|
+
const [payload] = frames();
|
|
120
|
+
expect(payload.placeholders).toEqual(['header']);
|
|
121
|
+
expect(payload.customBlocks.vendor).toBe('acme');
|
|
122
|
+
expect(payload.customBlocks.blocks.map((block) => block.type)).toEqual([
|
|
123
|
+
'custom:acme:promo-strip'
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('omits the catalog key when the build registers no custom block', () => {
|
|
128
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
129
|
+
true;
|
|
130
|
+
const { registry } = load();
|
|
131
|
+
|
|
132
|
+
registry.registerPlaceholder('header');
|
|
133
|
+
jest.advanceTimersByTime(100);
|
|
134
|
+
|
|
135
|
+
const [payload] = frames();
|
|
136
|
+
expect(payload.placeholders).toEqual(['header']);
|
|
137
|
+
// Absent, not empty — a build with no custom blocks has to be
|
|
138
|
+
// indistinguishable from one whose catalog is not this frame's business.
|
|
139
|
+
expect('customBlocks' in payload).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('withholds the catalog from a frame that has not proven it is the editor', () => {
|
|
143
|
+
const { registry, register } = load();
|
|
144
|
+
|
|
145
|
+
register.registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
146
|
+
registry.registerPlaceholder('header');
|
|
147
|
+
jest.advanceTimersByTime(100);
|
|
148
|
+
|
|
149
|
+
// Being framed is not the designer flag. Payment providers frame live
|
|
150
|
+
// storefronts; the slugs are route names and still go out, but every block
|
|
151
|
+
// name, setting key and option list this build ships does not.
|
|
152
|
+
const [payload] = frames();
|
|
153
|
+
expect(payload.placeholders).toEqual(['header']);
|
|
154
|
+
expect('customBlocks' in payload).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('re-announces the catalog once the editor proves itself', () => {
|
|
158
|
+
const { registry, register } = load();
|
|
159
|
+
|
|
160
|
+
register.registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
161
|
+
registry.registerPlaceholder('header');
|
|
162
|
+
jest.advanceTimersByTime(100);
|
|
163
|
+
expect('customBlocks' in frames()[0]).toBe(false);
|
|
164
|
+
|
|
165
|
+
// The designer flag arrives in the editor's reply to IFRAME_READY — a
|
|
166
|
+
// postMessage round trip that is not guaranteed to land inside the 100ms
|
|
167
|
+
// debounce. Without the re-announce the catalog would be lost for the life
|
|
168
|
+
// of the document on exactly the loads where the handshake was slow.
|
|
169
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
170
|
+
true;
|
|
171
|
+
window.dispatchEvent(
|
|
172
|
+
new MessageEvent('message', {
|
|
173
|
+
origin: EDITOR_ORIGIN,
|
|
174
|
+
source: window.parent as Window,
|
|
175
|
+
data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
jest.advanceTimersByTime(100);
|
|
179
|
+
|
|
180
|
+
expect(postMessage).toHaveBeenCalledTimes(2);
|
|
181
|
+
// The vendor and the type the barrel actually produced, not merely one
|
|
182
|
+
// block: this second frame is the whole of what the editor ever learns
|
|
183
|
+
// about this build, and "the block is in the picker" is the only claim a
|
|
184
|
+
// human checks.
|
|
185
|
+
expect(frames()[1].customBlocks.vendor).toBe('acme');
|
|
186
|
+
expect(frames()[1].customBlocks.blocks.map((block) => block.type)).toEqual([
|
|
187
|
+
'custom:acme:promo-strip'
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('settles the announce/cookie handshake instead of ping-ponging', () => {
|
|
192
|
+
const { registry, register } = load();
|
|
193
|
+
|
|
194
|
+
register.registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
195
|
+
registry.registerPlaceholder('header');
|
|
196
|
+
jest.advanceTimersByTime(100);
|
|
197
|
+
|
|
198
|
+
// The editor answers EVERY announce with this cookie, not just the first
|
|
199
|
+
// (theme-iframe-service.ts sends it from the same handler that ingests the
|
|
200
|
+
// catalog). An unlatched re-arm would therefore loop for the life of the
|
|
201
|
+
// document, and every lap costs the editor a full theme reload —
|
|
202
|
+
// loadThemeForPlaceholders clears the widget cache and refetches every
|
|
203
|
+
// widget on each announce it receives.
|
|
204
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
205
|
+
true;
|
|
206
|
+
|
|
207
|
+
for (let lap = 0; lap < 5; lap += 1) {
|
|
208
|
+
window.dispatchEvent(
|
|
209
|
+
new MessageEvent('message', {
|
|
210
|
+
origin: EDITOR_ORIGIN,
|
|
211
|
+
source: window.parent as Window,
|
|
212
|
+
data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
jest.advanceTimersByTime(100);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// One frame to say which placeholders mounted, one to hand over the
|
|
219
|
+
// catalog the first could not carry, and then quiet.
|
|
220
|
+
expect(postMessage).toHaveBeenCalledTimes(2);
|
|
221
|
+
expect(frames()[1].customBlocks.blocks).toHaveLength(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('does not spend a second frame when the build has no custom block', () => {
|
|
225
|
+
const { registry } = load();
|
|
226
|
+
|
|
227
|
+
registry.registerPlaceholder('header');
|
|
228
|
+
jest.advanceTimersByTime(100);
|
|
229
|
+
|
|
230
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
231
|
+
true;
|
|
232
|
+
window.dispatchEvent(
|
|
233
|
+
new MessageEvent('message', {
|
|
234
|
+
origin: EDITOR_ORIGIN,
|
|
235
|
+
source: window.parent as Window,
|
|
236
|
+
data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
jest.advanceTimersByTime(100);
|
|
240
|
+
|
|
241
|
+
// Every brand shipping today registers no custom block. The re-announce
|
|
242
|
+
// exists to deliver a catalog; with none to deliver it must cost them
|
|
243
|
+
// nothing at all, or this feature would add a theme reload to every
|
|
244
|
+
// preview load in the fleet.
|
|
245
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('sends the whole catalog every time, so a re-announce replaces', () => {
|
|
249
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
250
|
+
true;
|
|
251
|
+
const { registry, register } = load();
|
|
252
|
+
|
|
253
|
+
register.registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
254
|
+
registry.registerPlaceholder('header');
|
|
255
|
+
jest.advanceTimersByTime(100);
|
|
256
|
+
|
|
257
|
+
// A second barrel, then a second announce — as an in-iframe route change
|
|
258
|
+
// produces when its placeholders remount.
|
|
259
|
+
register.registerCustomBlocks('acme', { 'promo-banner': Promo });
|
|
260
|
+
registry.registerPlaceholder('footer');
|
|
261
|
+
jest.advanceTimersByTime(100);
|
|
262
|
+
|
|
263
|
+
const [first, second] = frames();
|
|
264
|
+
expect(first.customBlocks.blocks.map((block) => block.type)).toEqual([
|
|
265
|
+
'custom:acme:promo-strip'
|
|
266
|
+
]);
|
|
267
|
+
// The full snapshot, not the delta. The receiver replaces what it holds;
|
|
268
|
+
// if this frame carried only the new block, replacing would drop the
|
|
269
|
+
// first one and the editor would offer a type it can no longer describe.
|
|
270
|
+
expect(second.customBlocks.blocks.map((block) => block.type)).toEqual([
|
|
271
|
+
'custom:acme:promo-strip',
|
|
272
|
+
'custom:acme:promo-banner'
|
|
273
|
+
]);
|
|
274
|
+
expect(second.placeholders).toEqual(['header', 'footer']);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -2,10 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import NextLink from 'next/link';
|
|
3
3
|
import { getResponsiveValue } from '../utils';
|
|
4
4
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
import { postToEditor } from '../editor-bridge';
|
|
8
|
-
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
5
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
9
6
|
|
|
10
7
|
/**
|
|
11
8
|
* Composed anchors to the storefront's own routes navigate client-side. A
|
|
@@ -68,59 +65,14 @@ const GroupBlock = ({
|
|
|
68
65
|
);
|
|
69
66
|
}
|
|
70
67
|
|
|
71
|
-
const renderedChildren =
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
type: actionType,
|
|
80
|
-
data: {
|
|
81
|
-
placeholderId,
|
|
82
|
-
sectionId,
|
|
83
|
-
blockId: childBlock.id
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const handleRename = (newLabel: string) => {
|
|
90
|
-
if (window.parent) {
|
|
91
|
-
postToEditor({
|
|
92
|
-
type: 'RENAME_BLOCK',
|
|
93
|
-
data: {
|
|
94
|
-
placeholderId,
|
|
95
|
-
sectionId,
|
|
96
|
-
blockId: childBlock.id,
|
|
97
|
-
label: newLabel
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
return (
|
|
104
|
-
<ThemeBlock
|
|
105
|
-
key={childBlock.id || `block-${index}`}
|
|
106
|
-
block={childBlock}
|
|
107
|
-
placeholderId={placeholderId}
|
|
108
|
-
sectionId={sectionId}
|
|
109
|
-
isDesigner={isDesigner}
|
|
110
|
-
isSelected={selectedBlockId === childBlock.id}
|
|
111
|
-
selectedBlockId={selectedBlockId}
|
|
112
|
-
currentBreakpoint={currentBreakpoint}
|
|
113
|
-
onMoveUp={createActionHandler('MOVE_BLOCK_UP')}
|
|
114
|
-
onMoveDown={createActionHandler('MOVE_BLOCK_DOWN')}
|
|
115
|
-
onDuplicate={createActionHandler('DUPLICATE_BLOCK')}
|
|
116
|
-
onToggleVisibility={createActionHandler(
|
|
117
|
-
'TOGGLE_BLOCK_VISIBILITY'
|
|
118
|
-
)}
|
|
119
|
-
onDelete={createActionHandler('DELETE_BLOCK')}
|
|
120
|
-
onRename={handleRename}
|
|
121
|
-
/>
|
|
122
|
-
);
|
|
123
|
-
});
|
|
68
|
+
const renderedChildren = renderComposedChildren({
|
|
69
|
+
blocks: block.blocks,
|
|
70
|
+
placeholderId,
|
|
71
|
+
sectionId,
|
|
72
|
+
isDesigner,
|
|
73
|
+
selectedBlockId,
|
|
74
|
+
currentBreakpoint
|
|
75
|
+
});
|
|
124
76
|
|
|
125
77
|
if (useRouterLink) {
|
|
126
78
|
return (
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
3
|
+
import ThemeBlock, { Block } from '../theme-block';
|
|
4
|
+
import { postToEditor } from '../editor-bridge';
|
|
5
|
+
|
|
6
|
+
export interface ComposedChildrenOptions {
|
|
7
|
+
/** A container's `block.blocks`. Absent and empty are the same thing here. */
|
|
8
|
+
blocks?: Block[];
|
|
9
|
+
placeholderId: string;
|
|
10
|
+
sectionId: string;
|
|
11
|
+
isDesigner: boolean;
|
|
12
|
+
selectedBlockId?: string | null;
|
|
13
|
+
currentBreakpoint?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renders the children of any container block: visibility filter, order sort,
|
|
18
|
+
* and the six editor action callbacks every child needs to be movable,
|
|
19
|
+
* duplicable, hideable, deletable and renamable on the canvas.
|
|
20
|
+
*
|
|
21
|
+
* Extracted because the callbacks are the expensive half. `tabs-section.tsx`
|
|
22
|
+
* hand-writes this same block of `postToEditor` closures twice — once at line
|
|
23
|
+
* 225 for its non-tab children and again at line 363 for the tab strip — and
|
|
24
|
+
* every copy is a place a new message field has to be remembered. A container
|
|
25
|
+
* that reimplemented them would be the third copy, and copies drift: a child
|
|
26
|
+
* whose `onRename` was forgotten simply refuses to rename on the canvas, with
|
|
27
|
+
* nothing logged anywhere.
|
|
28
|
+
*
|
|
29
|
+
* A plain function rather than a component on purpose. A `<ComposedChildren>`
|
|
30
|
+
* wrapper would insert a fiber between the container and its children, and
|
|
31
|
+
* the container's own element (GroupBlock's `<Tag className="contents">`) is
|
|
32
|
+
* frequently `display: contents` precisely so the children inherit the
|
|
33
|
+
* grandparent's grid — an extra layer there is not free. Returning the array
|
|
34
|
+
* keeps the rendered tree byte-identical to hand-writing the loop inline.
|
|
35
|
+
*/
|
|
36
|
+
export const renderComposedChildren = ({
|
|
37
|
+
blocks,
|
|
38
|
+
placeholderId,
|
|
39
|
+
sectionId,
|
|
40
|
+
isDesigner,
|
|
41
|
+
selectedBlockId,
|
|
42
|
+
currentBreakpoint
|
|
43
|
+
}: ComposedChildrenOptions): React.ReactElement[] => {
|
|
44
|
+
if (!blocks || blocks.length === 0) return [];
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
blocks
|
|
48
|
+
// The designer keeps hidden children mounted so they stay selectable in
|
|
49
|
+
// the layer tree; the storefront must never ship them.
|
|
50
|
+
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
51
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
52
|
+
.map((childBlock, index) => {
|
|
53
|
+
const createActionHandler =
|
|
54
|
+
(actionType: PreviewToEditorMessageType) => () => {
|
|
55
|
+
if (window.parent) {
|
|
56
|
+
postToEditor({
|
|
57
|
+
type: actionType,
|
|
58
|
+
data: {
|
|
59
|
+
placeholderId,
|
|
60
|
+
sectionId,
|
|
61
|
+
blockId: childBlock.id
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Not foldable into `createActionHandler`: rename is the one action
|
|
68
|
+
// that carries a payload beyond the block identity, so its handler
|
|
69
|
+
// takes an argument the others have no way to supply.
|
|
70
|
+
const handleRename = (newLabel: string) => {
|
|
71
|
+
if (window.parent) {
|
|
72
|
+
postToEditor({
|
|
73
|
+
type: 'RENAME_BLOCK',
|
|
74
|
+
data: {
|
|
75
|
+
placeholderId,
|
|
76
|
+
sectionId,
|
|
77
|
+
blockId: childBlock.id,
|
|
78
|
+
label: newLabel
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<ThemeBlock
|
|
86
|
+
key={childBlock.id || `block-${index}`}
|
|
87
|
+
block={childBlock}
|
|
88
|
+
placeholderId={placeholderId}
|
|
89
|
+
sectionId={sectionId}
|
|
90
|
+
isDesigner={isDesigner}
|
|
91
|
+
isSelected={selectedBlockId === childBlock.id}
|
|
92
|
+
selectedBlockId={selectedBlockId}
|
|
93
|
+
currentBreakpoint={currentBreakpoint}
|
|
94
|
+
onMoveUp={createActionHandler('MOVE_BLOCK_UP')}
|
|
95
|
+
onMoveDown={createActionHandler('MOVE_BLOCK_DOWN')}
|
|
96
|
+
onDuplicate={createActionHandler('DUPLICATE_BLOCK')}
|
|
97
|
+
onToggleVisibility={createActionHandler('TOGGLE_BLOCK_VISIBILITY')}
|
|
98
|
+
onDelete={createActionHandler('DELETE_BLOCK')}
|
|
99
|
+
onRename={handleRename}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
};
|