@simmalugnt-se/payload-visual-editing 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simmalugnt AB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @simmalugnt-se/payload-visual-editing
2
+
3
+ Click a block or field in Payload's Live Preview to open it in the edit form. Tabs are opened, rows
4
+ expanded, the field scrolled into view and highlighted; rich text puts the cursor in the clicked
5
+ paragraph. Focusing a field in Admin outlines it in the preview; clicking anything else clears it.
6
+
7
+ Requires Payload `>=3.85.2 <4` and React 19.
8
+
9
+ ## Setup
10
+
11
+ 1. Install:
12
+
13
+ ```bash
14
+ pnpm add @simmalugnt-se/payload-visual-editing
15
+ ```
16
+
17
+ 2. Enable it for the collections that have Live Preview, then regenerate the import map:
18
+
19
+ ```ts
20
+ import { visualEditingPlugin } from "@simmalugnt-se/payload-visual-editing";
21
+
22
+ plugins: [visualEditingPlugin({ collections: ["pages"] })];
23
+ ```
24
+
25
+ ```bash
26
+ pnpm payload generate:importmap
27
+ ```
28
+
29
+ 3. Render the preview listener next to Payload's own, only in draft mode:
30
+
31
+ ```tsx
32
+ "use client";
33
+ import { RefreshRouteOnSave } from "@payloadcms/live-preview-react";
34
+ import { VisualEditingPreview } from "@simmalugnt-se/payload-visual-editing/frontend";
35
+
36
+ export function LivePreviewListener() {
37
+ const router = useRouter();
38
+ return (
39
+ <>
40
+ <RefreshRouteOnSave refresh={router.refresh} serverURL={serverURL} />
41
+ <VisualEditingPreview />
42
+ </>
43
+ );
44
+ }
45
+ ```
46
+
47
+ 4. Mark what can be clicked. Spread the markers only in draft mode:
48
+
49
+ ```tsx
50
+ import {
51
+ editableBlock,
52
+ editableField,
53
+ editableRichText,
54
+ } from "@simmalugnt-se/payload-visual-editing/frontend";
55
+
56
+ // Each block (or array row) on its outermost element:
57
+ <section {...editableBlock(block)}>
58
+ // A field inside it, by its name relative to the block ("button.label" inside a group):
59
+ <h1 {...editableField("headline")}>{block.headline}</h1>
60
+ // Rich text, on the element whose direct children are the paragraphs:
61
+ <div {...editableRichText("content")}>
62
+ <RichText data={block.content} disableContainer />
63
+ </div>
64
+ </section>
65
+ ```
66
+
67
+ Marking blocks alone is enough to start: a click then opens the whole block. Add `editableField`
68
+ where editors should land on a specific field.
69
+
70
+ ## Labels
71
+
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.
@@ -0,0 +1,7 @@
1
+ import "../styles/admin.css";
2
+ /**
3
+ * Mounted in the document edit view. Listens for clicks reported by the preview window
4
+ * and brings the matching block (or field) into view in the form: opens tabs, expands rows,
5
+ * scrolls, focuses and highlights.
6
+ */
7
+ export declare function VisualEditingAdminBridge(): import("react").JSX.Element;
@@ -0,0 +1,310 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useConfig, useDocumentForm, useDocumentInfo, useLivePreviewContext, useTranslation, } from "@payloadcms/ui";
4
+ import { useEffect, useRef } from "react";
5
+ import "../styles/admin.css";
6
+ import { collectBlockLabels } from "../labels.js";
7
+ import { fieldDomId, focusTargets, isReadyMessage, isSelectMessage, MESSAGE_TYPE, matchNodeIndex, pathFromDomId, pathFromRowDomId, resolveBlockTarget, rowDomId, } from "../protocol.js";
8
+ import { planReveal } from "../reveal-plan.js";
9
+ import { HIGHLIGHT_TINT } from "../theme.js";
10
+ const HIGHLIGHT_CLASS = "pve-highlight";
11
+ const HIGHLIGHT_MS = 1600;
12
+ /** Tabs and rows render within a few frames. */
13
+ const WAIT_MS = 1000;
14
+ /** Rich text editors are lazy loaded and can take noticeably longer to mount. Only rich text clicks wait this long. */
15
+ const FIELD_WAIT_MS = 4000;
16
+ /**
17
+ * Mounted in the document edit view. Listens for clicks reported by the preview window
18
+ * and brings the matching block (or field) into view in the form: opens tabs, expands rows,
19
+ * scrolls, focuses and highlights.
20
+ */
21
+ export function VisualEditingAdminBridge() {
22
+ const livePreview = useLivePreviewContext();
23
+ const form = useDocumentForm();
24
+ const { config, getEntityConfig } = useConfig();
25
+ const { collectionSlug } = useDocumentInfo();
26
+ const { i18n } = useTranslation();
27
+ const language = i18n.language;
28
+ const anchorRef = useRef(null);
29
+ const latest = useRef({ form, livePreview, config, getEntityConfig, collectionSlug, language });
30
+ useEffect(() => {
31
+ latest.current = { form, livePreview, config, getEntityConfig, collectionSlug, language };
32
+ });
33
+ useEffect(() => {
34
+ let run = 0;
35
+ const onMessage = (event) => {
36
+ // Only trust the preview window Payload itself opened; origin alone is not enough
37
+ // because the preview often shares the Admin origin.
38
+ const preview = latest.current.livePreview;
39
+ const fromPreview = event.source !== null &&
40
+ (event.source === preview?.iframeRef?.current?.contentWindow ||
41
+ event.source === preview?.popupRef?.current);
42
+ if (!fromPreview) {
43
+ return;
44
+ }
45
+ if (isReadyMessage(event.data)) {
46
+ sendLabels(event.source, event.origin);
47
+ return;
48
+ }
49
+ if (!isSelectMessage(event.data)) {
50
+ return;
51
+ }
52
+ run += 1;
53
+ void select(event.data, run);
54
+ };
55
+ const sendLabels = (preview, origin) => {
56
+ const { config, getEntityConfig, collectionSlug, language } = latest.current;
57
+ const message = {
58
+ type: MESSAGE_TYPE,
59
+ action: "labels",
60
+ blocks: collectBlockLabels(rootFieldsOf(getEntityConfig, collectionSlug), config.blocksMap, language),
61
+ };
62
+ preview.postMessage(message, origin);
63
+ };
64
+ const select = async (message, id) => {
65
+ const { form, config, getEntityConfig, collectionSlug } = latest.current;
66
+ const fields = form.getFields();
67
+ const target = resolveBlockTarget(fields, message.blockId);
68
+ if (!target) {
69
+ return;
70
+ }
71
+ expandRows(target, fields, form.dispatchFields);
72
+ const rootFields = rootFieldsOf(getEntityConfig, collectionSlug);
73
+ const lookup = {
74
+ blockTypeAt: (rowPath) => rowAt(fields, rowPath)?.blockType,
75
+ blocksBySlug: config.blocksMap,
76
+ };
77
+ const fieldPath = message.field ? `${target.rowPath}.${message.field}` : undefined;
78
+ const fieldPlan = fieldPath ? planReveal(rootFields, fieldPath, lookup) : null;
79
+ const plan = fieldPlan ?? planReveal(rootFields, target.rowPath, lookup) ?? [];
80
+ const root = formRoot(anchorRef.current);
81
+ const opened = await openSteps(plan, root, () => id === run);
82
+ if (!opened || id !== run) {
83
+ return;
84
+ }
85
+ // Wait for the field itself; the row renders first and would otherwise win.
86
+ const field = fieldPlan && message.field ? `${target.rowPath}.${message.field}` : undefined;
87
+ const element = (field
88
+ ? await waitFor(() => findField(field), message.node ? FIELD_WAIT_MS : WAIT_MS)
89
+ : null) ?? (await waitFor(() => findRow(target)));
90
+ if (!element || id !== run) {
91
+ return;
92
+ }
93
+ if (message.node && (await revealRichTextNode(element, message.node))) {
94
+ return;
95
+ }
96
+ reveal(element, field !== undefined && element !== findRow(target));
97
+ };
98
+ window.addEventListener("message", onMessage);
99
+ return () => window.removeEventListener("message", onMessage);
100
+ }, []);
101
+ // Admin → preview: outline the block the editor is working in, or clear the outline.
102
+ // Sent on every click and focus change, even back to the same field: a click in the preview
103
+ // may have moved the selection since. Anything outside a block's fields and row (the document
104
+ // title, empty space, the nav) has no targets, which clears the selection.
105
+ useEffect(() => {
106
+ const onInteract = (event) => {
107
+ if (!(event.target instanceof Element) || event.target instanceof HTMLIFrameElement) {
108
+ // Focus moving into the preview iframe is the preview's own click; it already selected.
109
+ return;
110
+ }
111
+ const root = formRoot(anchorRef.current);
112
+ const path = root.contains(event.target) ? pathOf(event.target) : undefined;
113
+ const fields = latest.current.form.getFields();
114
+ const targets = path ? focusTargets(path, fields) : [];
115
+ postToPreview(latest.current.livePreview, { type: MESSAGE_TYPE, action: "focus", targets });
116
+ };
117
+ document.addEventListener("pointerdown", onInteract, true);
118
+ document.addEventListener("focusin", onInteract);
119
+ return () => {
120
+ document.removeEventListener("pointerdown", onInteract, true);
121
+ document.removeEventListener("focusin", onInteract);
122
+ };
123
+ }, []);
124
+ return _jsx("span", { hidden: true, ref: anchorRef });
125
+ }
126
+ function rootFieldsOf(getEntityConfig, collectionSlug) {
127
+ return collectionSlug
128
+ ? (getEntityConfig({ collectionSlug })?.fields ?? [])
129
+ : [];
130
+ }
131
+ /**
132
+ * Form path of the field or row an element belongs to. Rich text wrappers carry
133
+ * `data-field-path`, inputs carry `field-…` ids, block and array rows (header included) `…-row-N` ids.
134
+ */
135
+ function pathOf(element) {
136
+ const field = element.closest("[data-field-path], [id^='field-']");
137
+ const row = closestRow(element);
138
+ // The blocks field itself carries a `field-…` id, so a row header sits inside a "field";
139
+ // whichever is innermost is what was clicked.
140
+ if (row && (!field || field.contains(row.element))) {
141
+ return row.path;
142
+ }
143
+ return field ? (field.getAttribute("data-field-path") ?? pathFromDomId(field.id)) : undefined;
144
+ }
145
+ function closestRow(element) {
146
+ let row = element.closest("[id*='-row-']");
147
+ while (row) {
148
+ const path = pathFromRowDomId(row.id);
149
+ if (path) {
150
+ return { element: row, path };
151
+ }
152
+ row = row.parentElement?.closest("[id*='-row-']") ?? null;
153
+ }
154
+ return undefined;
155
+ }
156
+ function postToPreview(preview, message) {
157
+ const target = preview?.popupRef?.current ?? preview?.iframeRef?.current?.contentWindow;
158
+ const url = typeof preview?.url === "string" ? preview.url : undefined;
159
+ if (!target || !url) {
160
+ return;
161
+ }
162
+ try {
163
+ target.postMessage(message, new URL(url, window.location.href).origin);
164
+ }
165
+ catch {
166
+ // Preview URL was not a valid URL; nothing to notify.
167
+ }
168
+ }
169
+ function expandRows(target, fields, dispatchFields) {
170
+ for (const step of target.rows) {
171
+ const rows = fields[step.parentPath]?.rows;
172
+ if (!rows?.[step.index]?.collapsed) {
173
+ continue;
174
+ }
175
+ dispatchFields({
176
+ type: "SET_ROW_COLLAPSED",
177
+ path: step.parentPath,
178
+ updatedRows: rows.map((row, index) => index === step.index ? { ...row, collapsed: false } : row),
179
+ });
180
+ }
181
+ }
182
+ function rowAt(fields, rowPath) {
183
+ const cut = rowPath.lastIndexOf(".");
184
+ return fields[rowPath.slice(0, cut)]?.rows?.[Number(rowPath.slice(cut + 1))];
185
+ }
186
+ /** The edit form this bridge belongs to, not one in a document drawer. */
187
+ function formRoot(anchor) {
188
+ const view = anchor?.closest(".collection-edit");
189
+ return view?.querySelector(".collection-edit__form") ?? document;
190
+ }
191
+ /** Click the tabs the plan needs, one level at a time, waiting for each tab's content to render. */
192
+ async function openSteps(steps, root, current) {
193
+ let scope = root;
194
+ for (const step of steps) {
195
+ if (!current()) {
196
+ return false;
197
+ }
198
+ if (step.kind === "row") {
199
+ const row = await waitFor(() => document.getElementById(rowDomId(step)));
200
+ if (!row) {
201
+ return false;
202
+ }
203
+ scope = row;
204
+ continue;
205
+ }
206
+ const tabs = await waitFor(() => tabsFieldsIn(scope)[step.ordinal] ?? null);
207
+ const button = tabs?.querySelectorAll(":scope > .tabs-field__tabs-wrap > .tabs-field__tabs > .tabs-field__tab-button")[step.index];
208
+ if (!tabs || !button) {
209
+ return false;
210
+ }
211
+ if (!button.classList.contains("tabs-field__tab-button--active")) {
212
+ button.click();
213
+ }
214
+ const content = await waitFor(() => button.classList.contains("tabs-field__tab-button--active")
215
+ ? tabs.querySelector(":scope > .tabs-field__content-wrap")
216
+ : null);
217
+ if (!content) {
218
+ return false;
219
+ }
220
+ scope = content;
221
+ }
222
+ return true;
223
+ }
224
+ /** Tabs fields at this level of the scope, in DOM order; nested tabs and rows are other scopes. */
225
+ function tabsFieldsIn(scope) {
226
+ return Array.from(scope.querySelectorAll(".tabs-field")).filter((element) => {
227
+ const outer = element.parentElement?.closest(".tabs-field, .blocks-field, .array-field");
228
+ return !outer || !scope.contains(outer);
229
+ });
230
+ }
231
+ function waitFor(find, timeoutMs = WAIT_MS) {
232
+ const deadline = performance.now() + timeoutMs;
233
+ return new Promise((resolve) => {
234
+ const tick = () => {
235
+ const found = find();
236
+ if (found || performance.now() >= deadline) {
237
+ resolve(found);
238
+ return;
239
+ }
240
+ requestAnimationFrame(tick);
241
+ };
242
+ tick();
243
+ });
244
+ }
245
+ /** Inputs carry `field-…` ids; rich text and some other fields only carry `data-field-path`. */
246
+ function findField(path) {
247
+ return (document.getElementById(fieldDomId(path)) ??
248
+ document.querySelector(`[data-field-path="${CSS.escape(path)}"]`));
249
+ }
250
+ function findRow(target) {
251
+ const last = target.rows.at(-1);
252
+ return last ? document.getElementById(rowDomId(last)) : null;
253
+ }
254
+ /**
255
+ * Put the cursor at the start of the clicked paragraph in a Lexical editor.
256
+ * Lexical renders each top-level node as one child of the contenteditable root.
257
+ */
258
+ async function revealRichTextNode(field, node) {
259
+ const editor = await waitFor(() => field.querySelector("[contenteditable='true'][data-lexical-editor='true']") ??
260
+ field.querySelector("[contenteditable='true']"), FIELD_WAIT_MS);
261
+ const children = editor ? Array.from(editor.children) : [];
262
+ if (!editor || children.length === 0) {
263
+ return false;
264
+ }
265
+ const target = children[matchNodeIndex(children.map((child) => child.textContent ?? ""), node)];
266
+ if (!(target instanceof HTMLElement)) {
267
+ return false;
268
+ }
269
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
270
+ editor.focus({ preventScroll: true });
271
+ const range = document.createRange();
272
+ const firstText = document.createTreeWalker(target, NodeFilter.SHOW_TEXT).nextNode();
273
+ range.setStart(firstText ?? target, 0);
274
+ range.collapse(true);
275
+ const selection = window.getSelection();
276
+ selection?.removeAllRanges();
277
+ selection?.addRange(range);
278
+ // Animate instead of adding a class: Lexical owns this element's attributes.
279
+ target.animate([{ backgroundColor: HIGHLIGHT_TINT }, { backgroundColor: "transparent" }], {
280
+ duration: HIGHLIGHT_MS,
281
+ easing: "ease-out",
282
+ });
283
+ return true;
284
+ }
285
+ /**
286
+ * Scroll to and highlight a field or a block row. Only a field gets focus: focusing a row
287
+ * would land in its first input (the block name), which is not what was clicked.
288
+ */
289
+ function reveal(element, isField) {
290
+ // A row sits inside the blocks field's `.field-type`; highlighting that would mark the whole list.
291
+ const highlighted = isField ? (element.closest(".field-type") ?? element) : element;
292
+ highlighted.scrollIntoView({ behavior: "smooth", block: "center" });
293
+ if (isField) {
294
+ const focusable = isFocusable(element)
295
+ ? element
296
+ : (element.querySelector("input:not([type='hidden']):not([type='file']), textarea, select, [contenteditable='true']") ?? element.querySelector("button:not([disabled])"));
297
+ focusable?.focus({ preventScroll: true });
298
+ }
299
+ highlighted.classList.remove(HIGHLIGHT_CLASS);
300
+ // Restart the animation when the same element is clicked twice.
301
+ void highlighted.offsetWidth;
302
+ highlighted.classList.add(HIGHLIGHT_CLASS);
303
+ window.setTimeout(() => highlighted.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_MS);
304
+ }
305
+ function isFocusable(element) {
306
+ return (element instanceof HTMLInputElement ||
307
+ element instanceof HTMLTextAreaElement ||
308
+ element instanceof HTMLSelectElement ||
309
+ element.isContentEditable);
310
+ }
@@ -0,0 +1 @@
1
+ export { VisualEditingAdminBridge } from "../admin/VisualEditingAdminBridge.tsx";
@@ -0,0 +1 @@
1
+ export { VisualEditingAdminBridge } from "../admin/VisualEditingAdminBridge.js";
@@ -0,0 +1,2 @@
1
+ export { editableBlock, editableField, editableRichText } from "../frontend/editable.ts";
2
+ export { VisualEditingPreview } from "../frontend/VisualEditingPreview.tsx";
@@ -0,0 +1,2 @@
1
+ export { editableBlock, editableField, editableRichText } from "../frontend/editable.js";
2
+ export { VisualEditingPreview } from "../frontend/VisualEditingPreview.js";
@@ -0,0 +1,10 @@
1
+ type Props = {
2
+ /** Origin of Payload Admin. Defaults to the page that embedded the preview. */
3
+ adminOrigin?: string;
4
+ };
5
+ /**
6
+ * Render only in draft mode. Inside the Live Preview window it outlines editable blocks
7
+ * on hover and tells Admin which block or field was clicked. Outside a preview window it does nothing.
8
+ */
9
+ export declare function VisualEditingPreview({ adminOrigin }: Props): null;
10
+ export {};