@avocadostudio-ai/preview-adapter 0.7.0 → 0.9.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.
@@ -0,0 +1,61 @@
1
+ import type { LivePreviewBridgeApi } from "./live-preview-store-core.ts";
2
+ export type BridgeControllerConfig = {
3
+ /** The CONTENT slug, not the browser pathname — see {@link postRouteChanged}. */
4
+ slug: string;
5
+ editorOrigin: string;
6
+ pathname: string;
7
+ /** Navigate within the preview, preserving the editor's query parameters. */
8
+ navigate: (href: string) => void;
9
+ /**
10
+ * Re-render the current page against the latest draft.
11
+ *
12
+ * **Not `location.reload()`.** `smoothRefresh()` schedules `restoreAndFocus()`
13
+ * behind two animation frames and then polls `applyBlockFocus` twenty times at
14
+ * 45 ms, so that the block being edited keeps focus and scroll position across
15
+ * the refresh. A full document reload destroys every one of those timers along
16
+ * with the DOM they were going to act on, and the editor's caret jumps to the
17
+ * top of the page on each keystroke that commits.
18
+ */
19
+ refresh: () => void;
20
+ /**
21
+ * When provided, streamed `liveDraft` content is pushed into this store and
22
+ * the host re-renders the blocks itself, instead of the bridge mutating
23
+ * `innerHTML`.
24
+ *
25
+ * The store exists because React's virtual DOM desynchronises when the overlay
26
+ * writes into a subtree React owns. A host with no virtual DOM has no such
27
+ * hazard, so passing `null` there is the *better* branch, not a degraded one:
28
+ * rich fields stream through the overlay with nothing to desynchronise.
29
+ */
30
+ liveStore?: LivePreviewBridgeApi | null;
31
+ /**
32
+ * Emit integration warnings for a preview that renders but is unmarked.
33
+ * @default `process.env.NODE_ENV !== "production"`
34
+ */
35
+ warnOnMissingMarkers?: boolean;
36
+ };
37
+ /**
38
+ * Attach the bridge to the current document. Returns the detach function.
39
+ *
40
+ * Call it once the page's markup is in the DOM, and call the returned function
41
+ * before that markup goes away. On a host with client-side routing that swaps
42
+ * the document — Astro's `<ClientRouter />`, for one — that means detaching and
43
+ * re-attaching per navigation: the listeners are bound to nodes that no longer
44
+ * exist, and the preview goes quietly inert while still looking correct.
45
+ */
46
+ export declare function attachBridge(config: BridgeControllerConfig): () => void;
47
+ /**
48
+ * Tell the editor which page the preview is showing.
49
+ *
50
+ * Report the CONTENT slug, not the browser pathname. They are identical for a
51
+ * root-mounted site and diverge the moment one is mounted under a subpath: the
52
+ * pathname is `/studio/home` while the orchestrator's draft is keyed `/home`, so
53
+ * reporting the pathname sends `/studio/home` to `/chat` and `/ops` and the
54
+ * planner answers "page not found".
55
+ */
56
+ export declare function postRouteChanged(slug: string, editorOrigin: string): void;
57
+ /**
58
+ * Reveal AI-generated images with a blur-to-sharp animation when the real file
59
+ * replaces its placeholder. Returns the detach function.
60
+ */
61
+ export declare function attachImageRevealWatcher(): () => void;
@@ -0,0 +1,387 @@
1
+ import { createBridgeFunctions, createBridgeState, findBlockNode, isProgrammaticScroll, parseListItemPath, ensureBlockBadges, previewIntegrationWarnings, showIntegrationWarnings, scrollBlockIntoView, setNestedLabelsVisibility, showSkeleton, removeSkeletons, clearChildFocus, clearAllHighlights, clearListItemSelection, applyAiFieldLoading, cleanupOverlayElements, withPreviewParams, } from "./bridge-functions.js";
2
+ /**
3
+ * Attach the bridge to the current document. Returns the detach function.
4
+ *
5
+ * Call it once the page's markup is in the DOM, and call the returned function
6
+ * before that markup goes away. On a host with client-side routing that swaps
7
+ * the document — Astro's `<ClientRouter />`, for one — that means detaching and
8
+ * re-attaching per navigation: the listeners are bound to nodes that no longer
9
+ * exist, and the preview goes quietly inert while still looking correct.
10
+ */
11
+ export function attachBridge(config) {
12
+ const { slug, editorOrigin, pathname, navigate, refresh, liveStore } = config;
13
+ const warnOnMissingMarkers = config.warnOnMissingMarkers ?? process.env.NODE_ENV !== "production";
14
+ // -- postMessage helpers ------------------------------------------------
15
+ const postToEditor = (type, payload) => {
16
+ window.parent.postMessage({ protocol: "site-editor/v1", type, payload }, editorOrigin);
17
+ };
18
+ const emitPatchAck = (txId, accepted, reason) => {
19
+ const msg = { type: "patchAck", txId, accepted, reason };
20
+ window.parent.postMessage({ source: "site-editor/v1", ...msg }, editorOrigin);
21
+ };
22
+ // -- Bridge callbacks (postMessage transport) ---------------------------
23
+ const callbacks = {
24
+ onBlockClicked: (p) => postToEditor("blockClicked", p),
25
+ onBlockDeleteRequested: (p) => postToEditor("blockDeleteRequested", p),
26
+ onBlockReordered: (p) => postToEditor("blockReordered", p),
27
+ onBlockAddRequested: (p) => postToEditor("blockAddRequested", p),
28
+ onListItemRemoveRequested: (p) => postToEditor("listItemRemoveRequested", p),
29
+ onListItemAddRequested: (p) => postToEditor("listItemAddRequested", p),
30
+ onListItemMoveRequested: (p) => postToEditor("listItemMoveRequested", p),
31
+ onInlineTextCommitted: (p) => postToEditor("inlineTextCommitted", p),
32
+ onOpenImagePicker: (p) => postToEditor("openImagePicker", p),
33
+ onScroll: () => postToEditor("iframeScrolled", {}),
34
+ };
35
+ // -- State & functions -------------------------------------------------
36
+ const state = createBridgeState();
37
+ const bridge = createBridgeFunctions(state, callbacks, { slug, pathname, refresh, navigate });
38
+ // -- Initialization ----------------------------------------------------
39
+ setNestedLabelsVisibility(false);
40
+ document.documentElement.setAttribute("data-editor-active", "");
41
+ // Restore selection mode if it was active before effect re-run
42
+ if (state.selectionMode) {
43
+ document.documentElement.setAttribute("data-editor-selection-mode", "");
44
+ }
45
+ // Clear stale selection classes — Fast Refresh can preserve DOM across remounts.
46
+ clearAllHighlights();
47
+ clearChildFocus();
48
+ clearListItemSelection();
49
+ ensureBlockBadges();
50
+ bridge.mountGlobalImageButtons();
51
+ /*
52
+ * Deferred a frame so a page still streaming in is not accused of having no
53
+ * blocks. Development only: in production the site owner cannot act on it
54
+ * and the visitor should never see it.
55
+ */
56
+ if (warnOnMissingMarkers) {
57
+ requestAnimationFrame(() => {
58
+ const warnings = previewIntegrationWarnings(document.querySelectorAll("[data-block-id]").length, document.querySelectorAll("[data-editable-target]").length);
59
+ for (const warning of warnings)
60
+ console.error(warning);
61
+ // Both of these describe a preview that *looks* correct, so the iframe's
62
+ // console is the last place they would be found. Say it in the frame.
63
+ showIntegrationWarnings(warnings);
64
+ });
65
+ }
66
+ // -- Mutation observer -------------------------------------------------
67
+ let detectNewBlocksRaf = null;
68
+ const scheduleDetectNewBlocks = () => {
69
+ if (detectNewBlocksRaf !== null)
70
+ return;
71
+ detectNewBlocksRaf = requestAnimationFrame(() => {
72
+ detectNewBlocksRaf = null;
73
+ bridge.detectNewBlocks();
74
+ });
75
+ };
76
+ let imageButtonsRaf = null;
77
+ const scheduleImageButtons = () => {
78
+ if (imageButtonsRaf !== null)
79
+ return;
80
+ imageButtonsRaf = requestAnimationFrame(() => {
81
+ imageButtonsRaf = null;
82
+ bridge.mountGlobalImageButtons();
83
+ });
84
+ };
85
+ state.observer = new MutationObserver(() => {
86
+ ensureBlockBadges();
87
+ scheduleImageButtons();
88
+ scheduleDetectNewBlocks();
89
+ });
90
+ if (document.body)
91
+ state.observer.observe(document.body, { childList: true, subtree: true });
92
+ // -- postMessage handler -----------------------------------------------
93
+ const onMessage = (event) => {
94
+ if (event.origin !== editorOrigin)
95
+ return;
96
+ const msg = event.data;
97
+ if (!msg || msg.protocol !== "site-editor/v1")
98
+ return;
99
+ if (msg.type === "navigate") {
100
+ const rawHref = String(msg.payload.href ?? "").trim();
101
+ if (!rawHref)
102
+ return;
103
+ const href = rawHref.startsWith("/") ? rawHref : `/${rawHref}`;
104
+ const currentHref = `${window.location.pathname}${window.location.search}`;
105
+ if (href === currentHref)
106
+ return;
107
+ navigate(href);
108
+ return;
109
+ }
110
+ if (msg.type === "setSelectionMode") {
111
+ const enabled = !!msg.payload.enabled;
112
+ state.selectionMode = enabled;
113
+ if (enabled) {
114
+ document.documentElement.setAttribute("data-editor-selection-mode", "");
115
+ bridge.mountGlobalImageButtons();
116
+ }
117
+ else {
118
+ document.documentElement.removeAttribute("data-editor-selection-mode");
119
+ document.querySelectorAll(".editor-image-change-btn").forEach((el) => el.remove());
120
+ }
121
+ return;
122
+ }
123
+ if (msg.type === "applyPatch") {
124
+ if (state.serverVersion !== msg.fromVersion) {
125
+ emitPatchAck(msg.txId, false, "version_mismatch");
126
+ return;
127
+ }
128
+ state.serverVersion = msg.toVersion;
129
+ if (msg.focusBlockId) {
130
+ state.pendingFocusId = msg.focusBlockId;
131
+ const isStructural = msg.op?.op === "add_block" || msg.op?.op === "move_block";
132
+ state.pendingScrollIntoView = isStructural || state.pendingScrollIntoView;
133
+ }
134
+ if (msg.op?.op === "move_item") {
135
+ state.skipParentAnimationOnce = true;
136
+ }
137
+ state.expectingNewBlocks = msg.op?.op === "add_block";
138
+ bridge.smoothRefresh();
139
+ emitPatchAck(msg.txId, true);
140
+ return;
141
+ }
142
+ if (msg.type === "resetToServer") {
143
+ state.serverVersion = msg.toVersion;
144
+ state.expectingNewBlocks = true;
145
+ if (msg.focusBlockId) {
146
+ state.pendingFocusId = msg.focusBlockId;
147
+ state.pendingScrollIntoView = false;
148
+ }
149
+ state.pendingListItemMovePath = null;
150
+ bridge.setChildSelectionLock(null);
151
+ bridge.smoothRefresh(true);
152
+ return;
153
+ }
154
+ if (msg.type === "draftUpdated") {
155
+ const focusBlockId = String(msg.payload.focusBlockId ?? "");
156
+ state.pendingFocusId = focusBlockId || null;
157
+ // Opt-in: the editor sets this when the changed/added block should be
158
+ // scrolled into view after the refresh (a newly added block is only in
159
+ // the DOM now). Explicit so the other ~20 draftUpdated callers are unaffected.
160
+ state.pendingScrollIntoView = msg.payload.scrollIntoView === true;
161
+ state.expectingNewBlocks = true;
162
+ bridge.clearLiveDraft();
163
+ clearChildFocus();
164
+ state.selectedEditablePath = null;
165
+ state.pendingListItemMovePath = null;
166
+ bridge.setChildSelectionLock(null);
167
+ const navigateTo = typeof msg.payload.navigateTo === "string" ? msg.payload.navigateTo.trim() : "";
168
+ if (navigateTo) {
169
+ const href = navigateTo.startsWith("/") ? navigateTo : `/${navigateTo}`;
170
+ navigate(withPreviewParams(href, window.location.search));
171
+ }
172
+ else {
173
+ bridge.smoothRefresh();
174
+ }
175
+ }
176
+ if (msg.type === "highlightBlock") {
177
+ const blockId = String(msg.payload.blockId ?? "");
178
+ let editablePath = String(msg.payload.editablePath ?? "") || undefined;
179
+ if (!editablePath && state.pendingListItemMovePath && state.selectedBlockId === blockId) {
180
+ editablePath = state.pendingListItemMovePath;
181
+ }
182
+ if (!editablePath) {
183
+ state.selectedEditablePath = null;
184
+ state.pendingListItemMovePath = null;
185
+ bridge.setChildSelectionLock(null);
186
+ }
187
+ else if (state.pendingListItemMovePath === editablePath) {
188
+ state.pendingListItemMovePath = null;
189
+ const parsed = parseListItemPath(editablePath);
190
+ if (parsed) {
191
+ const existing = state.childSelectionLock;
192
+ const position = existing && existing.blockId === blockId && existing.listKey === parsed.listKey
193
+ ? existing.position
194
+ : undefined;
195
+ bridge.setChildSelectionLock({ blockId, listKey: parsed.listKey, index: parsed.index, position });
196
+ }
197
+ }
198
+ else {
199
+ const parsed = parseListItemPath(editablePath);
200
+ if (parsed) {
201
+ const existing = state.childSelectionLock;
202
+ const position = existing && existing.blockId === blockId && existing.listKey === parsed.listKey
203
+ ? existing.position
204
+ : undefined;
205
+ bridge.setChildSelectionLock({ blockId, listKey: parsed.listKey, index: parsed.index, position });
206
+ }
207
+ else {
208
+ bridge.setChildSelectionLock(null);
209
+ }
210
+ }
211
+ const wantsScroll = msg.payload.scrollIntoView === true;
212
+ bridge.applyBlockFocus(blockId, false, editablePath, { scrollIntoView: wantsScroll });
213
+ }
214
+ if (msg.type === "setNestedLabelsVisibility") {
215
+ setNestedLabelsVisibility(Boolean(msg.payload.visible));
216
+ }
217
+ if (msg.type === "liveDraft") {
218
+ const blockId = String(msg.payload.blockId ?? "");
219
+ const text = String(msg.payload.text ?? "");
220
+ const active = Boolean(msg.payload.active);
221
+ const commit = Boolean(msg.payload.commit);
222
+ const fields = (msg.payload.fields && typeof msg.payload.fields === "object")
223
+ ? msg.payload.fields
224
+ : undefined;
225
+ if (liveStore) {
226
+ // Store path: push streamed values into React state; the blocks
227
+ // re-render natively (rich/markdown fields included). The bridge only
228
+ // paints the "Updating" chrome — no innerHTML mutation.
229
+ if (!active) {
230
+ if (commit)
231
+ liveStore.commitFieldDrafts(blockId);
232
+ else
233
+ liveStore.clearFieldDrafts();
234
+ }
235
+ else if (fields) {
236
+ liveStore.setFieldDrafts(blockId, fields);
237
+ }
238
+ bridge.renderLiveDraftChromeOnly(blockId, active);
239
+ }
240
+ else {
241
+ bridge.renderLiveDraft(blockId, text, active, fields, commit);
242
+ }
243
+ }
244
+ if (msg.type === "showSkeleton") {
245
+ const afterBlockId = msg.payload.afterBlockId ? String(msg.payload.afterBlockId) : null;
246
+ const blockType = String(msg.payload.blockType ?? "Block");
247
+ showSkeleton(afterBlockId, blockType);
248
+ }
249
+ if (msg.type === "removeSkeleton") {
250
+ removeSkeletons();
251
+ }
252
+ if (msg.type === "aiFieldLoading") {
253
+ const blockId = String(msg.payload.blockId ?? "");
254
+ const editablePath = String(msg.payload.editablePath ?? "");
255
+ const active = Boolean(msg.payload.active);
256
+ state.activeShimmer = active ? { blockId, editablePath } : null;
257
+ applyAiFieldLoading(blockId, editablePath, active);
258
+ }
259
+ if (msg.type === "scrollToBlock") {
260
+ const blockId = String(msg.payload.blockId ?? "");
261
+ if (!blockId)
262
+ return;
263
+ const behavior = msg.payload.behavior === "auto" ? "auto" : "smooth";
264
+ const block = ["start", "center", "end", "nearest"].includes(String(msg.payload.block))
265
+ ? String(msg.payload.block)
266
+ : "center";
267
+ const run = () => {
268
+ const el = findBlockNode(blockId);
269
+ if (!el)
270
+ return false;
271
+ scrollBlockIntoView(el, { behavior, block });
272
+ return true;
273
+ };
274
+ // The block may not yet be in the DOM if the iframe just re-rendered;
275
+ // retry on the next frame and again after a short delay.
276
+ if (!run())
277
+ requestAnimationFrame(() => { if (!run())
278
+ setTimeout(run, 120); });
279
+ }
280
+ };
281
+ // -- Scroll handler ----------------------------------------------------
282
+ const onScroll = () => {
283
+ // Swallow the scroll event if it originated from our own scrollIntoView.
284
+ if (isProgrammaticScroll())
285
+ return;
286
+ callbacks.onScroll();
287
+ };
288
+ // -- Attach event listeners --------------------------------------------
289
+ document.addEventListener("click", bridge.onClick, true);
290
+ document.addEventListener("dblclick", bridge.onDoubleClick, true);
291
+ document.addEventListener("pointermove", bridge.onPointerMove, true);
292
+ document.addEventListener("keydown", bridge.onKeyDown, true);
293
+ window.addEventListener("message", onMessage);
294
+ window.addEventListener("scroll", onScroll, { passive: true });
295
+ // -- Cleanup -----------------------------------------------------------
296
+ return () => {
297
+ if (detectNewBlocksRaf !== null)
298
+ cancelAnimationFrame(detectNewBlocksRaf);
299
+ if (imageButtonsRaf !== null)
300
+ cancelAnimationFrame(imageButtonsRaf);
301
+ bridge.cancelInlineEdit();
302
+ clearChildFocus();
303
+ bridge.restoreLiveDraftOriginals();
304
+ bridge.clearLiveDraft();
305
+ bridge.removeSelectedDeleteHandle();
306
+ state.observer?.disconnect();
307
+ document.removeEventListener("click", bridge.onClick, true);
308
+ document.removeEventListener("dblclick", bridge.onDoubleClick, true);
309
+ document.removeEventListener("pointermove", bridge.onPointerMove, true);
310
+ document.removeEventListener("keydown", bridge.onKeyDown, true);
311
+ window.removeEventListener("message", onMessage);
312
+ window.removeEventListener("scroll", onScroll);
313
+ cleanupOverlayElements();
314
+ };
315
+ }
316
+ /**
317
+ * Tell the editor which page the preview is showing.
318
+ *
319
+ * Report the CONTENT slug, not the browser pathname. They are identical for a
320
+ * root-mounted site and diverge the moment one is mounted under a subpath: the
321
+ * pathname is `/studio/home` while the orchestrator's draft is keyed `/home`, so
322
+ * reporting the pathname sends `/studio/home` to `/chat` and `/ops` and the
323
+ * planner answers "page not found".
324
+ */
325
+ export function postRouteChanged(slug, editorOrigin) {
326
+ window.parent.postMessage({ protocol: "site-editor/v1", type: "routeChanged", payload: { slug: slug || "/" } }, editorOrigin);
327
+ }
328
+ /**
329
+ * Reveal AI-generated images with a blur-to-sharp animation when the real file
330
+ * replaces its placeholder. Returns the detach function.
331
+ */
332
+ export function attachImageRevealWatcher() {
333
+ const placeholderBlockIds = new Set();
334
+ const PLACEHOLDER_PREFIX = "data:image/svg+xml";
335
+ function scanForPlaceholders() {
336
+ document.querySelectorAll(".hero__media img").forEach((img) => {
337
+ const src = img.getAttribute("src") ?? "";
338
+ const blockEl = img.closest("[data-block-id]");
339
+ if (!blockEl)
340
+ return;
341
+ const blockId = blockEl.getAttribute("data-block-id") ?? "";
342
+ if (src.startsWith(PLACEHOLDER_PREFIX)) {
343
+ placeholderBlockIds.add(blockId);
344
+ }
345
+ });
346
+ }
347
+ function applyReveal(img) {
348
+ const doReveal = () => {
349
+ img.classList.add("ai-image-reveal");
350
+ img.addEventListener("animationend", () => img.classList.remove("ai-image-reveal"), { once: true });
351
+ };
352
+ if (img.complete && img.naturalWidth > 0)
353
+ doReveal();
354
+ else
355
+ img.addEventListener("load", doReveal, { once: true });
356
+ }
357
+ const observer = new MutationObserver((mutations) => {
358
+ for (const mutation of mutations) {
359
+ if (mutation.type !== "childList")
360
+ continue;
361
+ for (const node of mutation.addedNodes) {
362
+ if (!(node instanceof HTMLElement))
363
+ continue;
364
+ const imgs = node.matches?.(".hero__media img")
365
+ ? [node]
366
+ : Array.from(node.querySelectorAll(".hero__media img"));
367
+ for (const img of imgs) {
368
+ const src = img.getAttribute("src") ?? "";
369
+ const blockEl = img.closest("[data-block-id]");
370
+ if (!blockEl)
371
+ continue;
372
+ const blockId = blockEl.getAttribute("data-block-id") ?? "";
373
+ if (src.startsWith(PLACEHOLDER_PREFIX)) {
374
+ placeholderBlockIds.add(blockId);
375
+ }
376
+ else if (src.startsWith("http") && placeholderBlockIds.has(blockId)) {
377
+ placeholderBlockIds.delete(blockId);
378
+ applyReveal(img);
379
+ }
380
+ }
381
+ }
382
+ }
383
+ });
384
+ observer.observe(document.body, { childList: true, subtree: true });
385
+ scanForPlaceholders();
386
+ return () => observer.disconnect();
387
+ }
@@ -200,39 +200,26 @@ export declare function removeSkeletons(): void;
200
200
  */
