@avocadostudio-ai/preview-adapter 0.6.0 → 0.8.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
|
+
}
|
|
@@ -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
|
-
|
|
21
|
-
|
|
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.
|
|
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
|
|
4
|
-
import {
|
|
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
|
-
|
|
13
|
-
useEffect(() =>
|
|
14
|
-
|
|
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.
|
|
3
|
+
"version": "0.8.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.
|
|
40
|
+
"@avocadostudio-ai/shared": "^0.8.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://
|
|
72
|
+
"url": "https://docs.avocadostudio.dev"
|
|
65
73
|
},
|
|
66
74
|
"scripts": {
|
|
67
75
|
"build": "tsc -p tsconfig.build.json",
|