@simmalugnt-se/payload-visual-editing 0.1.0 → 0.1.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 ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - Array rows in the preview outline are labeled like Admin's row headers ("Item 02 › Title")
6
+ instead of "Block › Title".
7
+
8
+ ## 0.1.0
9
+
10
+ - First release: click a block or field in Live Preview to open it in the edit form, with Admin's
11
+ block and field labels in the preview outline.
package/README.md CHANGED
@@ -70,5 +70,5 @@ Requires Payload `>=3.85.2 <4` and React 19.
70
70
  ## Labels
71
71
 
72
72
  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". Admin sends them to the
74
- preview when it loads, so nothing needs configuring.
73
+ `labels.singular`, then the field's `label`, e.g. "CTA › Button label". Array rows get their row
74
+ 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";
@@ -53,11 +53,15 @@ export function VisualEditingAdminBridge() {
53
53
  void select(event.data, run);
54
54
  };
55
55
  const sendLabels = (preview, origin) => {
56
- const { config, getEntityConfig, collectionSlug, language } = latest.current;
56
+ const { form, config, getEntityConfig, collectionSlug, language } = latest.current;
57
+ const fields = form.getFields();
58
+ const rootFields = rootFieldsOf(getEntityConfig, collectionSlug);
59
+ const blocksBySlug = config.blocksMap;
57
60
  const message = {
58
61
  type: MESSAGE_TYPE,
59
62
  action: "labels",
60
- blocks: collectBlockLabels(rootFieldsOf(getEntityConfig, collectionSlug), config.blocksMap, language),
63
+ blocks: collectBlockLabels(rootFields, blocksBySlug, language),
64
+ rows: collectRowLabels(rootFields, fields, { blockTypeAt: (rowPath) => rowAt(fields, rowPath)?.blockType, blocksBySlug }, language),
61
65
  };
62
66
  preview.postMessage(message, origin);
63
67
  };
@@ -19,7 +19,14 @@ 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;
24
31
  const redraw = () => {
25
32
  cancelAnimationFrame(frame);
@@ -34,6 +41,7 @@ export function VisualEditingPreview({ adminOrigin }) {
34
41
  return;
35
42
  }
36
43
  hovered = hit;
44
+ describe(hit);
37
45
  redraw();
38
46
  };
39
47
  const onPointerLeave = () => {
@@ -72,7 +80,7 @@ export function VisualEditingPreview({ adminOrigin }) {
72
80
  return;
73
81
  }
74
82
  if (isLabelsMessage(event.data)) {
75
- labels = event.data.blocks;
83
+ labels = { blocks: event.data.blocks, rows: event.data.rows ?? labels.rows };
76
84
  hovered = hovered && relabel(hovered, labels);
77
85
  current = current && relabel(current, labels);
78
86
  redraw();
@@ -164,13 +172,18 @@ function hitForBlock(blockId, field, labels) {
164
172
  /** Same order as Admin's row header: the editor's block name, then the block's label, then its slug. */
165
173
  function labelFor(block, field, labels) {
166
174
  const type = block.getAttribute(ATTR_BLOCK_TYPE) ?? undefined;
167
- const known = type ? labels[type] : undefined;
175
+ const known = type ? labels.blocks[type] : labels.rows[block.getAttribute(ATTR_BLOCK) ?? ""];
168
176
  const blockLabel = block.getAttribute(ATTR_BLOCK_NAME) || known?.label || (type ? humanize(type) : "Block");
169
177
  if (!field) {
170
178
  return blockLabel;
171
179
  }
172
180
  return `${blockLabel} › ${known?.fields[field] ?? humanize(field)}`;
173
181
  }
182
+ /** Rows marked without a block type are array rows. */
183
+ function isArrayRow(element) {
184
+ const row = element.closest(`[${ATTR_BLOCK}]`);
185
+ return !!row && !row.hasAttribute(ATTR_BLOCK_TYPE);
186
+ }
174
187
  function relabel(hit, labels) {
175
188
  const block = hit.element.closest(`[${ATTR_BLOCK}]`);
176
189
  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 { BlockLike, FieldLike } from "./reveal-plan.ts";
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) {
@@ -43,17 +43,21 @@ export type FocusMessage = {
43
43
  }>;
44
44
  };
45
45
  export declare function isFocusMessage(value: unknown): value is FocusMessage;
46
- /** Preview → Admin: the preview has loaded and wants the block labels. */
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 block slug. */
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
- return (!!value &&
81
- typeof value === "object" &&
82
- value.type === MESSAGE_TYPE &&
83
- value.action === "ready");
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
- if (message.type !== MESSAGE_TYPE || message.action !== "labels" || !isRecord(message.blocks)) {
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(message.blocks);
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)) {
@@ -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
- export {};
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;
@@ -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.0",
3
+ "version": "0.1.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",
@@ -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"