@avocadostudio-ai/preview-adapter 0.1.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.
- package/LICENSE +201 -0
- package/dist/bridge-functions.d.ts +226 -0
- package/dist/bridge-functions.js +1527 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/live-preview-store-core.d.ts +49 -0
- package/dist/live-preview-store-core.js +99 -0
- package/dist/live-preview-store.d.ts +36 -0
- package/dist/live-preview-store.js +55 -0
- package/dist/preview-bridge-core.d.ts +19 -0
- package/dist/preview-bridge-core.js +377 -0
- package/dist/preview-bridge.d.ts +4 -0
- package/dist/preview-bridge.js +22 -0
- package/dist/selectable.d.ts +13 -0
- package/dist/selectable.js +10 -0
- package/package.json +59 -0
- package/src/styles.css +1207 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { PreviewBridge } from "./preview-bridge.tsx";
|
|
2
|
+
export { PreviewBridgeCore } from "./preview-bridge-core.tsx";
|
|
3
|
+
export type { PreviewBridgeConfig, PreviewBridgeCoreProps } from "./preview-bridge-core.tsx";
|
|
4
|
+
export { getPreviewWrapperProps } from "./selectable.ts";
|
|
5
|
+
export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.ts";
|
|
6
|
+
export type { BridgeCallbacks, BridgeState, BridgeFunctions } from "./bridge-functions.ts";
|
|
7
|
+
export { LivePreviewProvider, useLivePreviewBlocks, useLivePreviewBridgeApi, } from "./live-preview-store.tsx";
|
|
8
|
+
export type { LivePreviewPage, LivePreviewBridgeApi } from "./live-preview-store.tsx";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PreviewBridge } from "./preview-bridge.js";
|
|
2
|
+
export { PreviewBridgeCore } from "./preview-bridge-core.js";
|
|
3
|
+
export { getPreviewWrapperProps } from "./selectable.js";
|
|
4
|
+
export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.js";
|
|
5
|
+
export { LivePreviewProvider, useLivePreviewBlocks, useLivePreviewBridgeApi, } from "./live-preview-store.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state transitions for the live-preview draft store.
|
|
3
|
+
*
|
|
4
|
+
* Kept React-free (no "use client") so the reducer is unit-testable in isolation.
|
|
5
|
+
* The provider in `live-preview-store.tsx` holds a `LivePreviewState` and dispatches
|
|
6
|
+
* to these functions; every function returns the SAME reference when nothing
|
|
7
|
+
* changed, so React/`useMemo` can short-circuit.
|
|
8
|
+
*/
|
|
9
|
+
import { type BlockInstance } from "@avocadostudio-ai/shared";
|
|
10
|
+
/**
|
|
11
|
+
* Minimal structural page shape — intentionally NOT the exact `PageDoc` so the
|
|
12
|
+
* structurally-identical PageDoc definitions across shared / site-sdk / apps-site
|
|
13
|
+
* all pass without casts. Extra fields (`meta`, …) ride along untouched.
|
|
14
|
+
*/
|
|
15
|
+
export type LivePreviewPage = {
|
|
16
|
+
id: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
title: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
blocks: BlockInstance[];
|
|
21
|
+
};
|
|
22
|
+
/** blockId → (editablePath → streamed value). */
|
|
23
|
+
export type LivePreviewDrafts = Map<string, Map<string, string>>;
|
|
24
|
+
export type LivePreviewState = {
|
|
25
|
+
committed: LivePreviewPage;
|
|
26
|
+
committedVersion: number;
|
|
27
|
+
drafts: LivePreviewDrafts;
|
|
28
|
+
};
|
|
29
|
+
/** Use the seed's `updatedAt` (bumped on every server mutation) as the version. */
|
|
30
|
+
export declare function versionOf(page: LivePreviewPage): number;
|
|
31
|
+
export declare function initLivePreviewState(page: LivePreviewPage): LivePreviewState;
|
|
32
|
+
/** Layer the in-flight drafts over the committed blocks (copy-on-write per block). */
|
|
33
|
+
export declare function selectEffectiveBlocks(state: LivePreviewState): BlockInstance[];
|
|
34
|
+
/** Replace a block's in-flight draft fields with the full set streamed so far. */
|
|
35
|
+
export declare function setFieldDrafts(state: LivePreviewState, blockId: string, fields: Record<string, string>): LivePreviewState;
|
|
36
|
+
/**
|
|
37
|
+
* Fold a block's drafts into the committed baseline (server has applied the op),
|
|
38
|
+
* so the streamed values stay on screen until the authoritative seed catches up.
|
|
39
|
+
*/
|
|
40
|
+
export declare function commitFieldDrafts(state: LivePreviewState, blockId: string): LivePreviewState;
|
|
41
|
+
/** Drop all in-flight drafts (stream cancelled / rolled back). */
|
|
42
|
+
export declare function clearFieldDrafts(state: LivePreviewState): LivePreviewState;
|
|
43
|
+
/**
|
|
44
|
+
* Adopt a fresh server render as the new committed baseline and drop optimistic
|
|
45
|
+
* drafts — the seed already reflects every op applied up to its version. A stale
|
|
46
|
+
* / out-of-order refresh (version <= current, same page) is ignored so it can't
|
|
47
|
+
* clobber a newer optimistic baseline.
|
|
48
|
+
*/
|
|
49
|
+
export declare function syncServerPage(state: LivePreviewState, page: LivePreviewPage): LivePreviewState;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state transitions for the live-preview draft store.
|
|
3
|
+
*
|
|
4
|
+
* Kept React-free (no "use client") so the reducer is unit-testable in isolation.
|
|
5
|
+
* The provider in `live-preview-store.tsx` holds a `LivePreviewState` and dispatches
|
|
6
|
+
* to these functions; every function returns the SAME reference when nothing
|
|
7
|
+
* changed, so React/`useMemo` can short-circuit.
|
|
8
|
+
*/
|
|
9
|
+
import { isImagePath, setPropAtPath } from "@avocadostudio-ai/shared";
|
|
10
|
+
const EMPTY_DRAFTS = new Map();
|
|
11
|
+
/** Use the seed's `updatedAt` (bumped on every server mutation) as the version. */
|
|
12
|
+
export function versionOf(page) {
|
|
13
|
+
const t = Date.parse(page.updatedAt);
|
|
14
|
+
return Number.isNaN(t) ? 0 : t;
|
|
15
|
+
}
|
|
16
|
+
export function initLivePreviewState(page) {
|
|
17
|
+
return { committed: page, committedVersion: versionOf(page), drafts: EMPTY_DRAFTS };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Layer a block's streamed drafts over its committed props (copy-on-write).
|
|
21
|
+
*
|
|
22
|
+
* Image-path drafts are intentionally skipped. An image URL streams in character
|
|
23
|
+
* by character, so its in-flight value spends time as a truncated string like
|
|
24
|
+
* `"https://"` or `"https://images.unsplash."` — and `next/image` validates the
|
|
25
|
+
* URL + hostname *synchronously during render* and THROWS on a malformed one,
|
|
26
|
+
* which the error boundary turns into "Failed to render <Block> block" until the
|
|
27
|
+
* stream settles. Image fields gain nothing from char-by-char streaming anyway;
|
|
28
|
+
* the committed/authoritative value carries the final URL once the op closes and
|
|
29
|
+
* a fresh server seed is adopted (`syncServerPage`). The legacy innerHTML overlay
|
|
30
|
+
* skips image paths identically — see `applyLiveDraftFields` in bridge-functions.
|
|
31
|
+
*/
|
|
32
|
+
function applyBlockDrafts(props, blockDrafts) {
|
|
33
|
+
let next = props;
|
|
34
|
+
for (const [path, value] of blockDrafts) {
|
|
35
|
+
if (isImagePath(path))
|
|
36
|
+
continue;
|
|
37
|
+
next = setPropAtPath(next, path, value);
|
|
38
|
+
}
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
/** Layer the in-flight drafts over the committed blocks (copy-on-write per block). */
|
|
42
|
+
export function selectEffectiveBlocks(state) {
|
|
43
|
+
const { drafts } = state;
|
|
44
|
+
const blocks = state.committed.blocks;
|
|
45
|
+
if (drafts.size === 0)
|
|
46
|
+
return blocks;
|
|
47
|
+
return blocks.map((block) => {
|
|
48
|
+
const blockDrafts = drafts.get(block.id);
|
|
49
|
+
if (!blockDrafts || blockDrafts.size === 0)
|
|
50
|
+
return block;
|
|
51
|
+
const props = applyBlockDrafts(block.props, blockDrafts);
|
|
52
|
+
return props === block.props ? block : { ...block, props };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Replace a block's in-flight draft fields with the full set streamed so far. */
|
|
56
|
+
export function setFieldDrafts(state, blockId, fields) {
|
|
57
|
+
const drafts = new Map(state.drafts);
|
|
58
|
+
drafts.set(blockId, new Map(Object.entries(fields)));
|
|
59
|
+
return { ...state, drafts };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Fold a block's drafts into the committed baseline (server has applied the op),
|
|
63
|
+
* so the streamed values stay on screen until the authoritative seed catches up.
|
|
64
|
+
*/
|
|
65
|
+
export function commitFieldDrafts(state, blockId) {
|
|
66
|
+
if (!state.drafts.has(blockId))
|
|
67
|
+
return state;
|
|
68
|
+
const blockDrafts = state.drafts.get(blockId);
|
|
69
|
+
const drafts = new Map(state.drafts);
|
|
70
|
+
drafts.delete(blockId);
|
|
71
|
+
if (!blockDrafts || blockDrafts.size === 0)
|
|
72
|
+
return { ...state, drafts };
|
|
73
|
+
const blocks = state.committed.blocks.map((block) => {
|
|
74
|
+
if (block.id !== blockId)
|
|
75
|
+
return block;
|
|
76
|
+
const props = applyBlockDrafts(block.props, blockDrafts);
|
|
77
|
+
return props === block.props ? block : { ...block, props };
|
|
78
|
+
});
|
|
79
|
+
return { ...state, committed: { ...state.committed, blocks }, drafts };
|
|
80
|
+
}
|
|
81
|
+
/** Drop all in-flight drafts (stream cancelled / rolled back). */
|
|
82
|
+
export function clearFieldDrafts(state) {
|
|
83
|
+
return state.drafts.size === 0 ? state : { ...state, drafts: EMPTY_DRAFTS };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Adopt a fresh server render as the new committed baseline and drop optimistic
|
|
87
|
+
* drafts — the seed already reflects every op applied up to its version. A stale
|
|
88
|
+
* / out-of-order refresh (version <= current, same page) is ignored so it can't
|
|
89
|
+
* clobber a newer optimistic baseline.
|
|
90
|
+
*/
|
|
91
|
+
export function syncServerPage(state, page) {
|
|
92
|
+
const v = versionOf(page);
|
|
93
|
+
const isNewer = page.id !== state.committed.id ||
|
|
94
|
+
page.slug !== state.committed.slug ||
|
|
95
|
+
v > state.committedVersion;
|
|
96
|
+
if (!isNewer)
|
|
97
|
+
return state;
|
|
98
|
+
return { committed: page, committedVersion: v, drafts: EMPTY_DRAFTS };
|
|
99
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-authoritative live-preview draft store.
|
|
3
|
+
*
|
|
4
|
+
* During a streaming chat edit the orchestrator emits `field_draft` events; the
|
|
5
|
+
* editor forwards them to the iframe as `liveDraft` postMessages. Instead of
|
|
6
|
+
* mutating the DOM out-of-band (the legacy innerHTML overlay — which can't touch
|
|
7
|
+
* React-owned rich/markdown subtrees without desyncing the vdom), the bridge
|
|
8
|
+
* pushes each streamed field into THIS store, and the blocks re-render natively
|
|
9
|
+
* through the real renderers. Rich vs. plain fields stop being a distinction:
|
|
10
|
+
* a draft is just an optimistic `setPropAtPath` over the committed props.
|
|
11
|
+
*
|
|
12
|
+
* Lives in preview-adapter (the lowest shared layer) so both the bridge (same
|
|
13
|
+
* package) and the site app's RenderedBlocks/EditorPageWrapper can use it. The
|
|
14
|
+
* pure state transitions live in ./live-preview-store-core (unit-tested).
|
|
15
|
+
*/
|
|
16
|
+
import { type ReactNode } from "react";
|
|
17
|
+
import type { BlockInstance } from "@avocadostudio-ai/shared";
|
|
18
|
+
import { type LivePreviewPage } from "./live-preview-store-core.ts";
|
|
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
|
+
};
|
|
29
|
+
export declare function LivePreviewProvider({ initialPage, children, }: {
|
|
30
|
+
initialPage: LivePreviewPage;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
33
|
+
/** Effective blocks (committed ⊕ in-flight drafts). Null when no provider is mounted. */
|
|
34
|
+
export declare function useLivePreviewBlocks(): BlockInstance[] | null;
|
|
35
|
+
/** Narrow store API for the preview bridge. Null when no provider is mounted (legacy path). */
|
|
36
|
+
export declare function useLivePreviewBridgeApi(): LivePreviewBridgeApi | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Client-authoritative live-preview draft store.
|
|
5
|
+
*
|
|
6
|
+
* During a streaming chat edit the orchestrator emits `field_draft` events; the
|
|
7
|
+
* editor forwards them to the iframe as `liveDraft` postMessages. Instead of
|
|
8
|
+
* mutating the DOM out-of-band (the legacy innerHTML overlay — which can't touch
|
|
9
|
+
* React-owned rich/markdown subtrees without desyncing the vdom), the bridge
|
|
10
|
+
* pushes each streamed field into THIS store, and the blocks re-render natively
|
|
11
|
+
* through the real renderers. Rich vs. plain fields stop being a distinction:
|
|
12
|
+
* a draft is just an optimistic `setPropAtPath` over the committed props.
|
|
13
|
+
*
|
|
14
|
+
* Lives in preview-adapter (the lowest shared layer) so both the bridge (same
|
|
15
|
+
* package) and the site app's RenderedBlocks/EditorPageWrapper can use it. The
|
|
16
|
+
* pure state transitions live in ./live-preview-store-core (unit-tested).
|
|
17
|
+
*/
|
|
18
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
|
|
19
|
+
import { initLivePreviewState, selectEffectiveBlocks, setFieldDrafts as coreSetFieldDrafts, commitFieldDrafts as coreCommitFieldDrafts, clearFieldDrafts as coreClearFieldDrafts, syncServerPage as coreSyncServerPage, } from "./live-preview-store-core.js";
|
|
20
|
+
// Two contexts so the bridge (API only) never re-renders on a keystroke, while
|
|
21
|
+
// RenderedBlocks (blocks) re-renders per draft.
|
|
22
|
+
const BlocksContext = createContext(null);
|
|
23
|
+
const ApiContext = createContext(null);
|
|
24
|
+
export function LivePreviewProvider({ initialPage, children, }) {
|
|
25
|
+
const [state, setState] = useState(() => initLivePreviewState(initialPage));
|
|
26
|
+
const setFieldDrafts = useCallback((blockId, fields) => {
|
|
27
|
+
setState((prev) => coreSetFieldDrafts(prev, blockId, fields));
|
|
28
|
+
}, []);
|
|
29
|
+
const commitFieldDrafts = useCallback((blockId) => {
|
|
30
|
+
setState((prev) => coreCommitFieldDrafts(prev, blockId));
|
|
31
|
+
}, []);
|
|
32
|
+
const clearFieldDrafts = useCallback(() => {
|
|
33
|
+
setState((prev) => coreClearFieldDrafts(prev));
|
|
34
|
+
}, []);
|
|
35
|
+
const syncServerPage = useCallback((page) => {
|
|
36
|
+
setState((prev) => coreSyncServerPage(prev, page));
|
|
37
|
+
}, []);
|
|
38
|
+
// A fresh server render (router.refresh / navigation) arrives as a new
|
|
39
|
+
// `initialPage` prop. useState ignores prop changes post-mount, so adopt it
|
|
40
|
+
// here. Idempotent: same-version seeds are no-ops (first mount included).
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
syncServerPage(initialPage);
|
|
43
|
+
}, [initialPage, syncServerPage]);
|
|
44
|
+
const api = useMemo(() => ({ setFieldDrafts, commitFieldDrafts, clearFieldDrafts, syncServerPage }), [setFieldDrafts, commitFieldDrafts, clearFieldDrafts, syncServerPage]);
|
|
45
|
+
const blocks = useMemo(() => selectEffectiveBlocks(state), [state]);
|
|
46
|
+
return (_jsx(ApiContext.Provider, { value: api, children: _jsx(BlocksContext.Provider, { value: blocks, children: children }) }));
|
|
47
|
+
}
|
|
48
|
+
/** Effective blocks (committed ⊕ in-flight drafts). Null when no provider is mounted. */
|
|
49
|
+
export function useLivePreviewBlocks() {
|
|
50
|
+
return useContext(BlocksContext);
|
|
51
|
+
}
|
|
52
|
+
/** Narrow store API for the preview bridge. Null when no provider is mounted (legacy path). */
|
|
53
|
+
export function useLivePreviewBridgeApi() {
|
|
54
|
+
return useContext(ApiContext);
|
|
55
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LivePreviewBridgeApi } from "./live-preview-store.tsx";
|
|
2
|
+
export type PreviewBridgeConfig = {
|
|
3
|
+
navigate: (href: string) => void;
|
|
4
|
+
refresh: () => void;
|
|
5
|
+
};
|
|
6
|
+
export type PreviewBridgeCoreProps = {
|
|
7
|
+
slug: string;
|
|
8
|
+
editorOrigin: string;
|
|
9
|
+
navigate: (href: string) => void;
|
|
10
|
+
refresh: () => void;
|
|
11
|
+
pathname: string;
|
|
12
|
+
/**
|
|
13
|
+
* When provided, streamed `liveDraft` content is pushed into this store (React
|
|
14
|
+
* re-renders the blocks natively) instead of via the innerHTML overlay. Absent
|
|
15
|
+
* (no provider mounted / flag off) → the legacy renderLiveDraft path runs.
|
|
16
|
+
*/
|
|
17
|
+
liveStore?: LivePreviewBridgeApi | null;
|
|
18
|
+
};
|
|
19
|
+
export declare function PreviewBridgeCore(props: PreviewBridgeCoreProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { createBridgeFunctions, createBridgeState, findBlockNode, parseListItemPath, ensureBlockBadges, setNestedLabelsVisibility, showSkeleton, removeSkeletons, clearChildFocus, clearAllHighlights, clearListItemSelection, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.js";
|
|
5
|
+
export function PreviewBridgeCore(props) {
|
|
6
|
+
// When running standalone (no editor origin) or not embedded in an iframe, render nothing.
|
|
7
|
+
if (!props.editorOrigin || typeof window !== "undefined" && window.parent === window)
|
|
8
|
+
return null;
|
|
9
|
+
return _jsx(PreviewBridgeCoreInner, { ...props });
|
|
10
|
+
}
|
|
11
|
+
function PreviewBridgeCoreInner({ slug, editorOrigin, navigate, refresh, pathname, liveStore }) {
|
|
12
|
+
const stateRef = useRef(null);
|
|
13
|
+
const programmaticScrollUntilRef = useRef(0);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
// -- postMessage helpers ------------------------------------------------
|
|
16
|
+
const postToEditor = (type, payload) => {
|
|
17
|
+
window.parent.postMessage({ protocol: "site-editor/v1", type, payload }, editorOrigin);
|
|
18
|
+
};
|
|
19
|
+
const emitPatchAck = (txId, accepted, reason) => {
|
|
20
|
+
const msg = { type: "patchAck", txId, accepted, reason };
|
|
21
|
+
window.parent.postMessage({ source: "site-editor/v1", ...msg }, editorOrigin);
|
|
22
|
+
};
|
|
23
|
+
// -- Bridge callbacks (postMessage transport) ---------------------------
|
|
24
|
+
const callbacks = {
|
|
25
|
+
onBlockClicked: (p) => postToEditor("blockClicked", p),
|
|
26
|
+
onBlockDeleteRequested: (p) => postToEditor("blockDeleteRequested", p),
|
|
27
|
+
onBlockReordered: (p) => postToEditor("blockReordered", p),
|
|
28
|
+
onBlockAddRequested: (p) => postToEditor("blockAddRequested", p),
|
|
29
|
+
onListItemRemoveRequested: (p) => postToEditor("listItemRemoveRequested", p),
|
|
30
|
+
onListItemAddRequested: (p) => postToEditor("listItemAddRequested", p),
|
|
31
|
+
onListItemMoveRequested: (p) => postToEditor("listItemMoveRequested", p),
|
|
32
|
+
onInlineTextCommitted: (p) => postToEditor("inlineTextCommitted", p),
|
|
33
|
+
onOpenImagePicker: (p) => postToEditor("openImagePicker", p),
|
|
34
|
+
onScroll: () => postToEditor("iframeScrolled", {}),
|
|
35
|
+
};
|
|
36
|
+
// -- State & functions -------------------------------------------------
|
|
37
|
+
const state = createBridgeState();
|
|
38
|
+
stateRef.current = state;
|
|
39
|
+
const bridge = createBridgeFunctions(state, callbacks, { slug, pathname, refresh, navigate });
|
|
40
|
+
// -- Initialization ----------------------------------------------------
|
|
41
|
+
setNestedLabelsVisibility(false);
|
|
42
|
+
document.documentElement.setAttribute("data-editor-active", "");
|
|
43
|
+
// Restore selection mode if it was active before effect re-run
|
|
44
|
+
if (state.selectionMode) {
|
|
45
|
+
document.documentElement.setAttribute("data-editor-selection-mode", "");
|
|
46
|
+
}
|
|
47
|
+
// Clear stale selection classes — Fast Refresh can preserve DOM across remounts.
|
|
48
|
+
clearAllHighlights();
|
|
49
|
+
clearChildFocus();
|
|
50
|
+
clearListItemSelection();
|
|
51
|
+
ensureBlockBadges();
|
|
52
|
+
bridge.mountGlobalImageButtons();
|
|
53
|
+
// -- Mutation observer -------------------------------------------------
|
|
54
|
+
let detectNewBlocksRaf = null;
|
|
55
|
+
const scheduleDetectNewBlocks = () => {
|
|
56
|
+
if (detectNewBlocksRaf !== null)
|
|
57
|
+
return;
|
|
58
|
+
detectNewBlocksRaf = requestAnimationFrame(() => {
|
|
59
|
+
detectNewBlocksRaf = null;
|
|
60
|
+
bridge.detectNewBlocks();
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
let imageButtonsRaf = null;
|
|
64
|
+
const scheduleImageButtons = () => {
|
|
65
|
+
if (imageButtonsRaf !== null)
|
|
66
|
+
return;
|
|
67
|
+
imageButtonsRaf = requestAnimationFrame(() => {
|
|
68
|
+
imageButtonsRaf = null;
|
|
69
|
+
bridge.mountGlobalImageButtons();
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
state.observer = new MutationObserver(() => {
|
|
73
|
+
ensureBlockBadges();
|
|
74
|
+
scheduleImageButtons();
|
|
75
|
+
scheduleDetectNewBlocks();
|
|
76
|
+
});
|
|
77
|
+
if (document.body)
|
|
78
|
+
state.observer.observe(document.body, { childList: true, subtree: true });
|
|
79
|
+
// -- postMessage handler -----------------------------------------------
|
|
80
|
+
const onMessage = (event) => {
|
|
81
|
+
if (event.origin !== editorOrigin)
|
|
82
|
+
return;
|
|
83
|
+
const msg = event.data;
|
|
84
|
+
if (!msg || msg.protocol !== "site-editor/v1")
|
|
85
|
+
return;
|
|
86
|
+
if (msg.type === "navigate") {
|
|
87
|
+
const rawHref = String(msg.payload.href ?? "").trim();
|
|
88
|
+
if (!rawHref)
|
|
89
|
+
return;
|
|
90
|
+
const href = rawHref.startsWith("/") ? rawHref : `/${rawHref}`;
|
|
91
|
+
const currentHref = `${window.location.pathname}${window.location.search}`;
|
|
92
|
+
if (href === currentHref)
|
|
93
|
+
return;
|
|
94
|
+
navigate(href);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (msg.type === "setSelectionMode") {
|
|
98
|
+
const enabled = !!msg.payload.enabled;
|
|
99
|
+
state.selectionMode = enabled;
|
|
100
|
+
if (enabled) {
|
|
101
|
+
document.documentElement.setAttribute("data-editor-selection-mode", "");
|
|
102
|
+
bridge.mountGlobalImageButtons();
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
document.documentElement.removeAttribute("data-editor-selection-mode");
|
|
106
|
+
document.querySelectorAll(".editor-image-change-btn").forEach((el) => el.remove());
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (msg.type === "applyPatch") {
|
|
111
|
+
if (state.serverVersion !== msg.fromVersion) {
|
|
112
|
+
emitPatchAck(msg.txId, false, "version_mismatch");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
state.serverVersion = msg.toVersion;
|
|
116
|
+
if (msg.focusBlockId) {
|
|
117
|
+
state.pendingFocusId = msg.focusBlockId;
|
|
118
|
+
const isStructural = msg.op?.op === "add_block" || msg.op?.op === "move_block";
|
|
119
|
+
state.pendingScrollIntoView = isStructural || state.pendingScrollIntoView;
|
|
120
|
+
}
|
|
121
|
+
if (msg.op?.op === "move_item") {
|
|
122
|
+
state.skipParentAnimationOnce = true;
|
|
123
|
+
}
|
|
124
|
+
state.expectingNewBlocks = msg.op?.op === "add_block";
|
|
125
|
+
bridge.smoothRefresh();
|
|
126
|
+
emitPatchAck(msg.txId, true);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (msg.type === "resetToServer") {
|
|
130
|
+
state.serverVersion = msg.toVersion;
|
|
131
|
+
state.expectingNewBlocks = true;
|
|
132
|
+
if (msg.focusBlockId) {
|
|
133
|
+
state.pendingFocusId = msg.focusBlockId;
|
|
134
|
+
state.pendingScrollIntoView = false;
|
|
135
|
+
}
|
|
136
|
+
state.pendingListItemMovePath = null;
|
|
137
|
+
bridge.setChildSelectionLock(null);
|
|
138
|
+
bridge.smoothRefresh(true);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (msg.type === "draftUpdated") {
|
|
142
|
+
const focusBlockId = String(msg.payload.focusBlockId ?? "");
|
|
143
|
+
state.pendingFocusId = focusBlockId || null;
|
|
144
|
+
// Opt-in: the editor sets this when the changed/added block should be
|
|
145
|
+
// scrolled into view after the refresh (a newly added block is only in
|
|
146
|
+
// the DOM now). Explicit so the other ~20 draftUpdated callers are unaffected.
|
|
147
|
+
state.pendingScrollIntoView = msg.payload.scrollIntoView === true;
|
|
148
|
+
state.expectingNewBlocks = true;
|
|
149
|
+
bridge.clearLiveDraft();
|
|
150
|
+
clearChildFocus();
|
|
151
|
+
state.selectedEditablePath = null;
|
|
152
|
+
state.pendingListItemMovePath = null;
|
|
153
|
+
bridge.setChildSelectionLock(null);
|
|
154
|
+
const navigateTo = typeof msg.payload.navigateTo === "string" ? msg.payload.navigateTo.trim() : "";
|
|
155
|
+
if (navigateTo) {
|
|
156
|
+
const href = navigateTo.startsWith("/") ? navigateTo : `/${navigateTo}`;
|
|
157
|
+
navigate(`${href}${window.location.search}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
bridge.smoothRefresh();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (msg.type === "highlightBlock") {
|
|
164
|
+
const blockId = String(msg.payload.blockId ?? "");
|
|
165
|
+
let editablePath = String(msg.payload.editablePath ?? "") || undefined;
|
|
166
|
+
if (!editablePath && state.pendingListItemMovePath && state.selectedBlockId === blockId) {
|
|
167
|
+
editablePath = state.pendingListItemMovePath;
|
|
168
|
+
}
|
|
169
|
+
if (!editablePath) {
|
|
170
|
+
state.selectedEditablePath = null;
|
|
171
|
+
state.pendingListItemMovePath = null;
|
|
172
|
+
bridge.setChildSelectionLock(null);
|
|
173
|
+
}
|
|
174
|
+
else if (state.pendingListItemMovePath === editablePath) {
|
|
175
|
+
state.pendingListItemMovePath = null;
|
|
176
|
+
const parsed = parseListItemPath(editablePath);
|
|
177
|
+
if (parsed) {
|
|
178
|
+
const existing = state.childSelectionLock;
|
|
179
|
+
const position = existing && existing.blockId === blockId && existing.listKey === parsed.listKey
|
|
180
|
+
? existing.position
|
|
181
|
+
: undefined;
|
|
182
|
+
bridge.setChildSelectionLock({ blockId, listKey: parsed.listKey, index: parsed.index, position });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const parsed = parseListItemPath(editablePath);
|
|
187
|
+
if (parsed) {
|
|
188
|
+
const existing = state.childSelectionLock;
|
|
189
|
+
const position = existing && existing.blockId === blockId && existing.listKey === parsed.listKey
|
|
190
|
+
? existing.position
|
|
191
|
+
: undefined;
|
|
192
|
+
bridge.setChildSelectionLock({ blockId, listKey: parsed.listKey, index: parsed.index, position });
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
bridge.setChildSelectionLock(null);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const wantsScroll = msg.payload.scrollIntoView === true;
|
|
199
|
+
bridge.applyBlockFocus(blockId, false, editablePath, { scrollIntoView: wantsScroll });
|
|
200
|
+
}
|
|
201
|
+
if (msg.type === "setNestedLabelsVisibility") {
|
|
202
|
+
setNestedLabelsVisibility(Boolean(msg.payload.visible));
|
|
203
|
+
}
|
|
204
|
+
if (msg.type === "liveDraft") {
|
|
205
|
+
const blockId = String(msg.payload.blockId ?? "");
|
|
206
|
+
const text = String(msg.payload.text ?? "");
|
|
207
|
+
const active = Boolean(msg.payload.active);
|
|
208
|
+
const commit = Boolean(msg.payload.commit);
|
|
209
|
+
const fields = (msg.payload.fields && typeof msg.payload.fields === "object")
|
|
210
|
+
? msg.payload.fields
|
|
211
|
+
: undefined;
|
|
212
|
+
if (liveStore) {
|
|
213
|
+
// Store path: push streamed values into React state; the blocks
|
|
214
|
+
// re-render natively (rich/markdown fields included). The bridge only
|
|
215
|
+
// paints the "Updating" chrome — no innerHTML mutation.
|
|
216
|
+
if (!active) {
|
|
217
|
+
if (commit)
|
|
218
|
+
liveStore.commitFieldDrafts(blockId);
|
|
219
|
+
else
|
|
220
|
+
liveStore.clearFieldDrafts();
|
|
221
|
+
}
|
|
222
|
+
else if (fields) {
|
|
223
|
+
liveStore.setFieldDrafts(blockId, fields);
|
|
224
|
+
}
|
|
225
|
+
bridge.renderLiveDraftChromeOnly(blockId, active);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
bridge.renderLiveDraft(blockId, text, active, fields, commit);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (msg.type === "showSkeleton") {
|
|
232
|
+
const afterBlockId = msg.payload.afterBlockId ? String(msg.payload.afterBlockId) : null;
|
|
233
|
+
const blockType = String(msg.payload.blockType ?? "Block");
|
|
234
|
+
showSkeleton(afterBlockId, blockType);
|
|
235
|
+
}
|
|
236
|
+
if (msg.type === "removeSkeleton") {
|
|
237
|
+
removeSkeletons();
|
|
238
|
+
}
|
|
239
|
+
if (msg.type === "aiFieldLoading") {
|
|
240
|
+
const blockId = String(msg.payload.blockId ?? "");
|
|
241
|
+
const editablePath = String(msg.payload.editablePath ?? "");
|
|
242
|
+
const active = Boolean(msg.payload.active);
|
|
243
|
+
state.activeShimmer = active ? { blockId, editablePath } : null;
|
|
244
|
+
applyAiFieldLoading(blockId, editablePath, active);
|
|
245
|
+
}
|
|
246
|
+
if (msg.type === "scrollToBlock") {
|
|
247
|
+
const blockId = String(msg.payload.blockId ?? "");
|
|
248
|
+
if (!blockId)
|
|
249
|
+
return;
|
|
250
|
+
const behavior = msg.payload.behavior === "auto" ? "auto" : "smooth";
|
|
251
|
+
const block = ["start", "center", "end", "nearest"].includes(String(msg.payload.block))
|
|
252
|
+
? String(msg.payload.block)
|
|
253
|
+
: "center";
|
|
254
|
+
const run = () => {
|
|
255
|
+
const el = findBlockNode(blockId);
|
|
256
|
+
if (!el)
|
|
257
|
+
return false;
|
|
258
|
+
// Mark that the next scroll event is programmatic so we don't
|
|
259
|
+
// interpret our own scroll as a user cancel.
|
|
260
|
+
programmaticScrollUntilRef.current = Date.now() + 800;
|
|
261
|
+
el.scrollIntoView({ behavior, block, inline: "nearest" });
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
// The block may not yet be in the DOM if the iframe just re-rendered;
|
|
265
|
+
// retry on the next frame and again after a short delay.
|
|
266
|
+
if (!run())
|
|
267
|
+
requestAnimationFrame(() => { if (!run())
|
|
268
|
+
setTimeout(run, 120); });
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
// -- Scroll handler ----------------------------------------------------
|
|
272
|
+
const onScroll = () => {
|
|
273
|
+
// Swallow the scroll event if it originated from our own scrollIntoView.
|
|
274
|
+
if (Date.now() < programmaticScrollUntilRef.current)
|
|
275
|
+
return;
|
|
276
|
+
callbacks.onScroll();
|
|
277
|
+
};
|
|
278
|
+
// -- Attach event listeners --------------------------------------------
|
|
279
|
+
document.addEventListener("click", bridge.onClick, true);
|
|
280
|
+
document.addEventListener("dblclick", bridge.onDoubleClick, true);
|
|
281
|
+
document.addEventListener("pointermove", bridge.onPointerMove, true);
|
|
282
|
+
document.addEventListener("keydown", bridge.onKeyDown, true);
|
|
283
|
+
window.addEventListener("message", onMessage);
|
|
284
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
285
|
+
// -- Cleanup -----------------------------------------------------------
|
|
286
|
+
return () => {
|
|
287
|
+
if (detectNewBlocksRaf !== null)
|
|
288
|
+
cancelAnimationFrame(detectNewBlocksRaf);
|
|
289
|
+
if (imageButtonsRaf !== null)
|
|
290
|
+
cancelAnimationFrame(imageButtonsRaf);
|
|
291
|
+
bridge.cancelInlineEdit();
|
|
292
|
+
clearChildFocus();
|
|
293
|
+
bridge.restoreLiveDraftOriginals();
|
|
294
|
+
bridge.clearLiveDraft();
|
|
295
|
+
bridge.removeSelectedDeleteHandle();
|
|
296
|
+
state.observer?.disconnect();
|
|
297
|
+
document.removeEventListener("click", bridge.onClick, true);
|
|
298
|
+
document.removeEventListener("dblclick", bridge.onDoubleClick, true);
|
|
299
|
+
document.removeEventListener("pointermove", bridge.onPointerMove, true);
|
|
300
|
+
document.removeEventListener("keydown", bridge.onKeyDown, true);
|
|
301
|
+
window.removeEventListener("message", onMessage);
|
|
302
|
+
window.removeEventListener("scroll", onScroll);
|
|
303
|
+
cleanupOverlayElements();
|
|
304
|
+
};
|
|
305
|
+
}, [editorOrigin, navigate, refresh, slug, liveStore]);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
// Report the CONTENT slug, not the browser pathname. They're identical
|
|
308
|
+
// for root-mounted sites, but diverge when the site mounts Avocado pages
|
|
309
|
+
// under a subpath (e.g. /studio): pathname is "/studio/home" while the
|
|
310
|
+
// orchestrator's draft is keyed by the content slug "/home". Reporting
|
|
311
|
+
// pathname there makes the editor send "/studio/home" to /chat and /ops,
|
|
312
|
+
// which the planner can't resolve ("page not found").
|
|
313
|
+
window.parent.postMessage({
|
|
314
|
+
protocol: "site-editor/v1",
|
|
315
|
+
type: "routeChanged",
|
|
316
|
+
payload: { slug: slug || "/" }
|
|
317
|
+
}, editorOrigin);
|
|
318
|
+
}, [editorOrigin, slug]);
|
|
319
|
+
// Track placeholder→real image swaps for blur→sharp reveal animation
|
|
320
|
+
const placeholderBlockIdsRef = useRef(new Set());
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
const PLACEHOLDER_PREFIX = "data:image/svg+xml";
|
|
323
|
+
function scanForPlaceholders() {
|
|
324
|
+
document.querySelectorAll(".hero__media img").forEach((img) => {
|
|
325
|
+
const src = img.getAttribute("src") ?? "";
|
|
326
|
+
const blockEl = img.closest("[data-block-id]");
|
|
327
|
+
if (!blockEl)
|
|
328
|
+
return;
|
|
329
|
+
const blockId = blockEl.getAttribute("data-block-id") ?? "";
|
|
330
|
+
if (src.startsWith(PLACEHOLDER_PREFIX)) {
|
|
331
|
+
placeholderBlockIdsRef.current.add(blockId);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
function applyReveal(img) {
|
|
336
|
+
const doReveal = () => {
|
|
337
|
+
img.classList.add("ai-image-reveal");
|
|
338
|
+
img.addEventListener("animationend", () => img.classList.remove("ai-image-reveal"), { once: true });
|
|
339
|
+
};
|
|
340
|
+
if (img.complete && img.naturalWidth > 0)
|
|
341
|
+
doReveal();
|
|
342
|
+
else
|
|
343
|
+
img.addEventListener("load", doReveal, { once: true });
|
|
344
|
+
}
|
|
345
|
+
const observer = new MutationObserver((mutations) => {
|
|
346
|
+
for (const mutation of mutations) {
|
|
347
|
+
if (mutation.type !== "childList")
|
|
348
|
+
continue;
|
|
349
|
+
for (const node of mutation.addedNodes) {
|
|
350
|
+
if (!(node instanceof HTMLElement))
|
|
351
|
+
continue;
|
|
352
|
+
const imgs = node.matches?.(".hero__media img")
|
|
353
|
+
? [node]
|
|
354
|
+
: Array.from(node.querySelectorAll(".hero__media img"));
|
|
355
|
+
for (const img of imgs) {
|
|
356
|
+
const src = img.getAttribute("src") ?? "";
|
|
357
|
+
const blockEl = img.closest("[data-block-id]");
|
|
358
|
+
if (!blockEl)
|
|
359
|
+
continue;
|
|
360
|
+
const blockId = blockEl.getAttribute("data-block-id") ?? "";
|
|
361
|
+
if (src.startsWith(PLACEHOLDER_PREFIX)) {
|
|
362
|
+
placeholderBlockIdsRef.current.add(blockId);
|
|
363
|
+
}
|
|
364
|
+
else if (src.startsWith("http") && placeholderBlockIdsRef.current.has(blockId)) {
|
|
365
|
+
placeholderBlockIdsRef.current.delete(blockId);
|
|
366
|
+
applyReveal(img);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
373
|
+
scanForPlaceholders();
|
|
374
|
+
return () => observer.disconnect();
|
|
375
|
+
}, []);
|
|
376
|
+
return null;
|
|
377
|
+
}
|