201
201
  export declare function missingBlockWrapperWarning(blockCount: number, editableCount: number): string | null;
202
202
  /**
203
- * The other half of a half-finished integration — and the likelier half.
203
+ * Every integration problem the overlay can diagnose from the page alone.
204
204
  *
205
- * `missingBlockWrapperWarning` names the case where the fields are marked and
206
- * the blocks are not. This names its mirror, which is what following the
207
- * integration guide to its end and stopping one step early actually produces:
208
- * every block wrapped, no field marked. Wrapping blocks is a single call in the
209
- * preview route; marking fields means touching the site's own components, so it
210
- * is the step that gets deferred and then forgotten.
211
- *
212
- * What it costs is invisible rather than broken. Selection works. Highlighting,
213
- * scroll-to-block, the badges, the property panel and every edit made through
214
- * it all work, because the panel is built from the block manifest and never
215
- * looks at the page. What is silently off is everything the overlay finds by
216
- * walking the DOM: inline text editing, the field pills on hover, the image
217
- * Change/Remove buttons, and the live-draft path that streams a field into the
218
- * node that draws it. A reviewer sees a preview that responds to clicks and
219
- * concludes it is wired.
220
- *
221
- * Like its mirror this is an all-or-nothing test, not a coverage check: one
222
- * marked field means the integrator knows the attribute exists, and which of
223
- * their fields deserve it is their call, not ours.
205
+ * One, today. Its mirror — every block wrapped and not one field marked — used
206
+ * to be reported here too, and was withdrawn: that state is a preview whose
207
+ * selection, property panel, chat and publish all work, missing an opt-in step
208
+ * the integrator has not taken yet. Saying so on a dev overlay put a red
209
+ * Console Error on a working integration, which reads as a defect in the
210
+ * product rather than as an invitation. Marking fields is now its own named
211
+ * step in the docs, and whether a page has taken it is what
212
+ * `site-sdk/coverage` measures, on demand, for someone who asked.
224
213
  */
