@avocadostudio-ai/preview-adapter 0.3.2 → 0.4.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.
@@ -16,7 +16,106 @@ export declare function markdownToHtml(md: string): string;
16
16
  */
17
17
  export declare function withPreviewParams(href: string, currentSearch: string): string;
18
18
  export declare function findBlockNode(blockId: string): HTMLElement | null;
19
+ /**
20
+ * True while a scroll this module started is still running or being corrected.
21
+ *
22
+ * The editor treats an `iframeScrolled` report as the reader taking over —
23
+ * it drops the field anchor and stops following a running chat stream — so our
24
+ * own scrolling must not be reported. One flag for every scroll we initiate,
25
+ * rather than a marker at each call site, because the one call site that had no
26
+ * marker (block focus, which is what "Go to" uses) is exactly the one that
27
+ * scrolls in response to a click somewhere else.
28
+ */
29
+ export declare function isProgrammaticScroll(): boolean;
30
+ /**
31
+ * Bring a block on screen and *keep* it there while the page settles.
32
+ *
33
+ * One `scrollIntoView` cannot do this. It resolves its destination from the
34
+ * layout at the instant it is called, and the preview is hardly ever settled at
35
+ * that instant — selection chrome is mounting, hero images are swapping in,
36
+ * blocks above the target are still changing height. Measured against the demo
37
+ * site, the target moved 478–1152px between the call and the end of the
38
+ * animation, every time, which left it *entirely above the viewport*: "Go to"
39
+ * appeared to jump to a random section, and the block the panel was now editing
40
+ * was nowhere on screen.
41
+ *
42
+ * So: scroll, then watch. Correct whenever the block has drifted out of the
43
+ * band we aimed for, and stop as soon as it holds still. Corrections wait for
44
+ * the previous scroll to finish (a stable `scrollY`) so we never interrupt the
45
+ * smooth animation we started, and they use `auto` — a second smooth animation
46
+ * queued behind the first reads as a lurch.
47
+ *
48
+ * A reader who starts scrolling wins immediately. Being 300px off is a smaller
49
+ * insult than dragging the page back out from under someone's hand.
50
+ */
51
+ export declare function scrollBlockIntoView(el: HTMLElement, options?: {
52
+ behavior?: ScrollBehavior;
53
+ block?: ScrollLogicalPosition;
54
+ }): void;
19
55
  export declare function findEditableNode(parent: HTMLElement, editablePath: string): HTMLElement | null;
