@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 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 map:
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 latest = useRef({ form, livePreview, config, getEntityConfig, collectionSlug, language });
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, collectionSlug, language };
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, collectionSlug, language } = latest.current;
66
+ const { form, config, getEntityConfig, entity, language } = latest.current;
66
67
  const fields = form.getFields();
67
- const rootFields = rootFieldsOf(getEntityConfig, collectionSlug);
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, collectionSlug } = latest.current;
82
+ const { form, config, getEntityConfig, entity } = latest.current;
79
83
  const fields = form.getFields();
80
- const target = resolveBlockTarget(fields, message.blockId);
81
- if (!target) {
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
- expandRows(target, fields, form.dispatchFields);
85
- const rootFields = rootFieldsOf(getEntityConfig, collectionSlug);
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 ? `${target.rowPath}.${message.field}` : undefined;
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 plan = fieldPlan ?? planReveal(rootFields, target.rowPath, lookup) ?? [];
93
- window.dispatchEvent(new CustomEvent(SELECT_EVENT, {
94
- detail: { path: fieldPlan && fieldPath ? fieldPath : target.rowPath },
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 && message.field ? `${target.rowPath}.${message.field}` : undefined;
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(() => findRow(target)));
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 !== findRow(target), current);
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
- return collectionSlug
148
- ? (getEntityConfig({ collectionSlug })?.fields ?? [])
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
- /** The edit form this bridge belongs to, not one in a document drawer. */
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 tabs the plan needs, one level at a time, waiting for each to render.
213
- * Returns the innermost scope (row or tab content), where the target field renders.
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 tabs and rows are other scopes. */
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";
@@ -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 = { blocks: event.data.blocks, rows: event.data.rows ?? labels.rows };
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?.blockId === hit.blockId && current.field === hit.field) {
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 block = marked?.closest(`[${ATTR_BLOCK}]`);
185
- const blockId = block?.getAttribute(ATTR_BLOCK);
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
- /** Same order as Admin's row header: the editor's block name, then the block's label, then its slug. */
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 block = hit.element.closest(`[${ATTR_BLOCK}]`);
238
- return block ? { ...hit, label: labelFor(block, hit.field, labels) } : hit;
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 enabled = new Set(options.collections);
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) => enabled.has(collection.slug) ? withBridge(collection) : 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
+ }
@@ -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: string;
25
- /** Field name relative to the block, e.g. "headline". */
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
- /** Candidates from the innermost row outwards; the preview uses the first it has marked. */
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: string;
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
- /** Admin → preview: labels for the overlay, blocks keyed by slug and array rows by row id. */
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
- typeof message.blockId === "string" &&
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
  /**
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Work out which tabs must be opened to show a form path. Payload only renders the active tab,
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";
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Work out which tabs must be opened to show a form path. Payload only renders the active tab,
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 = { value: 0 };
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.tabs);
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 = { value: 0 };
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 = { value: 0 };
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 tabs.
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.value;
71
- counter.value += 1;
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
- tabs: [step],
79
- counter: { value: 0 },
78
+ steps: [step],
79
+ counter: { tabs: 0 },
80
80
  };
81
81
  }
82
82
  if (!tab.name) {
83
- const inner = findIn(tab.fields ?? [], name, { value: 0 });
83
+ const inner = findIn(tab.fields ?? [], name, { tabs: 0 }, unnamedChild(tabsPlace, index));
84
84
  if (inner) {
85
- return { ...inner, tabs: [step, ...inner.tabs] };
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, tabs: [], counter };
92
+ return { kind: "field", field, steps: [], counter };
93
93
  }
94
94
  if (!field.name && field.fields) {
95
- // Row, collapsible or unnamed group: transparent for paths.
96
- const inner = findIn(field.fields, name, counter);
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.value += countTabs(field);
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" || field.type === "array" || !field.fields) {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-visual-editing",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Click a block in Payload Live Preview to open its fields in the edit form",
5
5
  "keywords": [
6
6
  "payload",