225
- export declare function missingEditableTargetsWarning(blockCount: number, editableCount: number): string | null;
226
- /** Every integration problem the overlay can diagnose from the page alone. */
227
214
  export declare function previewIntegrationWarnings(blockCount: number, editableCount: number): string[];
228
215
  /**
229
216
  * Put the diagnosis where the person is looking.
230
217
  *
231
- * Both warnings above describe a preview that looks like it works, so the
232
- * console they would print to is the one nobody opens — and in the editor it is
218
+ * The warning above describes a preview that looks like it works, so the
219
+ * console it would print to is the one nobody opens — and in the editor it is
233
220
  * the *iframe's* console, two context menus away from the window in front of
234
- * the user. The symptom is "the button is missing from the preview"; the
235
- * explanation has to be in the preview.
221
+ * the user. The symptom is "clicking a block does nothing"; the explanation
222
+ * has to be in the preview.
236
223
  *
237
224
  * Development only, and dismissible: it sits over the site's own design, and
238
225
  * once read it has done its work.
@@ -606,54 +606,28 @@ export function missingBlockWrapperWarning(blockCount, editableCount) {
606
606
  "editor-selectable class that the field pills are scoped to.");
607
607
  }
608
608
  /**
609
- * The other half of a half-finished integration — and the likelier half.
609
+ * Every integration problem the overlay can diagnose from the page alone.
610
610
  *
611
- * `missingBlockWrapperWarning` names the case where the fields are marked and
612
- * the blocks are not. This names its mirror, which is what following the
613
- * integration guide to its end and stopping one step early actually produces:
614
- * every block wrapped, no field marked. Wrapping blocks is a single call in the
615
- * preview route; marking fields means touching the site's own components, so it
616
- * is the step that gets deferred and then forgotten.
617
- *
618
- * What it costs is invisible rather than broken. Selection works. Highlighting,
619
- * scroll-to-block, the badges, the property panel and every edit made through
620
- * it all work, because the panel is built from the block manifest and never
621
- * looks at the page. What is silently off is everything the overlay finds by
622
- * walking the DOM: inline text editing, the field pills on hover, the image
623
- * Change/Remove buttons, and the live-draft path that streams a field into the
624
- * node that draws it. A reviewer sees a preview that responds to clicks and
625
- * concludes it is wired.
626
- *
627
- * Like its mirror this is an all-or-nothing test, not a coverage check: one
628
- * marked field means the integrator knows the attribute exists, and which of
629
- * their fields deserve it is their call, not ours.
611
+ * One, today. Its mirror — every block wrapped and not one field marked — used
612
+ * to be reported here too, and was withdrawn: that state is a preview whose
613
+ * selection, property panel, chat and publish all work, missing an opt-in step
614
+ * the integrator has not taken yet. Saying so on a dev overlay put a red
615
+ * Console Error on a working integration, which reads as a defect in the
616
+ * product rather than as an invitation. Marking fields is now its own named
617
+ * step in the docs, and whether a page has taken it is what
618
+ * `site-sdk/coverage` measures, on demand, for someone who asked.
630
619
  */