56
+ /**
57
+ * The node to point at for a field, which is not always the field's own node.
58
+ *
59
+ * Plenty of editable props are never rendered as an element. Alt text is an
60
+ * attribute on an image; a link target is an attribute on an anchor. A renderer
61
+ * emits `data-editable-target` for what it *draws*, so `cards[0].imageAlt` and
62
+ * `cards[0].ctaHref` match nothing at all, and asking to show somebody where
63
+ * the problem is has so far shown them nothing.
64
+ *
65
+ * Three steps, most specific first:
66
+ * 1. the field's own node, when it has one
67
+ * 2. for alt text, the image it describes — the only thing on the page that
68
+ * alt text *is* about, and the inverse of the `toAltPath` convention the
69
+ * renderers already follow
70
+ * 3. the nearest enclosing node: the card, the list item, the block. Less
71
+ * precise, and still the difference between "that one" and nothing.
72
+ */
73
+ export declare function resolveEditableNode(parent: HTMLElement, editablePath: string): HTMLElement | null;
74
+ /**
75
+ * Does this marked node draw an image?
76
+ *
77
+ * Ask the node first. `data-editable-kind`, which `editableProps` emits from
78
+ * whatever the site's own block manifest says, is a statement by the only party
79
+ * that knows. `isImagePath` is a guess from the prop's *name* against Avocado's
80
+ * naming for its own catalogue — `imageUrl`, `*.src` — and it is right about
81
+ * Avocado's blocks for the circular reason that Avocado named them. A site
82
+ * calling the field `photoUrl` or `heroSrc` gets an image picker in the
83
+ * property panel, which reads the manifest, and no button in the preview, which
84
+ * does not; nothing anywhere reports a disagreement.
85
+ *
86
+ * The guess stays as the fallback: every built-in renderer, and every
87
+ * integration written before the attribute existed, emits a path and no kind.
88
+ */
89
+ export declare function isImageEditable(node: HTMLElement, editablePath: string): boolean;
90
+ /**
91
+ * May this marked node be typed into?
92
+ *
93
+ * The other half of `inlineEditable: false`, which until now existed only in the
94
+ * property panel: the manifest could say a string is not something anybody edits
95
+ * on the page, and the preview had no way to hear it. Marking such a field was
96
+ * therefore all-or-nothing — mark it and accept that a double-click starts a
97
+ * text edit, or leave it unmarked and lose the hover pill, the field selection
98
+ * and, when it holds a picture, the Change button.
99
+ *
100
+ * The field this was written for is the built-in blocks' `icon`: an emoji, a
101
+ * symbol, or the URL of a small image. Everything else renders as nothing at
102
+ * all, so typing the word the planner reaches for — "rocket" — into a marked
103
+ * emoji makes the icon disappear, with the value stored and invisible.
104
+ *
105
+ * The attribute is read from the marked element, so a site declares it once
106
+ * where it declares the target.
107
+ */
108
+ export declare function isInlineEditable(node: HTMLElement): boolean;
109
+ /**
110
+ * Should Enter insert a newline rather than committing the edit?
111
+ *
112
+ * `body` is grandfathered: it is the name Avocado's own long-text fields use,
113
+ * and it was the entire rule before `data-editable-multiline` existed — the same
114
+ * shape of mistake as guessing an image from `imageUrl`, and a site whose prose
115
+ * field is called `intro` or `beschreibung` got a single-line editor for a
116
+ * paragraph. A node that states its own answer is believed either way.
117
+ */
118
+ export declare function isMultilineEditable(node: HTMLElement, editablePath: string): boolean;
20
119
  /**
21
120
  * True when React rendered this editable field as block-level element children
22
121
  * (parsed markdown), not a plain text node.
@@ -61,6 +160,61 @@ export declare function setNestedLabelsVisibility(visible: boolean): void;
61
160
  export declare function clearChildFocus(): void;
62
161
  export declare function showSkeleton(afterBlockId: string | null, blockType: string): void;
63
162
  export declare function removeSkeletons(): void;
163
+ /**
164
+ * The one misintegration the overlay can diagnose for itself.
165
+ *
166
+ * Selection is entirely `[data-block-id]`: a click resolves through
167
+ * `closest("[data-block-id]")`, and a null result is read as "clicked outside
168
+ * any block", which *clears* the selection. A site that added
169
+ * `data-editable-target` to its components but no block wrappers therefore
170
+ * frames, renders, styles and scrolls correctly, and silently deselects on
171
+ * every click — and the README has already trained the reader to expect a
172
+ * preview that ignores clicks, because that is what selection mode being off
173
+ * looks like.
174
+ *
175
+ * Editable targets with no wrapper around them is unambiguous: nobody adds the
176
+ * per-field attributes by accident. Say so once, loudly, in development.
177
+ */
178
+ export declare function missingBlockWrapperWarning(blockCount: number, editableCount: number): string | null;
179
+ /**
180
+ * The other half of a half-finished integration — and the likelier half.
181
+ *
182
+ * `missingBlockWrapperWarning` names the case where the fields are marked and
183
+ * the blocks are not. This names its mirror, which is what following the
184
+ * integration guide to its end and stopping one step early actually produces:
185
+ * every block wrapped, no field marked. Wrapping blocks is a single call in the
186
+ * preview route; marking fields means touching the site's own components, so it
187
+ * is the step that gets deferred and then forgotten.
188
+ *
189
+ * What it costs is invisible rather than broken. Selection works. Highlighting,
190
+ * scroll-to-block, the badges, the property panel and every edit made through
191
+ * it all work, because the panel is built from the block manifest and never
192
+ * looks at the page. What is silently off is everything the overlay finds by
193
+ * walking the DOM: inline text editing, the field pills on hover, the image
194
+ * Change/Remove buttons, and the live-draft path that streams a field into the
195
+ * node that draws it. A reviewer sees a preview that responds to clicks and
196
+ * concludes it is wired.
197
+ *
198
+ * Like its mirror this is an all-or-nothing test, not a coverage check: one
199
+ * marked field means the integrator knows the attribute exists, and which of
200
+ * their fields deserve it is their call, not ours.
201
+ */
202
+ export declare function missingEditableTargetsWarning(blockCount: number, editableCount: number): string | null;
203
+ /** Every integration problem the overlay can diagnose from the page alone. */
204
+ export declare function previewIntegrationWarnings(blockCount: number, editableCount: number): string[];
205
+ /**
206
+ * Put the diagnosis where the person is looking.
207
+ *
208
+ * Both warnings above describe a preview that looks like it works, so the
209
+ * console they would print to is the one nobody opens — and in the editor it is
210
+ * the *iframe's* console, two context menus away from the window in front of
211
+ * the user. The symptom is "the button is missing from the preview"; the
212
+ * explanation has to be in the preview.
213
+ *
214
+ * Development only, and dismissible: it sits over the site's own design, and
215
+ * once read it has done its work.
216
+ */
217
+ export declare function showIntegrationWarnings(messages: string[]): void;
64
218
  export declare function ensureBlockBadges(): void;
65
219
  export declare function clearListItemSelection(scope?: ParentNode): void;
