@simmalugnt-se/payload-visual-editing 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Globals: `visualEditingPlugin({ globals: ["header", "footer"] })` supports click-to-edit in a
6
+ global's Live Preview.
7
+ - `editableField` outside a block marks a top-level field of the document, such as a header's site
8
+ name.
9
+ - `editableGlobal("header")` on the element that renders a global: its markers respond only while
10
+ that global is edited, so a header shown on a page preview no longer targets the page's form.
11
+
12
+ ## 0.2.1
13
+
14
+ - A preview click right after the page loads no longer lands on the wrong tab: Payload restores the
15
+ editor's last tab once preferences load, and now leaves it alone because the bridge always clicks
16
+ the tab it needs.
17
+
3
18
  ## 0.2.0
4
19
 
5
20
  - A click in the preview dispatches `visual-editing:select` on Admin's `window` with the form path,
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,7 +221,10 @@ 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;
@@ -232,9 +253,9 @@ async function openSteps(steps, root, current) {
232
253
  if (!tabs || !button) {
233
254
  return null;
234
255
  }
235
- if (!button.classList.contains("tabs-field__tab-button--active")) {
236
- button.click();
237
- }
256
+ // Click even the active tab: Payload restores the editor's last tab from preferences once they
257
+ // load, unless a tab was clicked first. A click right after the page loads would lose to it.
258
+ button.click();
238
259
  const content = await waitFor(() => button.classList.contains("tabs-field__tab-button--active")
239
260
  ? tabs.querySelector(":scope > .tabs-field__content-wrap")
240
261
  : null);
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-visual-editing",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Click a block in Payload Live Preview to open its fields in the edit form",
5
5
  "keywords": [
6
6
  "payload",
@@ -51,11 +51,11 @@
51
51
  "react-dom": "^19.0.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@payloadcms/ui": "3.87.1",
54
+ "@payloadcms/ui": "3.90.2",
55
55
  "@types/node": "^22",
56
56
  "@types/react": "^19.2.18",
57
57
  "@types/react-dom": "^19.2.4",
58
- "payload": "3.87.1",
58
+ "payload": "3.90.2",
59
59
  "react": "19.2.8",
60
60
  "react-dom": "19.2.8",
61
61
  "tsx": "^4.21.0",