631
- export function missingEditableTargetsWarning(blockCount, editableCount) {
632
- if (editableCount > 0 || blockCount === 0)
633
- return null;
634
- return (`[avocado] The overlay found ${blockCount} [data-block-id] wrapper(s) and no ` +
635
- "data-editable-target attribute. Selection and the property panel work; inline text " +
636
- "editing, the field pills and the image Change/Remove buttons are all off, because every " +
637
- "one of them is found by walking the marked fields in the page.\n" +
638
- "Mark the element that draws each editable prop with editableProps(path, { kind }) from " +
639
- "@avocadostudio-ai/site-sdk/editor — e.g. editableProps(\"imageUrl\", { kind: \"image\" }) " +
640
- "on the wrapper around an image, editableProps(\"heading\") on the element holding it.");
641
- }
642
- /** Every integration problem the overlay can diagnose from the page alone. */
643
620
  export function previewIntegrationWarnings(blockCount, editableCount) {
644
- return [
645
- missingBlockWrapperWarning(blockCount, editableCount),
646
- missingEditableTargetsWarning(blockCount, editableCount),
647
- ].filter((w) => w !== null);
621
+ return [missingBlockWrapperWarning(blockCount, editableCount)].filter((w) => w !== null);
648
622
  }
649
623
  /**
650
624
  * Put the diagnosis where the person is looking.
651
625
  *
652
- * Both warnings above describe a preview that looks like it works, so the
653
- * console they would print to is the one nobody opens — and in the editor it is
626
+ * The warning above describes a preview that looks like it works, so the
627
+ * console it would print to is the one nobody opens — and in the editor it is
654
628
  * the *iframe's* console, two context menus away from the window in front of
655
- * the user. The symptom is "the button is missing from the preview"; the
656
- * explanation has to be in the preview.
629
+ * the user. The symptom is "clicking a block does nothing"; the explanation
630
+ * has to be in the preview.
657
631
  *
658
632
  * Development only, and dismissible: it sits over the site's own design, and
659
633
  * once read it has done its work.
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, 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";
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, 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, 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";
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, previewIntegrationWarnings, } from "./bridge-functions.js";
5
5
  export { LivePreviewProvider, useLivePreviewBlocks, useLivePreviewBridgeApi, } from "./live-preview-store.js";
@@ -19,6 +19,20 @@ export type LivePreviewPage = {
19
19
  updatedAt: string;
20
20
  blocks: BlockInstance[];
21
21
  };
22
+ /**
23
+ * Narrow API the preview bridge uses to push streamed values into the store.
24
+ *
25
+ * Declared here rather than beside the provider because the bridge controller
26
+ * takes one and must not import React to name its type.
27
+ */
28
+ export type LivePreviewBridgeApi = {
29
+ /** Replace a block's in-flight draft fields (the full set streamed so far). */
30
+ setFieldDrafts: (blockId: string, fields: Record<string, string>) => void;
31
+ /** Fold a block's drafts into the committed baseline (server applied the op). */
32
+ commitFieldDrafts: (blockId: string) => void;
33
+ /** Drop all in-flight drafts (stream cancelled / rolled back). */
34
+ clearFieldDrafts: () => void;
35
+ };
22
36
  /** blockId → (editablePath → streamed value). */
