@simmalugnt-se/payload-visual-editing 0.1.1 → 0.2.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 +21 -0
- package/README.md +26 -0
- package/dist/admin/VisualEditingAdminBridge.js +108 -28
- package/dist/frontend/VisualEditingPreview.js +54 -5
- package/dist/protocol.d.ts +15 -0
- package/dist/protocol.js +16 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- A click in the preview dispatches `visual-editing:select` on Admin's `window` with the form path,
|
|
6
|
+
so other Admin components can follow the selection, and with `path: null` when the editor clears
|
|
7
|
+
it in the preview (empty space, Escape).
|
|
8
|
+
- Clicks inside `data-visual-editing-keep-selection` keep the preview outline.
|
|
9
|
+
|
|
10
|
+
## 0.1.2
|
|
11
|
+
|
|
12
|
+
- A click in the preview no longer blocks the page's own click handlers: accordions, tabs and
|
|
13
|
+
sliders keep working while the click also selects. Only links and form submits are cancelled, so
|
|
14
|
+
the preview does not navigate away.
|
|
15
|
+
- The outline follows its element when it changes size or moves without scrolling, e.g. an accordion
|
|
16
|
+
opening, instead of waiting for the next pointer move.
|
|
17
|
+
- Admin scrolls to the right field even while the form grows under the scroll (field groups
|
|
18
|
+
rendering, textareas resizing): it waits for the layout to settle, and scrolls again if the target
|
|
19
|
+
moved out of view.
|
|
20
|
+
- Clicks in the preview no longer sometimes do nothing. Payload renders a group of fields only when
|
|
21
|
+
it is near the viewport, so rows, tabs and fields far down the form were not in the DOM yet; the
|
|
22
|
+
Admin bridge now scrolls each level into view before waiting for the next one.
|
|
23
|
+
|
|
3
24
|
## 0.1.1
|
|
4
25
|
|
|
5
26
|
- Array rows in the preview outline are labeled like Admin's row headers ("Item 02 › Title")
|
package/README.md
CHANGED
|
@@ -4,6 +4,9 @@ Click a block or field in Payload's Live Preview to open it in the edit form. Ta
|
|
|
4
4
|
expanded, the field scrolled into view and highlighted; rich text puts the cursor in the clicked
|
|
5
5
|
paragraph. Focusing a field in Admin outlines it in the preview; clicking anything else clears it.
|
|
6
6
|
|
|
7
|
+
In the preview a click selects without leaving the page: links and form submits are cancelled, while
|
|
8
|
+
the page's own click handlers (accordions, tabs, sliders) still run.
|
|
9
|
+
|
|
7
10
|
Requires Payload `>=3.85.2 <4` and React 19.
|
|
8
11
|
|
|
9
12
|
## Setup
|
|
@@ -64,6 +67,9 @@ Requires Payload `>=3.85.2 <4` and React 19.
|
|
|
64
67
|
</section>
|
|
65
68
|
```
|
|
66
69
|
|
|
70
|
+
In lists, put `key` before the spread (`<li key={row.id} {...editableBlock(row)}>`). A spread
|
|
71
|
+
before `key` makes React warn that list children have no unique key.
|
|
72
|
+
|
|
67
73
|
Marking blocks alone is enough to start: a click then opens the whole block. Add `editableField`
|
|
68
74
|
where editors should land on a specific field.
|
|
69
75
|
|
|
@@ -72,3 +78,23 @@ Requires Payload `>=3.85.2 <4` and React 19.
|
|
|
72
78
|
The outline shows the same names as Admin: the editor's block name, otherwise the block's
|
|
73
79
|
`labels.singular`, then the field's `label`, e.g. "CTA › Button label". Array rows get their row
|
|
74
80
|
header, e.g. "Item 02 › Title". Admin sends the labels to the preview, so nothing needs configuring.
|
|
81
|
+
|
|
82
|
+
## Following the selection in other Admin components
|
|
83
|
+
|
|
84
|
+
A click in the preview dispatches `visual-editing:select` on Admin's `window`, with the form path
|
|
85
|
+
in `detail.path` (`layout.2` for a block, `layout.2.headline` for a field), and with `path: null`
|
|
86
|
+
when the editor clears the selection in the preview (empty space, Escape). Other components can
|
|
87
|
+
follow the editor's selection without depending on this package;
|
|
88
|
+
`@simmalugnt-se/payload-editor-assistant` uses it so "this" in a message means the clicked block.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
window.addEventListener("visual-editing:select", (event) => {
|
|
92
|
+
const { path } = (event as CustomEvent<{ path: string | null }>).detail;
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Clicks in Admin clear the preview outline. Put `data-visual-editing-keep-selection` on a panel
|
|
97
|
+
whose clicks should keep it, such as a chat next to the form.
|
|
98
|
+
|
|
99
|
+
The event name, its `detail` and the attribute are public API: renaming or removing them is a
|
|
100
|
+
breaking change and needs a major version.
|
|
@@ -4,7 +4,7 @@ import { useConfig, useDocumentForm, useDocumentInfo, useLivePreviewContext, use
|
|
|
4
4
|
import { useEffect, useRef } from "react";
|
|
5
5
|
import "../styles/admin.css";
|
|
6
6
|
import { collectBlockLabels, collectRowLabels } from "../labels.js";
|
|
7
|
-
import { fieldDomId, focusTargets, isReadyMessage, isSelectMessage, MESSAGE_TYPE, matchNodeIndex, pathFromDomId, pathFromRowDomId, resolveBlockTarget, rowDomId, } from "../protocol.js";
|
|
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";
|
|
10
10
|
const HIGHLIGHT_CLASS = "pve-highlight";
|
|
@@ -13,6 +13,10 @@ const HIGHLIGHT_MS = 1600;
|
|
|
13
13
|
const WAIT_MS = 1000;
|
|
14
14
|
/** Rich text editors are lazy loaded and can take noticeably longer to mount. Only rich text clicks wait this long. */
|
|
15
15
|
const FIELD_WAIT_MS = 4000;
|
|
16
|
+
/** Frames an element must keep its position before the layout counts as settled. */
|
|
17
|
+
const STABLE_FRAMES = 3;
|
|
18
|
+
/** Roughly how long a smooth scroll takes. */
|
|
19
|
+
const SCROLL_MS = 600;
|
|
16
20
|
/**
|
|
17
21
|
* Mounted in the document edit view. Listens for clicks reported by the preview window
|
|
18
22
|
* and brings the matching block (or field) into view in the form: opens tabs, expands rows,
|
|
@@ -46,6 +50,11 @@ export function VisualEditingAdminBridge() {
|
|
|
46
50
|
sendLabels(event.source, event.origin);
|
|
47
51
|
return;
|
|
48
52
|
}
|
|
53
|
+
if (isClearMessage(event.data)) {
|
|
54
|
+
run += 1;
|
|
55
|
+
window.dispatchEvent(new CustomEvent(SELECT_EVENT, { detail: { path: null } }));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
49
58
|
if (!isSelectMessage(event.data)) {
|
|
50
59
|
return;
|
|
51
60
|
}
|
|
@@ -81,23 +90,27 @@ export function VisualEditingAdminBridge() {
|
|
|
81
90
|
const fieldPath = message.field ? `${target.rowPath}.${message.field}` : undefined;
|
|
82
91
|
const fieldPlan = fieldPath ? planReveal(rootFields, fieldPath, lookup) : null;
|
|
83
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
|
+
}));
|
|
84
96
|
const root = formRoot(anchorRef.current);
|
|
85
|
-
const
|
|
86
|
-
if (!
|
|
97
|
+
const scope = await openSteps(plan, root, () => id === run);
|
|
98
|
+
if (!scope || id !== run) {
|
|
87
99
|
return;
|
|
88
100
|
}
|
|
89
101
|
// Wait for the field itself; the row renders first and would otherwise win.
|
|
90
102
|
const field = fieldPlan && message.field ? `${target.rowPath}.${message.field}` : undefined;
|
|
91
103
|
const element = (field
|
|
92
|
-
? await
|
|
104
|
+
? await waitForRendered(() => findField(field), scope, message.node ? FIELD_WAIT_MS : WAIT_MS)
|
|
93
105
|
: null) ?? (await waitFor(() => findRow(target)));
|
|
94
106
|
if (!element || id !== run) {
|
|
95
107
|
return;
|
|
96
108
|
}
|
|
97
|
-
|
|
109
|
+
const current = () => id === run;
|
|
110
|
+
if (message.node && (await revealRichTextNode(element, message.node, current))) {
|
|
98
111
|
return;
|
|
99
112
|
}
|
|
100
|
-
reveal(element, field !== undefined && element !== findRow(target));
|
|
113
|
+
await reveal(element, field !== undefined && element !== findRow(target), current);
|
|
101
114
|
};
|
|
102
115
|
window.addEventListener("message", onMessage);
|
|
103
116
|
return () => window.removeEventListener("message", onMessage);
|
|
@@ -112,6 +125,9 @@ export function VisualEditingAdminBridge() {
|
|
|
112
125
|
// Focus moving into the preview iframe is the preview's own click; it already selected.
|
|
113
126
|
return;
|
|
114
127
|
}
|
|
128
|
+
if (event.target.closest(`[${ATTR_KEEP_SELECTION}]`)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
115
131
|
const root = formRoot(anchorRef.current);
|
|
116
132
|
const path = root.contains(event.target) ? pathOf(event.target) : undefined;
|
|
117
133
|
const fields = latest.current.form.getFields();
|
|
@@ -192,25 +208,29 @@ function formRoot(anchor) {
|
|
|
192
208
|
const view = anchor?.closest(".collection-edit");
|
|
193
209
|
return view?.querySelector(".collection-edit__form") ?? document;
|
|
194
210
|
}
|
|
195
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Open the rows and tabs the plan needs, one level at a time, waiting for each to render.
|
|
213
|
+
* Returns the innermost scope (row or tab content), where the target field renders.
|
|
214
|
+
*/
|
|
196
215
|
async function openSteps(steps, root, current) {
|
|
197
216
|
let scope = root;
|
|
198
217
|
for (const step of steps) {
|
|
199
218
|
if (!current()) {
|
|
200
|
-
return
|
|
219
|
+
return null;
|
|
201
220
|
}
|
|
202
221
|
if (step.kind === "row") {
|
|
203
|
-
const row = await
|
|
222
|
+
const row = await waitForRendered(() => document.getElementById(rowDomId(step)), scope);
|
|
204
223
|
if (!row) {
|
|
205
|
-
return
|
|
224
|
+
return null;
|
|
206
225
|
}
|
|
207
226
|
scope = row;
|
|
208
227
|
continue;
|
|
209
228
|
}
|
|
210
|
-
const
|
|
229
|
+
const tabsScope = scope;
|
|
230
|
+
const tabs = await waitForRendered(() => tabsFieldsIn(tabsScope)[step.ordinal] ?? null, tabsScope);
|
|
211
231
|
const button = tabs?.querySelectorAll(":scope > .tabs-field__tabs-wrap > .tabs-field__tabs > .tabs-field__tab-button")[step.index];
|
|
212
232
|
if (!tabs || !button) {
|
|
213
|
-
return
|
|
233
|
+
return null;
|
|
214
234
|
}
|
|
215
235
|
if (!button.classList.contains("tabs-field__tab-button--active")) {
|
|
216
236
|
button.click();
|
|
@@ -219,11 +239,26 @@ async function openSteps(steps, root, current) {
|
|
|
219
239
|
? tabs.querySelector(":scope > .tabs-field__content-wrap")
|
|
220
240
|
: null);
|
|
221
241
|
if (!content) {
|
|
222
|
-
return
|
|
242
|
+
return null;
|
|
223
243
|
}
|
|
224
244
|
scope = content;
|
|
225
245
|
}
|
|
226
|
-
return
|
|
246
|
+
return scope;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Payload renders a group of fields only once it is within 1000px of the viewport
|
|
250
|
+
* (`RenderIfInViewport`); until then the group is an empty div. So when the next level is not in
|
|
251
|
+
* the DOM yet, bring the enclosing scope into view to make Payload render it, then wait.
|
|
252
|
+
*/
|
|
253
|
+
async function waitForRendered(find, scope, timeoutMs = WAIT_MS) {
|
|
254
|
+
const found = find();
|
|
255
|
+
if (found) {
|
|
256
|
+
return found;
|
|
257
|
+
}
|
|
258
|
+
if (scope instanceof HTMLElement) {
|
|
259
|
+
scope.scrollIntoView({ block: "start" });
|
|
260
|
+
}
|
|
261
|
+
return waitFor(find, timeoutMs);
|
|
227
262
|
}
|
|
228
263
|
/** Tabs fields at this level of the scope, in DOM order; nested tabs and rows are other scopes. */
|
|
229
264
|
function tabsFieldsIn(scope) {
|
|
@@ -259,7 +294,7 @@ function findRow(target) {
|
|
|
259
294
|
* Put the cursor at the start of the clicked paragraph in a Lexical editor.
|
|
260
295
|
* Lexical renders each top-level node as one child of the contenteditable root.
|
|
261
296
|
*/
|
|
262
|
-
async function revealRichTextNode(field, node) {
|
|
297
|
+
async function revealRichTextNode(field, node, current) {
|
|
263
298
|
const editor = await waitFor(() => field.querySelector("[contenteditable='true'][data-lexical-editor='true']") ??
|
|
264
299
|
field.querySelector("[contenteditable='true']"), FIELD_WAIT_MS);
|
|
265
300
|
const children = editor ? Array.from(editor.children) : [];
|
|
@@ -270,7 +305,7 @@ async function revealRichTextNode(field, node) {
|
|
|
270
305
|
if (!(target instanceof HTMLElement)) {
|
|
271
306
|
return false;
|
|
272
307
|
}
|
|
273
|
-
target
|
|
308
|
+
await scrollWhenSettled(target, current);
|
|
274
309
|
editor.focus({ preventScroll: true });
|
|
275
310
|
const range = document.createRange();
|
|
276
311
|
const firstText = document.createTreeWalker(target, NodeFilter.SHOW_TEXT).nextNode();
|
|
@@ -290,21 +325,66 @@ async function revealRichTextNode(field, node) {
|
|
|
290
325
|
* Scroll to and highlight a field or a block row. Only a field gets focus: focusing a row
|
|
291
326
|
* would land in its first input (the block name), which is not what was clicked.
|
|
292
327
|
*/
|
|
293
|
-
function reveal(element, isField) {
|
|
328
|
+
async function reveal(element, isField, current) {
|
|
294
329
|
// A row sits inside the blocks field's `.field-type`; highlighting that would mark the whole list.
|
|
295
330
|
const highlighted = isField ? (element.closest(".field-type") ?? element) : element;
|
|
296
|
-
highlighted
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
331
|
+
await scrollWhenSettled(highlighted, current, () => {
|
|
332
|
+
if (isField) {
|
|
333
|
+
const focusable = isFocusable(element)
|
|
334
|
+
? element
|
|
335
|
+
: (element.querySelector("input:not([type='hidden']):not([type='file']), textarea, select, [contenteditable='true']") ?? element.querySelector("button:not([disabled])"));
|
|
336
|
+
focusable?.focus({ preventScroll: true });
|
|
337
|
+
}
|
|
338
|
+
highlighted.classList.remove(HIGHLIGHT_CLASS);
|
|
339
|
+
// Restart the animation when the same element is clicked twice.
|
|
340
|
+
void highlighted.offsetWidth;
|
|
341
|
+
highlighted.classList.add(HIGHLIGHT_CLASS);
|
|
342
|
+
window.setTimeout(() => highlighted.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_MS);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Scroll `element` to the middle of the form, robust to the form changing under the scroll:
|
|
347
|
+
* Payload renders field groups as they come near the viewport and textareas grow to fit their
|
|
348
|
+
* text, which pushes the target down. Start once its position is stable, and scroll again if it
|
|
349
|
+
* has moved out of view by the time the scroll is done. `onScroll` runs as the scroll starts.
|
|
350
|
+
*/
|
|
351
|
+
async function scrollWhenSettled(element, current, onScroll) {
|
|
352
|
+
await layoutSettled(element);
|
|
353
|
+
if (!current()) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
357
|
+
onScroll?.();
|
|
358
|
+
await new Promise((resolve) => window.setTimeout(resolve, SCROLL_MS));
|
|
359
|
+
await layoutSettled(element);
|
|
360
|
+
if (current() && !inView(element)) {
|
|
361
|
+
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
302
362
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
363
|
+
}
|
|
364
|
+
/** Resolves once the element has kept its position for a few frames (or after a timeout). */
|
|
365
|
+
function layoutSettled(element, timeoutMs = WAIT_MS) {
|
|
366
|
+
const deadline = performance.now() + timeoutMs;
|
|
367
|
+
let last = Number.NaN;
|
|
368
|
+
let stable = 0;
|
|
369
|
+
return new Promise((resolve) => {
|
|
370
|
+
const tick = () => {
|
|
371
|
+
const top = element.getBoundingClientRect().top;
|
|
372
|
+
stable = top === last ? stable + 1 : 0;
|
|
373
|
+
last = top;
|
|
374
|
+
if (stable >= STABLE_FRAMES || performance.now() >= deadline) {
|
|
375
|
+
resolve();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
requestAnimationFrame(tick);
|
|
379
|
+
};
|
|
380
|
+
tick();
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/** Mostly visible: its middle, or for a tall element any part, is within the middle of the viewport. */
|
|
384
|
+
function inView(element) {
|
|
385
|
+
const rect = element.getBoundingClientRect();
|
|
386
|
+
const margin = window.innerHeight * 0.15;
|
|
387
|
+
return rect.bottom > margin && rect.top < window.innerHeight - margin;
|
|
308
388
|
}
|
|
309
389
|
function isFocusable(element) {
|
|
310
390
|
return (element instanceof HTMLInputElement ||
|
|
@@ -28,9 +28,30 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
let frame = 0;
|
|
31
|
+
// Outlined elements can change size without any scroll or pointer event (an accordion opening,
|
|
32
|
+
// an image loading); follow them. Observing a new element runs the callback once, which only
|
|
33
|
+
// re-places the outlines, so this settles.
|
|
34
|
+
const observed = new Set();
|
|
35
|
+
const resizes = new ResizeObserver(() => redraw());
|
|
36
|
+
const observeOutlined = () => {
|
|
37
|
+
const wanted = new Set([hovered?.element, current?.element].filter((el) => !!el));
|
|
38
|
+
for (const element of observed) {
|
|
39
|
+
if (!wanted.has(element)) {
|
|
40
|
+
resizes.unobserve(element);
|
|
41
|
+
observed.delete(element);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const element of wanted) {
|
|
45
|
+
if (!observed.has(element)) {
|
|
46
|
+
resizes.observe(element);
|
|
47
|
+
observed.add(element);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
31
51
|
const redraw = () => {
|
|
32
52
|
cancelAnimationFrame(frame);
|
|
33
53
|
frame = requestAnimationFrame(() => {
|
|
54
|
+
observeOutlined();
|
|
34
55
|
place(hover, hovered && hovered.element !== current?.element ? hovered : null);
|
|
35
56
|
place(selected, current);
|
|
36
57
|
});
|
|
@@ -54,16 +75,27 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
54
75
|
redraw();
|
|
55
76
|
}
|
|
56
77
|
};
|
|
78
|
+
/** The editor dropped the selection here; Admin tells components following it. */
|
|
79
|
+
const dropSelection = () => {
|
|
80
|
+
if (!current) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
clearSelection();
|
|
84
|
+
const message = { type: MESSAGE_TYPE, action: "clear" };
|
|
85
|
+
admin.postMessage(message, targetOrigin);
|
|
86
|
+
};
|
|
57
87
|
const onClick = (event) => {
|
|
58
88
|
const hit = hitFromTarget(event.target, labels);
|
|
59
89
|
if (!hit) {
|
|
60
90
|
// Outside any editable element the page behaves normally; only the selection is dropped.
|
|
61
|
-
|
|
91
|
+
dropSelection();
|
|
62
92
|
return;
|
|
63
93
|
}
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
event.
|
|
94
|
+
// A click selects, and must not leave the page: links and submits are cancelled. Anything
|
|
95
|
+
// else still reaches the page's own handlers, so accordions, tabs and sliders keep working.
|
|
96
|
+
if (navigates(event.target)) {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
}
|
|
67
99
|
current = hit;
|
|
68
100
|
redraw();
|
|
69
101
|
const message = {
|
|
@@ -111,7 +143,7 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
111
143
|
};
|
|
112
144
|
const onKeyDown = (event) => {
|
|
113
145
|
if (event.key === "Escape") {
|
|
114
|
-
|
|
146
|
+
dropSelection();
|
|
115
147
|
}
|
|
116
148
|
};
|
|
117
149
|
document.addEventListener("pointermove", onPointerMove, { passive: true });
|
|
@@ -121,6 +153,9 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
121
153
|
window.addEventListener("message", onMessage);
|
|
122
154
|
window.addEventListener("scroll", redraw, { passive: true, capture: true });
|
|
123
155
|
window.addEventListener("resize", redraw);
|
|
156
|
+
// Elements that move because something above them grew or shrank.
|
|
157
|
+
document.addEventListener("transitionend", redraw, true);
|
|
158
|
+
document.addEventListener("animationend", redraw, true);
|
|
124
159
|
const ready = { type: MESSAGE_TYPE, action: "ready" };
|
|
125
160
|
admin.postMessage(ready, targetOrigin);
|
|
126
161
|
return () => {
|
|
@@ -132,6 +167,9 @@ export function VisualEditingPreview({ adminOrigin }) {
|
|
|
132
167
|
window.removeEventListener("message", onMessage);
|
|
133
168
|
window.removeEventListener("scroll", redraw, { capture: true });
|
|
134
169
|
window.removeEventListener("resize", redraw);
|
|
170
|
+
document.removeEventListener("transitionend", redraw, true);
|
|
171
|
+
document.removeEventListener("animationend", redraw, true);
|
|
172
|
+
resizes.disconnect();
|
|
135
173
|
hover.remove();
|
|
136
174
|
selected.remove();
|
|
137
175
|
};
|
|
@@ -179,6 +217,17 @@ function labelFor(block, field, labels) {
|
|
|
179
217
|
}
|
|
180
218
|
return `${blockLabel} › ${known?.fields[field] ?? humanize(field)}`;
|
|
181
219
|
}
|
|
220
|
+
/** Whether the default action of a click here would navigate away or submit a form. */
|
|
221
|
+
function navigates(target) {
|
|
222
|
+
if (!(target instanceof Element)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
if (target.closest("a[href]")) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
const control = target.closest("button, input");
|
|
229
|
+
return !!control?.form && control.type === "submit";
|
|
230
|
+
}
|
|
182
231
|
/** Rows marked without a block type are array rows. */
|
|
183
232
|
function isArrayRow(element) {
|
|
184
233
|
const row = element.closest(`[${ATTR_BLOCK}]`);
|
package/dist/protocol.d.ts
CHANGED
|
@@ -9,6 +9,15 @@ 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
|
+
/**
|
|
13
|
+
* Dispatched on Admin's `window` when a preview click selects something, with the form path as
|
|
14
|
+
* `detail.path` (`layout.2` for a block, `layout.2.headline` for a field), and with `path: null`
|
|
15
|
+
* when the editor clears the selection in the preview. Other Admin components, such as an
|
|
16
|
+
* assistant, can follow the editor's selection without depending on this package.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SELECT_EVENT = "visual-editing:select";
|
|
19
|
+
/** Clicks and focus inside an element with this attribute keep the preview outline. */
|
|
20
|
+
export declare const ATTR_KEEP_SELECTION = "data-visual-editing-keep-selection";
|
|
12
21
|
export type SelectMessage = {
|
|
13
22
|
type: typeof MESSAGE_TYPE;
|
|
14
23
|
action: "select";
|
|
@@ -21,6 +30,12 @@ export type SelectMessage = {
|
|
|
21
30
|
text?: string;
|
|
22
31
|
};
|
|
23
32
|
};
|
|
33
|
+
/** Preview → Admin: the editor dropped the selection in the preview (empty space, Escape). */
|
|
34
|
+
export type ClearMessage = {
|
|
35
|
+
type: typeof MESSAGE_TYPE;
|
|
36
|
+
action: "clear";
|
|
37
|
+
};
|
|
38
|
+
export declare function isClearMessage(value: unknown): value is ClearMessage;
|
|
24
39
|
export declare function isSelectMessage(value: unknown): value is SelectMessage;
|
|
25
40
|
/**
|
|
26
41
|
* Pick the editor node matching the clicked preview node. Text wins over index because
|
package/dist/protocol.js
CHANGED
|
@@ -8,7 +8,23 @@ 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
|
+
/**
|
|
12
|
+
* Dispatched on Admin's `window` when a preview click selects something, with the form path as
|
|
13
|
+
* `detail.path` (`layout.2` for a block, `layout.2.headline` for a field), and with `path: null`
|
|
14
|
+
* when the editor clears the selection in the preview. Other Admin components, such as an
|
|
15
|
+
* assistant, can follow the editor's selection without depending on this package.
|
|
16
|
+
*/
|
|
17
|
+
export const SELECT_EVENT = "visual-editing:select";
|
|
18
|
+
/** Clicks and focus inside an element with this attribute keep the preview outline. */
|
|
19
|
+
export const ATTR_KEEP_SELECTION = "data-visual-editing-keep-selection";
|
|
11
20
|
const MAX_NODE_TEXT = 200;
|
|
21
|
+
export function isClearMessage(value) {
|
|
22
|
+
if (!value || typeof value !== "object") {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const message = value;
|
|
26
|
+
return message.type === MESSAGE_TYPE && message.action === "clear";
|
|
27
|
+
}
|
|
12
28
|
export function isSelectMessage(value) {
|
|
13
29
|
if (!value || typeof value !== "object") {
|
|
14
30
|
return false;
|