@simmalugnt-se/payload-visual-editing 0.1.0 → 0.1.2
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 +25 -0
- package/README.md +8 -2
- package/dist/admin/VisualEditingAdminBridge.js +103 -30
- package/dist/frontend/VisualEditingPreview.js +59 -6
- package/dist/labels.d.ts +12 -2
- package/dist/labels.js +31 -0
- package/dist/protocol.d.ts +7 -3
- package/dist/protocol.js +13 -6
- package/dist/reveal-plan.d.ts +7 -2
- package/dist/reveal-plan.js +11 -2
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
- A click in the preview no longer blocks the page's own click handlers: accordions, tabs and
|
|
6
|
+
sliders keep working while the click also selects. Only links and form submits are cancelled, so
|
|
7
|
+
the preview does not navigate away.
|
|
8
|
+
- The outline follows its element when it changes size or moves without scrolling, e.g. an accordion
|
|
9
|
+
opening, instead of waiting for the next pointer move.
|
|
10
|
+
- Admin scrolls to the right field even while the form grows under the scroll (field groups
|
|
11
|
+
rendering, textareas resizing): it waits for the layout to settle, and scrolls again if the target
|
|
12
|
+
moved out of view.
|
|
13
|
+
- Clicks in the preview no longer sometimes do nothing. Payload renders a group of fields only when
|
|
14
|
+
it is near the viewport, so rows, tabs and fields far down the form were not in the DOM yet; the
|
|
15
|
+
Admin bridge now scrolls each level into view before waiting for the next one.
|
|
16
|
+
|
|
17
|
+
## 0.1.1
|
|
18
|
+
|
|
19
|
+
- Array rows in the preview outline are labeled like Admin's row headers ("Item 02 › Title")
|
|
20
|
+
instead of "Block › Title".
|
|
21
|
+
|
|
22
|
+
## 0.1.0
|
|
23
|
+
|
|
24
|
+
- First release: click a block or field in Live Preview to open it in the edit form, with Admin's
|
|
25
|
+
block and field labels in the preview outline.
|
package/README.md
CHANGED
|
@@ -4,6 +4,9 @@ Click a block or field in Payload's Live Preview to open it in the edit form. Ta
|
|
|
4
4
|
expanded, the field scrolled into view and highlighted; rich text puts the cursor in the clicked
|
|
5
5
|
paragraph. Focusing a field in Admin outlines it in the preview; clicking anything else clears it.
|
|
6
6
|
|
|
7
|
+
In the preview a click selects without leaving the page: links and form submits are cancelled, while
|
|
8
|
+
the page's own click handlers (accordions, tabs, sliders) still run.
|
|
9
|
+
|
|
7
10
|
Requires Payload `>=3.85.2 <4` and React 19.
|
|
8
11
|
|
|
9
12
|
## Setup
|
|
@@ -64,11 +67,14 @@ Requires Payload `>=3.85.2 <4` and React 19.
|
|
|
64
67
|
</section>
|
|
65
68
|
```
|
|
66
69
|
|
|
70
|
+
In lists, put `key` before the spread (`<li key={row.id} {...editableBlock(row)}>`). A spread
|
|
71
|
+
before `key` makes React warn that list children have no unique key.
|
|
72
|
+
|
|
67
73
|
Marking blocks alone is enough to start: a click then opens the whole block. Add `editableField`
|
|
68
74
|
where editors should land on a specific field.
|
|
69
75
|
|
|
70
76
|
## Labels
|
|
71
77
|
|
|
72
78
|
The outline shows the same names as Admin: the editor's block name, otherwise the block's
|
|
73
|
-
`labels.singular`, then the field's `label`, e.g. "CTA › Button label".
|
|
74
|
-
|
|
79
|
+
`labels.singular`, then the field's `label`, e.g. "CTA › Button label". Array rows get their row
|
|
80
|
+
header, e.g. "Item 02 › Title". Admin sends the labels to the preview, so nothing needs configuring.
|
|
@@ -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 } from "../labels.js";
|
|
6
|
+
import { collectBlockLabels, collectRowLabels } from "../labels.js";
|
|
7
7
|
import { fieldDomId, focusTargets, isReadyMessage, isSelectMessage, MESSAGE_TYPE, matchNodeIndex, pathFromDomId, pathFromRowDomId, resolveBlockTarget, rowDomId, } from "../protocol.js";
|
|
8
8
|
import { planReveal } from "../reveal-plan.js";
|
|
9
9
|
import { HIGHLIGHT_TINT } from "../theme.js";
|
|
@@ -13,6 +13,10 @@ const HIGHLIGHT_MS = 1600;
|
|
|
13
13
|
const WAIT_MS = 1000;
|
|
14
14
|
/** Rich text editors are lazy loaded and can take noticeably longer to mount. Only rich text clicks wait this long. */
|
|
15
15
|
const FIELD_WAIT_MS = 4000;
|
|
16
|
+
/** Frames an element must keep its position before the layout counts as settled. */
|
|
17
|
+
const STABLE_FRAMES = 3;
|
|
18
|
+
/** Roughly how long a smooth scroll takes. */
|
|
19
|
+
const SCROLL_MS = 600;
|
|
16
20
|
/**
|
|
17
21
|
* Mounted in the document edit view. Listens for clicks reported by the preview window
|
|
18
22
|
* and brings the matching block (or field) into view in the form: opens tabs, expands rows,
|
|
@@ -53,11 +57,15 @@ export function VisualEditingAdminBridge() {
|
|
|
53
57
|
void select(event.data, run);
|
|
54
58
|
};
|
|
55
59
|
const sendLabels = (preview, origin) => {
|
|
56
|
-
const { config, getEntityConfig, collectionSlug, language } = latest.current;
|
|
60
|
+
const { form, config, getEntityConfig, collectionSlug, language } = latest.current;
|
|
61
|
+
const fields = form.getFields();
|
|
62
|
+
const rootFields = rootFieldsOf(getEntityConfig, collectionSlug);
|
|
63
|
+
const blocksBySlug = config.blocksMap;
|
|
57
64
|
const message = {
|
|
58
65
|
type: MESSAGE_TYPE,
|
|
59
66
|
action: "labels",
|
|
60
|
-
blocks: collectBlockLabels(
|
|
67
|
+
blocks: collectBlockLabels(rootFields, blocksBySlug, language),
|
|
68
|
+
rows: collectRowLabels(rootFields, fields, { blockTypeAt: (rowPath) => rowAt(fields, rowPath)?.blockType, blocksBySlug }, language),
|
|
61
69
|
};
|
|
62
70
|
preview.postMessage(message, origin);
|
|
63
71
|
};
|
|
@@ -78,22 +86,23 @@ export function VisualEditingAdminBridge() {
|
|
|
78
86
|
const fieldPlan = fieldPath ? planReveal(rootFields, fieldPath, lookup) : null;
|
|
79
87
|
const plan = fieldPlan ?? planReveal(rootFields, target.rowPath, lookup) ?? [];
|
|
80
88
|
const root = formRoot(anchorRef.current);
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
89
|
+
const scope = await openSteps(plan, root, () => id === run);
|
|
90
|
+
if (!scope || id !== run) {
|
|
83
91
|
return;
|
|
84
92
|
}
|
|
85
93
|
// Wait for the field itself; the row renders first and would otherwise win.
|
|
86
94
|
const field = fieldPlan && message.field ? `${target.rowPath}.${message.field}` : undefined;
|
|
87
95
|
const element = (field
|
|
88
|
-
? await
|
|
96
|
+
? await waitForRendered(() => findField(field), scope, message.node ? FIELD_WAIT_MS : WAIT_MS)
|
|
89
97
|
: null) ?? (await waitFor(() => findRow(target)));
|
|
90
98
|
if (!element || id !== run) {
|
|
91
99
|
return;
|
|
92
100
|
}
|
|
93
|
-
|
|
101
|
+
const current = () => id === run;
|
|
102
|
+
if (message.node && (await revealRichTextNode(element, message.node, current))) {
|
|
94
103
|
return;
|
|
95
104
|
}
|
|
96
|
-
reveal(element, field !== undefined && element !== findRow(target));
|
|
105
|
+
await reveal(element, field !== undefined && element !== findRow(target), current);
|
|
97
106
|
};
|
|
98
107
|
window.addEventListener("message", onMessage);
|
|
99
108
|
return () => window.removeEventListener("message", onMessage);
|
|
@@ -188,25 +197,29 @@ function formRoot(anchor) {
|
|
|
188
197
|
const view = anchor?.closest(".collection-edit");
|
|
189
198
|
return view?.querySelector(".collection-edit__form") ?? document;
|
|
190
199
|
}
|
|
191
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* Open the rows and tabs the plan needs, one level at a time, waiting for each to render.
|
|
202
|
+
* Returns the innermost scope (row or tab content), where the target field renders.
|
|
203
|
+
*/
|
|
192
204
|
async function openSteps(steps, root, current) {
|
|
193
205
|
let scope = root;
|
|
194
206
|
for (const step of steps) {
|
|
195
207
|
if (!current()) {
|
|
196
|
-
return
|
|
208
|
+
return null;
|
|
197
209
|
}
|
|
198
210
|
if (step.kind === "row") {
|
|
199
|
-
const row = await
|
|
211
|
+
const row = await waitForRendered(() => document.getElementById(rowDomId(step)), scope);
|
|
200
212
|
if (!row) {
|
|
201
|
-
return
|
|
213
|
+
return null;
|
|
202
214
|
}
|
|
203
215
|
scope = row;
|
|
204
216
|
continue;
|
|
205
217
|
}
|
|
206
|
-
const
|
|
218
|
+
const tabsScope = scope;
|
|
219
|
+
const tabs = await waitForRendered(() => tabsFieldsIn(tabsScope)[step.ordinal] ?? null, tabsScope);
|
|
207
220
|
const button = tabs?.querySelectorAll(":scope > .tabs-field__tabs-wrap > .tabs-field__tabs > .tabs-field__tab-button")[step.index];
|
|
208
221
|
if (!tabs || !button) {
|
|
209
|
-
return
|
|
222
|
+
return null;
|
|
210
223
|
}
|
|
211
224
|
if (!button.classList.contains("tabs-field__tab-button--active")) {
|
|
212
225
|
button.click();
|
|
@@ -215,11 +228,26 @@ async function openSteps(steps, root, current) {
|
|
|
215
228
|
? tabs.querySelector(":scope > .tabs-field__content-wrap")
|
|
216
229
|
: null);
|
|
217
230
|
if (!content) {
|
|
218
|
-
return
|
|
231
|
+
return null;
|
|
219
232
|
}
|
|
220
233
|
scope = content;
|
|
221
234
|
}
|
|
222
|
-
return
|
|
235
|
+
return scope;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Payload renders a group of fields only once it is within 1000px of the viewport
|
|
239
|
+
* (`RenderIfInViewport`); until then the group is an empty div. So when the next level is not in
|
|
240
|
+
* the DOM yet, bring the enclosing scope into view to make Payload render it, then wait.
|
|
241
|
+
*/
|
|
242
|
+
async function waitForRendered(find, scope, timeoutMs = WAIT_MS) {
|
|
243
|
+
const found = find();
|
|
244
|
+
if (found) {
|
|
245
|
+
return found;
|
|
246
|
+
}
|
|
247
|
+
if (scope instanceof HTMLElement) {
|
|
248
|
+
scope.scrollIntoView({ block: "start" });
|
|
249
|
+
}
|
|
250
|
+
return waitFor(find, timeoutMs);
|
|
223
251
|
}
|
|
224
252
|
/** Tabs fields at this level of the scope, in DOM order; nested tabs and rows are other scopes. */
|
|
225
253
|
function tabsFieldsIn(scope) {
|
|
@@ -255,7 +283,7 @@ function findRow(target) {
|
|
|
255
283
|
* Put the cursor at the start of the clicked paragraph in a Lexical editor.
|
|
256
284
|
* Lexical renders each top-level node as one child of the contenteditable root.
|
|
257
285
|
*/
|
|
258
|
-
async function revealRichTextNode(field, node) {
|
|
286
|
+
async function revealRichTextNode(field, node, current) {
|
|
259
287
|
const editor = await waitFor(() => field.querySelector("[contenteditable='true'][data-lexical-editor='true']") ??
|
|
260
288
|
field.querySelector("[contenteditable='true']"), FIELD_WAIT_MS);
|
|
261
289
|
const children = editor ? Array.from(editor.children) : [];
|
|
@@ -266,7 +294,7 @@ async function revealRichTextNode(field, node) {
|
|
|
266
294
|
if (!(target instanceof HTMLElement)) {
|
|
267
295
|
return false;
|
|
268
296
|
}
|
|
269
|
-
target
|
|
297
|
+
await scrollWhenSettled(target, current);
|
|
270
298
|
editor.focus({ preventScroll: true });
|
|
271
299
|
const range = document.createRange();
|
|
272
300
|
const firstText = document.createTreeWalker(target, NodeFilter.SHOW_TEXT).nextNode();
|
|
@@ -286,21 +314,66 @@ async function revealRichTextNode(field, node) {
|
|
|
286
314
|
* Scroll to and highlight a field or a block row. Only a field gets focus: focusing a row
|
|
287
315
|
* would land in its first input (the block name), which is not what was clicked.
|
|
288
316
|
*/
|
|
289
|
-
function reveal(element, isField) {
|
|
317
|
+
async function reveal(element, isField, current) {
|
|
290
318
|
// A row sits inside the blocks field's `.field-type`; highlighting that would mark the whole list.
|
|
291
319
|
const highlighted = isField ? (element.closest(".field-type") ?? element) : element;
|
|
292
|
-
highlighted
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
320
|
+
await scrollWhenSettled(highlighted, current, () => {
|
|
321
|
+
if (isField) {
|
|
322
|
+
const focusable = isFocusable(element)
|
|
323
|
+
? element
|
|
324
|
+
: (element.querySelector("input:not([type='hidden']):not([type='file']), textarea, select, [contenteditable='true']") ?? element.querySelector("button:not([disabled])"));
|
|
325
|
+
focusable?.focus({ preventScroll: true });
|
|
326
|
+
}
|
|
327
|
+
highlighted.classList.remove(HIGHLIGHT_CLASS);
|
|
328
|
+
// Restart the animation when the same element is clicked twice.
|
|
329
|
+
void highlighted.offsetWidth;
|
|
330
|
+
highlighted.classList.add(HIGHLIGHT_CLASS);
|
|
331
|
+
window.setTimeout(() => highlighted.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_MS);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Scroll `element` to the middle of the form, robust to the form changing under the scroll:
|
|
336
|
+
* Payload renders field groups as they come near the viewport and textareas grow to fit their
|
|
337
|
+
* text, which pushes the target down. Start once its position is stable, and scroll again if it
|
|
338
|
+
* has moved out of view by the time the scroll is done. `onScroll` runs as the scroll starts.
|
|
339
|
+
*/
|
|
340
|
+
async function scrollWhenSettled(element, current, onScroll) {
|
|
341
|
+
await layoutSettled(element);
|
|
342
|
+
if (!current()) {
|
|
343
|
+
return;
|
|
298
344
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
345
|
+
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
346
|
+
onScroll?.();
|
|
347
|
+
await new Promise((resolve) => window.setTimeout(resolve, SCROLL_MS));
|
|
348
|
+
await layoutSettled(element);
|
|
349
|
+
if (current() && !inView(element)) {
|
|
350
|
+
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Resolves once the element has kept its position for a few frames (or after a timeout). */
|
|
354
|
+
function layoutSettled(element, timeoutMs = WAIT_MS) {
|
|
355
|
+
const deadline = performance.now() + timeoutMs;
|
|
356
|
+
let last = Number.NaN;
|
|
357
|
+
let stable = 0;
|
|
358
|
+
return new Promise((resolve) => {
|
|
359
|
+
const tick = () => {
|
|
360
|
+
const top = element.getBoundingClientRect().top;
|
|
361
|
+
stable = top === last ? stable + 1 : 0;
|
|
362
|
+
last = top;
|
|
363
|
+
if (stable >= STABLE_FRAMES || performance.now() >= deadline) {
|
|
364
|
+
resolve();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
requestAnimationFrame(tick);
|
|
368
|
+
};
|
|
369
|
+
tick();
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/** Mostly visible: its middle, or for a tall element any part, is within the middle of the viewport. */
|
|
373
|
+
function inView(element) {
|
|
374
|
+
const rect = element.getBoundingClientRect();
|
|
375
|
+
const margin = window.innerHeight * 0.15;
|
|
376
|
+
return rect.bottom > margin && rect.top < window.innerHeight - margin;
|
|
304
377
|
}
|
|
305
378
|
function isFocusable(element) {
|
|
306
379
|
return (element instanceof HTMLInputElement ||
|
|
@@ -19,11 +19,39 @@ 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 = {};
|
|
22
|
+
let labels = { blocks: {}, rows: {} };
|
|
23
|
+
// Array row numbers change as rows move, so ask again whenever the pointer reaches a row.
|
|
24
|
+
const describe = (hit) => {
|
|
25
|
+
if (hit && isArrayRow(hit.element)) {
|
|
26
|
+
const message = { type: MESSAGE_TYPE, action: "describe" };
|
|
27
|
+
admin.postMessage(message, targetOrigin);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
23
30
|
let frame = 0;
|
|
31
|
+
// Outlined elements can change size without any scroll or pointer event (an accordion opening,
|
|
32
|
+
// an image loading); follow them. Observing a new element runs the callback once, which only
|
|
33
|
+
// re-places the outlines, so this settles.
|
|
34
|
+
const observed = new Set();
|
|
35
|
+
const resizes = new ResizeObserver(() => redraw());
|
|
36
|
+
const observeOutlined = () => {
|
|
37
|
+
const wanted = new Set([hovered?.element, current?.element].filter((el) => !!el));
|
|
38
|
+
for (const element of observed) {
|
|
39
|
+
if (!wanted.has(element)) {
|
|
40
|
+
resizes.unobserve(element);
|
|
41
|
+
observed.delete(element);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const element of wanted) {
|
|
45
|
+
if (!observed.has(element)) {
|
|
46
|
+
resizes.observe(element);
|
|
47
|
+
observed.add(element);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
24
51
|
const redraw = () => {
|
|
25
52
|
cancelAnimationFrame(frame);
|
|
26
53
|
frame = requestAnimationFrame(() => {
|
|
54
|
+
observeOutlined();
|
|
27
55
|
place(hover, hovered && hovered.element !== current?.element ? hovered : null);
|
|
28
56
|
place(selected, current);
|
|
29
57
|
});
|
|
@@ -34,6 +62,7 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
34
62
|
return;
|
|
35
63
|
}
|
|
36
64
|
hovered = hit;
|
|
65
|
+
describe(hit);
|
|
37
66
|
redraw();
|
|
38
67
|
};
|
|
39
68
|
const onPointerLeave = () => {
|
|
@@ -53,9 +82,11 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
53
82
|
clearSelection();
|
|
54
83
|
return;
|
|
55
84
|
}
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
event.
|
|
85
|
+
// A click selects, and must not leave the page: links and submits are cancelled. Anything
|
|
86
|
+
// else still reaches the page's own handlers, so accordions, tabs and sliders keep working.
|
|
87
|
+
if (navigates(event.target)) {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
}
|
|
59
90
|
current = hit;
|
|
60
91
|
redraw();
|
|
61
92
|
const message = {
|
|
@@ -72,7 +103,7 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
72
103
|
return;
|
|
73
104
|
}
|
|
74
105
|
if (isLabelsMessage(event.data)) {
|
|
75
|
-
labels = event.data.blocks;
|
|
106
|
+
labels = { blocks: event.data.blocks, rows: event.data.rows ?? labels.rows };
|
|
76
107
|
hovered = hovered && relabel(hovered, labels);
|
|
77
108
|
current = current && relabel(current, labels);
|
|
78
109
|
redraw();
|
|
@@ -113,6 +144,9 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
113
144
|
window.addEventListener("message", onMessage);
|
|
114
145
|
window.addEventListener("scroll", redraw, { passive: true, capture: true });
|
|
115
146
|
window.addEventListener("resize", redraw);
|
|
147
|
+
// Elements that move because something above them grew or shrank.
|
|
148
|
+
document.addEventListener("transitionend", redraw, true);
|
|
149
|
+
document.addEventListener("animationend", redraw, true);
|
|
116
150
|
const ready = { type: MESSAGE_TYPE, action: "ready" };
|
|
117
151
|
admin.postMessage(ready, targetOrigin);
|
|
118
152
|
return () => {
|
|
@@ -124,6 +158,9 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
124
158
|
window.removeEventListener("message", onMessage);
|
|
125
159
|
window.removeEventListener("scroll", redraw, { capture: true });
|
|
126
160
|
window.removeEventListener("resize", redraw);
|
|
161
|
+
document.removeEventListener("transitionend", redraw, true);
|
|
162
|
+
document.removeEventListener("animationend", redraw, true);
|
|
163
|
+
resizes.disconnect();
|
|
127
164
|
hover.remove();
|
|
128
165
|
selected.remove();
|
|
129
166
|
};
|
|
@@ -164,13 +201,29 @@ function hitForBlock(blockId, field, labels) {
|
|
|
164
201
|
/** Same order as Admin's row header: the editor's block name, then the block's label, then its slug. */
|
|
165
202
|
function labelFor(block, field, labels) {
|
|
166
203
|
const type = block.getAttribute(ATTR_BLOCK_TYPE) ?? undefined;
|
|
167
|
-
const known = type ? labels[type] :
|
|
204
|
+
const known = type ? labels.blocks[type] : labels.rows[block.getAttribute(ATTR_BLOCK) ?? ""];
|
|
168
205
|
const blockLabel = block.getAttribute(ATTR_BLOCK_NAME) || known?.label || (type ? humanize(type) : "Block");
|
|
169
206
|
if (!field) {
|
|
170
207
|
return blockLabel;
|
|
171
208
|
}
|
|
172
209
|
return `${blockLabel} › ${known?.fields[field] ?? humanize(field)}`;
|
|
173
210
|
}
|
|
211
|
+
/** Whether the default action of a click here would navigate away or submit a form. */
|
|
212
|
+
function navigates(target) {
|
|
213
|
+
if (!(target instanceof Element)) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
if (target.closest("a[href]")) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
const control = target.closest("button, input");
|
|
220
|
+
return !!control?.form && control.type === "submit";
|
|
221
|
+
}
|
|
222
|
+
/** Rows marked without a block type are array rows. */
|
|
223
|
+
function isArrayRow(element) {
|
|
224
|
+
const row = element.closest(`[${ATTR_BLOCK}]`);
|
|
225
|
+
return !!row && !row.hasAttribute(ATTR_BLOCK_TYPE);
|
|
226
|
+
}
|
|
174
227
|
function relabel(hit, labels) {
|
|
175
228
|
const block = hit.element.closest(`[${ATTR_BLOCK}]`);
|
|
176
229
|
return block ? { ...hit, label: labelFor(block, hit.field, labels) } : hit;
|
package/dist/labels.d.ts
CHANGED
|
@@ -3,15 +3,25 @@
|
|
|
3
3
|
* the same names ("CTA › Title") instead of guessing from slugs ("C ta › Title").
|
|
4
4
|
* Pure: takes client field config, returns plain strings safe to post to the preview.
|
|
5
5
|
*/
|
|
6
|
-
import type
|
|
6
|
+
import { type BlockLike, type FieldLike, type Lookup } from "./reveal-plan.ts";
|
|
7
7
|
export type BlockLabel = {
|
|
8
8
|
/** `labels.singular` of the block config. */
|
|
9
9
|
label?: string;
|
|
10
10
|
/** Field path relative to the block (e.g. "title", "hero.headline") → its label. */
|
|
11
11
|
fields: Record<string, string>;
|
|
12
12
|
};
|
|
13
|
-
/** Block slug → labels. */
|
|
13
|
+
/** Block slug (or, for array rows, row id) → labels. */
|
|
14
14
|
export type BlockLabels = Record<string, BlockLabel>;
|
|
15
|
+
/** More than any page has; keeps the message within what the preview accepts. */
|
|
16
|
+
export declare const MAX_ROW_LABELS = 500;
|
|
15
17
|
export declare function collectBlockLabels(rootFields: FieldLike[], blocksBySlug: Record<string, BlockLike | undefined> | undefined, language: string): BlockLabels;
|
|
18
|
+
/**
|
|
19
|
+
* Labels for array rows, keyed by row id, as Admin's row headers show them: "Item 02".
|
|
20
|
+
* Array rows have no block slug to look labels up by, and their number changes when rows move,
|
|
21
|
+
* so these come from the current form state rather than the config alone.
|
|
22
|
+
*/
|
|
23
|
+
export declare function collectRowLabels(rootFields: FieldLike[], formFields: Record<string, {
|
|
24
|
+
value?: unknown;
|
|
25
|
+
} | undefined>, lookup: Lookup, language: string): BlockLabels;
|
|
16
26
|
/** Client config labels are a string or a `{ [language]: string }` map; anything else has no static text. */
|
|
17
27
|
export declare function labelText(label: unknown, language: string): string | undefined;
|
package/dist/labels.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* the same names ("CTA › Title") instead of guessing from slugs ("C ta › Title").
|
|
4
4
|
* Pure: takes client field config, returns plain strings safe to post to the preview.
|
|
5
5
|
*/
|
|
6
|
+
import { fieldAtPath } from "./reveal-plan.js";
|
|
7
|
+
/** More than any page has; keeps the message within what the preview accepts. */
|
|
8
|
+
export const MAX_ROW_LABELS = 500;
|
|
6
9
|
export function collectBlockLabels(rootFields, blocksBySlug, language) {
|
|
7
10
|
const result = {};
|
|
8
11
|
const visitFields = (fields) => {
|
|
@@ -28,6 +31,34 @@ export function collectBlockLabels(rootFields, blocksBySlug, language) {
|
|
|
28
31
|
visitFields(rootFields);
|
|
29
32
|
return result;
|
|
30
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Labels for array rows, keyed by row id, as Admin's row headers show them: "Item 02".
|
|
36
|
+
* Array rows have no block slug to look labels up by, and their number changes when rows move,
|
|
37
|
+
* so these come from the current form state rather than the config alone.
|
|
38
|
+
*/
|
|
39
|
+
export function collectRowLabels(rootFields, formFields, lookup, language) {
|
|
40
|
+
const result = {};
|
|
41
|
+
for (const [path, formField] of Object.entries(formFields)) {
|
|
42
|
+
const id = formField?.value;
|
|
43
|
+
const match = /^(.+)\.(\d+)\.id$/.exec(path);
|
|
44
|
+
if (typeof id !== "string" || !id || !match) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const [, parentPath = "", index = "0"] = match;
|
|
48
|
+
const field = fieldAtPath(rootFields, parentPath, lookup);
|
|
49
|
+
if (field?.type !== "array") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const singular = labelText(field.labels?.singular, language) ?? labelText(field.label, language) ?? field.name;
|
|
53
|
+
const fields = {};
|
|
54
|
+
collectFieldLabels(field.fields ?? [], "", language, fields);
|
|
55
|
+
result[id] = { label: `${singular} ${String(Number(index) + 1).padStart(2, "0")}`, fields };
|
|
56
|
+
if (Object.keys(result).length >= MAX_ROW_LABELS) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
31
62
|
/** Labels of the fields a block's preview can mark: named fields and paths into named groups and tabs. */
|
|
32
63
|
function collectFieldLabels(fields, prefix, language, into) {
|
|
33
64
|
for (const field of fields) {
|
package/dist/protocol.d.ts
CHANGED
|
@@ -43,17 +43,21 @@ export type FocusMessage = {
|
|
|
43
43
|
}>;
|
|
44
44
|
};
|
|
45
45
|
export declare function isFocusMessage(value: unknown): value is FocusMessage;
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Preview → Admin: send the labels. "ready" when the preview loads; "describe" when it points at
|
|
48
|
+
* an array row, whose number may have changed since.
|
|
49
|
+
*/
|
|
47
50
|
export type ReadyMessage = {
|
|
48
51
|
type: typeof MESSAGE_TYPE;
|
|
49
|
-
action: "ready";
|
|
52
|
+
action: "ready" | "describe";
|
|
50
53
|
};
|
|
51
54
|
export declare function isReadyMessage(value: unknown): value is ReadyMessage;
|
|
52
|
-
/** Admin → preview: labels for the overlay, keyed by
|
|
55
|
+
/** Admin → preview: labels for the overlay, blocks keyed by slug and array rows by row id. */
|
|
53
56
|
export type LabelsMessage = {
|
|
54
57
|
type: typeof MESSAGE_TYPE;
|
|
55
58
|
action: "labels";
|
|
56
59
|
blocks: BlockLabels;
|
|
60
|
+
rows?: BlockLabels;
|
|
57
61
|
};
|
|
58
62
|
export declare function isLabelsMessage(value: unknown): value is LabelsMessage;
|
|
59
63
|
/** Inverse of `fieldDomId`: "field-layout__0__headline" → "layout.0.headline". */
|
package/dist/protocol.js
CHANGED
|
@@ -77,10 +77,11 @@ export function isFocusMessage(value) {
|
|
|
77
77
|
(target.field === undefined || isFieldName(target.field))));
|
|
78
78
|
}
|
|
79
79
|
export function isReadyMessage(value) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
if (!value || typeof value !== "object") {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const message = value;
|
|
84
|
+
return (message.type === MESSAGE_TYPE && (message.action === "ready" || message.action === "describe"));
|
|
84
85
|
}
|
|
85
86
|
const MAX_LABELED_BLOCKS = 500;
|
|
86
87
|
const MAX_LABELED_FIELDS = 500;
|
|
@@ -90,10 +91,16 @@ export function isLabelsMessage(value) {
|
|
|
90
91
|
return false;
|
|
91
92
|
}
|
|
92
93
|
const message = value;
|
|
93
|
-
|
|
94
|
+
return (message.type === MESSAGE_TYPE &&
|
|
95
|
+
message.action === "labels" &&
|
|
96
|
+
isLabelMap(message.blocks) &&
|
|
97
|
+
(message.rows === undefined || isLabelMap(message.rows)));
|
|
98
|
+
}
|
|
99
|
+
function isLabelMap(value) {
|
|
100
|
+
if (!isRecord(value)) {
|
|
94
101
|
return false;
|
|
95
102
|
}
|
|
96
|
-
const blocks = Object.values(
|
|
103
|
+
const blocks = Object.values(value);
|
|
97
104
|
return (blocks.length <= MAX_LABELED_BLOCKS &&
|
|
98
105
|
blocks.every((block) => {
|
|
99
106
|
if (!isRecord(block) || !isRecord(block.fields)) {
|
package/dist/reveal-plan.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ export type FieldLike = {
|
|
|
7
7
|
type: string;
|
|
8
8
|
name?: string;
|
|
9
9
|
label?: unknown;
|
|
10
|
+
/** Array fields: Payload fills `singular` from the name when the config leaves it out. */
|
|
11
|
+
labels?: {
|
|
12
|
+
singular?: unknown;
|
|
13
|
+
};
|
|
10
14
|
fields?: FieldLike[];
|
|
11
15
|
tabs?: Array<{
|
|
12
16
|
name?: string;
|
|
@@ -35,11 +39,12 @@ export type RevealStep =
|
|
|
35
39
|
parentPath: string;
|
|
36
40
|
index: number;
|
|
37
41
|
};
|
|
38
|
-
type Lookup = {
|
|
42
|
+
export type Lookup = {
|
|
39
43
|
/** Block slug of the row at a form path, e.g. "layout.0" → "hero". */
|
|
40
44
|
blockTypeAt: (rowPath: string) => string | undefined;
|
|
41
45
|
/** Blocks declared once in config and referenced by slug. */
|
|
42
46
|
blocksBySlug?: Record<string, BlockLike | undefined>;
|
|
43
47
|
};
|
|
44
48
|
export declare function planReveal(rootFields: FieldLike[], path: string, lookup: Lookup): RevealStep[] | null;
|
|
45
|
-
|
|
49
|
+
/** The field config a form path ends at, e.g. "layout.2.items" → the `items` array field. */
|
|
50
|
+
export declare function fieldAtPath(rootFields: FieldLike[], path: string, lookup: Lookup): FieldLike | null;
|
package/dist/reveal-plan.js
CHANGED
|
@@ -4,11 +4,19 @@
|
|
|
4
4
|
* Pure: takes client field config, returns steps for the Admin bridge to perform.
|
|
5
5
|
*/
|
|
6
6
|
export function planReveal(rootFields, path, lookup) {
|
|
7
|
+
return walkPath(rootFields, path, lookup)?.steps ?? null;
|
|
8
|
+
}
|
|
9
|
+
/** The field config a form path ends at, e.g. "layout.2.items" → the `items` array field. */
|
|
10
|
+
export function fieldAtPath(rootFields, path, lookup) {
|
|
11
|
+
return walkPath(rootFields, path, lookup)?.field ?? null;
|
|
12
|
+
}
|
|
13
|
+
function walkPath(rootFields, path, lookup) {
|
|
7
14
|
const segments = path.split(".");
|
|
8
15
|
const steps = [];
|
|
9
16
|
let fields = rootFields;
|
|
10
17
|
let counter = { value: 0 };
|
|
11
18
|
let walked = [];
|
|
19
|
+
let last = null;
|
|
12
20
|
for (let i = 0; i < segments.length;) {
|
|
13
21
|
const segment = segments[i] ?? "";
|
|
14
22
|
const found = findIn(fields, segment, counter);
|
|
@@ -25,6 +33,7 @@ export function planReveal(rootFields, path, lookup) {
|
|
|
25
33
|
continue;
|
|
26
34
|
}
|
|
27
35
|
const field = found.field;
|
|
36
|
+
last = field;
|
|
28
37
|
const next = segments[i];
|
|
29
38
|
if ((field.type === "blocks" || field.type === "array") &&
|
|
30
39
|
next !== undefined &&
|
|
@@ -51,9 +60,9 @@ export function planReveal(rootFields, path, lookup) {
|
|
|
51
60
|
continue;
|
|
52
61
|
}
|
|
53
62
|
// A leaf; anything after it (e.g. rich text internals) is not a form field.
|
|
54
|
-
return i === segments.length ? steps : null;
|
|
63
|
+
return i === segments.length ? { steps, field } : null;
|
|
55
64
|
}
|
|
56
|
-
return steps;
|
|
65
|
+
return { steps, field: last };
|
|
57
66
|
}
|
|
58
67
|
function findIn(fields, name, counter) {
|
|
59
68
|
for (const field of fields) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simmalugnt-se/payload-visual-editing",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Click a block in Payload Live Preview to open its fields in the edit form",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"payload",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"node": ">=22"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
-
"dist"
|
|
21
|
+
"dist",
|
|
22
|
+
"CHANGELOG.md"
|
|
22
23
|
],
|
|
23
24
|
"sideEffects": [
|
|
24
25
|
"*.css"
|