@simmalugnt-se/payload-visual-editing 0.1.1 → 0.1.2

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ - A click in the preview no longer blocks the page's own click handlers: accordions, tabs and
6
+ sliders keep working while the click also selects. Only links and form submits are cancelled, so
7
+ the preview does not navigate away.
8
+ - The outline follows its element when it changes size or moves without scrolling, e.g. an accordion
9
+ opening, instead of waiting for the next pointer move.
10
+ - Admin scrolls to the right field even while the form grows under the scroll (field groups
11
+ rendering, textareas resizing): it waits for the layout to settle, and scrolls again if the target
12
+ moved out of view.
13
+ - Clicks in the preview no longer sometimes do nothing. Payload renders a group of fields only when
14
+ it is near the viewport, so rows, tabs and fields far down the form were not in the DOM yet; the
15
+ Admin bridge now scrolls each level into view before waiting for the next one.
16
+
3
17
  ## 0.1.1
4
18
 
5
19
  - 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
 
@@ -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,
@@ -82,22 +86,23 @@ export function VisualEditingAdminBridge() {
82
86
  const fieldPlan = fieldPath ? planReveal(rootFields, fieldPath, lookup) : null;
83
87
  const plan = fieldPlan ?? planReveal(rootFields, target.rowPath, lookup) ?? [];
84
88
  const root = formRoot(anchorRef.current);
85
- const opened = await openSteps(plan, root, () => id === run);
86
- if (!opened || id !== run) {
89
+ const scope = await openSteps(plan, root, () => id === run);
90
+ if (!scope || id !== run) {
87
91
  return;
88
92
  }
89
93
  // Wait for the field itself; the row renders first and would otherwise win.
90
94
  const field = fieldPlan && message.field ? `${target.rowPath}.${message.field}` : undefined;
91
95
  const element = (field
92
- ? await waitFor(() => findField(field), message.node ? FIELD_WAIT_MS : WAIT_MS)
96
+ ? await waitForRendered(() => findField(field), scope, message.node ? FIELD_WAIT_MS : WAIT_MS)
93
97
  : null) ?? (await waitFor(() => findRow(target)));
94
98
  if (!element || id !== run) {
95
99
  return;
96
100
  }
97
- if (message.node && (await revealRichTextNode(element, message.node))) {
101
+ const current = () => id === run;
102
+ if (message.node && (await revealRichTextNode(element, message.node, current))) {
98
103
  return;
99
104
  }
100
- reveal(element, field !== undefined && element !== findRow(target));
105
+ await reveal(element, field !== undefined && element !== findRow(target), current);
101
106
  };
102
107
  window.addEventListener("message", onMessage);
103
108
  return () => window.removeEventListener("message", onMessage);
@@ -192,25 +197,29 @@ function formRoot(anchor) {
192
197
  const view = anchor?.closest(".collection-edit");
193
198
  return view?.querySelector(".collection-edit__form") ?? document;
194
199
  }
195
- /** Click the tabs the plan needs, one level at a time, waiting for each tab's content to render. */
200
+ /**
201
+ * Open the rows and tabs the plan needs, one level at a time, waiting for each to render.
202
+ * Returns the innermost scope (row or tab content), where the target field renders.
203
+ */
196
204
  async function openSteps(steps, root, current) {
197
205
  let scope = root;
198
206
  for (const step of steps) {
199
207
  if (!current()) {
200
- return false;
208
+ return null;
201
209
  }
202
210
  if (step.kind === "row") {
203
- const row = await waitFor(() => document.getElementById(rowDomId(step)));
211
+ const row = await waitForRendered(() => document.getElementById(rowDomId(step)), scope);
204
212
  if (!row) {
205
- return false;
213
+ return null;
206
214
  }
207
215
  scope = row;
208
216
  continue;
209
217
  }
210
- const tabs = await waitFor(() => tabsFieldsIn(scope)[step.ordinal] ?? null);
218
+ const tabsScope = scope;
219
+ const tabs = await waitForRendered(() => tabsFieldsIn(tabsScope)[step.ordinal] ?? null, tabsScope);
211
220
  const button = tabs?.querySelectorAll(":scope > .tabs-field__tabs-wrap > .tabs-field__tabs > .tabs-field__tab-button")[step.index];
212
221
  if (!tabs || !button) {
213
- return false;
222
+ return null;
214
223
  }
215
224
  if (!button.classList.contains("tabs-field__tab-button--active")) {
216
225
  button.click();
@@ -219,11 +228,26 @@ async function openSteps(steps, root, current) {
219
228
  ? tabs.querySelector(":scope > .tabs-field__content-wrap")
220
229
  : null);
221
230
  if (!content) {
222
- return false;
231
+ return null;
223
232
  }
224
233
  scope = content;
225
234
  }
226
- return true;
235
+ return scope;
236
+ }
237
+ /**
238
+ * Payload renders a group of fields only once it is within 1000px of the viewport
239
+ * (`RenderIfInViewport`); until then the group is an empty div. So when the next level is not in
240
+ * the DOM yet, bring the enclosing scope into view to make Payload render it, then wait.
241
+ */
242
+ async function waitForRendered(find, scope, timeoutMs = WAIT_MS) {
243
+ const found = find();
244
+ if (found) {
245
+ return found;
246
+ }
247
+ if (scope instanceof HTMLElement) {
248
+ scope.scrollIntoView({ block: "start" });
249
+ }
250
+ return waitFor(find, timeoutMs);
227
251
  }
228
252
  /** Tabs fields at this level of the scope, in DOM order; nested tabs and rows are other scopes. */
229
253
  function tabsFieldsIn(scope) {
@@ -259,7 +283,7 @@ function findRow(target) {
259
283
  * Put the cursor at the start of the clicked paragraph in a Lexical editor.
260
284
  * Lexical renders each top-level node as one child of the contenteditable root.
261
285
  */
262
- async function revealRichTextNode(field, node) {
286
+ async function revealRichTextNode(field, node, current) {
263
287
  const editor = await waitFor(() => field.querySelector("[contenteditable='true'][data-lexical-editor='true']") ??
264
288
  field.querySelector("[contenteditable='true']"), FIELD_WAIT_MS);
265
289
  const children = editor ? Array.from(editor.children) : [];
@@ -270,7 +294,7 @@ async function revealRichTextNode(field, node) {
270
294
  if (!(target instanceof HTMLElement)) {
271
295
  return false;
272
296
  }
273
- target.scrollIntoView({ behavior: "smooth", block: "center" });
297
+ await scrollWhenSettled(target, current);
274
298
  editor.focus({ preventScroll: true });
275
299
  const range = document.createRange();
276
300
  const firstText = document.createTreeWalker(target, NodeFilter.SHOW_TEXT).nextNode();
@@ -290,21 +314,66 @@ async function revealRichTextNode(field, node) {
290
314
  * Scroll to and highlight a field or a block row. Only a field gets focus: focusing a row
291
315
  * would land in its first input (the block name), which is not what was clicked.
292
316
  */
293
- function reveal(element, isField) {
317
+ async function reveal(element, isField, current) {
294
318
  // A row sits inside the blocks field's `.field-type`; highlighting that would mark the whole list.
295
319
  const highlighted = isField ? (element.closest(".field-type") ?? element) : element;
296
- highlighted.scrollIntoView({ behavior: "smooth", block: "center" });
297
- if (isField) {
298
- const focusable = isFocusable(element)
299
- ? element
300
- : (element.querySelector("input:not([type='hidden']):not([type='file']), textarea, select, [contenteditable='true']") ?? element.querySelector("button:not([disabled])"));
301
- focusable?.focus({ preventScroll: true });
320
+ await scrollWhenSettled(highlighted, current, () => {
321
+ if (isField) {
322
+ const focusable = isFocusable(element)
323
+ ? element
324
+ : (element.querySelector("input:not([type='hidden']):not([type='file']), textarea, select, [contenteditable='true']") ?? element.querySelector("button:not([disabled])"));
325
+ focusable?.focus({ preventScroll: true });
326
+ }
327
+ highlighted.classList.remove(HIGHLIGHT_CLASS);
328
+ // Restart the animation when the same element is clicked twice.
329
+ void highlighted.offsetWidth;
330
+ highlighted.classList.add(HIGHLIGHT_CLASS);
331
+ window.setTimeout(() => highlighted.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_MS);
332
+ });
333
+ }
334
+ /**
335
+ * Scroll `element` to the middle of the form, robust to the form changing under the scroll:
336
+ * Payload renders field groups as they come near the viewport and textareas grow to fit their
337
+ * text, which pushes the target down. Start once its position is stable, and scroll again if it
338
+ * has moved out of view by the time the scroll is done. `onScroll` runs as the scroll starts.
339
+ */
340
+ async function scrollWhenSettled(element, current, onScroll) {
341
+ await layoutSettled(element);
342
+ if (!current()) {
343
+ return;
344
+ }
345
+ element.scrollIntoView({ behavior: "smooth", block: "center" });
346
+ onScroll?.();
347
+ await new Promise((resolve) => window.setTimeout(resolve, SCROLL_MS));
348
+ await layoutSettled(element);
349
+ if (current() && !inView(element)) {
350
+ element.scrollIntoView({ behavior: "smooth", block: "center" });
302
351
  }
303
- highlighted.classList.remove(HIGHLIGHT_CLASS);
304
- // Restart the animation when the same element is clicked twice.
305
- void highlighted.offsetWidth;
306
- highlighted.classList.add(HIGHLIGHT_CLASS);
307
- window.setTimeout(() => highlighted.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_MS);
352
+ }
353
+ /** Resolves once the element has kept its position for a few frames (or after a timeout). */
354
+ function layoutSettled(element, timeoutMs = WAIT_MS) {
355
+ const deadline = performance.now() + timeoutMs;
356
+ let last = Number.NaN;
357
+ let stable = 0;
358
+ return new Promise((resolve) => {
359
+ const tick = () => {
360
+ const top = element.getBoundingClientRect().top;
361
+ stable = top === last ? stable + 1 : 0;
362
+ last = top;
363
+ if (stable >= STABLE_FRAMES || performance.now() >= deadline) {
364
+ resolve();
365
+ return;
366
+ }
367
+ requestAnimationFrame(tick);
368
+ };
369
+ tick();
370
+ });
371
+ }
372
+ /** Mostly visible: its middle, or for a tall element any part, is within the middle of the viewport. */
373
+ function inView(element) {
374
+ const rect = element.getBoundingClientRect();
375
+ const margin = window.innerHeight * 0.15;
376
+ return rect.bottom > margin && rect.top < window.innerHeight - margin;
308
377
  }
309
378
  function isFocusable(element) {
310
379
  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
  });
@@ -61,9 +82,11 @@ export function VisualEditingPreview({ adminOrigin }) {
61
82
  clearSelection();
62
83
  return;
63
84
  }
64
- // In the editor a click selects; it must not follow links or submit forms.
65
- event.preventDefault();
66
- event.stopPropagation();
85
+ // A click selects, and must not leave the page: links and submits are cancelled. Anything
86
+ // else still reaches the page's own handlers, so accordions, tabs and sliders keep working.
87
+ if (navigates(event.target)) {
88
+ event.preventDefault();
89
+ }
67
90
  current = hit;
68
91
  redraw();
69
92
  const message = {
@@ -121,6 +144,9 @@ export function VisualEditingPreview({ adminOrigin }) {
121
144
  window.addEventListener("message", onMessage);
122
145
  window.addEventListener("scroll", redraw, { passive: true, capture: true });
123
146
  window.addEventListener("resize", redraw);
147
+ // Elements that move because something above them grew or shrank.
148
+ document.addEventListener("transitionend", redraw, true);
149
+ document.addEventListener("animationend", redraw, true);
124
150
  const ready = { type: MESSAGE_TYPE, action: "ready" };
125
151
  admin.postMessage(ready, targetOrigin);
126
152
  return () => {
@@ -132,6 +158,9 @@ export function VisualEditingPreview({ adminOrigin }) {
132
158
  window.removeEventListener("message", onMessage);
133
159
  window.removeEventListener("scroll", redraw, { capture: true });
134
160
  window.removeEventListener("resize", redraw);
161
+ document.removeEventListener("transitionend", redraw, true);
162
+ document.removeEventListener("animationend", redraw, true);
163
+ resizes.disconnect();
135
164
  hover.remove();
136
165
  selected.remove();
137
166
  };
@@ -179,6 +208,17 @@ function labelFor(block, field, labels) {
179
208
  }
180
209
  return `${blockLabel} › ${known?.fields[field] ?? humanize(field)}`;
181
210
  }
211
+ /** Whether the default action of a click here would navigate away or submit a form. */
212
+ function navigates(target) {
213
+ if (!(target instanceof Element)) {
214
+ return false;
215
+ }
216
+ if (target.closest("a[href]")) {
217
+ return true;
218
+ }
219
+ const control = target.closest("button, input");
220
+ return !!control?.form && control.type === "submit";
221
+ }
182
222
  /** Rows marked without a block type are array rows. */
183
223
  function isArrayRow(element) {
184
224
  const row = element.closest(`[${ATTR_BLOCK}]`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simmalugnt-se/payload-visual-editing",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Click a block in Payload Live Preview to open its fields in the edit form",
5
5
  "keywords": [
6
6
  "payload",