66
220
  export declare function removeOverlayControls(deleteConfirmTimer: {
@@ -6,7 +6,7 @@
6
6
  * communicate back to the editor through injectable callbacks, decoupled from any
7
7
  * specific transport (postMessage vs direct).
8
8
  */
9
- import { isImagePath, parseInline, parseRichTextBlocks, normalizeRichTextBody, resolveRichTextHeadingLevel } from "@avocadostudio-ai/shared";
9
+ import { isAltPath, isImagePath, toImagePath, parseInline, parseRichTextBlocks, normalizeRichTextBody, resolveRichTextHeadingLevel } from "@avocadostudio-ai/shared";
10
10
  // ---------------------------------------------------------------------------
11
11
  // Markdown → HTML for the live-draft overlay
12
12
  // ---------------------------------------------------------------------------
@@ -141,6 +141,108 @@ export function findBlockNode(blockId) {
141
141
  }
142
142
  return document.querySelector(`[data-block-id='${blockId}']`);
143
143
  }
144
+ /** How long we keep correcting a scroll after asking for it. */
145
+ const SCROLL_SETTLE_BUDGET_MS = 1500;
146
+ let programmaticScrollUntil = 0;
147
+ /**
148
+ * True while a scroll this module started is still running or being corrected.
149
+ *
150
+ * The editor treats an `iframeScrolled` report as the reader taking over —
151
+ * it drops the field anchor and stops following a running chat stream — so our
152
+ * own scrolling must not be reported. One flag for every scroll we initiate,
153
+ * rather than a marker at each call site, because the one call site that had no
154
+ * marker (block focus, which is what "Go to" uses) is exactly the one that
155
+ * scrolls in response to a click somewhere else.
156
+ */
157
+ export function isProgrammaticScroll() {
158
+ return Date.now() < programmaticScrollUntil;
159
+ }
160
+ /** Close enough to centred that another correction would only read as a twitch. */
161
+ const SCROLL_SETTLE_TOLERANCE_PX = 32;
162
+ /** Between samples: long enough that a smooth animation shows movement. */
163
+ const SCROLL_SETTLE_INTERVAL_MS = 120;
164
+ /**
165
+ * Bring a block on screen and *keep* it there while the page settles.
166
+ *
167
+ * One `scrollIntoView` cannot do this. It resolves its destination from the
168
+ * layout at the instant it is called, and the preview is hardly ever settled at
169
+ * that instant — selection chrome is mounting, hero images are swapping in,
170
+ * blocks above the target are still changing height. Measured against the demo
171
+ * site, the target moved 478–1152px between the call and the end of the
172
+ * animation, every time, which left it *entirely above the viewport*: "Go to"
173
+ * appeared to jump to a random section, and the block the panel was now editing
174
+ * was nowhere on screen.
175
+ *
176
+ * So: scroll, then watch. Correct whenever the block has drifted out of the
177
+ * band we aimed for, and stop as soon as it holds still. Corrections wait for
178
+ * the previous scroll to finish (a stable `scrollY`) so we never interrupt the
179
+ * smooth animation we started, and they use `auto` — a second smooth animation
180
+ * queued behind the first reads as a lurch.
181
+ *
182
+ * A reader who starts scrolling wins immediately. Being 300px off is a smaller
183
+ * insult than dragging the page back out from under someone's hand.
184
+ */
185
+ export function scrollBlockIntoView(el, options) {
186
+ const block = options?.block ?? "center";
187
+ /** Where `block` wants the element's top edge, in viewport coordinates. */
188
+ const desiredTop = () => {
189
+ const height = Math.min(el.getBoundingClientRect().height, window.innerHeight);
190
+ if (block === "start")
191
+ return 0;
192
+ if (block === "end")
193
+ return Math.max(0, window.innerHeight - height);
194
+ return Math.max(0, Math.round((window.innerHeight - height) / 2));
195
+ };
196
+ /** `nearest` asks only that the block be on screen, so that is all we check. */
197
+ const offTarget = () => {
198
+ const rect = el.getBoundingClientRect();
199
+ if (block === "nearest")
200
+ return rect.bottom <= 0 || rect.top >= window.innerHeight;
201
+ return Math.abs(rect.top - desiredTop()) > SCROLL_SETTLE_TOLERANCE_PX;
202
+ };
203
+ const deadline = Date.now() + SCROLL_SETTLE_BUDGET_MS;
204
+ // A little past the last correction we could make, so the scroll it settles
205
+ // into is not reported to the editor as the reader's own.
206
+ programmaticScrollUntil = deadline + SCROLL_SETTLE_INTERVAL_MS * 3;
207
+ el.scrollIntoView({ behavior: options?.behavior ?? "smooth", block, inline: "nearest" });
208
+ let lastY = window.scrollY;
209
+ let timer = 0;
210
+ const stop = () => {
211
+ window.clearInterval(timer);
212
+ window.removeEventListener("wheel", handOver);
213
+ window.removeEventListener("touchstart", handOver);
214
+ window.removeEventListener("keydown", handOver);
215
+ };
216
+ // The reader took the wheel. Give the scroll position back to them at once,
217
+ // including the right to have their scrolling reported as theirs.
218
+ const handOver = () => {
219
+ programmaticScrollUntil = 0;
220
+ stop();
221
+ };
222
+ window.addEventListener("wheel", handOver, { passive: true });
223
+ window.addEventListener("touchstart", handOver, { passive: true });
224
+ window.addEventListener("keydown", handOver);
225
+ timer = window.setInterval(() => {
226
+ if (Date.now() > deadline || !el.isConnected)
227
+ return stop();
228
+ const y = window.scrollY;
229
+ const wasMoving = y !== lastY;
230
+ lastY = y;
231
+ // Still animating — let it land before judging where it landed.
232
+ if (wasMoving)
233
+ return;
234
+ if (!offTarget())
235
+ return stop();
236
+ el.scrollIntoView({ behavior: "auto", block, inline: "nearest" });
237
+ // No movement means the document cannot get any closer — a block near the
238
+ // end of the page can never be centred, and retrying would only burn the
239
+ // budget re-deciding that.
240
+ if (window.scrollY === y)
241
+ stop();
242
+ else
243
+ lastY = window.scrollY;
244
+ }, SCROLL_SETTLE_INTERVAL_MS);
245
+ }
144
246
  export function findEditableNode(parent, editablePath) {
145
247
  const nodes = parent.querySelectorAll("[data-editable-target]");
146
248
  for (const node of nodes) {
@@ -149,6 +251,101 @@ export function findEditableNode(parent, editablePath) {
149
251
  }
150
252
  return null;
151
253
  }
254
+ /** Drop the last `.segment` of a path: `cards[0].ctaHref` → `cards[0]`. */
255
+ function parentPath(editablePath) {
256
+ const cut = editablePath.lastIndexOf(".");
257
+ return cut > 0 ? editablePath.slice(0, cut) : null;
258
+ }
259
+ /**
260
+ * The node to point at for a field, which is not always the field's own node.
261
+ *
262
+ * Plenty of editable props are never rendered as an element. Alt text is an
263
+ * attribute on an image; a link target is an attribute on an anchor. A renderer
264
+ * emits `data-editable-target` for what it *draws*, so `cards[0].imageAlt` and
265
+ * `cards[0].ctaHref` match nothing at all, and asking to show somebody where
266
+ * the problem is has so far shown them nothing.
267
+ *
268
+ * Three steps, most specific first:
269
+ * 1. the field's own node, when it has one
270
+ * 2. for alt text, the image it describes — the only thing on the page that
271
+ * alt text *is* about, and the inverse of the `toAltPath` convention the
272
+ * renderers already follow
273
+ * 3. the nearest enclosing node: the card, the list item, the block. Less
274
+ * precise, and still the difference between "that one" and nothing.
275
+ */
276
+ export function resolveEditableNode(parent, editablePath) {
277
+ const exact = findEditableNode(parent, editablePath);
278
+ if (exact)
279
+ return exact;
280
+ if (isAltPath(editablePath)) {
281
+ const image = findEditableNode(parent, toImagePath(editablePath));
282
+ if (image)
283
+ return image;
284
+ }
285
+ for (let path = parentPath(editablePath); path; path = parentPath(path)) {
286
+ const node = findEditableNode(parent, path);
287
+ if (node)
288
+ return node;
289
+ }
290
+ return null;
291
+ }
292
+ /**
293
+ * Does this marked node draw an image?
294
+ *
295
+ * Ask the node first. `data-editable-kind`, which `editableProps` emits from
296
+ * whatever the site's own block manifest says, is a statement by the only party
297
+ * that knows. `isImagePath` is a guess from the prop's *name* against Avocado's
298
+ * naming for its own catalogue — `imageUrl`, `*.src` — and it is right about
299
+ * Avocado's blocks for the circular reason that Avocado named them. A site
300
+ * calling the field `photoUrl` or `heroSrc` gets an image picker in the
301
+ * property panel, which reads the manifest, and no button in the preview, which
302
+ * does not; nothing anywhere reports a disagreement.
303
+ *
304
+ * The guess stays as the fallback: every built-in renderer, and every
305
+ * integration written before the attribute existed, emits a path and no kind.
306
+ */
307
+ export function isImageEditable(node, editablePath) {
308
+ const declared = node.getAttribute("data-editable-kind");
309
+ if (declared)
310
+ return declared === "image";
311
+ return isImagePath(editablePath);
312
+ }
313
+ /**
314
+ * May this marked node be typed into?
315
+ *
316
+ * The other half of `inlineEditable: false`, which until now existed only in the
317
+ * property panel: the manifest could say a string is not something anybody edits
318
+ * on the page, and the preview had no way to hear it. Marking such a field was
319
+ * therefore all-or-nothing — mark it and accept that a double-click starts a
320
+ * text edit, or leave it unmarked and lose the hover pill, the field selection
321
+ * and, when it holds a picture, the Change button.
322
+ *
323
+ * The field this was written for is the built-in blocks' `icon`: an emoji, a
324
+ * symbol, or the URL of a small image. Everything else renders as nothing at
325
+ * all, so typing the word the planner reaches for — "rocket" — into a marked
326
+ * emoji makes the icon disappear, with the value stored and invisible.
327
+ *
328
+ * The attribute is read from the marked element, so a site declares it once
329
+ * where it declares the target.
330
+ */
331
+ export function isInlineEditable(node) {
332
+ return node.getAttribute("data-editable-inline") !== "false";
333
+ }
334
+ /**
335
+ * Should Enter insert a newline rather than committing the edit?
336
+ *
337
+ * `body` is grandfathered: it is the name Avocado's own long-text fields use,
338
+ * and it was the entire rule before `data-editable-multiline` existed — the same
339
+ * shape of mistake as guessing an image from `imageUrl`, and a site whose prose
340
+ * field is called `intro` or `beschreibung` got a single-line editor for a
341
+ * paragraph. A node that states its own answer is believed either way.
342
+ */
343
+ export function isMultilineEditable(node, editablePath) {
344
+ const declared = node.getAttribute("data-editable-multiline");
345
+ if (declared)
346
+ return declared !== "false";
347
+ return editablePath === "body";
348
+ }
152
349
  // Block-level tags that signal a field React rendered as parsed-markdown element
153
350
  // children (e.g. RichText `body`, Tabs `content`) rather than a single text node.
154
351
  const RICH_EDITABLE_SELECTOR = "p,ul,ol,li,blockquote,pre,table,h1,h2,h3,h4,h5,h6,hr";
@@ -334,6 +531,113 @@ export function showSkeleton(afterBlockId, blockType) {
334
531
  export function removeSkeletons() {
335
532
  document.querySelectorAll(".editor-skeleton-block").forEach((node) => node.remove());
336
533
  }
534
+ /**
535
+ * The one misintegration the overlay can diagnose for itself.
536
+ *
537
+ * Selection is entirely `[data-block-id]`: a click resolves through
538
+ * `closest("[data-block-id]")`, and a null result is read as "clicked outside
539
+ * any block", which *clears* the selection. A site that added
540
+ * `data-editable-target` to its components but no block wrappers therefore
541
+ * frames, renders, styles and scrolls correctly, and silently deselects on
542
+ * every click — and the README has already trained the reader to expect a
543
+ * preview that ignores clicks, because that is what selection mode being off
544
+ * looks like.
545
+ *
546
+ * Editable targets with no wrapper around them is unambiguous: nobody adds the
547
+ * per-field attributes by accident. Say so once, loudly, in development.
548
+ */
549
+ export function missingBlockWrapperWarning(blockCount, editableCount) {
550
+ if (blockCount > 0 || editableCount === 0)
551
+ return null;
552
+ return (`[avocado] The overlay found ${editableCount} data-editable-target attribute(s) and no ` +
553
+ "[data-block-id] wrapper. Nothing on this page can be selected: every click resolves to " +
554
+ "no block and clears the selection, whether or not selection mode is on.\n" +
555
+ "Wrap each block with getPreviewWrapperProps(editorMode, block.id, block.type) from " +
556
+ "@avocadostudio-ai/site-sdk/editor — it sets data-block-id, data-block-type and the " +
557
+ "editor-selectable class that the field pills are scoped to.");
558
+ }
559
+ /**
560
+ * The other half of a half-finished integration — and the likelier half.
561
+ *
562
+ * `missingBlockWrapperWarning` names the case where the fields are marked and
563
+ * the blocks are not. This names its mirror, which is what following the
564
+ * integration guide to its end and stopping one step early actually produces:
565
+ * every block wrapped, no field marked. Wrapping blocks is a single call in the
566
+ * preview route; marking fields means touching the site's own components, so it
567
+ * is the step that gets deferred and then forgotten.
568
+ *
569
+ * What it costs is invisible rather than broken. Selection works. Highlighting,
570
+ * scroll-to-block, the badges, the property panel and every edit made through
571
+ * it all work, because the panel is built from the block manifest and never
572
+ * looks at the page. What is silently off is everything the overlay finds by
573
+ * walking the DOM: inline text editing, the field pills on hover, the image
574
+ * Change/Remove buttons, and the live-draft path that streams a field into the
575
+ * node that draws it. A reviewer sees a preview that responds to clicks and
576
+ * concludes it is wired.
577
+ *
578
+ * Like its mirror this is an all-or-nothing test, not a coverage check: one
579
+ * marked field means the integrator knows the attribute exists, and which of
580
+ * their fields deserve it is their call, not ours.
581
+ */
582
+ export function missingEditableTargetsWarning(blockCount, editableCount) {
583
+ if (editableCount > 0 || blockCount === 0)
584
+ return null;
585
+ return (`[avocado] The overlay found ${blockCount} [data-block-id] wrapper(s) and no ` +
586
+ "data-editable-target attribute. Selection and the property panel work; inline text " +
587
+ "editing, the field pills and the image Change/Remove buttons are all off, because every " +
588
+ "one of them is found by walking the marked fields in the page.\n" +
589
+ "Mark the element that draws each editable prop with editableProps(path, { kind }) from " +
590
+ "@avocadostudio-ai/site-sdk/editor — e.g. editableProps(\"imageUrl\", { kind: \"image\" }) " +
591
+ "on the wrapper around an image, editableProps(\"heading\") on the element holding it.");
592
+ }
593
+ /** Every integration problem the overlay can diagnose from the page alone. */
594
+ export function previewIntegrationWarnings(blockCount, editableCount) {
595
+ return [
596
+ missingBlockWrapperWarning(blockCount, editableCount),
597
+ missingEditableTargetsWarning(blockCount, editableCount),
598
+ ].filter((w) => w !== null);
599
+ }
600
+ /**
601
+ * Put the diagnosis where the person is looking.
602
+ *
603
+ * Both warnings above describe a preview that looks like it works, so the
604
+ * console they would print to is the one nobody opens — and in the editor it is
605
+ * the *iframe's* console, two context menus away from the window in front of
606
+ * the user. The symptom is "the button is missing from the preview"; the
607
+ * explanation has to be in the preview.
608
+ *
609
+ * Development only, and dismissible: it sits over the site's own design, and
610
+ * once read it has done its work.
611
+ */
612
+ export function showIntegrationWarnings(messages) {
613
+ document.querySelector(".editor-integration-warning")?.remove();
614
+ if (messages.length === 0)
615
+ return;
616
+ const banner = document.createElement("div");
617
+ banner.className = "editor-integration-warning";
618
+ banner.setAttribute("role", "alert");
619
+ for (const message of messages) {
620
+ const [headline, ...rest] = message.replace(/^\[avocado] /, "").split("\n");
621
+ const item = document.createElement("div");
622
+ item.className = "editor-integration-warning__item";
623
+ const title = document.createElement("strong");
624
+ title.textContent = headline ?? "";
625
+ item.append(title);
626
+ for (const line of rest) {
627
+ const p = document.createElement("p");
628
+ p.textContent = line;
629
+ item.append(p);
630
+ }
631
+ banner.append(item);
632
+ }
633
+ const dismiss = document.createElement("button");
634
+ dismiss.type = "button";
635
+ dismiss.className = "editor-integration-warning__dismiss";
636
+ dismiss.textContent = "Dismiss";
637
+ dismiss.addEventListener("click", () => banner.remove());
638
+ banner.append(dismiss);
639
+ document.body.append(banner);
640
+ }
337
641
  export function ensureBlockBadges() {
338
642
  document.querySelectorAll("[data-block-id]").forEach((node) => {
339
643
  const blockType = node.getAttribute("data-block-type") ?? "Block";
@@ -440,7 +744,9 @@ export function applyAiFieldLoading(blockId, editablePath, active) {
440
744
  }
441
745
  }
442
746
  export function cleanupOverlayElements() {
443
- document.querySelectorAll(".aifx-shimmer-overlay, .aifx-shimmer-sparkle, .editor-image-change-btn").forEach((el) => el.remove());
747
+ document
748
+ .querySelectorAll(".aifx-shimmer-overlay, .aifx-shimmer-sparkle, .editor-image-change-btn, .editor-integration-warning")
749
+ .forEach((el) => el.remove());
444
750
  document.documentElement.removeAttribute("data-editor-active");
445
751
  document.documentElement.removeAttribute("data-editor-selection-mode");
446
752
  }
@@ -541,11 +847,11 @@ export function createBridgeFunctions(state, callbacks, config) {
541
847
  }
542
848
  }
543
849
  for (const [path, value] of Object.entries(fields)) {
544
- if (isImagePath(path))
545
- continue;
546
850
  const node = findEditableNode(block, path);
547
851
  if (!node)
548
852
  continue;
853
+ if (isImageEditable(node, path))
854
+ continue;
549
855
  // Rich fields (RichText body, Tabs content, etc.) render as block-level
550
856
  // React children. Writing innerHTML here desyncs React's vdom and the
551
857
  // final router.refresh() then mis-reconciles the subtree (truncated text
@@ -710,7 +1016,7 @@ export function createBridgeFunctions(state, callbacks, config) {
710
1016
  const parent = findBlockNode(parentBlockId);
711
1017
  if (!parent)
712
1018
  return;
713
- const child = findEditableNode(parent, editablePath);
1019
+ const child = resolveEditableNode(parent, editablePath);
714
1020
  if (!child)
715
1021
  return;
716
1022
  child.classList.add("editor-child-highlight");
@@ -740,6 +1046,8 @@ export function createBridgeFunctions(state, callbacks, config) {
740
1046
  const startInlineEdit = (args) => {
741
1047
  if (!supportsInlineEditablePath(args.editablePath))
742
1048
  return;
1049
+ if (!isInlineEditable(args.node))
1050
+ return;
743
1051
  if (args.node.children.length > 0)
744
1052
  return;
745
1053
  const existing = state.inlineEditing;
@@ -754,7 +1062,7 @@ export function createBridgeFunctions(state, callbacks, config) {
754
1062
  blockType: args.blockType,
755
1063
  editablePath: args.editablePath,
756
1064
  initialValue,
757
- isMultiline: args.editablePath === "body"
1065
+ isMultiline: isMultilineEditable(args.node, args.editablePath)
758
1066
  };
759
1067
  args.node.setAttribute("contenteditable", "true");
760
1068
  args.node.classList.add("editor-inline-editing");
@@ -1043,7 +1351,7 @@ export function createBridgeFunctions(state, callbacks, config) {
1043
1351
  }
1044
1352
  document.querySelectorAll(".editor-selectable [data-editable-target]").forEach((el) => {
1045
1353
  const path = el.getAttribute("data-editable-target") ?? "";
1046
- if (!isImagePath(path))
1354
+ if (!isImageEditable(el, path))
1047
1355
  return;
1048
1356
  const block = el.closest("[data-block-id]");
1049
1357
  if (!block)
@@ -1103,7 +1411,7 @@ export function createBridgeFunctions(state, callbacks, config) {
1103
1411
  state.pendingScrollAnchorY = null;
1104
1412
  }
1105
1413
  else {
1106
- match.scrollIntoView({ behavior: "smooth", block: "center" });
1414
+ scrollBlockIntoView(match);
1107
1415
  }
1108
1416
  }
1109
1417
  if (shouldAnimate) {
@@ -1473,7 +1781,7 @@ export function createBridgeFunctions(state, callbacks, config) {
1473
1781
  }
1474
1782
  applyBlockFocus(blockId, false, editablePath, { scrollIntoView: false });
1475
1783
  let editableValue = null;
1476
- if (editablePath && isImagePath(editablePath) && childNode) {
1784
+ if (editablePath && childNode && isImageEditable(childNode, editablePath)) {
1477
1785
  const img = childNode.querySelector("img");
1478
1786
  if (img?.src)
1479
1787
  editableValue = img.src;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { PreviewBridge } from "./preview-bridge.tsx";
2
2
  export { PreviewBridgeCore } from "./preview-bridge-core.tsx";
3
3
  export type { PreviewBridgeConfig, PreviewBridgeCoreProps } from "./preview-bridge-core.tsx";
4
4
  export { getPreviewWrapperProps } from "./selectable.ts";
5
- export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, withPreviewParams, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.ts";
5
+ export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, scrollBlockIntoView, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, withPreviewParams, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, isImageEditable, isInlineEditable, isMultilineEditable, missingBlockWrapperWarning, missingEditableTargetsWarning, previewIntegrationWarnings, } from "./bridge-functions.ts";
6
6
  export type { BridgeCallbacks, BridgeState, BridgeFunctions } from "./bridge-functions.ts";
7
7
  export { LivePreviewProvider, useLivePreviewBlocks, useLivePreviewBridgeApi, } from "./live-preview-store.tsx";
8
8
  export type { LivePreviewPage, LivePreviewBridgeApi } from "./live-preview-store.tsx";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { PreviewBridge } from "./preview-bridge.js";
2
2
  export { PreviewBridgeCore } from "./preview-bridge-core.js";
3
3
  export { getPreviewWrapperProps } from "./selectable.js";
4
- export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, withPreviewParams, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.js";
4
+ export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, scrollBlockIntoView, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, withPreviewParams, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, isImageEditable, isInlineEditable, isMultilineEditable, missingBlockWrapperWarning, missingEditableTargetsWarning, previewIntegrationWarnings, } from "./bridge-functions.js";
5
5
  export { LivePreviewProvider, useLivePreviewBlocks, useLivePreviewBridgeApi, } from "./live-preview-store.js";
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useEffect, useRef } from "react";
4
- import { createBridgeFunctions, createBridgeState, findBlockNode, parseListItemPath, ensureBlockBadges, setNestedLabelsVisibility, showSkeleton, removeSkeletons, clearChildFocus, clearAllHighlights, clearListItemSelection, applyAiFieldLoading, cleanupOverlayElements, withPreviewParams, } from "./bridge-functions.js";
4
+ import { createBridgeFunctions, createBridgeState, findBlockNode, isProgrammaticScroll, parseListItemPath, ensureBlockBadges, previewIntegrationWarnings, showIntegrationWarnings, scrollBlockIntoView, setNestedLabelsVisibility, showSkeleton, removeSkeletons, clearChildFocus, clearAllHighlights, clearListItemSelection, applyAiFieldLoading, cleanupOverlayElements, withPreviewParams, } from "./bridge-functions.js";
5
5
  export function PreviewBridgeCore(props) {
6
6
  // When running standalone (no editor origin) or not embedded in an iframe, render nothing.
7
7
  if (!props.editorOrigin || typeof window !== "undefined" && window.parent === window)
@@ -10,7 +10,6 @@ export function PreviewBridgeCore(props) {
10
10
  }
11
11
  function PreviewBridgeCoreInner({ slug, editorOrigin, navigate, refresh, pathname, liveStore }) {
12
12
  const stateRef = useRef(null);
13
- const programmaticScrollUntilRef = useRef(0);
14
13
  useEffect(() => {
15
14
  // -- postMessage helpers ------------------------------------------------
16
15
  const postToEditor = (type, payload) => {
@@ -50,6 +49,21 @@ function PreviewBridgeCoreInner({ slug, editorOrigin, navigate, refresh, pathnam
50
49
  clearListItemSelection();
51
50
  ensureBlockBadges();
52
51
  bridge.mountGlobalImageButtons();
52
+ /*
53
+ * Deferred a frame so a page still streaming in is not accused of having no
54
+ * blocks. Development only: in production the site owner cannot act on it
55
+ * and the visitor should never see it.
56
+ */
57
+ if (process.env.NODE_ENV !== "production") {
58
+ requestAnimationFrame(() => {
59
+ const warnings = previewIntegrationWarnings(document.querySelectorAll("[data-block-id]").length, document.querySelectorAll("[data-editable-target]").length);
60
+ for (const warning of warnings)
61
+ console.error(warning);
62
+ // Both of these describe a preview that *looks* correct, so the iframe's
63
+ // console is the last place they would be found. Say it in the frame.
64
+ showIntegrationWarnings(warnings);
65
+ });
66
+ }
53
67
  // -- Mutation observer -------------------------------------------------
54
68
  let detectNewBlocksRaf = null;
55
69
  const scheduleDetectNewBlocks = () => {
@@ -255,10 +269,7 @@ function PreviewBridgeCoreInner({ slug, editorOrigin, navigate, refresh, pathnam
255
269
  const el = findBlockNode(blockId);
256
270
  if (!el)
257
271
  return false;
258
- // Mark that the next scroll event is programmatic so we don't
259
- // interpret our own scroll as a user cancel.
260
- programmaticScrollUntilRef.current = Date.now() + 800;
261
- el.scrollIntoView({ behavior, block, inline: "nearest" });
272
+ scrollBlockIntoView(el, { behavior, block });
262
273
  return true;
263
274
  };
264
275
  // The block may not yet be in the DOM if the iframe just re-rendered;
@@ -271,7 +282,7 @@ function PreviewBridgeCoreInner({ slug, editorOrigin, navigate, refresh, pathnam
271
282
  // -- Scroll handler ----------------------------------------------------
272
283
  const onScroll = () => {
273
284
  // Swallow the scroll event if it originated from our own scrollIntoView.
274
- if (Date.now() < programmaticScrollUntilRef.current)
285
+ if (isProgrammaticScroll())
275
286
  return;
276
287
  callbacks.onScroll();
277
288
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/preview-adapter",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -32,7 +32,7 @@
32
32
  "src/styles.css"
33
33
  ],
34
34
  "dependencies": {
35
- "@avocadostudio-ai/shared": "^0.3.2"
35
+ "@avocadostudio-ai/shared": "^0.4.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "next": ">=15.0.0",
package/src/styles.css CHANGED
@@ -1152,6 +1152,27 @@
1152
1152
  box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18);
1153
1153
  }
1154
1154
 
1155
+ /*
1156
+ * Hovering the *block* reveals its image buttons, not hovering the image.
1157
+ *
1158
+ * An image is very often not the top thing over its own area, and both ways it
1159
+ * happens are ordinary design, not mistakes. A full-bleed photo behind a
1160
+ * headline has the copy column painted over it; a card that is one big click
1161
+ * target has a stretched `::after` over the whole card, photo included. In
1162
+ * both, the marked element never receives `:hover`, so the button it holds
1163
+ * never became visible — the image simply had no Change button, which is
1164
+ * exactly how this reads as a bug in the button.
1165
+ *
1166
+ * `:hover` propagates to ancestors, and whatever covers the image is inside
1167
+ * the same block wrapper, so the wrapper is hovered in every one of these
1168
+ * cases. On a grid this reveals each card's button at once, which is the
1169
+ * better behaviour anyway: hover a block, see what it lets you change.
1170
+ *
1171
+ * Clicking then works without further help. The button carries z-index 24 and
1172
+ * its marked element sets no z-index of its own, so it competes — and wins —
1173
+ * in the block's own stacking context against the copy that covers it.
1174
+ */
1175
+ [data-editor-active] .editor-selectable:hover .editor-image-change-btn,
1155
1176
  [data-editor-active] [data-editable-target]:hover > .editor-image-change-btn,
1156
1177
  [data-editor-active] [data-editable-target]:focus-within > .editor-image-change-btn {
1157
1178
  opacity: 1;
@@ -1205,3 +1226,61 @@
1205
1226
  animation: none !important;
1206
1227
  }
1207
1228
  }
1229
+
1230
+ /* ---------------------------------------------------------------------------
1231
+ Integration diagnostics
1232
+
1233
+ Development only, injected by the bridge. Both warnings it can raise describe
1234
+ a preview that renders and responds to clicks and is missing half of what the
1235
+ overlay does, so the message has to appear over the page itself rather than
1236
+ in the iframe's console.
1237
+ --------------------------------------------------------------------------- */
1238
+
1239
+ .editor-integration-warning {
1240
+ position: fixed;
1241
+ z-index: 2147483000;
1242
+ inset: 12px 12px auto 12px;
1243
+ max-width: 720px;
1244
+ margin-inline: auto;
1245
+ display: flex;
1246
+ flex-direction: column;
1247
+ gap: 10px;
1248
+ padding: 14px 16px;
1249
+ border-radius: 12px;
1250
+ border: 1px solid rgba(180, 83, 9, 0.45);
1251
+ background: #fffbeb;
1252
+ color: #78350f;
1253
+ font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
1254
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
1255
+ }
1256
+
1257
+ .editor-integration-warning__item {
1258
+ display: flex;
1259
+ flex-direction: column;
1260
+ gap: 6px;
1261
+ }
1262
+
1263
+ .editor-integration-warning__item strong {
1264
+ font-weight: 650;
1265
+ }
1266
+
1267
+ .editor-integration-warning__item p {
1268
+ margin: 0;
1269
+ color: #92400e;
1270
+ }
1271
+
1272
+ .editor-integration-warning__dismiss {
1273
+ align-self: flex-start;
1274
+ padding: 5px 12px;
1275
+ border: 1px solid rgba(120, 53, 15, 0.35);
1276
+ border-radius: 8px;
1277
+ background: transparent;
1278
+ color: inherit;
1279
+ font: inherit;
1280
+ font-weight: 600;
1281
+ cursor: pointer;
1282
+ }
1283
+
1284
+ .editor-integration-warning__dismiss:hover {
1285
+ background: rgba(120, 53, 15, 0.08);
1286
+ }