@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 +21 -0
- package/README.md +74 -0
- package/dist/admin/VisualEditingAdminBridge.d.ts +7 -0
- package/dist/admin/VisualEditingAdminBridge.js +310 -0
- package/dist/exports/client.d.ts +1 -0
- package/dist/exports/client.js +1 -0
- package/dist/exports/frontend.d.ts +2 -0
- package/dist/exports/frontend.js +2 -0
- package/dist/frontend/VisualEditingPreview.d.ts +10 -0
- package/dist/frontend/VisualEditingPreview.js +256 -0
- package/dist/frontend/editable.d.ts +21 -0
- package/dist/frontend/editable.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/labels.d.ts +17 -0
- package/dist/labels.js +73 -0
- package/dist/package-name.d.ts +2 -0
- package/dist/package-name.js +2 -0
- package/dist/plugin.d.ts +7 -0
- package/dist/plugin.js +30 -0
- package/dist/protocol.d.ts +92 -0
- package/dist/protocol.js +184 -0
- package/dist/reveal-plan.d.ts +45 -0
- package/dist/reveal-plan.js +122 -0
- package/dist/styles/admin.css +19 -0
- package/dist/theme.d.ts +3 -0
- package/dist/theme.js +3 -0
- package/package.json +70 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"use client";
|
|
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";
|
|
4
|
+
import { HIGHLIGHT_COLOR, HIGHLIGHT_TINT } from "../theme.js";
|
|
5
|
+
const SELECTOR = `[${ATTR_FIELD}], [${ATTR_BLOCK}]`;
|
|
6
|
+
/**
|
|
7
|
+
* Render only in draft mode. Inside the Live Preview window it outlines editable blocks
|
|
8
|
+
* on hover and tells Admin which block or field was clicked. Outside a preview window it does nothing.
|
|
9
|
+
*/
|
|
10
|
+
export function VisualEditingPreview({ adminOrigin }) {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const admin = window.opener ?? (window.parent !== window ? window.parent : null);
|
|
13
|
+
if (!admin) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const targetOrigin = adminOrigin ?? referrerOrigin() ?? window.location.origin;
|
|
17
|
+
const hover = createOverlay(false);
|
|
18
|
+
const selected = createOverlay(true);
|
|
19
|
+
let hovered = null;
|
|
20
|
+
let current = null;
|
|
21
|
+
// Until Admin answers, labels fall back to the slugs.
|
|
22
|
+
let labels = {};
|
|
23
|
+
let frame = 0;
|
|
24
|
+
const redraw = () => {
|
|
25
|
+
cancelAnimationFrame(frame);
|
|
26
|
+
frame = requestAnimationFrame(() => {
|
|
27
|
+
place(hover, hovered && hovered.element !== current?.element ? hovered : null);
|
|
28
|
+
place(selected, current);
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
const onPointerMove = (event) => {
|
|
32
|
+
const hit = hitFromTarget(event.target, labels);
|
|
33
|
+
if (hit?.element === hovered?.element && hit?.label === hovered?.label) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
hovered = hit;
|
|
37
|
+
redraw();
|
|
38
|
+
};
|
|
39
|
+
const onPointerLeave = () => {
|
|
40
|
+
hovered = null;
|
|
41
|
+
redraw();
|
|
42
|
+
};
|
|
43
|
+
const clearSelection = () => {
|
|
44
|
+
if (current) {
|
|
45
|
+
current = null;
|
|
46
|
+
redraw();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const onClick = (event) => {
|
|
50
|
+
const hit = hitFromTarget(event.target, labels);
|
|
51
|
+
if (!hit) {
|
|
52
|
+
// Outside any editable element the page behaves normally; only the selection is dropped.
|
|
53
|
+
clearSelection();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// In the editor a click selects; it must not follow links or submit forms.
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
event.stopPropagation();
|
|
59
|
+
current = hit;
|
|
60
|
+
redraw();
|
|
61
|
+
const message = {
|
|
62
|
+
type: MESSAGE_TYPE,
|
|
63
|
+
action: "select",
|
|
64
|
+
blockId: hit.blockId,
|
|
65
|
+
...(hit.field ? { field: hit.field } : {}),
|
|
66
|
+
...(hit.node ? { node: hit.node } : {}),
|
|
67
|
+
};
|
|
68
|
+
admin.postMessage(message, targetOrigin);
|
|
69
|
+
};
|
|
70
|
+
const onMessage = (event) => {
|
|
71
|
+
if (event.source !== admin || event.origin !== targetOrigin) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (isLabelsMessage(event.data)) {
|
|
75
|
+
labels = event.data.blocks;
|
|
76
|
+
hovered = hovered && relabel(hovered, labels);
|
|
77
|
+
current = current && relabel(current, labels);
|
|
78
|
+
redraw();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!isFocusMessage(event.data)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Admin → preview: the editor focused a field; show where it is on the page.
|
|
85
|
+
for (const target of event.data.targets) {
|
|
86
|
+
const hit = hitForBlock(target.blockId, target.field, labels);
|
|
87
|
+
if (!hit) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// Keep a finer selection (e.g. a paragraph) that Admin just echoed back as its field.
|
|
91
|
+
if (current?.blockId === hit.blockId && current.field === hit.field) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
current = hit;
|
|
95
|
+
redraw();
|
|
96
|
+
if (!inViewport(hit.element)) {
|
|
97
|
+
hit.element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// The editor moved to a field this page does not mark, e.g. the document title.
|
|
102
|
+
clearSelection();
|
|
103
|
+
};
|
|
104
|
+
const onKeyDown = (event) => {
|
|
105
|
+
if (event.key === "Escape") {
|
|
106
|
+
clearSelection();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
document.addEventListener("pointermove", onPointerMove, { passive: true });
|
|
110
|
+
document.documentElement.addEventListener("pointerleave", onPointerLeave);
|
|
111
|
+
document.addEventListener("click", onClick, true);
|
|
112
|
+
document.addEventListener("keydown", onKeyDown);
|
|
113
|
+
window.addEventListener("message", onMessage);
|
|
114
|
+
window.addEventListener("scroll", redraw, { passive: true, capture: true });
|
|
115
|
+
window.addEventListener("resize", redraw);
|
|
116
|
+
const ready = { type: MESSAGE_TYPE, action: "ready" };
|
|
117
|
+
admin.postMessage(ready, targetOrigin);
|
|
118
|
+
return () => {
|
|
119
|
+
cancelAnimationFrame(frame);
|
|
120
|
+
document.removeEventListener("pointermove", onPointerMove);
|
|
121
|
+
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
|
|
122
|
+
document.removeEventListener("click", onClick, true);
|
|
123
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
124
|
+
window.removeEventListener("message", onMessage);
|
|
125
|
+
window.removeEventListener("scroll", redraw, { capture: true });
|
|
126
|
+
window.removeEventListener("resize", redraw);
|
|
127
|
+
hover.remove();
|
|
128
|
+
selected.remove();
|
|
129
|
+
};
|
|
130
|
+
}, [adminOrigin]);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
function hitFromTarget(target, labels) {
|
|
134
|
+
if (!(target instanceof Element)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const marked = target.closest(SELECTOR);
|
|
138
|
+
const block = marked?.closest(`[${ATTR_BLOCK}]`);
|
|
139
|
+
const blockId = block?.getAttribute(ATTR_BLOCK);
|
|
140
|
+
if (!marked || !block || !blockId) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const field = marked !== block ? (marked.getAttribute(ATTR_FIELD) ?? undefined) : undefined;
|
|
144
|
+
const label = labelFor(block, field, labels);
|
|
145
|
+
const node = field && marked.hasAttribute(ATTR_RICHTEXT) ? richTextNode(marked, target) : null;
|
|
146
|
+
if (node) {
|
|
147
|
+
return { element: node.element, blockId, field, node: node.ref, label };
|
|
148
|
+
}
|
|
149
|
+
return { element: marked, blockId, field, label };
|
|
150
|
+
}
|
|
151
|
+
function hitForBlock(blockId, field, labels) {
|
|
152
|
+
const block = document.querySelector(`[${ATTR_BLOCK}="${CSS.escape(blockId)}"]`);
|
|
153
|
+
if (!block) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const fieldElement = field
|
|
157
|
+
? Array.from(block.querySelectorAll(`[${ATTR_FIELD}="${CSS.escape(field)}"]`)).find((element) => element.closest(`[${ATTR_BLOCK}]`) === block)
|
|
158
|
+
: undefined;
|
|
159
|
+
if (field && fieldElement) {
|
|
160
|
+
return { element: fieldElement, blockId, field, label: labelFor(block, field, labels) };
|
|
161
|
+
}
|
|
162
|
+
return { element: block, blockId, label: labelFor(block, undefined, labels) };
|
|
163
|
+
}
|
|
164
|
+
/** Same order as Admin's row header: the editor's block name, then the block's label, then its slug. */
|
|
165
|
+
function labelFor(block, field, labels) {
|
|
166
|
+
const type = block.getAttribute(ATTR_BLOCK_TYPE) ?? undefined;
|
|
167
|
+
const known = type ? labels[type] : undefined;
|
|
168
|
+
const blockLabel = block.getAttribute(ATTR_BLOCK_NAME) || known?.label || (type ? humanize(type) : "Block");
|
|
169
|
+
if (!field) {
|
|
170
|
+
return blockLabel;
|
|
171
|
+
}
|
|
172
|
+
return `${blockLabel} › ${known?.fields[field] ?? humanize(field)}`;
|
|
173
|
+
}
|
|
174
|
+
function relabel(hit, labels) {
|
|
175
|
+
const block = hit.element.closest(`[${ATTR_BLOCK}]`);
|
|
176
|
+
return block ? { ...hit, label: labelFor(block, hit.field, labels) } : hit;
|
|
177
|
+
}
|
|
178
|
+
function inViewport(element) {
|
|
179
|
+
const rect = element.getBoundingClientRect();
|
|
180
|
+
return rect.bottom > 0 && rect.top < window.innerHeight;
|
|
181
|
+
}
|
|
182
|
+
/** The top-level node (direct child of the rich text container) that contains the target. */
|
|
183
|
+
function richTextNode(container, target) {
|
|
184
|
+
let element = target;
|
|
185
|
+
while (element && element.parentElement !== container) {
|
|
186
|
+
element = element.parentElement;
|
|
187
|
+
}
|
|
188
|
+
if (!(element instanceof HTMLElement)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const index = Array.from(container.children).indexOf(element);
|
|
192
|
+
const text = nodeSnippet(element.textContent);
|
|
193
|
+
return { element, ref: text ? { index, text } : { index } };
|
|
194
|
+
}
|
|
195
|
+
function createOverlay(solid) {
|
|
196
|
+
const box = document.createElement("div");
|
|
197
|
+
Object.assign(box.style, {
|
|
198
|
+
position: "fixed",
|
|
199
|
+
zIndex: "2147483646",
|
|
200
|
+
pointerEvents: "none",
|
|
201
|
+
display: "none",
|
|
202
|
+
boxSizing: "border-box",
|
|
203
|
+
border: `2px ${solid ? "solid" : "dashed"} ${HIGHLIGHT_COLOR}`,
|
|
204
|
+
borderRadius: "4px",
|
|
205
|
+
background: solid ? HIGHLIGHT_TINT : "transparent",
|
|
206
|
+
transition: "top 60ms, left 60ms, width 60ms, height 60ms",
|
|
207
|
+
});
|
|
208
|
+
const tag = document.createElement("span");
|
|
209
|
+
Object.assign(tag.style, {
|
|
210
|
+
position: "absolute",
|
|
211
|
+
top: "-22px",
|
|
212
|
+
left: "-2px",
|
|
213
|
+
padding: "2px 6px",
|
|
214
|
+
borderRadius: "4px 4px 0 0",
|
|
215
|
+
background: HIGHLIGHT_COLOR,
|
|
216
|
+
color: "#fff",
|
|
217
|
+
font: "500 11px/16px system-ui, sans-serif",
|
|
218
|
+
whiteSpace: "nowrap",
|
|
219
|
+
});
|
|
220
|
+
box.append(tag);
|
|
221
|
+
document.body.append(box);
|
|
222
|
+
return box;
|
|
223
|
+
}
|
|
224
|
+
function place(box, hit) {
|
|
225
|
+
if (!hit || !hit.element.isConnected) {
|
|
226
|
+
box.style.display = "none";
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const rect = hit.element.getBoundingClientRect();
|
|
230
|
+
Object.assign(box.style, {
|
|
231
|
+
display: "block",
|
|
232
|
+
top: `${rect.top}px`,
|
|
233
|
+
left: `${rect.left}px`,
|
|
234
|
+
width: `${rect.width}px`,
|
|
235
|
+
height: `${rect.height}px`,
|
|
236
|
+
});
|
|
237
|
+
const tag = box.firstElementChild;
|
|
238
|
+
tag.textContent = hit.label;
|
|
239
|
+
// Keep the label inside the viewport for blocks that start at the top.
|
|
240
|
+
tag.style.top = rect.top < 22 ? "0" : "-22px";
|
|
241
|
+
}
|
|
242
|
+
function humanize(value) {
|
|
243
|
+
const words = value
|
|
244
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
245
|
+
.replace(/[-_.]+/g, " ")
|
|
246
|
+
.toLowerCase();
|
|
247
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
248
|
+
}
|
|
249
|
+
function referrerOrigin() {
|
|
250
|
+
try {
|
|
251
|
+
return document.referrer ? new URL(document.referrer).origin : undefined;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type EditableAttributes = Partial<Record<string, string>>;
|
|
2
|
+
/**
|
|
3
|
+
* Mark the element that renders a block. Spread it on the block's outermost element:
|
|
4
|
+
* `<section {...editableBlock(block)}>`. Emits nothing without a block id.
|
|
5
|
+
*/
|
|
6
|
+
export declare function editableBlock(block: {
|
|
7
|
+
id?: string | null;
|
|
8
|
+
blockType?: string | null;
|
|
9
|
+
blockName?: string | null;
|
|
10
|
+
}): EditableAttributes;
|
|
11
|
+
/**
|
|
12
|
+
* Mark one field inside a block, e.g. `<h1 {...editableField("headline")}>`.
|
|
13
|
+
* Clicking it opens that field instead of the whole block.
|
|
14
|
+
*/
|
|
15
|
+
export declare function editableField(name: string): EditableAttributes;
|
|
16
|
+
/**
|
|
17
|
+
* Mark a rich text field on the element whose direct children are the rendered top-level nodes
|
|
18
|
+
* (paragraphs, headings, lists). Clicking a paragraph puts the cursor in that paragraph in Admin.
|
|
19
|
+
*/
|
|
20
|
+
export declare function editableRichText(name: string): EditableAttributes;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ATTR_BLOCK, ATTR_BLOCK_NAME, ATTR_BLOCK_TYPE, ATTR_FIELD, ATTR_RICHTEXT, } from "../protocol.js";
|
|
2
|
+
/**
|
|
3
|
+
* Mark the element that renders a block. Spread it on the block's outermost element:
|
|
4
|
+
* `<section {...editableBlock(block)}>`. Emits nothing without a block id.
|
|
5
|
+
*/
|
|
6
|
+
export function editableBlock(block) {
|
|
7
|
+
if (!block.id) {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
[ATTR_BLOCK]: block.id,
|
|
12
|
+
...(block.blockType ? { [ATTR_BLOCK_TYPE]: block.blockType } : {}),
|
|
13
|
+
...(block.blockName ? { [ATTR_BLOCK_NAME]: block.blockName } : {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Mark one field inside a block, e.g. `<h1 {...editableField("headline")}>`.
|
|
18
|
+
* Clicking it opens that field instead of the whole block.
|
|
19
|
+
*/
|
|
20
|
+
export function editableField(name) {
|
|
21
|
+
return { [ATTR_FIELD]: name };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Mark a rich text field on the element whose direct children are the rendered top-level nodes
|
|
25
|
+
* (paragraphs, headings, lists). Clicking a paragraph puts the cursor in that paragraph in Admin.
|
|
26
|
+
*/
|
|
27
|
+
export function editableRichText(name) {
|
|
28
|
+
return { [ATTR_FIELD]: name, [ATTR_RICHTEXT]: "" };
|
|
29
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { visualEditingPlugin } from "./plugin.js";
|
package/dist/labels.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect the labels Admin shows for blocks and their fields, so the preview overlay can use
|
|
3
|
+
* the same names ("CTA › Title") instead of guessing from slugs ("C ta › Title").
|
|
4
|
+
* Pure: takes client field config, returns plain strings safe to post to the preview.
|
|
5
|
+
*/
|
|
6
|
+
import type { BlockLike, FieldLike } from "./reveal-plan.ts";
|
|
7
|
+
export type BlockLabel = {
|
|
8
|
+
/** `labels.singular` of the block config. */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Field path relative to the block (e.g. "title", "hero.headline") → its label. */
|
|
11
|
+
fields: Record<string, string>;
|
|
12
|
+
};
|
|
13
|
+
/** Block slug → labels. */
|
|
14
|
+
export type BlockLabels = Record<string, BlockLabel>;
|
|
15
|
+
export declare function collectBlockLabels(rootFields: FieldLike[], blocksBySlug: Record<string, BlockLike | undefined> | undefined, language: string): BlockLabels;
|
|
16
|
+
/** Client config labels are a string or a `{ [language]: string }` map; anything else has no static text. */
|
|
17
|
+
export declare function labelText(label: unknown, language: string): string | undefined;
|
package/dist/labels.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect the labels Admin shows for blocks and their fields, so the preview overlay can use
|
|
3
|
+
* the same names ("CTA › Title") instead of guessing from slugs ("C ta › Title").
|
|
4
|
+
* Pure: takes client field config, returns plain strings safe to post to the preview.
|
|
5
|
+
*/
|
|
6
|
+
export function collectBlockLabels(rootFields, blocksBySlug, language) {
|
|
7
|
+
const result = {};
|
|
8
|
+
const visitFields = (fields) => {
|
|
9
|
+
for (const field of fields) {
|
|
10
|
+
if (field.type === "blocks") {
|
|
11
|
+
for (const block of blocksOf(field, blocksBySlug)) {
|
|
12
|
+
if (result[block.slug]) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const fields = {};
|
|
16
|
+
result[block.slug] = { label: labelText(block.labels?.singular, language), fields };
|
|
17
|
+
collectFieldLabels(block.fields ?? [], "", language, fields);
|
|
18
|
+
visitFields(block.fields ?? []);
|
|
19
|
+
}
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
for (const tab of field.tabs ?? []) {
|
|
23
|
+
visitFields(tab.fields ?? []);
|
|
24
|
+
}
|
|
25
|
+
visitFields(field.fields ?? []);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
visitFields(rootFields);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
/** Labels of the fields a block's preview can mark: named fields and paths into named groups and tabs. */
|
|
32
|
+
function collectFieldLabels(fields, prefix, language, into) {
|
|
33
|
+
for (const field of fields) {
|
|
34
|
+
if (field.type === "tabs") {
|
|
35
|
+
for (const tab of field.tabs ?? []) {
|
|
36
|
+
collectFieldLabels(tab.fields ?? [], tab.name ? `${prefix}${tab.name}.` : prefix, language, into);
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!field.name) {
|
|
41
|
+
// Row, collapsible or unnamed group: transparent for paths.
|
|
42
|
+
collectFieldLabels(field.fields ?? [], prefix, language, into);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const path = `${prefix}${field.name}`;
|
|
46
|
+
const label = labelText(field.label, language);
|
|
47
|
+
if (label) {
|
|
48
|
+
into[path] = label;
|
|
49
|
+
}
|
|
50
|
+
// Array rows and nested blocks are marked as their own rows, not as fields of this block.
|
|
51
|
+
if (field.type === "group") {
|
|
52
|
+
collectFieldLabels(field.fields ?? [], `${path}.`, language, into);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function blocksOf(field, blocksBySlug) {
|
|
57
|
+
const referenced = (field.blockReferences ?? []).map((reference) => typeof reference === "string" ? blocksBySlug?.[reference] : reference);
|
|
58
|
+
return [...(field.blocks ?? []), ...referenced].filter((block) => typeof block?.slug === "string");
|
|
59
|
+
}
|
|
60
|
+
/** Client config labels are a string or a `{ [language]: string }` map; anything else has no static text. */
|
|
61
|
+
export function labelText(label, language) {
|
|
62
|
+
if (typeof label === "string") {
|
|
63
|
+
return label || undefined;
|
|
64
|
+
}
|
|
65
|
+
if (!label || typeof label !== "object") {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const translations = label;
|
|
69
|
+
const text = translations[language] ??
|
|
70
|
+
translations.en ??
|
|
71
|
+
Object.values(translations).find((value) => typeof value === "string");
|
|
72
|
+
return typeof text === "string" && text ? text : undefined;
|
|
73
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Plugin } from "payload";
|
|
2
|
+
export type VisualEditingPluginOptions = {
|
|
3
|
+
/** Collection slugs whose Live Preview should support click-to-edit. */
|
|
4
|
+
collections: string[];
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare function visualEditingPlugin(options: VisualEditingPluginOptions): Plugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CLIENT_ENTRY } from "./package-name.js";
|
|
2
|
+
const BRIDGE = `${CLIENT_ENTRY}#VisualEditingAdminBridge`;
|
|
3
|
+
export function visualEditingPlugin(options) {
|
|
4
|
+
return (config) => {
|
|
5
|
+
if (options.disabled) {
|
|
6
|
+
return config;
|
|
7
|
+
}
|
|
8
|
+
const enabled = new Set(options.collections);
|
|
9
|
+
return {
|
|
10
|
+
...config,
|
|
11
|
+
collections: (config.collections ?? []).map((collection) => enabled.has(collection.slug) ? withBridge(collection) : collection),
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function withBridge(collection) {
|
|
16
|
+
const edit = collection.admin?.components?.edit;
|
|
17
|
+
return {
|
|
18
|
+
...collection,
|
|
19
|
+
admin: {
|
|
20
|
+
...collection.admin,
|
|
21
|
+
components: {
|
|
22
|
+
...collection.admin?.components,
|
|
23
|
+
edit: {
|
|
24
|
+
...edit,
|
|
25
|
+
beforeDocumentControls: [...(edit?.beforeDocumentControls ?? []), BRIDGE],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Shared between the preview (iframe) and Admin. No React, no DOM access. */
|
|
2
|
+
import type { BlockLabels } from "./labels.ts";
|
|
3
|
+
export declare const MESSAGE_TYPE = "payload-visual-editing";
|
|
4
|
+
export declare const ATTR_BLOCK = "data-payload-block";
|
|
5
|
+
/** Block slug; the preview looks up the label Admin sent for it. */
|
|
6
|
+
export declare const ATTR_BLOCK_TYPE = "data-payload-block-type";
|
|
7
|
+
/** The editor's own block name, which Admin also prefers over the block label in row headers. */
|
|
8
|
+
export declare const ATTR_BLOCK_NAME = "data-payload-block-name";
|
|
9
|
+
export declare const ATTR_FIELD = "data-payload-field";
|
|
10
|
+
/** On the rich text container whose direct children are the top-level editor nodes. */
|
|
11
|
+
export declare const ATTR_RICHTEXT = "data-payload-richtext";
|
|
12
|
+
export type SelectMessage = {
|
|
13
|
+
type: typeof MESSAGE_TYPE;
|
|
14
|
+
action: "select";
|
|
15
|
+
blockId: string;
|
|
16
|
+
/** Field name relative to the block, e.g. "headline". */
|
|
17
|
+
field?: string;
|
|
18
|
+
/** Top-level rich text node that was clicked; `text` disambiguates when indexes drift. */
|
|
19
|
+
node?: {
|
|
20
|
+
index: number;
|
|
21
|
+
text?: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export declare function isSelectMessage(value: unknown): value is SelectMessage;
|
|
25
|
+
/**
|
|
26
|
+
* Pick the editor node matching the clicked preview node. Text wins over index because
|
|
27
|
+
* converters may render a node as zero or several elements, shifting indexes.
|
|
28
|
+
*/
|
|
29
|
+
export declare function matchNodeIndex(nodeTexts: string[], node: {
|
|
30
|
+
index: number;
|
|
31
|
+
text?: string;
|
|
32
|
+
}): number;
|
|
33
|
+
/** Preview text sent with a node: enough to recognise it, never the whole document. */
|
|
34
|
+
export declare function nodeSnippet(text: string | null | undefined): string | undefined;
|
|
35
|
+
/** Admin → preview: the editor moved focus to a field; outline the matching element. */
|
|
36
|
+
export type FocusMessage = {
|
|
37
|
+
type: typeof MESSAGE_TYPE;
|
|
38
|
+
action: "focus";
|
|
39
|
+
/** Candidates from the innermost row outwards; the preview uses the first it has marked. */
|
|
40
|
+
targets: Array<{
|
|
41
|
+
blockId: string;
|
|
42
|
+
field?: string;
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
45
|
+
export declare function isFocusMessage(value: unknown): value is FocusMessage;
|
|
46
|
+
/** Preview → Admin: the preview has loaded and wants the block labels. */
|
|
47
|
+
export type ReadyMessage = {
|
|
48
|
+
type: typeof MESSAGE_TYPE;
|
|
49
|
+
action: "ready";
|
|
50
|
+
};
|
|
51
|
+
export declare function isReadyMessage(value: unknown): value is ReadyMessage;
|
|
52
|
+
/** Admin → preview: labels for the overlay, keyed by block slug. */
|
|
53
|
+
export type LabelsMessage = {
|
|
54
|
+
type: typeof MESSAGE_TYPE;
|
|
55
|
+
action: "labels";
|
|
56
|
+
blocks: BlockLabels;
|
|
57
|
+
};
|
|
58
|
+
export declare function isLabelsMessage(value: unknown): value is LabelsMessage;
|
|
59
|
+
/** Inverse of `fieldDomId`: "field-layout__0__headline" → "layout.0.headline". */
|
|
60
|
+
export declare function pathFromDomId(id: string): string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Rows enclosing a form path, innermost first, with the rest of the path as the field.
|
|
63
|
+
* "layout.1.items.0.title" → the items row (field "title"), then the layout row (field undefined:
|
|
64
|
+
* "items.0.title" is not a plain field name the preview could have marked).
|
|
65
|
+
*/
|
|
66
|
+
export declare function focusTargets(path: string, fields: Record<string, {
|
|
67
|
+
value?: unknown;
|
|
68
|
+
} | undefined>): FocusMessage["targets"];
|
|
69
|
+
/** One step from the document root down to the clicked row. */
|
|
70
|
+
export type RowStep = {
|
|
71
|
+
parentPath: string;
|
|
72
|
+
index: number;
|
|
73
|
+
};
|
|
74
|
+
export type BlockTarget = {
|
|
75
|
+
/** Form path of the block row, e.g. "layout.2". */
|
|
76
|
+
rowPath: string;
|
|
77
|
+
/** Rows to expand, outermost first. */
|
|
78
|
+
rows: RowStep[];
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Find the block row whose `id` matches, using Payload form state keys ("layout.2.id").
|
|
82
|
+
* Ids survive reordering in the unsaved form; indexes from the preview would not.
|
|
83
|
+
*/
|
|
84
|
+
export declare function resolveBlockTarget(fields: Record<string, {
|
|
85
|
+
value?: unknown;
|
|
86
|
+
} | undefined>, blockId: string): BlockTarget | null;
|
|
87
|
+
/** DOM id Payload gives field inputs: "layout.0.headline" → "field-layout__0__headline". */
|
|
88
|
+
export declare function fieldDomId(path: string): string;
|
|
89
|
+
/** Inverse of `rowDomId`: "layout-0-items-row-1" → "layout.0.items.1". */
|
|
90
|
+
export declare function pathFromRowDomId(id: string): string | undefined;
|
|
91
|
+
/** DOM id Payload gives block and array rows: ("layout", 0) → "layout-row-0". */
|
|
92
|
+
export declare function rowDomId(step: RowStep): string;
|