23
37
  export type LivePreviewDrafts = Map<string, Map<string, string>>;
24
38
  export type LivePreviewState = {
@@ -17,15 +17,8 @@ import { type ReactNode } from "react";
17
17
  import type { BlockInstance } from "@avocadostudio-ai/shared";
18
18
  import { type LivePreviewPage } from "./live-preview-store-core.ts";
19
19
  export type { LivePreviewPage } from "./live-preview-store-core.ts";
20
- /** Narrow API the preview bridge uses to push streamed values into the store. */
21
- export type LivePreviewBridgeApi = {
22
- /** Replace a block's in-flight draft fields (the full set streamed so far). */
23
- setFieldDrafts: (blockId: string, fields: Record<string, string>) => void;
24
- /** Fold a block's drafts into the committed baseline (server applied the op). */
25
- commitFieldDrafts: (blockId: string) => void;
26
- /** Drop all in-flight drafts (stream cancelled / rolled back). */
27
- clearFieldDrafts: () => void;
28
- };
20
+ export type { LivePreviewBridgeApi } from "./live-preview-store-core.ts";
21
+ import type { LivePreviewBridgeApi } from "./live-preview-store-core.ts";
29
22
  export declare function LivePreviewProvider({ initialPage, children, }: {
30
23
  initialPage: LivePreviewPage;
31
24
  children: ReactNode;
@@ -1,4 +1,4 @@
1
- import type { LivePreviewBridgeApi } from "./live-preview-store.tsx";
1
+ import type { LivePreviewBridgeApi } from "./live-preview-store-core.ts";
2
2
  export type PreviewBridgeConfig = {
3
3
  navigate: (href: string) => void;
4
4
  refresh: () => void;
@@ -16,4 +16,14 @@ export type PreviewBridgeCoreProps = {
16
16
  */
17
17
  liveStore?: LivePreviewBridgeApi | null;
18
18
  };
19
+ /**
20
+ * The preview bridge, mounted by React.
21
+ *
22
+ * It renders nothing and always did. Everything it does — the `site-editor/v1`
23
+ * protocol, the overlay, the listeners — is in `bridge-controller.ts`, which has
24
+ * no framework in it; React supplies a mount point, a teardown hook, and the
25
+ * four values below. This component is the adapter between those two facts, and
26
+ * a host without React (see `@avocadostudio-ai/astro`) calls the controller
27
+ * directly.
28
+ */
19
29
  export declare function PreviewBridgeCore(props: PreviewBridgeCoreProps): import("react/jsx-runtime").JSX.Element | null;
@@ -1,388 +1,26 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useRef } from "react";
4
- import { createBridgeFunctions, createBridgeState, findBlockNode, isProgrammaticScroll, parseListItemPath, ensureBlockBadges, previewIntegrationWarnings, showIntegrationWarnings, scrollBlockIntoView, setNestedLabelsVisibility, showSkeleton, removeSkeletons, clearChildFocus, clearAllHighlights, clearListItemSelection, applyAiFieldLoading, cleanupOverlayElements, withPreviewParams, } from "./bridge-functions.js";
3
+ import { useEffect } from "react";
4
+ import { attachBridge, attachImageRevealWatcher, postRouteChanged } from "./bridge-controller.js";
5
+ /**
6
+ * The preview bridge, mounted by React.
7
+ *
8
+ * It renders nothing and always did. Everything it does — the `site-editor/v1`
9
+ * protocol, the overlay, the listeners — is in `bridge-controller.ts`, which has
10
+ * no framework in it; React supplies a mount point, a teardown hook, and the
11
+ * four values below. This component is the adapter between those two facts, and
12
+ * a host without React (see `@avocadostudio-ai/astro`) calls the controller
13
+ * directly.
14
+ */
5
15
  export function PreviewBridgeCore(props) {
6
16
  // When running standalone (no editor origin) or not embedded in an iframe, render nothing.
7
- if (!props.editorOrigin || typeof window !== "undefined" && window.parent === window)
17
+ if (!props.editorOrigin || (typeof window !== "undefined" && window.parent === window))
8
18
  return null;
9
19
  return _jsx(PreviewBridgeCoreInner, { ...props });
10
20
  }
11
21
  function PreviewBridgeCoreInner({ slug, editorOrigin, navigate, refresh, pathname, liveStore }) {
12
- const stateRef = useRef(null);
13
- useEffect(() => {
14
- // -- postMessage helpers ------------------------------------------------
15
- const postToEditor = (type, payload) => {
16
- window.parent.postMessage({ protocol: "site-editor/v1", type, payload }, editorOrigin);
17
- };
18
- const emitPatchAck = (txId, accepted, reason) => {
19
- const msg = { type: "patchAck", txId, accepted, reason };
20
- window.parent.postMessage({ source: "site-editor/v1", ...msg }, editorOrigin);
21
- };
22
- // -- Bridge callbacks (postMessage transport) ---------------------------
23
- const callbacks = {
24
- onBlockClicked: (p) => postToEditor("blockClicked", p),
25
- onBlockDeleteRequested: (p) => postToEditor("blockDeleteRequested", p),
26
- onBlockReordered: (p) => postToEditor("blockReordered", p),
27
- onBlockAddRequested: (p) => postToEditor("blockAddRequested", p),
28
- onListItemRemoveRequested: (p) => postToEditor("listItemRemoveRequested", p),
29
- onListItemAddRequested: (p) => postToEditor("listItemAddRequested", p),
30
- onListItemMoveRequested: (p) => postToEditor("listItemMoveRequested", p),
31
- onInlineTextCommitted: (p) => postToEditor("inlineTextCommitted", p),
32
- onOpenImagePicker: (p) => postToEditor("openImagePicker", p),
33
- onScroll: () => postToEditor("iframeScrolled", {}),
34
- };
35
- // -- State & functions -------------------------------------------------
36
- const state = createBridgeState();
37
- stateRef.current = state;
38
- const bridge = createBridgeFunctions(state, callbacks, { slug, pathname, refresh, navigate });
39
- // -- Initialization ----------------------------------------------------
40
- setNestedLabelsVisibility(false);
41
- document.documentElement.setAttribute("data-editor-active", "");
42
- // Restore selection mode if it was active before effect re-run
43
- if (state.selectionMode) {
44
- document.documentElement.setAttribute("data-editor-selection-mode", "");
45
- }
46
- // Clear stale selection classes — Fast Refresh can preserve DOM across remounts.
47
- clearAllHighlights();
48
- clearChildFocus();
49
- clearListItemSelection();
50
- ensureBlockBadges();
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
- }
67
- // -- Mutation observer -------------------------------------------------
68
- let detectNewBlocksRaf = null;
69
- const scheduleDetectNewBlocks = () => {
70
- if (detectNewBlocksRaf !== null)
71
- return;
72
- detectNewBlocksRaf = requestAnimationFrame(() => {
73
- detectNewBlocksRaf = null;
74
- bridge.detectNewBlocks();
75
- });
76
- };
77
- let imageButtonsRaf = null;
78
- const scheduleImageButtons = () => {
79
- if (imageButtonsRaf !== null)
80
- return;
81
- imageButtonsRaf = requestAnimationFrame(() => {
82
- imageButtonsRaf = null;
83
- bridge.mountGlobalImageButtons();
84
- });
85
- };
86
- state.observer = new MutationObserver(() => {
87
- ensureBlockBadges();
88
- scheduleImageButtons();
89
- scheduleDetectNewBlocks();
90
- });
91
- if (document.body)
92
- state.observer.observe(document.body, { childList: true, subtree: true });
93
- // -- postMessage handler -----------------------------------------------
94
- const onMessage = (event) => {
95
- if (event.origin !== editorOrigin)
96
- return;
97
- const msg = event.data;
98
- if (!msg || msg.protocol !== "site-editor/v1")
99
- return;
100
- if (msg.type === "navigate") {
101
- const rawHref = String(msg.payload.href ?? "").trim();
102
- if (!rawHref)
103
- return;
104
- const href = rawHref.startsWith("/") ? rawHref : `/${rawHref}`;
105
- const currentHref = `${window.location.pathname}${window.location.search}`;
106
- if (href === currentHref)
107
- return;
108
- navigate(href);
109
- return;
110
- }
111
- if (msg.type === "setSelectionMode") {
112
- const enabled = !!msg.payload.enabled;
113
- state.selectionMode = enabled;
114
- if (enabled) {
115
- document.documentElement.setAttribute("data-editor-selection-mode", "");
116
- bridge.mountGlobalImageButtons();
117
- }
118
- else {
119
- document.documentElement.removeAttribute("data-editor-selection-mode");
120
- document.querySelectorAll(".editor-image-change-btn").forEach((el) => el.remove());
121
- }
122
- return;
123
- }
124
- if (msg.type === "applyPatch") {
125
- if (state.serverVersion !== msg.fromVersion) {
126
- emitPatchAck(msg.txId, false, "version_mismatch");
127
- return;
128
- }
129
- state.serverVersion = msg.toVersion;
130
- if (msg.focusBlockId) {
131
- state.pendingFocusId = msg.focusBlockId;
132
- const isStructural = msg.op?.op === "add_block" || msg.op?.op === "move_block";
133
- state.pendingScrollIntoView = isStructural || state.pendingScrollIntoView;
134
- }
135
- if (msg.op?.op === "move_item") {
136
- state.skipParentAnimationOnce = true;
137
- }
138
- state.expectingNewBlocks = msg.op?.op === "add_block";
139
- bridge.smoothRefresh();
140
- emitPatchAck(msg.txId, true);
141
- return;
142
- }
143
- if (msg.type === "resetToServer") {
144
- state.serverVersion = msg.toVersion;
145
- state.expectingNewBlocks = true;
146
- if (msg.focusBlockId) {
147
- state.pendingFocusId = msg.focusBlockId;
148
- state.pendingScrollIntoView = false;
149
- }
150
- state.pendingListItemMovePath = null;
151
- bridge.setChildSelectionLock(null);
152
- bridge.smoothRefresh(true);
153
- return;
154
- }
155
- if (msg.type === "draftUpdated") {
156
- const focusBlockId = String(msg.payload.focusBlockId ?? "");
157
- state.pendingFocusId = focusBlockId || null;
158
- // Opt-in: the editor sets this when the changed/added block should be
159
- // scrolled into view after the refresh (a newly added block is only in
160
- // the DOM now). Explicit so the other ~20 draftUpdated callers are unaffected.
161
- state.pendingScrollIntoView = msg.payload.scrollIntoView === true;
162
- state.expectingNewBlocks = true;
163
- bridge.clearLiveDraft();
164
- clearChildFocus();
165
- state.selectedEditablePath = null;
166
- state.pendingListItemMovePath = null;
167
- bridge.setChildSelectionLock(null);
168
- const navigateTo = typeof msg.payload.navigateTo === "string" ? msg.payload.navigateTo.trim() : "";
169
- if (navigateTo) {
170
- const href = navigateTo.startsWith("/") ? navigateTo : `/${navigateTo}`;
171
- navigate(withPreviewParams(href, window.location.search));
172
- }
173
- else {
174
- bridge.smoothRefresh();
175
- }
176
- }
177
- if (msg.type === "highlightBlock") {
178
- const blockId = String(msg.payload.blockId ?? "");
179
- let editablePath = String(msg.payload.editablePath ?? "") || undefined;
180
- if (!editablePath && state.pendingListItemMovePath && state.selectedBlockId === blockId) {
181
- editablePath = state.pendingListItemMovePath;
182
- }
183
- if (!editablePath) {
184
- state.selectedEditablePath = null;
185
- state.pendingListItemMovePath = null;
186
- bridge.setChildSelectionLock(null);
187
- }
188
- else if (state.pendingListItemMovePath === editablePath) {
189
- state.pendingListItemMovePath = null;
190
- const parsed = parseListItemPath(editablePath);
191
- if (parsed) {
192
- const existing = state.childSelectionLock;
193
- const position = existing && existing.blockId === blockId && existing.listKey === parsed.listKey
194
- ? existing.position
195
- : undefined;
196
- bridge.setChildSelectionLock({ blockId, listKey: parsed.listKey, index: parsed.index, position });
197
- }
198
- }
199
- else {
200
- const parsed = parseListItemPath(editablePath);
201
- if (parsed) {
202
- const existing = state.childSelectionLock;
203
- const position = existing && existing.blockId === blockId && existing.listKey === parsed.listKey
204
- ? existing.position
205
- : undefined;
206
- bridge.setChildSelectionLock({ blockId, listKey: parsed.listKey, index: parsed.index, position });
207
- }
208
- else {
209
- bridge.setChildSelectionLock(null);
210
- }
211
- }
212
- const wantsScroll = msg.payload.scrollIntoView === true;
213
- bridge.applyBlockFocus(blockId, false, editablePath, { scrollIntoView: wantsScroll });
214
- }
215
- if (msg.type === "setNestedLabelsVisibility") {
216
- setNestedLabelsVisibility(Boolean(msg.payload.visible));
217
- }
218
- if (msg.type === "liveDraft") {
219
- const blockId = String(msg.payload.blockId ?? "");
220
- const text = String(msg.payload.text ?? "");
221
- const active = Boolean(msg.payload.active);
222
- const commit = Boolean(msg.payload.commit);
223
- const fields = (msg.payload.fields && typeof msg.payload.fields === "object")
224
- ? msg.payload.fields
225
- : undefined;
226
- if (liveStore) {
227
- // Store path: push streamed values into React state; the blocks
228
- // re-render natively (rich/markdown fields included). The bridge only
229
- // paints the "Updating" chrome — no innerHTML mutation.
230
- if (!active) {
231
- if (commit)
232
- liveStore.commitFieldDrafts(blockId);
233
- else
234
- liveStore.clearFieldDrafts();
235
- }
236
- else if (fields) {
237
- liveStore.setFieldDrafts(blockId, fields);
238
- }
239
- bridge.renderLiveDraftChromeOnly(blockId, active);
240
- }
241
- else {
242
- bridge.renderLiveDraft(blockId, text, active, fields, commit);
243
- }
244
- }
245
- if (msg.type === "showSkeleton") {
246
- const afterBlockId = msg.payload.afterBlockId ? String(msg.payload.afterBlockId) : null;
247
- const blockType = String(msg.payload.blockType ?? "Block");
248
- showSkeleton(afterBlockId, blockType);
249
- }
250
- if (msg.type === "removeSkeleton") {
251
- removeSkeletons();
252
- }
253
- if (msg.type === "aiFieldLoading") {
254
- const blockId = String(msg.payload.blockId ?? "");
255
- const editablePath = String(msg.payload.editablePath ?? "");
256
- const active = Boolean(msg.payload.active);
257
- state.activeShimmer = active ? { blockId, editablePath } : null;
258
- applyAiFieldLoading(blockId, editablePath, active);
259
- }
260
- if (msg.type === "scrollToBlock") {
261
- const blockId = String(msg.payload.blockId ?? "");
262
- if (!blockId)
263
- return;
264
- const behavior = msg.payload.behavior === "auto" ? "auto" : "smooth";
265
- const block = ["start", "center", "end", "nearest"].includes(String(msg.payload.block))
266
- ? String(msg.payload.block)
267
- : "center";
268
- const run = () => {
269
- const el = findBlockNode(blockId);
270
- if (!el)
271
- return false;
272
- scrollBlockIntoView(el, { behavior, block });
273
- return true;
274
- };
275
- // The block may not yet be in the DOM if the iframe just re-rendered;
276
- // retry on the next frame and again after a short delay.
277
- if (!run())
278
- requestAnimationFrame(() => { if (!run())
279
- setTimeout(run, 120); });
280
- }
281
- };
282
- // -- Scroll handler ----------------------------------------------------
283
- const onScroll = () => {
284
- // Swallow the scroll event if it originated from our own scrollIntoView.
285
- if (isProgrammaticScroll())
286
- return;
287
- callbacks.onScroll();
288
- };
289
- // -- Attach event listeners --------------------------------------------
290
- document.addEventListener("click", bridge.onClick, true);
291
- document.addEventListener("dblclick", bridge.onDoubleClick, true);
292
- document.addEventListener("pointermove", bridge.onPointerMove, true);
293
- document.addEventListener("keydown", bridge.onKeyDown, true);
294
- window.addEventListener("message", onMessage);
295
- window.addEventListener("scroll", onScroll, { passive: true });
296
- // -- Cleanup -----------------------------------------------------------
297
- return () => {
298
- if (detectNewBlocksRaf !== null)
299
- cancelAnimationFrame(detectNewBlocksRaf);
300
- if (imageButtonsRaf !== null)
301
- cancelAnimationFrame(imageButtonsRaf);
302
- bridge.cancelInlineEdit();
303
- clearChildFocus();
304
- bridge.restoreLiveDraftOriginals();
305
- bridge.clearLiveDraft();
306
- bridge.removeSelectedDeleteHandle();
307
- state.observer?.disconnect();
308
- document.removeEventListener("click", bridge.onClick, true);
309
- document.removeEventListener("dblclick", bridge.onDoubleClick, true);
310
- document.removeEventListener("pointermove", bridge.onPointerMove, true);
311
- document.removeEventListener("keydown", bridge.onKeyDown, true);
312
- window.removeEventListener("message", onMessage);
313
- window.removeEventListener("scroll", onScroll);
314
- cleanupOverlayElements();
315
- };
316
- }, [editorOrigin, navigate, refresh, slug, liveStore]);
317
- useEffect(() => {
318
- // Report the CONTENT slug, not the browser pathname. They're identical
319
- // for root-mounted sites, but diverge when the site mounts Avocado pages
320
- // under a subpath (e.g. /studio): pathname is "/studio/home" while the
321
- // orchestrator's draft is keyed by the content slug "/home". Reporting
322
- // pathname there makes the editor send "/studio/home" to /chat and /ops,
323
- // which the planner can't resolve ("page not found").
324
- window.parent.postMessage({
325
- protocol: "site-editor/v1",
326
- type: "routeChanged",
327
- payload: { slug: slug || "/" }
328
- }, editorOrigin);
329
- }, [editorOrigin, slug]);
330
- // Track placeholder→real image swaps for blur→sharp reveal animation
331
- const placeholderBlockIdsRef = useRef(new Set());
332
- useEffect(() => {
333
- const PLACEHOLDER_PREFIX = "data:image/svg+xml";
334
- function scanForPlaceholders() {
335
- document.querySelectorAll(".hero__media img").forEach((img) => {
336
- const src = img.getAttribute("src") ?? "";
337
- const blockEl = img.closest("[data-block-id]");
338
- if (!blockEl)
339
- return;
340
- const blockId = blockEl.getAttribute("data-block-id") ?? "";
341
- if (src.startsWith(PLACEHOLDER_PREFIX)) {
342
- placeholderBlockIdsRef.current.add(blockId);
343
- }
344
- });
345
- }
346
- function applyReveal(img) {
347
- const doReveal = () => {
348
- img.classList.add("ai-image-reveal");
349
- img.addEventListener("animationend", () => img.classList.remove("ai-image-reveal"), { once: true });
350
- };
351
- if (img.complete && img.naturalWidth > 0)
352
- doReveal();
353
- else
354
- img.addEventListener("load", doReveal, { once: true });
355
- }
356
- const observer = new MutationObserver((mutations) => {
357
- for (const mutation of mutations) {
358
- if (mutation.type !== "childList")
359
- continue;
360
- for (const node of mutation.addedNodes) {
361
- if (!(node instanceof HTMLElement))
362
- continue;
363
- const imgs = node.matches?.(".hero__media img")
364
- ? [node]
365
- : Array.from(node.querySelectorAll(".hero__media img"));
366
- for (const img of imgs) {
367
- const src = img.getAttribute("src") ?? "";
368
- const blockEl = img.closest("[data-block-id]");
369
- if (!blockEl)
370
- continue;
371
- const blockId = blockEl.getAttribute("data-block-id") ?? "";
372
- if (src.startsWith(PLACEHOLDER_PREFIX)) {
373
- placeholderBlockIdsRef.current.add(blockId);
374
- }
375
- else if (src.startsWith("http") && placeholderBlockIdsRef.current.has(blockId)) {
376
- placeholderBlockIdsRef.current.delete(blockId);
377
- applyReveal(img);
378
- }
379
- }
380
- }
381
- }
382
- });
383
- observer.observe(document.body, { childList: true, subtree: true });
384
- scanForPlaceholders();
385
- return () => observer.disconnect();
386
- }, []);
22
+ useEffect(() => attachBridge({ slug, editorOrigin, pathname, navigate, refresh, liveStore }), [editorOrigin, navigate, refresh, slug, pathname, liveStore]);
23
+ useEffect(() => postRouteChanged(slug, editorOrigin), [editorOrigin, slug]);
24
+ useEffect(() => attachImageRevealWatcher(), []);
387
25
  return null;
388
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/preview-adapter",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,13 +16,18 @@
16
16
  "import": "./dist/preview-bridge-core.js",
17
17
  "default": "./dist/preview-bridge-core.js"
18
18
  },
19
+ "./bridge": {
20
+ "types": "./dist/bridge-functions.d.ts",
21
+ "import": "./dist/bridge-functions.js",
22
+ "default": "./dist/bridge-functions.js"
23
+ },
24
+ "./bridge-controller": {
25
+ "types": "./dist/bridge-controller.d.ts",
26
+ "import": "./dist/bridge-controller.js",
27
+ "default": "./dist/bridge-controller.js"
28
+ },
19
29
  "./styles.css": "./src/styles.css"
20
30
  },
21
- "repository": {
22
- "type": "git",
23
- "url": "https://github.com/avocadostudio-ai/avocado.git",
24
- "directory": "packages/preview-adapter"
25
- },
26
31
  "publishConfig": {
27
32
  "registry": "https://registry.npmjs.org",
28
33
  "access": "public"
@@ -32,7 +37,7 @@
32
37
  "src/styles.css"
33
38
  ],
34
39
  "dependencies": {
35
- "@avocadostudio-ai/shared": "^0.7.0"
40
+ "@avocadostudio-ai/shared": "^0.9.0"
36
41
  },
37
42
  "peerDependencies": {
38
43
  "next": ">=15.0.0",
@@ -41,6 +46,9 @@
41
46
  "peerDependenciesMeta": {
42
47
  "next": {
43
48
  "optional": true
49
+ },
50
+ "react": {
51
+ "optional": true
44
52
  }
45
53
  },
46
54
  "devDependencies": {
@@ -61,7 +69,7 @@
61
69
  "license": "Apache-2.0",
62
70
  "homepage": "https://docs.avocadostudio.dev",
63
71
  "bugs": {
64
- "url": "https://github.com/avocadostudio-ai/avocado/issues"
72
+ "url": "https://docs.avocadostudio.dev"
65
73
  },
66
74
  "scripts": {
67
75
  "build": "tsc -p tsconfig.build.json",