@avocadostudio-ai/preview-adapter 0.3.2 → 0.3.3

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,61 @@ 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;
20
74
  /**
21
75
  * True when React rendered this editable field as block-level element children
22
76
  * (parsed markdown), not a plain text node.
@@ -61,6 +115,22 @@ export declare function setNestedLabelsVisibility(visible: boolean): void;
61
115
  export declare function clearChildFocus(): void;
62
116
  export declare function showSkeleton(afterBlockId: string | null, blockType: string): void;
63
117
  export declare function removeSkeletons(): void;
118
+ /**
119
+ * The one misintegration the overlay can diagnose for itself.
120
+ *
121
+ * Selection is entirely `[data-block-id]`: a click resolves through
122
+ * `closest("[data-block-id]")`, and a null result is read as "clicked outside
123
+ * any block", which *clears* the selection. A site that added
124
+ * `data-editable-target` to its components but no block wrappers therefore
125
+ * frames, renders, styles and scrolls correctly, and silently deselects on
126
+ * every click — and the README has already trained the reader to expect a
127
+ * preview that ignores clicks, because that is what selection mode being off
128
+ * looks like.
129
+ *
130
+ * Editable targets with no wrapper around them is unambiguous: nobody adds the
131
+ * per-field attributes by accident. Say so once, loudly, in development.
132
+ */
133
+ export declare function missingBlockWrapperWarning(blockCount: number, editableCount: number): string | null;
64
134
  export declare function ensureBlockBadges(): void;
65
135
  export declare function clearListItemSelection(scope?: ParentNode): void;
66
136
  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,44 @@ 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
+ }
152
292
  // Block-level tags that signal a field React rendered as parsed-markdown element
153
293
  // children (e.g. RichText `body`, Tabs `content`) rather than a single text node.
154
294
  const RICH_EDITABLE_SELECTOR = "p,ul,ol,li,blockquote,pre,table,h1,h2,h3,h4,h5,h6,hr";
@@ -334,6 +474,31 @@ export function showSkeleton(afterBlockId, blockType) {
334
474
  export function removeSkeletons() {
335
475
  document.querySelectorAll(".editor-skeleton-block").forEach((node) => node.remove());
336
476
  }
477
+ /**
478
+ * The one misintegration the overlay can diagnose for itself.
479
+ *
480
+ * Selection is entirely `[data-block-id]`: a click resolves through
481
+ * `closest("[data-block-id]")`, and a null result is read as "clicked outside
482
+ * any block", which *clears* the selection. A site that added
483
+ * `data-editable-target` to its components but no block wrappers therefore
484
+ * frames, renders, styles and scrolls correctly, and silently deselects on
485
+ * every click — and the README has already trained the reader to expect a
486
+ * preview that ignores clicks, because that is what selection mode being off
487
+ * looks like.
488
+ *
489
+ * Editable targets with no wrapper around them is unambiguous: nobody adds the
490
+ * per-field attributes by accident. Say so once, loudly, in development.
491
+ */
492
+ export function missingBlockWrapperWarning(blockCount, editableCount) {
493
+ if (blockCount > 0 || editableCount === 0)
494
+ return null;
495
+ return (`[avocado] The overlay found ${editableCount} data-editable-target attribute(s) and no ` +
496
+ "[data-block-id] wrapper. Nothing on this page can be selected: every click resolves to " +
497
+ "no block and clears the selection, whether or not selection mode is on.\n" +
498
+ "Wrap each block with getPreviewWrapperProps(editorMode, block.id, block.type) from " +
499
+ "@avocadostudio-ai/site-sdk/editor — it sets data-block-id, data-block-type and the " +
500
+ "editor-selectable class that the field pills are scoped to.");
501
+ }
337
502
  export function ensureBlockBadges() {
338
503
  document.querySelectorAll("[data-block-id]").forEach((node) => {
339
504
  const blockType = node.getAttribute("data-block-type") ?? "Block";
@@ -710,7 +875,7 @@ export function createBridgeFunctions(state, callbacks, config) {
710
875
  const parent = findBlockNode(parentBlockId);
711
876
  if (!parent)
712
877
  return;
713
- const child = findEditableNode(parent, editablePath);
878
+ const child = resolveEditableNode(parent, editablePath);
714
879
  if (!child)
715
880
  return;
716
881
  child.classList.add("editor-child-highlight");
@@ -1103,7 +1268,7 @@ export function createBridgeFunctions(state, callbacks, config) {
1103
1268
  state.pendingScrollAnchorY = null;
1104
1269
  }
1105
1270
  else {
1106
- match.scrollIntoView({ behavior: "smooth", block: "center" });
1271
+ scrollBlockIntoView(match);
1107
1272
  }
1108
1273
  }
1109
1274
  if (shouldAnimate) {
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, } 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, } 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, missingBlockWrapperWarning, 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,18 @@ 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 warning = missingBlockWrapperWarning(document.querySelectorAll("[data-block-id]").length, document.querySelectorAll("[data-editable-target]").length);
60
+ if (warning)
61
+ console.error(warning);
62
+ });
63
+ }
53
64
  // -- Mutation observer -------------------------------------------------
54
65
  let detectNewBlocksRaf = null;
55
66
  const scheduleDetectNewBlocks = () => {
@@ -255,10 +266,7 @@ function PreviewBridgeCoreInner({ slug, editorOrigin, navigate, refresh, pathnam
255
266
  const el = findBlockNode(blockId);
256
267
  if (!el)
257
268
  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" });
269
+ scrollBlockIntoView(el, { behavior, block });
262
270
  return true;
263
271
  };
264
272
  // The block may not yet be in the DOM if the iframe just re-rendered;
@@ -271,7 +279,7 @@ function PreviewBridgeCoreInner({ slug, editorOrigin, navigate, refresh, pathnam
271
279
  // -- Scroll handler ----------------------------------------------------
272
280
  const onScroll = () => {
273
281
  // Swallow the scroll event if it originated from our own scrollIntoView.
274
- if (Date.now() < programmaticScrollUntilRef.current)
282
+ if (isProgrammaticScroll())
275
283
  return;
276
284
  callbacks.onScroll();
277
285
  };
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.3.3",
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.3.3"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "next": ">=15.0.0",