@simmalugnt-se/payload-visual-editing 0.2.1 → 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/CHANGELOG.md +15 -0
- package/README.md +28 -2
- package/dist/admin/VisualEditingAdminBridge.js +73 -28
- package/dist/exports/frontend.d.ts +1 -1
- package/dist/exports/frontend.js +1 -1
- package/dist/frontend/VisualEditingPreview.js +49 -13
- package/dist/frontend/editable.d.ts +7 -1
- package/dist/frontend/editable.js +10 -2
- package/dist/labels.d.ts +2 -0
- package/dist/labels.js +6 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +21 -2
- package/dist/protocol.d.ts +22 -5
- package/dist/protocol.js +37 -9
- package/dist/reveal-plan.d.ts +10 -2
- package/dist/reveal-plan.js +41 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- Preview clicks open collapsed Payload `collapsible` fields around the target, including nested
|
|
6
|
+
collapsibles and those inside tabs, groups and rows. The bridge finds each by the id Payload
|
|
7
|
+
gives it, so a collapsible hidden by a condition does not change which one opens.
|
|
8
|
+
|
|
9
|
+
## 0.3.0
|
|
10
|
+
|
|
11
|
+
- Globals: `visualEditingPlugin({ globals: ["header", "footer"] })` supports click-to-edit in a
|
|
12
|
+
global's Live Preview.
|
|
13
|
+
- `editableField` outside a block marks a top-level field of the document, such as a header's site
|
|
14
|
+
name.
|
|
15
|
+
- `editableGlobal("header")` on the element that renders a global: its markers respond only while
|
|
16
|
+
that global is edited, so a header shown on a page preview no longer targets the page's form.
|
|
17
|
+
|
|
3
18
|
## 0.2.1
|
|
4
19
|
|
|
5
20
|
- A preview click right after the page loads no longer lands on the wrong tab: Payload restores the
|
package/README.md
CHANGED
|
@@ -17,12 +17,13 @@ Requires Payload `>=3.85.2 <4` and React 19.
|
|
|
17
17
|
pnpm add @simmalugnt-se/payload-visual-editing
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
2. Enable it for the collections that have Live Preview, then regenerate the import
|
|
20
|
+
2. Enable it for the collections and globals that have Live Preview, then regenerate the import
|
|
21
|
+
map:
|
|
21
22
|
|
|
22
23
|
```ts
|
|
23
24
|
import { visualEditingPlugin } from "@simmalugnt-se/payload-visual-editing";
|
|
24
25
|
|
|
25
|
-
plugins: [visualEditingPlugin({ collections: ["pages"] })];
|
|
26
|
+
plugins: [visualEditingPlugin({ collections: ["pages"], globals: ["header", "footer"] })];
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
```bash
|
|
@@ -73,6 +74,31 @@ Requires Payload `>=3.85.2 <4` and React 19.
|
|
|
73
74
|
Marking blocks alone is enough to start: a click then opens the whole block. Add `editableField`
|
|
74
75
|
where editors should land on a specific field.
|
|
75
76
|
|
|
77
|
+
## Globals and top-level fields
|
|
78
|
+
|
|
79
|
+
Outside a block, `editableField` names a top-level field of the document, such as a header's site
|
|
80
|
+
name or a page title shown on the page.
|
|
81
|
+
|
|
82
|
+
A global such as a header is rendered on every page, but its fields belong to the global's form,
|
|
83
|
+
not the page's. Wrap it in `editableGlobal` with its slug, so its markers respond only while that
|
|
84
|
+
global is edited, and the page's blocks only while the page is:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
<header {...editableGlobal("header")}>
|
|
88
|
+
<strong {...editableField("siteName")}>{header.siteName}</strong>
|
|
89
|
+
<nav>
|
|
90
|
+
{header.navItems.map((item) => (
|
|
91
|
+
<a key={item.id} {...editableBlock(item)} href={href(item)}>
|
|
92
|
+
<span {...editableField("link.label")}>{item.link.label}</span>
|
|
93
|
+
</a>
|
|
94
|
+
))}
|
|
95
|
+
</nav>
|
|
96
|
+
</header>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The global needs a `livePreview.url` of its own, for example a page that shows the header and
|
|
100
|
+
footer around neutral content.
|
|
101
|
+
|
|
76
102
|
## Labels
|
|
77
103
|
|
|
78
104
|
The outline shows the same names as Admin: the editor's block name, otherwise the block's
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { useConfig, useDocumentForm, useDocumentInfo, useLivePreviewContext, useTranslation, } from "@payloadcms/ui";
|
|
4
4
|
import { useEffect, useRef } from "react";
|
|
5
5
|
import "../styles/admin.css";
|
|
6
|
-
import { collectBlockLabels, collectRowLabels } from "../labels.js";
|
|
6
|
+
import { collectBlockLabels, collectRootLabels, collectRowLabels } from "../labels.js";
|
|
7
7
|
import { ATTR_KEEP_SELECTION, fieldDomId, focusTargets, isClearMessage, isReadyMessage, isSelectMessage, MESSAGE_TYPE, matchNodeIndex, pathFromDomId, pathFromRowDomId, resolveBlockTarget, rowDomId, SELECT_EVENT, } from "../protocol.js";
|
|
8
8
|
import { planReveal } from "../reveal-plan.js";
|
|
9
9
|
import { HIGHLIGHT_TINT } from "../theme.js";
|
|
@@ -26,13 +26,14 @@ export function VisualEditingAdminBridge() {
|
|
|
26
26
|
const livePreview = useLivePreviewContext();
|
|
27
27
|
const form = useDocumentForm();
|
|
28
28
|
const { config, getEntityConfig } = useConfig();
|
|
29
|
-
const { collectionSlug } = useDocumentInfo();
|
|
29
|
+
const { collectionSlug, globalSlug } = useDocumentInfo();
|
|
30
30
|
const { i18n } = useTranslation();
|
|
31
31
|
const language = i18n.language;
|
|
32
32
|
const anchorRef = useRef(null);
|
|
33
|
-
const
|
|
33
|
+
const entity = { collectionSlug, globalSlug };
|
|
34
|
+
const latest = useRef({ form, livePreview, config, getEntityConfig, entity, language });
|
|
34
35
|
useEffect(() => {
|
|
35
|
-
latest.current = { form, livePreview, config, getEntityConfig,
|
|
36
|
+
latest.current = { form, livePreview, config, getEntityConfig, entity, language };
|
|
36
37
|
});
|
|
37
38
|
useEffect(() => {
|
|
38
39
|
let run = 0;
|
|
@@ -62,47 +63,61 @@ export function VisualEditingAdminBridge() {
|
|
|
62
63
|
void select(event.data, run);
|
|
63
64
|
};
|
|
64
65
|
const sendLabels = (preview, origin) => {
|
|
65
|
-
const { form, config, getEntityConfig,
|
|
66
|
+
const { form, config, getEntityConfig, entity, language } = latest.current;
|
|
66
67
|
const fields = form.getFields();
|
|
67
|
-
const rootFields = rootFieldsOf(getEntityConfig,
|
|
68
|
+
const rootFields = rootFieldsOf(getEntityConfig, entity);
|
|
68
69
|
const blocksBySlug = config.blocksMap;
|
|
69
70
|
const message = {
|
|
70
71
|
type: MESSAGE_TYPE,
|
|
71
72
|
action: "labels",
|
|
72
73
|
blocks: collectBlockLabels(rootFields, blocksBySlug, language),
|
|
73
74
|
rows: collectRowLabels(rootFields, fields, { blockTypeAt: (rowPath) => rowAt(fields, rowPath)?.blockType, blocksBySlug }, language),
|
|
75
|
+
fields: collectRootLabels(rootFields, language),
|
|
76
|
+
// The preview keeps the markers of other documents (a header on a page preview) inert.
|
|
77
|
+
...(entity.globalSlug ? { global: entity.globalSlug } : {}),
|
|
74
78
|
};
|
|
75
79
|
preview.postMessage(message, origin);
|
|
76
80
|
};
|
|
77
81
|
const select = async (message, id) => {
|
|
78
|
-
const { form, config, getEntityConfig,
|
|
82
|
+
const { form, config, getEntityConfig, entity } = latest.current;
|
|
79
83
|
const fields = form.getFields();
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
// Without a block id the field is a top-level field of the document.
|
|
85
|
+
const target = message.blockId ? resolveBlockTarget(fields, message.blockId) : null;
|
|
86
|
+
if (message.blockId && !target) {
|
|
82
87
|
return;
|
|
83
88
|
}
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
if (target) {
|
|
90
|
+
expandRows(target, fields, form.dispatchFields);
|
|
91
|
+
}
|
|
92
|
+
const rootFields = rootFieldsOf(getEntityConfig, entity);
|
|
86
93
|
const lookup = {
|
|
87
94
|
blockTypeAt: (rowPath) => rowAt(fields, rowPath)?.blockType,
|
|
88
95
|
blocksBySlug: config.blocksMap,
|
|
89
96
|
};
|
|
90
|
-
const fieldPath = message.field
|
|
97
|
+
const fieldPath = message.field
|
|
98
|
+
? target
|
|
99
|
+
? `${target.rowPath}.${message.field}`
|
|
100
|
+
: message.field
|
|
101
|
+
: undefined;
|
|
91
102
|
const fieldPlan = fieldPath ? planReveal(rootFields, fieldPath, lookup) : null;
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
const rowPlan = target ? (planReveal(rootFields, target.rowPath, lookup) ?? []) : null;
|
|
104
|
+
const plan = fieldPlan ?? rowPlan;
|
|
105
|
+
const path = fieldPlan && fieldPath ? fieldPath : target?.rowPath;
|
|
106
|
+
if (!plan || !path) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
window.dispatchEvent(new CustomEvent(SELECT_EVENT, { detail: { path } }));
|
|
96
110
|
const root = formRoot(anchorRef.current);
|
|
97
111
|
const scope = await openSteps(plan, root, () => id === run);
|
|
98
112
|
if (!scope || id !== run) {
|
|
99
113
|
return;
|
|
100
114
|
}
|
|
101
115
|
// Wait for the field itself; the row renders first and would otherwise win.
|
|
102
|
-
const field = fieldPlan
|
|
116
|
+
const field = fieldPlan ? fieldPath : undefined;
|
|
117
|
+
const row = () => (target ? findRow(target) : null);
|
|
103
118
|
const element = (field
|
|
104
119
|
? await waitForRendered(() => findField(field), scope, message.node ? FIELD_WAIT_MS : WAIT_MS)
|
|
105
|
-
: null) ?? (await waitFor(
|
|
120
|
+
: null) ?? (target ? await waitFor(row) : null);
|
|
106
121
|
if (!element || id !== run) {
|
|
107
122
|
return;
|
|
108
123
|
}
|
|
@@ -110,7 +125,7 @@ export function VisualEditingAdminBridge() {
|
|
|
110
125
|
if (message.node && (await revealRichTextNode(element, message.node, current))) {
|
|
111
126
|
return;
|
|
112
127
|
}
|
|
113
|
-
await reveal(element, field !== undefined && element !==
|
|
128
|
+
await reveal(element, field !== undefined && element !== row(), current);
|
|
114
129
|
};
|
|
115
130
|
window.addEventListener("message", onMessage);
|
|
116
131
|
return () => window.removeEventListener("message", onMessage);
|
|
@@ -143,10 +158,13 @@ export function VisualEditingAdminBridge() {
|
|
|
143
158
|
}, []);
|
|
144
159
|
return _jsx("span", { hidden: true, ref: anchorRef });
|
|
145
160
|
}
|
|
146
|
-
function rootFieldsOf(getEntityConfig, collectionSlug) {
|
|
147
|
-
|
|
148
|
-
?
|
|
149
|
-
:
|
|
161
|
+
function rootFieldsOf(getEntityConfig, { collectionSlug, globalSlug }) {
|
|
162
|
+
const entity = collectionSlug
|
|
163
|
+
? getEntityConfig({ collectionSlug })
|
|
164
|
+
: globalSlug
|
|
165
|
+
? getEntityConfig({ globalSlug })
|
|
166
|
+
: undefined;
|
|
167
|
+
return entity?.fields ?? [];
|
|
150
168
|
}
|
|
151
169
|
/**
|
|
152
170
|
* Form path of the field or row an element belongs to. Rich text wrappers carry
|
|
@@ -203,14 +221,17 @@ function rowAt(fields, rowPath) {
|
|
|
203
221
|
const cut = rowPath.lastIndexOf(".");
|
|
204
222
|
return fields[rowPath.slice(0, cut)]?.rows?.[Number(rowPath.slice(cut + 1))];
|
|
205
223
|
}
|
|
206
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* The edit form this bridge belongs to, not one in a document drawer. Globals use the same edit
|
|
226
|
+
* view, so the classes are the same.
|
|
227
|
+
*/
|
|
207
228
|
function formRoot(anchor) {
|
|
208
229
|
const view = anchor?.closest(".collection-edit");
|
|
209
230
|
return view?.querySelector(".collection-edit__form") ?? document;
|
|
210
231
|
}
|
|
211
232
|
/**
|
|
212
|
-
* Open the rows and
|
|
213
|
-
* Returns the innermost scope
|
|
233
|
+
* Open the rows, tabs and collapsibles the plan needs, one level at a time.
|
|
234
|
+
* Returns the innermost scope, where the target field renders.
|
|
214
235
|
*/
|
|
215
236
|
async function openSteps(steps, root, current) {
|
|
216
237
|
let scope = root;
|
|
@@ -226,6 +247,26 @@ async function openSteps(steps, root, current) {
|
|
|
226
247
|
scope = row;
|
|
227
248
|
continue;
|
|
228
249
|
}
|
|
250
|
+
if (step.kind === "collapsible") {
|
|
251
|
+
const collapsibleScope = scope;
|
|
252
|
+
const field = await waitForRendered(() => collapsibleScope.querySelector(`[id="${collapsibleDomId(step.path)}"]`), collapsibleScope);
|
|
253
|
+
const element = field?.querySelector(":scope > .collapsible");
|
|
254
|
+
const button = element?.querySelector(":scope > .collapsible__toggle-wrap > .collapsible__toggle");
|
|
255
|
+
if (!element || !button) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (element.classList.contains("collapsible--collapsed")) {
|
|
259
|
+
button.click();
|
|
260
|
+
}
|
|
261
|
+
const content = await waitFor(() => element.classList.contains("collapsible--collapsed")
|
|
262
|
+
? null
|
|
263
|
+
: element.querySelector(".collapsible__content"));
|
|
264
|
+
if (!content) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
scope = content;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
229
270
|
const tabsScope = scope;
|
|
230
271
|
const tabs = await waitForRendered(() => tabsFieldsIn(tabsScope)[step.ordinal] ?? null, tabsScope);
|
|
231
272
|
const button = tabs?.querySelectorAll(":scope > .tabs-field__tabs-wrap > .tabs-field__tabs > .tabs-field__tab-button")[step.index];
|
|
@@ -260,13 +301,17 @@ async function waitForRendered(find, scope, timeoutMs = WAIT_MS) {
|
|
|
260
301
|
}
|
|
261
302
|
return waitFor(find, timeoutMs);
|
|
262
303
|
}
|
|
263
|
-
/** Tabs fields at this level of the scope, in DOM order; nested
|
|
304
|
+
/** Tabs fields at this level of the scope, in DOM order; nested containers are other scopes. */
|
|
264
305
|
function tabsFieldsIn(scope) {
|
|
265
306
|
return Array.from(scope.querySelectorAll(".tabs-field")).filter((element) => {
|
|
266
|
-
const outer = element.parentElement?.closest(".tabs-field, .blocks-field, .array-field");
|
|
307
|
+
const outer = element.parentElement?.closest(".tabs-field, .collapsible-field, .blocks-field, .array-field");
|
|
267
308
|
return !outer || !scope.contains(outer);
|
|
268
309
|
});
|
|
269
310
|
}
|
|
311
|
+
/** Payload's id for a collapsible field: `field-collapsible-` and its path with `__` for dots. */
|
|
312
|
+
function collapsibleDomId(path) {
|
|
313
|
+
return `field-collapsible-${path.replace(/\./g, "__")}`;
|
|
314
|
+
}
|
|
270
315
|
function waitFor(find, timeoutMs = WAIT_MS) {
|
|
271
316
|
const deadline = performance.now() + timeoutMs;
|
|
272
317
|
return new Promise((resolve) => {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { editableBlock, editableField, editableRichText } from "../frontend/editable.ts";
|
|
1
|
+
export { editableBlock, editableField, editableGlobal, editableRichText, } from "../frontend/editable.ts";
|
|
2
2
|
export { VisualEditingPreview } from "../frontend/VisualEditingPreview.tsx";
|
package/dist/exports/frontend.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { editableBlock, editableField, editableRichText } from "../frontend/editable.js";
|
|
1
|
+
export { editableBlock, editableField, editableGlobal, editableRichText, } from "../frontend/editable.js";
|
|
2
2
|
export { VisualEditingPreview } from "../frontend/VisualEditingPreview.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useEffect } from "react";
|
|
3
|
-
import { ATTR_BLOCK, ATTR_BLOCK_NAME, ATTR_BLOCK_TYPE, ATTR_FIELD, ATTR_RICHTEXT, isFocusMessage, isLabelsMessage, MESSAGE_TYPE, nodeSnippet, } from "../protocol.js";
|
|
3
|
+
import { ATTR_BLOCK, ATTR_BLOCK_NAME, ATTR_BLOCK_TYPE, ATTR_FIELD, ATTR_GLOBAL, ATTR_RICHTEXT, inScope, isFocusMessage, isLabelsMessage, MESSAGE_TYPE, nodeSnippet, } from "../protocol.js";
|
|
4
4
|
import { HIGHLIGHT_COLOR, HIGHLIGHT_TINT } from "../theme.js";
|
|
5
5
|
const SELECTOR = `[${ATTR_FIELD}], [${ATTR_BLOCK}]`;
|
|
6
6
|
/**
|
|
@@ -19,10 +19,10 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
19
19
|
let hovered = null;
|
|
20
20
|
let current = null;
|
|
21
21
|
// Until Admin answers, labels fall back to the slugs.
|
|
22
|
-
let labels = { blocks: {}, rows: {} };
|
|
22
|
+
let labels = { blocks: {}, rows: {}, fields: {} };
|
|
23
23
|
// Array row numbers change as rows move, so ask again whenever the pointer reaches a row.
|
|
24
24
|
const describe = (hit) => {
|
|
25
|
-
if (hit && isArrayRow(hit.element)) {
|
|
25
|
+
if (hit?.blockId && isArrayRow(hit.element)) {
|
|
26
26
|
const message = { type: MESSAGE_TYPE, action: "describe" };
|
|
27
27
|
admin.postMessage(message, targetOrigin);
|
|
28
28
|
}
|
|
@@ -101,7 +101,7 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
101
101
|
const message = {
|
|
102
102
|
type: MESSAGE_TYPE,
|
|
103
103
|
action: "select",
|
|
104
|
-
blockId: hit.blockId,
|
|
104
|
+
...(hit.blockId ? { blockId: hit.blockId } : {}),
|
|
105
105
|
...(hit.field ? { field: hit.field } : {}),
|
|
106
106
|
...(hit.node ? { node: hit.node } : {}),
|
|
107
107
|
};
|
|
@@ -112,7 +112,12 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
114
|
if (isLabelsMessage(event.data)) {
|
|
115
|
-
labels = {
|
|
115
|
+
labels = {
|
|
116
|
+
blocks: event.data.blocks,
|
|
117
|
+
rows: event.data.rows ?? labels.rows,
|
|
118
|
+
fields: event.data.fields ?? labels.fields,
|
|
119
|
+
global: event.data.global,
|
|
120
|
+
};
|
|
116
121
|
hovered = hovered && relabel(hovered, labels);
|
|
117
122
|
current = current && relabel(current, labels);
|
|
118
123
|
redraw();
|
|
@@ -128,7 +133,7 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
128
133
|
continue;
|
|
129
134
|
}
|
|
130
135
|
// Keep a finer selection (e.g. a paragraph) that Admin just echoed back as its field.
|
|
131
|
-
if (current
|
|
136
|
+
if (current && current.blockId === hit.blockId && current.field === hit.field) {
|
|
132
137
|
return;
|
|
133
138
|
}
|
|
134
139
|
current = hit;
|
|
@@ -176,17 +181,34 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
176
181
|
}, [adminOrigin]);
|
|
177
182
|
return null;
|
|
178
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* The block a marked element belongs to, and whether it responds at all: only markers of the
|
|
186
|
+
* document Admin is editing do. A block outside the element's global (a global rendered inside a
|
|
187
|
+
* page block) is another document's, so the element is then a top-level field of the global.
|
|
188
|
+
*/
|
|
189
|
+
function ownerOf(element, labels) {
|
|
190
|
+
const global = element.closest(`[${ATTR_GLOBAL}]`);
|
|
191
|
+
if (!inScope(global?.getAttribute(ATTR_GLOBAL) ?? undefined, labels.global)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const block = element.closest(`[${ATTR_BLOCK}]`);
|
|
195
|
+
return { block: block && (!global || global.contains(block)) ? block : null };
|
|
196
|
+
}
|
|
179
197
|
function hitFromTarget(target, labels) {
|
|
180
198
|
if (!(target instanceof Element)) {
|
|
181
199
|
return null;
|
|
182
200
|
}
|
|
183
201
|
const marked = target.closest(SELECTOR);
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
if (!marked || !block || !blockId) {
|
|
202
|
+
const owner = marked && ownerOf(marked, labels);
|
|
203
|
+
if (!marked || !owner) {
|
|
187
204
|
return null;
|
|
188
205
|
}
|
|
206
|
+
const { block } = owner;
|
|
207
|
+
const blockId = block?.getAttribute(ATTR_BLOCK) || undefined;
|
|
189
208
|
const field = marked !== block ? (marked.getAttribute(ATTR_FIELD) ?? undefined) : undefined;
|
|
209
|
+
if (!blockId && !field) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
190
212
|
const label = labelFor(block, field, labels);
|
|
191
213
|
const node = field && marked.hasAttribute(ATTR_RICHTEXT) ? richTextNode(marked, target) : null;
|
|
192
214
|
if (node) {
|
|
@@ -195,8 +217,15 @@ function hitFromTarget(target, labels) {
|
|
|
195
217
|
return { element: marked, blockId, field, label };
|
|
196
218
|
}
|
|
197
219
|
function hitForBlock(blockId, field, labels) {
|
|
220
|
+
if (!blockId) {
|
|
221
|
+
// A top-level field: marked outside any block of this document.
|
|
222
|
+
const element = field
|
|
223
|
+
? Array.from(document.querySelectorAll(`[${ATTR_FIELD}="${CSS.escape(field)}"]`)).find((candidate) => ownerOf(candidate, labels)?.block === null)
|
|
224
|
+
: undefined;
|
|
225
|
+
return element && field ? { element, field, label: labelFor(null, field, labels) } : null;
|
|
226
|
+
}
|
|
198
227
|
const block = document.querySelector(`[${ATTR_BLOCK}="${CSS.escape(blockId)}"]`);
|
|
199
|
-
if (!block) {
|
|
228
|
+
if (!block || !ownerOf(block, labels)) {
|
|
200
229
|
return null;
|
|
201
230
|
}
|
|
202
231
|
const fieldElement = field
|
|
@@ -207,8 +236,14 @@ function hitForBlock(blockId, field, labels) {
|
|
|
207
236
|
}
|
|
208
237
|
return { element: block, blockId, label: labelFor(block, undefined, labels) };
|
|
209
238
|
}
|
|
210
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* Same order as Admin's row header: the editor's block name, then the block's label, then its slug.
|
|
241
|
+
* A top-level field has only its own label.
|
|
242
|
+
*/
|
|
211
243
|
function labelFor(block, field, labels) {
|
|
244
|
+
if (!block) {
|
|
245
|
+
return field ? (labels.fields[field] ?? humanize(field)) : "";
|
|
246
|
+
}
|
|
212
247
|
const type = block.getAttribute(ATTR_BLOCK_TYPE) ?? undefined;
|
|
213
248
|
const known = type ? labels.blocks[type] : labels.rows[block.getAttribute(ATTR_BLOCK) ?? ""];
|
|
214
249
|
const blockLabel = block.getAttribute(ATTR_BLOCK_NAME) || known?.label || (type ? humanize(type) : "Block");
|
|
@@ -233,9 +268,10 @@ function isArrayRow(element) {
|
|
|
233
268
|
const row = element.closest(`[${ATTR_BLOCK}]`);
|
|
234
269
|
return !!row && !row.hasAttribute(ATTR_BLOCK_TYPE);
|
|
235
270
|
}
|
|
271
|
+
/** New labels may also name another global, which makes this hit's markers inert. */
|
|
236
272
|
function relabel(hit, labels) {
|
|
237
|
-
const
|
|
238
|
-
return
|
|
273
|
+
const owner = ownerOf(hit.element, labels);
|
|
274
|
+
return owner ? { ...hit, label: labelFor(owner.block, hit.field, labels) } : null;
|
|
239
275
|
}
|
|
240
276
|
function inViewport(element) {
|
|
241
277
|
const rect = element.getBoundingClientRect();
|
|
@@ -10,9 +10,15 @@ export declare function editableBlock(block: {
|
|
|
10
10
|
}): EditableAttributes;
|
|
11
11
|
/**
|
|
12
12
|
* Mark one field inside a block, e.g. `<h1 {...editableField("headline")}>`.
|
|
13
|
-
* Clicking it opens that field instead of the whole block.
|
|
13
|
+
* Clicking it opens that field instead of the whole block. Outside a block the name is a
|
|
14
|
+
* top-level field of the document, e.g. a header's `siteName`.
|
|
14
15
|
*/
|
|
15
16
|
export declare function editableField(name: string): EditableAttributes;
|
|
17
|
+
/**
|
|
18
|
+
* Mark the element that renders a global, e.g. `<header {...editableGlobal("header")}>`.
|
|
19
|
+
* The markers inside it respond only while that global is edited, not on every page it shows on.
|
|
20
|
+
*/
|
|
21
|
+
export declare function editableGlobal(slug: string): EditableAttributes;
|
|
16
22
|
/**
|
|
17
23
|
* Mark a rich text field on the element whose direct children are the rendered top-level nodes
|
|
18
24
|
* (paragraphs, headings, lists). Clicking a paragraph puts the cursor in that paragraph in Admin.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ATTR_BLOCK, ATTR_BLOCK_NAME, ATTR_BLOCK_TYPE, ATTR_FIELD, ATTR_RICHTEXT, } from "../protocol.js";
|
|
1
|
+
import { ATTR_BLOCK, ATTR_BLOCK_NAME, ATTR_BLOCK_TYPE, ATTR_FIELD, ATTR_GLOBAL, ATTR_RICHTEXT, } from "../protocol.js";
|
|
2
2
|
/**
|
|
3
3
|
* Mark the element that renders a block. Spread it on the block's outermost element:
|
|
4
4
|
* `<section {...editableBlock(block)}>`. Emits nothing without a block id.
|
|
@@ -15,11 +15,19 @@ export function editableBlock(block) {
|
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
17
|
* Mark one field inside a block, e.g. `<h1 {...editableField("headline")}>`.
|
|
18
|
-
* Clicking it opens that field instead of the whole block.
|
|
18
|
+
* Clicking it opens that field instead of the whole block. Outside a block the name is a
|
|
19
|
+
* top-level field of the document, e.g. a header's `siteName`.
|
|
19
20
|
*/
|
|
20
21
|
export function editableField(name) {
|
|
21
22
|
return { [ATTR_FIELD]: name };
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Mark the element that renders a global, e.g. `<header {...editableGlobal("header")}>`.
|
|
26
|
+
* The markers inside it respond only while that global is edited, not on every page it shows on.
|
|
27
|
+
*/
|
|
28
|
+
export function editableGlobal(slug) {
|
|
29
|
+
return { [ATTR_GLOBAL]: slug };
|
|
30
|
+
}
|
|
23
31
|
/**
|
|
24
32
|
* Mark a rich text field on the element whose direct children are the rendered top-level nodes
|
|
25
33
|
* (paragraphs, headings, lists). Clicking a paragraph puts the cursor in that paragraph in Admin.
|
package/dist/labels.d.ts
CHANGED
|
@@ -23,5 +23,7 @@ export declare function collectBlockLabels(rootFields: FieldLike[], blocksBySlug
|
|
|
23
23
|
export declare function collectRowLabels(rootFields: FieldLike[], formFields: Record<string, {
|
|
24
24
|
value?: unknown;
|
|
25
25
|
} | undefined>, lookup: Lookup, language: string): BlockLabels;
|
|
26
|
+
/** Labels of the document's own fields, for markers outside any block ("siteName", "meta.title"). */
|
|
27
|
+
export declare function collectRootLabels(rootFields: FieldLike[], language: string): Record<string, string>;
|
|
26
28
|
/** Client config labels are a string or a `{ [language]: string }` map; anything else has no static text. */
|
|
27
29
|
export declare function labelText(label: unknown, language: string): string | undefined;
|
package/dist/labels.js
CHANGED
|
@@ -59,6 +59,12 @@ export function collectRowLabels(rootFields, formFields, lookup, language) {
|
|
|
59
59
|
}
|
|
60
60
|
return result;
|
|
61
61
|
}
|
|
62
|
+
/** Labels of the document's own fields, for markers outside any block ("siteName", "meta.title"). */
|
|
63
|
+
export function collectRootLabels(rootFields, language) {
|
|
64
|
+
const result = {};
|
|
65
|
+
collectFieldLabels(rootFields, "", language, result);
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
62
68
|
/** Labels of the fields a block's preview can mark: named fields and paths into named groups and tabs. */
|
|
63
69
|
function collectFieldLabels(fields, prefix, language, into) {
|
|
64
70
|
for (const field of fields) {
|
package/dist/plugin.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { Plugin } from "payload";
|
|
|
2
2
|
export type VisualEditingPluginOptions = {
|
|
3
3
|
/** Collection slugs whose Live Preview should support click-to-edit. */
|
|
4
4
|
collections: string[];
|
|
5
|
+
/** Global slugs whose Live Preview should support click-to-edit, such as a header. */
|
|
6
|
+
globals?: string[];
|
|
5
7
|
disabled?: boolean;
|
|
6
8
|
};
|
|
7
9
|
export declare function visualEditingPlugin(options: VisualEditingPluginOptions): Plugin;
|
package/dist/plugin.js
CHANGED
|
@@ -5,10 +5,12 @@ export function visualEditingPlugin(options) {
|
|
|
5
5
|
if (options.disabled) {
|
|
6
6
|
return config;
|
|
7
7
|
}
|
|
8
|
-
const
|
|
8
|
+
const collections = new Set(options.collections);
|
|
9
|
+
const globals = new Set(options.globals ?? []);
|
|
9
10
|
return {
|
|
10
11
|
...config,
|
|
11
|
-
collections: (config.collections ?? []).map((collection) =>
|
|
12
|
+
collections: (config.collections ?? []).map((collection) => collections.has(collection.slug) ? withBridge(collection) : collection),
|
|
13
|
+
globals: (config.globals ?? []).map((global) => globals.has(global.slug) ? withGlobalBridge(global) : global),
|
|
12
14
|
};
|
|
13
15
|
};
|
|
14
16
|
}
|
|
@@ -28,3 +30,20 @@ function withBridge(collection) {
|
|
|
28
30
|
},
|
|
29
31
|
};
|
|
30
32
|
}
|
|
33
|
+
/** Globals have no `edit` components; their edit view reads `elements` instead. */
|
|
34
|
+
function withGlobalBridge(global) {
|
|
35
|
+
const elements = global.admin?.components?.elements;
|
|
36
|
+
return {
|
|
37
|
+
...global,
|
|
38
|
+
admin: {
|
|
39
|
+
...global.admin,
|
|
40
|
+
components: {
|
|
41
|
+
...global.admin?.components,
|
|
42
|
+
elements: {
|
|
43
|
+
...elements,
|
|
44
|
+
beforeDocumentControls: [...(elements?.beforeDocumentControls ?? []), BRIDGE],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -9,6 +9,13 @@ export declare const ATTR_BLOCK_NAME = "data-payload-block-name";
|
|
|
9
9
|
export declare const ATTR_FIELD = "data-payload-field";
|
|
10
10
|
/** On the rich text container whose direct children are the top-level editor nodes. */
|
|
11
11
|
export declare const ATTR_RICHTEXT = "data-payload-richtext";
|
|
12
|
+
/** On the element that renders a global; the markers inside it belong to that global's form. */
|
|
13
|
+
export declare const ATTR_GLOBAL = "data-payload-global";
|
|
14
|
+
/**
|
|
15
|
+
* Whether a marker belongs to the document being edited: a marker inside `editableGlobal("header")`
|
|
16
|
+
* only while the header is edited, any other marker only while a collection document is.
|
|
17
|
+
*/
|
|
18
|
+
export declare function inScope(markerGlobal: string | undefined, editedGlobal: string | undefined): boolean;
|
|
12
19
|
/**
|
|
13
20
|
* Dispatched on Admin's `window` when a preview click selects something, with the form path as
|
|
14
21
|
* `detail.path` (`layout.2` for a block, `layout.2.headline` for a field), and with `path: null`
|
|
@@ -18,11 +25,12 @@ export declare const ATTR_RICHTEXT = "data-payload-richtext";
|
|
|
18
25
|
export declare const SELECT_EVENT = "visual-editing:select";
|
|
19
26
|
/** Clicks and focus inside an element with this attribute keep the preview outline. */
|
|
20
27
|
export declare const ATTR_KEEP_SELECTION = "data-visual-editing-keep-selection";
|
|
28
|
+
/** A block, a field in it, or without `blockId` a top-level field of the document. */
|
|
21
29
|
export type SelectMessage = {
|
|
22
30
|
type: typeof MESSAGE_TYPE;
|
|
23
31
|
action: "select";
|
|
24
|
-
blockId
|
|
25
|
-
/** Field name relative to the block
|
|
32
|
+
blockId?: string;
|
|
33
|
+
/** Field name relative to the block (e.g. "headline"), or to the document without a block. */
|
|
26
34
|
field?: string;
|
|
27
35
|
/** Top-level rich text node that was clicked; `text` disambiguates when indexes drift. */
|
|
28
36
|
node?: {
|
|
@@ -51,9 +59,12 @@ export declare function nodeSnippet(text: string | null | undefined): string | u
|
|
|
51
59
|
export type FocusMessage = {
|
|
52
60
|
type: typeof MESSAGE_TYPE;
|
|
53
61
|
action: "focus";
|
|
54
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Candidates from the innermost row outwards; the preview uses the first it has marked.
|
|
64
|
+
* A target without `blockId` is a top-level field.
|
|
65
|
+
*/
|
|
55
66
|
targets: Array<{
|
|
56
|
-
blockId
|
|
67
|
+
blockId?: string;
|
|
57
68
|
field?: string;
|
|
58
69
|
}>;
|
|
59
70
|
};
|
|
@@ -67,12 +78,17 @@ export type ReadyMessage = {
|
|
|
67
78
|
action: "ready" | "describe";
|
|
68
79
|
};
|
|
69
80
|
export declare function isReadyMessage(value: unknown): value is ReadyMessage;
|
|
70
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Admin → preview: labels for the overlay, blocks keyed by slug, array rows by row id and
|
|
83
|
+
* top-level fields by path; and the global being edited, if the form is a global's.
|
|
84
|
+
*/
|
|
71
85
|
export type LabelsMessage = {
|
|
72
86
|
type: typeof MESSAGE_TYPE;
|
|
73
87
|
action: "labels";
|
|
74
88
|
blocks: BlockLabels;
|
|
75
89
|
rows?: BlockLabels;
|
|
90
|
+
fields?: Record<string, string>;
|
|
91
|
+
global?: string;
|
|
76
92
|
};
|
|
77
93
|
export declare function isLabelsMessage(value: unknown): value is LabelsMessage;
|
|
78
94
|
/** Inverse of `fieldDomId`: "field-layout__0__headline" → "layout.0.headline". */
|
|
@@ -81,6 +97,7 @@ export declare function pathFromDomId(id: string): string | undefined;
|
|
|
81
97
|
* Rows enclosing a form path, innermost first, with the rest of the path as the field.
|
|
82
98
|
* "layout.1.items.0.title" → the items row (field "title"), then the layout row (field undefined:
|
|
83
99
|
* "items.0.title" is not a plain field name the preview could have marked).
|
|
100
|
+
* A path outside any row ("siteName", "meta.title") targets the top-level field.
|
|
84
101
|
*/
|
|
85
102
|
export declare function focusTargets(path: string, fields: Record<string, {
|
|
86
103
|
value?: unknown;
|
package/dist/protocol.js
CHANGED
|
@@ -8,6 +8,15 @@ export const ATTR_BLOCK_NAME = "data-payload-block-name";
|
|
|
8
8
|
export const ATTR_FIELD = "data-payload-field";
|
|
9
9
|
/** On the rich text container whose direct children are the top-level editor nodes. */
|
|
10
10
|
export const ATTR_RICHTEXT = "data-payload-richtext";
|
|
11
|
+
/** On the element that renders a global; the markers inside it belong to that global's form. */
|
|
12
|
+
export const ATTR_GLOBAL = "data-payload-global";
|
|
13
|
+
/**
|
|
14
|
+
* Whether a marker belongs to the document being edited: a marker inside `editableGlobal("header")`
|
|
15
|
+
* only while the header is edited, any other marker only while a collection document is.
|
|
16
|
+
*/
|
|
17
|
+
export function inScope(markerGlobal, editedGlobal) {
|
|
18
|
+
return (markerGlobal || undefined) === (editedGlobal || undefined);
|
|
19
|
+
}
|
|
11
20
|
/**
|
|
12
21
|
* Dispatched on Admin's `window` when a preview click selects something, with the form path as
|
|
13
22
|
* `detail.path` (`layout.2` for a block, `layout.2.headline` for a field), and with `path: null`
|
|
@@ -32,11 +41,20 @@ export function isSelectMessage(value) {
|
|
|
32
41
|
const message = value;
|
|
33
42
|
return (message.type === MESSAGE_TYPE &&
|
|
34
43
|
message.action === "select" &&
|
|
35
|
-
|
|
36
|
-
message.blockId.length > 0 &&
|
|
37
|
-
(message.field === undefined || isFieldName(message.field)) &&
|
|
44
|
+
isTarget(message) &&
|
|
38
45
|
(message.node === undefined || isNode(message.node)));
|
|
39
46
|
}
|
|
47
|
+
/** A block id, a field name, or both; never neither. */
|
|
48
|
+
function isTarget(value) {
|
|
49
|
+
const { blockId, field } = value;
|
|
50
|
+
if (blockId !== undefined && (typeof blockId !== "string" || blockId.length === 0)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (field !== undefined && !isFieldName(field)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return blockId !== undefined || field !== undefined;
|
|
57
|
+
}
|
|
40
58
|
function isNode(value) {
|
|
41
59
|
if (!value || typeof value !== "object") {
|
|
42
60
|
return false;
|
|
@@ -86,11 +104,7 @@ export function isFocusMessage(value) {
|
|
|
86
104
|
message.action === "focus" &&
|
|
87
105
|
Array.isArray(message.targets) &&
|
|
88
106
|
message.targets.length <= 10 &&
|
|
89
|
-
message.targets.every((target) => target &&
|
|
90
|
-
typeof target === "object" &&
|
|
91
|
-
typeof target.blockId === "string" &&
|
|
92
|
-
target.blockId.length > 0 &&
|
|
93
|
-
(target.field === undefined || isFieldName(target.field))));
|
|
107
|
+
message.targets.every((target) => isRecord(target) && isTarget(target)));
|
|
94
108
|
}
|
|
95
109
|
export function isReadyMessage(value) {
|
|
96
110
|
if (!value || typeof value !== "object") {
|
|
@@ -110,7 +124,17 @@ export function isLabelsMessage(value) {
|
|
|
110
124
|
return (message.type === MESSAGE_TYPE &&
|
|
111
125
|
message.action === "labels" &&
|
|
112
126
|
isLabelMap(message.blocks) &&
|
|
113
|
-
(message.rows === undefined || isLabelMap(message.rows))
|
|
127
|
+
(message.rows === undefined || isLabelMap(message.rows)) &&
|
|
128
|
+
(message.fields === undefined || isFieldLabels(message.fields)) &&
|
|
129
|
+
(message.global === undefined ||
|
|
130
|
+
(typeof message.global === "string" && message.global.length <= MAX_LABEL)));
|
|
131
|
+
}
|
|
132
|
+
function isFieldLabels(value) {
|
|
133
|
+
if (!isRecord(value)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const labels = Object.values(value);
|
|
137
|
+
return labels.length <= MAX_LABELED_FIELDS && labels.every(isLabel);
|
|
114
138
|
}
|
|
115
139
|
function isLabelMap(value) {
|
|
116
140
|
if (!isRecord(value)) {
|
|
@@ -144,6 +168,7 @@ export function pathFromDomId(id) {
|
|
|
144
168
|
* Rows enclosing a form path, innermost first, with the rest of the path as the field.
|
|
145
169
|
* "layout.1.items.0.title" → the items row (field "title"), then the layout row (field undefined:
|
|
146
170
|
* "items.0.title" is not a plain field name the preview could have marked).
|
|
171
|
+
* A path outside any row ("siteName", "meta.title") targets the top-level field.
|
|
147
172
|
*/
|
|
148
173
|
export function focusTargets(path, fields) {
|
|
149
174
|
const segments = path.split(".");
|
|
@@ -160,6 +185,9 @@ export function focusTargets(path, fields) {
|
|
|
160
185
|
const rest = segments.slice(i + 1).join(".");
|
|
161
186
|
targets.push(rest && isFieldName(rest) ? { blockId: id, field: rest } : { blockId: id });
|
|
162
187
|
}
|
|
188
|
+
if (isFieldName(path)) {
|
|
189
|
+
targets.push({ field: path });
|
|
190
|
+
}
|
|
163
191
|
return targets;
|
|
164
192
|
}
|
|
165
193
|
/**
|
package/dist/reveal-plan.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Work out which tabs must be opened to show a form path.
|
|
3
|
-
* so a field in another tab has no DOM element until its tab button is clicked.
|
|
2
|
+
* Work out which tabs and collapsibles must be opened to show a form path.
|
|
4
3
|
* Pure: takes client field config, returns steps for the Admin bridge to perform.
|
|
5
4
|
*/
|
|
6
5
|
export type FieldLike = {
|
|
@@ -33,6 +32,15 @@ export type RevealStep =
|
|
|
33
32
|
ordinal: number;
|
|
34
33
|
index: number;
|
|
35
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Open the collapsible field Payload renders at this path, then enter its content. The path is
|
|
37
|
+
* Payload's own for an unnamed field (`meta._index-0-1`) and gives its DOM id, so a collapsible
|
|
38
|
+
* hidden by a condition does not shift which one is opened.
|
|
39
|
+
*/
|
|
40
|
+
| {
|
|
41
|
+
kind: "collapsible";
|
|
42
|
+
path: string;
|
|
43
|
+
}
|
|
36
44
|
/** Enter a block or array row. The row element becomes the scope. */
|
|
37
45
|
| {
|
|
38
46
|
kind: "row";
|
package/dist/reveal-plan.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Work out which tabs must be opened to show a form path.
|
|
3
|
-
* so a field in another tab has no DOM element until its tab button is clicked.
|
|
2
|
+
* Work out which tabs and collapsibles must be opened to show a form path.
|
|
4
3
|
* Pure: takes client field config, returns steps for the Admin bridge to perform.
|
|
5
4
|
*/
|
|
6
5
|
export function planReveal(rootFields, path, lookup) {
|
|
@@ -14,22 +13,22 @@ function walkPath(rootFields, path, lookup) {
|
|
|
14
13
|
const segments = path.split(".");
|
|
15
14
|
const steps = [];
|
|
16
15
|
let fields = rootFields;
|
|
17
|
-
let counter = {
|
|
16
|
+
let counter = { tabs: 0 };
|
|
18
17
|
let walked = [];
|
|
19
18
|
let last = null;
|
|
20
19
|
for (let i = 0; i < segments.length;) {
|
|
21
20
|
const segment = segments[i] ?? "";
|
|
22
|
-
const found = findIn(fields, segment, counter);
|
|
21
|
+
const found = findIn(fields, segment, counter, { dataPath: walked.join("."), indexPath: "" });
|
|
23
22
|
if (!found) {
|
|
24
23
|
return null;
|
|
25
24
|
}
|
|
26
|
-
steps.push(...found.
|
|
25
|
+
steps.push(...found.steps);
|
|
27
26
|
counter = found.counter;
|
|
28
27
|
walked = [...walked, segment];
|
|
29
28
|
i += 1;
|
|
30
29
|
if (found.kind === "namedTab") {
|
|
31
30
|
fields = found.fields;
|
|
32
|
-
counter = {
|
|
31
|
+
counter = { tabs: 0 };
|
|
33
32
|
continue;
|
|
34
33
|
}
|
|
35
34
|
const field = found.field;
|
|
@@ -42,7 +41,7 @@ function walkPath(rootFields, path, lookup) {
|
|
|
42
41
|
steps.push({ kind: "row", parentPath, index: Number(next) });
|
|
43
42
|
walked = [...walked, next];
|
|
44
43
|
i += 1;
|
|
45
|
-
counter = {
|
|
44
|
+
counter = { tabs: 0 };
|
|
46
45
|
if (field.type === "array") {
|
|
47
46
|
fields = field.fields ?? [];
|
|
48
47
|
continue;
|
|
@@ -55,7 +54,7 @@ function walkPath(rootFields, path, lookup) {
|
|
|
55
54
|
continue;
|
|
56
55
|
}
|
|
57
56
|
if (field.fields) {
|
|
58
|
-
// Named group: same DOM scope, keep counting
|
|
57
|
+
// Named group: same DOM scope, keep counting revealable fields.
|
|
59
58
|
fields = field.fields;
|
|
60
59
|
continue;
|
|
61
60
|
}
|
|
@@ -64,51 +63,72 @@ function walkPath(rootFields, path, lookup) {
|
|
|
64
63
|
}
|
|
65
64
|
return { steps, field: last };
|
|
66
65
|
}
|
|
67
|
-
function findIn(fields, name, counter) {
|
|
68
|
-
for (const field of fields) {
|
|
66
|
+
function findIn(fields, name, counter, place) {
|
|
67
|
+
for (const [position, field] of fields.entries()) {
|
|
69
68
|
if (field.type === "tabs") {
|
|
70
|
-
const ordinal = counter.
|
|
71
|
-
counter.
|
|
69
|
+
const ordinal = counter.tabs;
|
|
70
|
+
counter.tabs += 1;
|
|
71
|
+
const tabsPlace = unnamedChild(place, position);
|
|
72
72
|
for (const [index, tab] of (field.tabs ?? []).entries()) {
|
|
73
73
|
const step = { kind: "tab", ordinal, index };
|
|
74
74
|
if (tab.name === name) {
|
|
75
75
|
return {
|
|
76
76
|
kind: "namedTab",
|
|
77
77
|
fields: tab.fields ?? [],
|
|
78
|
-
|
|
79
|
-
counter: {
|
|
78
|
+
steps: [step],
|
|
79
|
+
counter: { tabs: 0 },
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
if (!tab.name) {
|
|
83
|
-
const inner = findIn(tab.fields ?? [], name, {
|
|
83
|
+
const inner = findIn(tab.fields ?? [], name, { tabs: 0 }, unnamedChild(tabsPlace, index));
|
|
84
84
|
if (inner) {
|
|
85
|
-
return { ...inner,
|
|
85
|
+
return { ...inner, steps: [step, ...inner.steps] };
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
continue;
|
|
90
90
|
}
|
|
91
91
|
if (field.name === name) {
|
|
92
|
-
return { kind: "field", field,
|
|
92
|
+
return { kind: "field", field, steps: [], counter };
|
|
93
93
|
}
|
|
94
94
|
if (!field.name && field.fields) {
|
|
95
|
-
// Row, collapsible or unnamed group:
|
|
96
|
-
|
|
95
|
+
// Row, collapsible or unnamed group: no segment in the form path. A collapsible is its own
|
|
96
|
+
// DOM scope, found by the path Payload gives it.
|
|
97
|
+
const own = unnamedChild(place, position);
|
|
98
|
+
if (field.type === "collapsible") {
|
|
99
|
+
const inner = findIn(field.fields, name, { tabs: 0 }, own);
|
|
100
|
+
if (inner) {
|
|
101
|
+
const path = [place.dataPath, `_index-${own.indexPath}`].filter(Boolean).join(".");
|
|
102
|
+
return { ...inner, steps: [{ kind: "collapsible", path }, ...inner.steps] };
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const inner = findIn(field.fields, name, counter, own);
|
|
97
107
|
if (inner) {
|
|
98
108
|
return inner;
|
|
99
109
|
}
|
|
100
110
|
continue;
|
|
101
111
|
}
|
|
102
|
-
counter.
|
|
112
|
+
counter.tabs += countTabs(field);
|
|
103
113
|
}
|
|
104
114
|
return null;
|
|
105
115
|
}
|
|
116
|
+
/** The place of the unnamed field at `position`, whose own fields render there too. */
|
|
117
|
+
function unnamedChild(place, position) {
|
|
118
|
+
return {
|
|
119
|
+
dataPath: place.dataPath,
|
|
120
|
+
indexPath: place.indexPath ? `${place.indexPath}-${position}` : String(position),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
106
123
|
/** Tabs fields rendered inside a field that is skipped, so later ordinals stay aligned with the DOM. */
|
|
107
124
|
function countTabs(field) {
|
|
108
125
|
if (field.type === "tabs") {
|
|
109
126
|
return 1;
|
|
110
127
|
}
|
|
111
|
-
if (field.type === "blocks" ||
|
|
128
|
+
if (field.type === "blocks" ||
|
|
129
|
+
field.type === "array" ||
|
|
130
|
+
field.type === "collapsible" ||
|
|
131
|
+
!field.fields) {
|
|
112
132
|
return 0;
|
|
113
133
|
}
|
|
114
134
|
return field.fields.reduce((sum, child) => sum + countTabs(child), 0);
|