@zalify/storefront-kit 0.4.0 → 0.5.1
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/dist/editor/bootstrap.d.ts +10 -0
- package/dist/editor/bootstrap.js +34 -0
- package/dist/editor/selection.d.ts +3 -0
- package/dist/editor/selection.js +42 -0
- package/dist/editor/visible-paths.d.ts +6 -0
- package/dist/editor/visible-paths.js +88 -0
- package/dist/react/design-mode.d.ts +7 -0
- package/dist/react/design-mode.js +17 -0
- package/dist/react/editor-origin.d.ts +2 -0
- package/dist/react/editor-origin.js +5 -0
- package/dist/react/server-preview.d.ts +55 -0
- package/dist/react/server-preview.js +216 -0
- package/dist/react/useEditorTemplate.d.ts +2 -3
- package/dist/react/useEditorTemplate.js +3 -23
- package/package.json +20 -3
- package/skills/zalify-storefront-editor/SKILL.md +17 -2
- package/src/editor/bootstrap.ts +37 -0
- package/src/editor/selection.ts +52 -0
- package/src/editor/visible-paths.ts +99 -0
- package/src/react/design-mode.ts +21 -0
- package/src/react/editor-origin.ts +7 -0
- package/src/react/server-preview.tsx +333 -0
- package/src/react/useEditorTemplate.ts +3 -28
|
@@ -6,6 +6,16 @@ export interface EditorDocuments {
|
|
|
6
6
|
groups: string;
|
|
7
7
|
settings: string;
|
|
8
8
|
}
|
|
9
|
+
/** Template types the editor can group, icon and offer an entity picker for. */
|
|
10
|
+
export declare const TEMPLATE_TYPES: readonly ["index", "product", "collection", "list-collections", "page", "blog", "article", "cart", "search", "404", "password", "gift_card"];
|
|
11
|
+
/**
|
|
12
|
+
* What is wrong with a set of template names, for a storefront's own tests:
|
|
13
|
+
* names must be `<type>` or `<type>.<suffix>` with a known type. A default is
|
|
14
|
+
* not required next to alternates — a site with `page.about` but no generic
|
|
15
|
+
* page route must not invent one; the editor groups such alternates itself.
|
|
16
|
+
* Empty means the editor can lay them out.
|
|
17
|
+
*/
|
|
18
|
+
export declare function templateNameProblems(names: readonly string[]): string[];
|
|
9
19
|
/** Only list routes the app can actually render; never fabricate handles. */
|
|
10
20
|
export declare function createEditorBootstrap(options: {
|
|
11
21
|
schema: DraftSchema;
|
package/dist/editor/bootstrap.js
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
/** Template types the editor can group, icon and offer an entity picker for. */
|
|
2
|
+
export const TEMPLATE_TYPES = [
|
|
3
|
+
'index',
|
|
4
|
+
'product',
|
|
5
|
+
'collection',
|
|
6
|
+
'list-collections',
|
|
7
|
+
'page',
|
|
8
|
+
'blog',
|
|
9
|
+
'article',
|
|
10
|
+
'cart',
|
|
11
|
+
'search',
|
|
12
|
+
'404',
|
|
13
|
+
'password',
|
|
14
|
+
'gift_card',
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* What is wrong with a set of template names, for a storefront's own tests:
|
|
18
|
+
* names must be `<type>` or `<type>.<suffix>` with a known type. A default is
|
|
19
|
+
* not required next to alternates — a site with `page.about` but no generic
|
|
20
|
+
* page route must not invent one; the editor groups such alternates itself.
|
|
21
|
+
* Empty means the editor can lay them out.
|
|
22
|
+
*/
|
|
23
|
+
export function templateNameProblems(names) {
|
|
24
|
+
const problems = [];
|
|
25
|
+
for (const name of names) {
|
|
26
|
+
if (name.startsWith('customers/'))
|
|
27
|
+
continue;
|
|
28
|
+
const type = name.split('.')[0];
|
|
29
|
+
if (!TEMPLATE_TYPES.includes(type)) {
|
|
30
|
+
problems.push(`"${name}": unknown template type "${type}" — use <type>.<suffix>, e.g. "page.${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}"`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return problems;
|
|
34
|
+
}
|
|
1
35
|
/** Only list routes the app can actually render; never fabricate handles. */
|
|
2
36
|
export function createEditorBootstrap(options) {
|
|
3
37
|
const { schema, manifest, paths, previews } = options;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { BRIDGE_NAMESPACE, CONTRACT_VERSION, isCompatibleVersion, } from "../schemas/index.js";
|
|
2
|
+
/** Observe the same authenticated parent selection as the SDK. Components can
|
|
3
|
+
* reveal the selected tab/menu before the SDK measures its DOM node. */
|
|
4
|
+
export function mountEditorSelection(win, onSelect) {
|
|
5
|
+
let origin;
|
|
6
|
+
const listener = (event) => {
|
|
7
|
+
if (event.source !== win.parent)
|
|
8
|
+
return;
|
|
9
|
+
const data = event.data;
|
|
10
|
+
if (!data ||
|
|
11
|
+
data.z !== BRIDGE_NAMESPACE ||
|
|
12
|
+
typeof data.v !== "string" ||
|
|
13
|
+
!isCompatibleVersion(CONTRACT_VERSION, data.v) ||
|
|
14
|
+
!data.payload)
|
|
15
|
+
return;
|
|
16
|
+
if (!origin &&
|
|
17
|
+
data.type === "bridge:init" &&
|
|
18
|
+
event.origin === data.payload.editorOrigin)
|
|
19
|
+
origin = event.origin;
|
|
20
|
+
if (!origin || event.origin !== origin)
|
|
21
|
+
return;
|
|
22
|
+
const path = data.type === "bridge:init"
|
|
23
|
+
? data.payload.selectedPath
|
|
24
|
+
: data.type === "block:select"
|
|
25
|
+
? data.payload.path
|
|
26
|
+
: undefined;
|
|
27
|
+
if (path === null || typeof path === "string")
|
|
28
|
+
onSelect(path);
|
|
29
|
+
};
|
|
30
|
+
const click = (event) => {
|
|
31
|
+
const target = event.target;
|
|
32
|
+
const path = target?.closest?.("[data-z-path]")?.getAttribute("data-z-path") ?? null;
|
|
33
|
+
if (path)
|
|
34
|
+
onSelect(path);
|
|
35
|
+
};
|
|
36
|
+
win.addEventListener("message", listener);
|
|
37
|
+
win.document.addEventListener("click", click, true);
|
|
38
|
+
return () => {
|
|
39
|
+
win.removeEventListener("message", listener);
|
|
40
|
+
win.document.removeEventListener("click", click, true);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The SDK selects the first matching path. Responsive copies must not let a
|
|
2
|
+
* hidden desktop node shadow its visible mobile counterpart (or vice versa). */
|
|
3
|
+
export declare function syncVisiblePaths(doc: Document): void;
|
|
4
|
+
export declare function restoreHiddenPaths(doc: Document): void;
|
|
5
|
+
/** Mounted only in the editor, before the SDK's message/resize listeners. */
|
|
6
|
+
export declare function mountVisiblePaths(win: Window, onChange: () => void): () => void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const PATH = "data-z-path";
|
|
2
|
+
const HIDDEN_PATH = "data-z-editor-hidden-path";
|
|
3
|
+
/** The SDK selects the first matching path. Responsive copies must not let a
|
|
4
|
+
* hidden desktop node shadow its visible mobile counterpart (or vice versa). */
|
|
5
|
+
export function syncVisiblePaths(doc) {
|
|
6
|
+
for (const node of doc.querySelectorAll(`[${PATH}], [${HIDDEN_PATH}]`)) {
|
|
7
|
+
const path = node.getAttribute(PATH) ?? node.getAttribute(HIDDEN_PATH);
|
|
8
|
+
if (!path)
|
|
9
|
+
continue;
|
|
10
|
+
// display:contents has no own box; a visible child still makes it selectable.
|
|
11
|
+
const visible = node.getClientRects().length > 0 ||
|
|
12
|
+
Array.from(node.children).some((child) => child.getClientRects().length > 0);
|
|
13
|
+
if (visible) {
|
|
14
|
+
if (node.getAttribute(PATH) !== path)
|
|
15
|
+
node.setAttribute(PATH, path);
|
|
16
|
+
if (node.getAttribute(HIDDEN_PATH) !== null)
|
|
17
|
+
node.removeAttribute(HIDDEN_PATH);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
if (node.getAttribute(HIDDEN_PATH) !== path)
|
|
21
|
+
node.setAttribute(HIDDEN_PATH, path);
|
|
22
|
+
if (node.getAttribute(PATH) !== null)
|
|
23
|
+
node.removeAttribute(PATH);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function restoreHiddenPaths(doc) {
|
|
28
|
+
for (const node of doc.querySelectorAll(`[${HIDDEN_PATH}]`)) {
|
|
29
|
+
const path = node.getAttribute(HIDDEN_PATH);
|
|
30
|
+
if (path && !node.hasAttribute(PATH))
|
|
31
|
+
node.setAttribute(PATH, path);
|
|
32
|
+
node.removeAttribute(HIDDEN_PATH);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Mounted only in the editor, before the SDK's message/resize listeners. */
|
|
36
|
+
export function mountVisiblePaths(win, onChange) {
|
|
37
|
+
let frame = 0;
|
|
38
|
+
const sync = () => syncVisiblePaths(win.document);
|
|
39
|
+
const schedule = () => {
|
|
40
|
+
if (frame)
|
|
41
|
+
return;
|
|
42
|
+
frame = win.requestAnimationFrame(() => {
|
|
43
|
+
frame = 0;
|
|
44
|
+
sync();
|
|
45
|
+
onChange();
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
// Transform/opacity animations do not change which responsive copy has a box.
|
|
49
|
+
// Scanning every path for each animation frame makes long previews expensive.
|
|
50
|
+
const boxStyle = (style) => ["display", "content-visibility"]
|
|
51
|
+
.map((property) => new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i")
|
|
52
|
+
.exec(style ?? "")?.[1]
|
|
53
|
+
.trim() ?? "")
|
|
54
|
+
.join("|");
|
|
55
|
+
const observer = new MutationObserver((records) => {
|
|
56
|
+
if (records.some((record) => {
|
|
57
|
+
const target = record.target;
|
|
58
|
+
if (target.id === "zalify-editor-highlight")
|
|
59
|
+
return false;
|
|
60
|
+
if (record.type === "attributes" && record.attributeName === "style")
|
|
61
|
+
return (boxStyle(record.oldValue) !== boxStyle(target.getAttribute("style")));
|
|
62
|
+
return true;
|
|
63
|
+
}))
|
|
64
|
+
schedule();
|
|
65
|
+
});
|
|
66
|
+
observer.observe(win.document.documentElement, {
|
|
67
|
+
subtree: true,
|
|
68
|
+
childList: true,
|
|
69
|
+
attributes: true,
|
|
70
|
+
attributeOldValue: true,
|
|
71
|
+
attributeFilter: [PATH, "class", "style", "hidden"],
|
|
72
|
+
});
|
|
73
|
+
const onMessage = (event) => {
|
|
74
|
+
if (event.data?.z === "zalify-editor-bridge" &&
|
|
75
|
+
["bridge:init", "block:select", "device:set", "template:apply"].includes(event.data.type))
|
|
76
|
+
sync();
|
|
77
|
+
};
|
|
78
|
+
win.addEventListener("resize", sync);
|
|
79
|
+
win.addEventListener("message", onMessage);
|
|
80
|
+
sync();
|
|
81
|
+
return () => {
|
|
82
|
+
observer.disconnect();
|
|
83
|
+
win.cancelAnimationFrame(frame);
|
|
84
|
+
win.removeEventListener("resize", sync);
|
|
85
|
+
win.removeEventListener("message", onMessage);
|
|
86
|
+
restoreHiddenPaths(win.document);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design mode is the SDK's to declare, not each storefront's: the editor
|
|
3
|
+
* sizes the iframe to its content, so `100vh` inside it grows with every
|
|
4
|
+
* height report. Themes read `--editor-viewport-height` under
|
|
5
|
+
* `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
|
|
6
|
+
*/
|
|
7
|
+
export declare function enterDesignMode(): () => void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Cap for `--editor-viewport-height`: a tall editor pane is not a tall phone. */
|
|
2
|
+
const MAX_EDITOR_VIEWPORT_HEIGHT = 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Design mode is the SDK's to declare, not each storefront's: the editor
|
|
5
|
+
* sizes the iframe to its content, so `100vh` inside it grows with every
|
|
6
|
+
* height report. Themes read `--editor-viewport-height` under
|
|
7
|
+
* `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
|
|
8
|
+
*/
|
|
9
|
+
export function enterDesignMode() {
|
|
10
|
+
const root = document.documentElement;
|
|
11
|
+
root.style.setProperty("--editor-viewport-height", `${Math.min(window.innerHeight, MAX_EDITOR_VIEWPORT_HEIGHT)}px`);
|
|
12
|
+
root.setAttribute("data-z-design-mode", "1");
|
|
13
|
+
return () => {
|
|
14
|
+
root.removeAttribute("data-z-design-mode");
|
|
15
|
+
root.style.removeProperty("--editor-viewport-height");
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* still present — always checked against the allowlist, never trusted bare.
|
|
10
10
|
*/
|
|
11
11
|
const STORAGE_KEY = "zalify-editor-origin";
|
|
12
|
+
/** Editors allowed to frame a storefront unless the app narrows the list. */
|
|
13
|
+
export const ZALIFY_EDITOR_ORIGINS = [
|
|
14
|
+
"https://app.zalify.com",
|
|
15
|
+
"http://localhost:3000",
|
|
16
|
+
];
|
|
12
17
|
export function resolveEditorOrigin(origins, win = window) {
|
|
13
18
|
if (!origins.length || win.self === win.top)
|
|
14
19
|
return null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor preview for storefronts that render templates on the server (React
|
|
3
|
+
* Server Components) instead of through the client theme engine.
|
|
4
|
+
*
|
|
5
|
+
* The app supplies two server functions — one returning its bootstrap, one
|
|
6
|
+
* rendering a draft template to React nodes — and wraps each editable region.
|
|
7
|
+
* Everything else (trust, bridge, design mode, selection, responsive-copy
|
|
8
|
+
* paths, error surface) is here, so a storefront repo never writes it again.
|
|
9
|
+
*
|
|
10
|
+
* <EditorPreviewProvider getBootstrap={…} renderTemplate={…}>
|
|
11
|
+
* <EditorGroupRegion name="header-group">…</EditorGroupRegion>
|
|
12
|
+
* <EditorTemplateRegion name="page.about">…</EditorTemplateRegion>
|
|
13
|
+
* </EditorPreviewProvider>
|
|
14
|
+
*/
|
|
15
|
+
import { type ReactNode } from "react";
|
|
16
|
+
import type { EditorBootstrap } from "../schemas/bridge";
|
|
17
|
+
import type { SectionGroupData, SettingsData, TemplateData } from "../schemas/data";
|
|
18
|
+
export interface RenderTemplateInput {
|
|
19
|
+
templateName: string;
|
|
20
|
+
template: TemplateData;
|
|
21
|
+
groups?: Record<string, SectionGroupData>;
|
|
22
|
+
/** Path + query of the page being previewed, without the editor param. */
|
|
23
|
+
pathname: string;
|
|
24
|
+
}
|
|
25
|
+
/** Keys are `template:<name>` and `group:<name>`. */
|
|
26
|
+
export type RenderedRegions = Record<string, ReactNode>;
|
|
27
|
+
export interface EditorPreviewProviderProps {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
/** Server function: the app's bootstrap (see `createEditorBootstrap`). */
|
|
30
|
+
getBootstrap: () => Promise<EditorBootstrap>;
|
|
31
|
+
/** Server function: render a draft. Must be read-only. */
|
|
32
|
+
renderTemplate: (input: RenderTemplateInput) => Promise<RenderedRegions>;
|
|
33
|
+
/** Map draft theme settings onto the page (CSS variables, usually). */
|
|
34
|
+
applySettings?: (settings: SettingsData, root: HTMLElement) => void;
|
|
35
|
+
/** Defaults to {@link ZALIFY_EDITOR_ORIGINS}. */
|
|
36
|
+
origins?: readonly string[];
|
|
37
|
+
/** A render slower than this fails the edit instead of hanging it. */
|
|
38
|
+
renderTimeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/** No bootstrap payload or bridge code is downloaded by ordinary visitors. */
|
|
41
|
+
export declare function EditorPreviewProvider({ children, getBootstrap, renderTemplate, applySettings, origins, renderTimeoutMs, }: EditorPreviewProviderProps): import("react").JSX.Element;
|
|
42
|
+
/** The page's template. Also tells the provider which template this route is. */
|
|
43
|
+
export declare function EditorTemplateRegion({ name, children, }: {
|
|
44
|
+
name: string;
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
}): ReactNode;
|
|
47
|
+
/** A section group (header-group, footer-group). */
|
|
48
|
+
export declare function EditorGroupRegion({ name, children, }: {
|
|
49
|
+
name: string;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
}): ReactNode;
|
|
52
|
+
/** True inside a trusted editor frame, after hydration. */
|
|
53
|
+
export declare function useEditorPreview(): boolean;
|
|
54
|
+
/** The editor's selected `data-z-path`, so a component can reveal it. */
|
|
55
|
+
export declare function useEditorSelection(): string | null;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Editor preview for storefronts that render templates on the server (React
|
|
5
|
+
* Server Components) instead of through the client theme engine.
|
|
6
|
+
*
|
|
7
|
+
* The app supplies two server functions — one returning its bootstrap, one
|
|
8
|
+
* rendering a draft template to React nodes — and wraps each editable region.
|
|
9
|
+
* Everything else (trust, bridge, design mode, selection, responsive-copy
|
|
10
|
+
* paths, error surface) is here, so a storefront repo never writes it again.
|
|
11
|
+
*
|
|
12
|
+
* <EditorPreviewProvider getBootstrap={…} renderTemplate={…}>
|
|
13
|
+
* <EditorGroupRegion name="header-group">…</EditorGroupRegion>
|
|
14
|
+
* <EditorTemplateRegion name="page.about">…</EditorTemplateRegion>
|
|
15
|
+
* </EditorPreviewProvider>
|
|
16
|
+
*/
|
|
17
|
+
import { createContext, useContext, useEffect, useRef, useState, } from "react";
|
|
18
|
+
import { flushSync } from "react-dom";
|
|
19
|
+
import { enterDesignMode } from "./design-mode";
|
|
20
|
+
import { resolveEditorOrigin, ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
|
|
21
|
+
const PreviewContext = createContext(null);
|
|
22
|
+
const CONNECT_TIMEOUT_MS = 30_000;
|
|
23
|
+
const GUARDED_EVENTS = ["click", "auxclick", "dblclick", "submit"];
|
|
24
|
+
async function withDeadline(request, ms) {
|
|
25
|
+
let timer;
|
|
26
|
+
try {
|
|
27
|
+
return await Promise.race([
|
|
28
|
+
request,
|
|
29
|
+
new Promise((_, reject) => {
|
|
30
|
+
timer = setTimeout(() => reject(new Error("Preview update timed out. Retry the edit.")), ms);
|
|
31
|
+
}),
|
|
32
|
+
]);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** No bootstrap payload or bridge code is downloaded by ordinary visitors. */
|
|
39
|
+
export function EditorPreviewProvider({ children, getBootstrap, renderTemplate, applySettings, origins = ZALIFY_EDITOR_ORIGINS, renderTimeoutMs = 20_000, }) {
|
|
40
|
+
const [editorOrigin, setEditorOrigin] = useState(null);
|
|
41
|
+
const [selectedPath, setSelectedPath] = useState(null);
|
|
42
|
+
const [templateName, setTemplateName] = useState(null);
|
|
43
|
+
const [regions, setRegions] = useState({});
|
|
44
|
+
const [error, setError] = useState(null);
|
|
45
|
+
const [retry, setRetry] = useState(0);
|
|
46
|
+
const controllerRef = useRef(null);
|
|
47
|
+
const sequenceRef = useRef(0);
|
|
48
|
+
// Server functions and callbacks may be new identities on every render;
|
|
49
|
+
// they must not reconnect the bridge.
|
|
50
|
+
const handlers = useRef({ getBootstrap, renderTemplate, applySettings });
|
|
51
|
+
handlers.current = { getBootstrap, renderTemplate, applySettings };
|
|
52
|
+
const enabled = editorOrigin !== null;
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
setEditorOrigin(resolveEditorOrigin(origins));
|
|
55
|
+
}, [origins]);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!enabled)
|
|
58
|
+
return;
|
|
59
|
+
const guard = (event) => {
|
|
60
|
+
if (event.type === "click" &&
|
|
61
|
+
event.target instanceof Element &&
|
|
62
|
+
event.target.closest("[data-z-editor-retry]")) {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
event.stopImmediatePropagation();
|
|
65
|
+
setRetry((value) => value + 1);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// The bridge owns clicks once connected. Until then nothing in the shop
|
|
69
|
+
// may run; and a preview never submits a real lead, order or login.
|
|
70
|
+
if (event.type === "submit" || !controllerRef.current) {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
event.stopImmediatePropagation();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
for (const type of GUARDED_EVENTS)
|
|
76
|
+
document.addEventListener(type, guard, true);
|
|
77
|
+
return () => {
|
|
78
|
+
for (const type of GUARDED_EVENTS)
|
|
79
|
+
document.removeEventListener(type, guard, true);
|
|
80
|
+
};
|
|
81
|
+
}, [enabled]);
|
|
82
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: retry intentionally tears down and reconnects the bridge.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!editorOrigin || !templateName)
|
|
85
|
+
return;
|
|
86
|
+
let disposed = false;
|
|
87
|
+
let unmountObservers = () => { };
|
|
88
|
+
const leaveDesignMode = enterDesignMode();
|
|
89
|
+
setError(null);
|
|
90
|
+
const timeout = window.setTimeout(() => setError("Editor connection timed out. Retry the preview connection."), CONNECT_TIMEOUT_MS);
|
|
91
|
+
void (async () => {
|
|
92
|
+
const [{ mountFrameBridge }, selection, visiblePaths, bootstrap] = await Promise.all([
|
|
93
|
+
import("../editor/frame"),
|
|
94
|
+
import("../editor/selection"),
|
|
95
|
+
import("../editor/visible-paths"),
|
|
96
|
+
handlers.current.getBootstrap(),
|
|
97
|
+
]);
|
|
98
|
+
if (disposed)
|
|
99
|
+
return;
|
|
100
|
+
// Both must listen before the bridge does: selection so a component can
|
|
101
|
+
// reveal the selected tab before it is measured, visible paths so a
|
|
102
|
+
// hidden responsive copy never shadows the visible one.
|
|
103
|
+
const unmountSelection = selection.mountEditorSelection(window, (path) => flushSync(() => setSelectedPath(path)));
|
|
104
|
+
const unmountVisiblePaths = visiblePaths.mountVisiblePaths(window, () => controllerRef.current?.reportRects());
|
|
105
|
+
unmountObservers = () => {
|
|
106
|
+
unmountVisiblePaths();
|
|
107
|
+
unmountSelection();
|
|
108
|
+
};
|
|
109
|
+
window.clearTimeout(timeout);
|
|
110
|
+
setError(null);
|
|
111
|
+
controllerRef.current = mountFrameBridge({
|
|
112
|
+
editorOrigin,
|
|
113
|
+
templateName,
|
|
114
|
+
hash: bootstrap.manifest.hash,
|
|
115
|
+
capabilities: [
|
|
116
|
+
"editor-bootstrap-v1",
|
|
117
|
+
"apply-template-v1",
|
|
118
|
+
"apply-groups-v1",
|
|
119
|
+
"apply-settings-v1",
|
|
120
|
+
"preview-navigation-v1",
|
|
121
|
+
],
|
|
122
|
+
getManifest: () => bootstrap.manifest,
|
|
123
|
+
getBootstrap: () => bootstrap,
|
|
124
|
+
applyTemplate: async (payload) => {
|
|
125
|
+
if (payload.templateName !== templateName)
|
|
126
|
+
return false;
|
|
127
|
+
const sequence = ++sequenceRef.current;
|
|
128
|
+
try {
|
|
129
|
+
const content = await withDeadline(handlers.current.renderTemplate({
|
|
130
|
+
templateName,
|
|
131
|
+
template: payload.template,
|
|
132
|
+
groups: payload.groups,
|
|
133
|
+
pathname: window.location.pathname + window.location.search,
|
|
134
|
+
}), renderTimeoutMs);
|
|
135
|
+
// A newer edit is already rendering: this result is stale.
|
|
136
|
+
if (disposed || sequence !== sequenceRef.current)
|
|
137
|
+
return true;
|
|
138
|
+
setRegions((current) => ({ ...current, ...content }));
|
|
139
|
+
if (payload.settingsData)
|
|
140
|
+
handlers.current.applySettings?.(payload.settingsData, document.documentElement);
|
|
141
|
+
setError(null);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch (cause) {
|
|
145
|
+
if (!disposed && sequence === sequenceRef.current)
|
|
146
|
+
setError(cause instanceof Error
|
|
147
|
+
? cause.message
|
|
148
|
+
: "Preview could not update");
|
|
149
|
+
throw cause;
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
})().catch((cause) => {
|
|
154
|
+
window.clearTimeout(timeout);
|
|
155
|
+
if (!disposed)
|
|
156
|
+
setError(cause instanceof Error ? cause.message : "Editor connection failed");
|
|
157
|
+
});
|
|
158
|
+
return () => {
|
|
159
|
+
disposed = true;
|
|
160
|
+
sequenceRef.current++;
|
|
161
|
+
window.clearTimeout(timeout);
|
|
162
|
+
controllerRef.current?.unmount();
|
|
163
|
+
controllerRef.current = null;
|
|
164
|
+
unmountObservers();
|
|
165
|
+
leaveDesignMode();
|
|
166
|
+
};
|
|
167
|
+
}, [editorOrigin, templateName, retry, renderTimeoutMs]);
|
|
168
|
+
// Measure after each committed region replacement.
|
|
169
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: regions is the trigger.
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
controllerRef.current?.reportRects();
|
|
172
|
+
}, [regions]);
|
|
173
|
+
const register = (name) => {
|
|
174
|
+
setTemplateName(name);
|
|
175
|
+
return () => setTemplateName((current) => (current === name ? null : current));
|
|
176
|
+
};
|
|
177
|
+
return (_jsxs(PreviewContext.Provider, { value: { enabled, selectedPath, regions, register }, children: [children, enabled && error ? (_jsxs("div", { role: "alert", style: {
|
|
178
|
+
position: "fixed",
|
|
179
|
+
left: 16,
|
|
180
|
+
right: 16,
|
|
181
|
+
bottom: 16,
|
|
182
|
+
zIndex: 2147483647,
|
|
183
|
+
padding: 16,
|
|
184
|
+
background: "#fff1f0",
|
|
185
|
+
color: "#8b1b16",
|
|
186
|
+
border: "1px solid #c63225",
|
|
187
|
+
}, children: [error, " ", _jsx("button", { type: "button", "data-z-editor-retry": true, onClick: () => setRetry((value) => value + 1), children: "Retry" })] })) : null] }));
|
|
188
|
+
}
|
|
189
|
+
function useRegion(key, children) {
|
|
190
|
+
const context = useContext(PreviewContext);
|
|
191
|
+
return context?.enabled && Object.hasOwn(context.regions, key)
|
|
192
|
+
? context.regions[key]
|
|
193
|
+
: children;
|
|
194
|
+
}
|
|
195
|
+
/** The page's template. Also tells the provider which template this route is. */
|
|
196
|
+
export function EditorTemplateRegion({ name, children, }) {
|
|
197
|
+
const context = useContext(PreviewContext);
|
|
198
|
+
// Registration follows route identity, not every update of preview state.
|
|
199
|
+
const registerRef = useRef(context?.register);
|
|
200
|
+
registerRef.current = context?.register;
|
|
201
|
+
useEffect(() => registerRef.current?.(name), [name]);
|
|
202
|
+
return useRegion(`template:${name}`, children);
|
|
203
|
+
}
|
|
204
|
+
/** A section group (header-group, footer-group). */
|
|
205
|
+
export function EditorGroupRegion({ name, children, }) {
|
|
206
|
+
return useRegion(`group:${name}`, children);
|
|
207
|
+
}
|
|
208
|
+
/** True inside a trusted editor frame, after hydration. */
|
|
209
|
+
export function useEditorPreview() {
|
|
210
|
+
return useContext(PreviewContext)?.enabled ?? false;
|
|
211
|
+
}
|
|
212
|
+
/** The editor's selected `data-z-path`, so a component can reveal it. */
|
|
213
|
+
export function useEditorSelection() {
|
|
214
|
+
const context = useContext(PreviewContext);
|
|
215
|
+
return context?.enabled ? context.selectedPath : null;
|
|
216
|
+
}
|
|
@@ -2,8 +2,8 @@ import type { TemplateData } from "../schemas/data";
|
|
|
2
2
|
import type { ThemeEditorManifest } from "../schemas/manifest";
|
|
3
3
|
import type { PreviewContext } from "../schemas/bridge";
|
|
4
4
|
import type { EditorDocuments } from "../editor/bootstrap";
|
|
5
|
-
|
|
6
|
-
export
|
|
5
|
+
import { ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
|
|
6
|
+
export { ZALIFY_EDITOR_ORIGINS };
|
|
7
7
|
type Options = {
|
|
8
8
|
/** Defaults to {@link ZALIFY_EDITOR_ORIGINS}. */
|
|
9
9
|
origins?: readonly string[];
|
|
@@ -15,4 +15,3 @@ type Options = {
|
|
|
15
15
|
};
|
|
16
16
|
/** No editor code or schema is fetched outside an explicitly allowed preview. */
|
|
17
17
|
export declare function useEditorTemplate(name: string, options: Options): TemplateData | null;
|
|
18
|
-
export {};
|
|
@@ -1,29 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import { getThemeStore, setThemePreview } from "./engine/store";
|
|
4
|
-
import { resolveEditorOrigin } from "./editor-origin";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"https://app.zalify.com",
|
|
8
|
-
"http://localhost:3000",
|
|
9
|
-
];
|
|
10
|
-
/** Cap for `--editor-viewport-height`: a tall editor pane is not a tall phone. */
|
|
11
|
-
const MAX_EDITOR_VIEWPORT_HEIGHT = 1000;
|
|
12
|
-
/**
|
|
13
|
-
* Design mode is the SDK's to declare, not each storefront's: the editor
|
|
14
|
-
* sizes the iframe to its content, so `100vh` inside it grows with every
|
|
15
|
-
* height report. Themes read `--editor-viewport-height` under
|
|
16
|
-
* `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
|
|
17
|
-
*/
|
|
18
|
-
function enterDesignMode() {
|
|
19
|
-
const root = document.documentElement;
|
|
20
|
-
root.style.setProperty("--editor-viewport-height", `${Math.min(window.innerHeight, MAX_EDITOR_VIEWPORT_HEIGHT)}px`);
|
|
21
|
-
root.setAttribute("data-z-design-mode", "1");
|
|
22
|
-
return () => {
|
|
23
|
-
root.removeAttribute("data-z-design-mode");
|
|
24
|
-
root.style.removeProperty("--editor-viewport-height");
|
|
25
|
-
};
|
|
26
|
-
}
|
|
4
|
+
import { resolveEditorOrigin, ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
|
|
5
|
+
export { ZALIFY_EDITOR_ORIGINS };
|
|
6
|
+
import { enterDesignMode } from "./design-mode";
|
|
27
7
|
/** No editor code or schema is fetched outside an explicitly allowed preview. */
|
|
28
8
|
export function useEditorTemplate(name, options) {
|
|
29
9
|
const [draft, setDraft] = useState(null);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalify/storefront-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Zalify storefront SDK: framework-agnostic commerce logic (/commerce), the theme contract types and validators (/schemas), the canvas-editor bridge (/editor), and the React theme engine + shared components (/ui, /react/server). Consumed as TypeScript source inside the zalify-storefronts monorepo; published as compiled ESM + d.ts.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
"types": "./dist/editor/index.d.ts",
|
|
19
19
|
"default": "./dist/editor/index.js"
|
|
20
20
|
},
|
|
21
|
+
"./editor/bootstrap": {
|
|
22
|
+
"types": "./dist/editor/bootstrap.d.ts",
|
|
23
|
+
"default": "./dist/editor/bootstrap.js"
|
|
24
|
+
},
|
|
21
25
|
"./editor/frame": {
|
|
22
26
|
"types": "./dist/editor/frame.d.ts",
|
|
23
27
|
"default": "./dist/editor/frame.js"
|
|
@@ -26,10 +30,18 @@
|
|
|
26
30
|
"types": "./dist/editor/host.d.ts",
|
|
27
31
|
"default": "./dist/editor/host.js"
|
|
28
32
|
},
|
|
33
|
+
"./editor/selection": {
|
|
34
|
+
"types": "./dist/editor/selection.d.ts",
|
|
35
|
+
"default": "./dist/editor/selection.js"
|
|
36
|
+
},
|
|
29
37
|
"./react": {
|
|
30
38
|
"types": "./dist/react/index.d.ts",
|
|
31
39
|
"default": "./dist/react/index.js"
|
|
32
40
|
},
|
|
41
|
+
"./react/preview": {
|
|
42
|
+
"types": "./dist/react/server-preview.d.ts",
|
|
43
|
+
"default": "./dist/react/server-preview.js"
|
|
44
|
+
},
|
|
33
45
|
"./react/server": {
|
|
34
46
|
"types": "./dist/react/server.d.ts",
|
|
35
47
|
"default": "./dist/react/server.js"
|
|
@@ -49,17 +61,22 @@
|
|
|
49
61
|
"access": "public"
|
|
50
62
|
},
|
|
51
63
|
"peerDependencies": {
|
|
52
|
-
"react": ">=18"
|
|
64
|
+
"react": ">=18",
|
|
65
|
+
"react-dom": ">=18"
|
|
53
66
|
},
|
|
54
67
|
"peerDependenciesMeta": {
|
|
55
68
|
"react": {
|
|
56
69
|
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"react-dom": {
|
|
72
|
+
"optional": true
|
|
57
73
|
}
|
|
58
74
|
},
|
|
59
75
|
"devDependencies": {
|
|
60
76
|
"@types/node": "^24.0.0",
|
|
61
77
|
"@types/react": "^18.3.28",
|
|
62
|
-
"typescript": "^5.9.2"
|
|
78
|
+
"typescript": "^5.9.2",
|
|
79
|
+
"@types/react-dom": "^18.3.7"
|
|
63
80
|
},
|
|
64
81
|
"repository": {
|
|
65
82
|
"type": "git",
|
|
@@ -23,6 +23,18 @@ is SDK code and must be imported, never re-implemented:
|
|
|
23
23
|
| Allowed editor origins | `ZALIFY_EDITOR_ORIGINS` (default) | a copied origin list |
|
|
24
24
|
| Design-mode flag + `--editor-viewport-height` | set by `useEditorTemplate` | a per-repo effect that sets them |
|
|
25
25
|
| Draft application | `createEditorDraft` | mutating template JSON in components |
|
|
26
|
+
| Preview for server-rendered (RSC) storefronts | `EditorPreviewProvider`, `EditorTemplateRegion`, `EditorGroupRegion`, `useEditorPreview`, `useEditorSelection` from `@zalify/storefront-kit/react/preview` | a local provider, selection listener or visible-path observer |
|
|
27
|
+
|
|
28
|
+
Two integration shapes exist, pick by how the storefront renders:
|
|
29
|
+
|
|
30
|
+
- **Client theme engine** (`installTheme` + `ThemeTemplate`): call
|
|
31
|
+
`useEditorTemplate(name, {paths, previews, loadManifest})`.
|
|
32
|
+
- **Server-rendered templates** (RSC): wrap the layout in
|
|
33
|
+
`<EditorPreviewProvider getBootstrap={…} renderTemplate={…}>`, where both
|
|
34
|
+
props are server functions of the app — `getBootstrap` returns
|
|
35
|
+
`createEditorBootstrap(...)`, `renderTemplate` renders a draft read-only —
|
|
36
|
+
and wrap each page in `<EditorTemplateRegion name="…">`, each header/footer
|
|
37
|
+
in `<EditorGroupRegion name="…">`.
|
|
26
38
|
|
|
27
39
|
If the kit cannot express what the storefront needs, the fix is a kit change
|
|
28
40
|
(open a PR on `zalify/zalify-theme-2026`, bump the version), not a local
|
|
@@ -38,8 +50,11 @@ and from the editor.
|
|
|
38
50
|
`product-drawing-projector` or `luka-features` is an unknown type: the editor
|
|
39
51
|
cannot group it, icon it, or offer an entity picker for it. Landing pages are
|
|
40
52
|
`page.<suffix>`.
|
|
41
|
-
-
|
|
42
|
-
`
|
|
53
|
+
- Assert `templateNameProblems(names)` (from
|
|
54
|
+
`@zalify/storefront-kit/editor/bootstrap`) is empty in the repo's tests.
|
|
55
|
+
- Register a type's default (`page`) only when the app has a route that renders
|
|
56
|
+
it. Alternates without a default are fine: the editor groups them under the
|
|
57
|
+
type. Never add a template just to satisfy the picker.
|
|
43
58
|
- `writePath` is `<templates dir>/<name>.json`; the file on disk has the same
|
|
44
59
|
name. Renaming a template renames the file and every route that loads it.
|
|
45
60
|
- Only advertise a preview route the app can really render. Resource routes
|
package/src/editor/bootstrap.ts
CHANGED
|
@@ -8,6 +8,43 @@ export interface EditorDocuments {
|
|
|
8
8
|
settings: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Template types the editor can group, icon and offer an entity picker for. */
|
|
12
|
+
export const TEMPLATE_TYPES = [
|
|
13
|
+
'index',
|
|
14
|
+
'product',
|
|
15
|
+
'collection',
|
|
16
|
+
'list-collections',
|
|
17
|
+
'page',
|
|
18
|
+
'blog',
|
|
19
|
+
'article',
|
|
20
|
+
'cart',
|
|
21
|
+
'search',
|
|
22
|
+
'404',
|
|
23
|
+
'password',
|
|
24
|
+
'gift_card',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What is wrong with a set of template names, for a storefront's own tests:
|
|
29
|
+
* names must be `<type>` or `<type>.<suffix>` with a known type. A default is
|
|
30
|
+
* not required next to alternates — a site with `page.about` but no generic
|
|
31
|
+
* page route must not invent one; the editor groups such alternates itself.
|
|
32
|
+
* Empty means the editor can lay them out.
|
|
33
|
+
*/
|
|
34
|
+
export function templateNameProblems(names: readonly string[]): string[] {
|
|
35
|
+
const problems: string[] = [];
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
if (name.startsWith('customers/')) continue;
|
|
38
|
+
const type = name.split('.')[0];
|
|
39
|
+
if (!(TEMPLATE_TYPES as readonly string[]).includes(type)) {
|
|
40
|
+
problems.push(
|
|
41
|
+
`"${name}": unknown template type "${type}" — use <type>.<suffix>, e.g. "page.${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}"`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return problems;
|
|
46
|
+
}
|
|
47
|
+
|
|
11
48
|
/** Only list routes the app can actually render; never fabricate handles. */
|
|
12
49
|
export function createEditorBootstrap(options: {
|
|
13
50
|
schema: DraftSchema;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BRIDGE_NAMESPACE,
|
|
3
|
+
CONTRACT_VERSION,
|
|
4
|
+
isCompatibleVersion,
|
|
5
|
+
} from "../schemas/index.ts";
|
|
6
|
+
|
|
7
|
+
/** Observe the same authenticated parent selection as the SDK. Components can
|
|
8
|
+
* reveal the selected tab/menu before the SDK measures its DOM node. */
|
|
9
|
+
export function mountEditorSelection(
|
|
10
|
+
win: Window,
|
|
11
|
+
onSelect: (path: string | null) => void,
|
|
12
|
+
) {
|
|
13
|
+
let origin: string | undefined;
|
|
14
|
+
const listener = (event: MessageEvent) => {
|
|
15
|
+
if (event.source !== win.parent) return;
|
|
16
|
+
const data = event.data;
|
|
17
|
+
if (
|
|
18
|
+
!data ||
|
|
19
|
+
data.z !== BRIDGE_NAMESPACE ||
|
|
20
|
+
typeof data.v !== "string" ||
|
|
21
|
+
!isCompatibleVersion(CONTRACT_VERSION, data.v) ||
|
|
22
|
+
!data.payload
|
|
23
|
+
)
|
|
24
|
+
return;
|
|
25
|
+
if (
|
|
26
|
+
!origin &&
|
|
27
|
+
data.type === "bridge:init" &&
|
|
28
|
+
event.origin === data.payload.editorOrigin
|
|
29
|
+
)
|
|
30
|
+
origin = event.origin;
|
|
31
|
+
if (!origin || event.origin !== origin) return;
|
|
32
|
+
const path =
|
|
33
|
+
data.type === "bridge:init"
|
|
34
|
+
? data.payload.selectedPath
|
|
35
|
+
: data.type === "block:select"
|
|
36
|
+
? data.payload.path
|
|
37
|
+
: undefined;
|
|
38
|
+
if (path === null || typeof path === "string") onSelect(path);
|
|
39
|
+
};
|
|
40
|
+
const click = (event: Event) => {
|
|
41
|
+
const target = event.target as Element | null;
|
|
42
|
+
const path =
|
|
43
|
+
target?.closest?.("[data-z-path]")?.getAttribute("data-z-path") ?? null;
|
|
44
|
+
if (path) onSelect(path);
|
|
45
|
+
};
|
|
46
|
+
win.addEventListener("message", listener);
|
|
47
|
+
win.document.addEventListener("click", click, true);
|
|
48
|
+
return () => {
|
|
49
|
+
win.removeEventListener("message", listener);
|
|
50
|
+
win.document.removeEventListener("click", click, true);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const PATH = "data-z-path";
|
|
2
|
+
const HIDDEN_PATH = "data-z-editor-hidden-path";
|
|
3
|
+
|
|
4
|
+
/** The SDK selects the first matching path. Responsive copies must not let a
|
|
5
|
+
* hidden desktop node shadow its visible mobile counterpart (or vice versa). */
|
|
6
|
+
export function syncVisiblePaths(doc: Document) {
|
|
7
|
+
for (const node of doc.querySelectorAll(`[${PATH}], [${HIDDEN_PATH}]`)) {
|
|
8
|
+
const path = node.getAttribute(PATH) ?? node.getAttribute(HIDDEN_PATH);
|
|
9
|
+
if (!path) continue;
|
|
10
|
+
// display:contents has no own box; a visible child still makes it selectable.
|
|
11
|
+
const visible =
|
|
12
|
+
node.getClientRects().length > 0 ||
|
|
13
|
+
Array.from(node.children).some(
|
|
14
|
+
(child) => child.getClientRects().length > 0,
|
|
15
|
+
);
|
|
16
|
+
if (visible) {
|
|
17
|
+
if (node.getAttribute(PATH) !== path) node.setAttribute(PATH, path);
|
|
18
|
+
if (node.getAttribute(HIDDEN_PATH) !== null)
|
|
19
|
+
node.removeAttribute(HIDDEN_PATH);
|
|
20
|
+
} else {
|
|
21
|
+
if (node.getAttribute(HIDDEN_PATH) !== path)
|
|
22
|
+
node.setAttribute(HIDDEN_PATH, path);
|
|
23
|
+
if (node.getAttribute(PATH) !== null) node.removeAttribute(PATH);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function restoreHiddenPaths(doc: Document) {
|
|
29
|
+
for (const node of doc.querySelectorAll(`[${HIDDEN_PATH}]`)) {
|
|
30
|
+
const path = node.getAttribute(HIDDEN_PATH);
|
|
31
|
+
if (path && !node.hasAttribute(PATH)) node.setAttribute(PATH, path);
|
|
32
|
+
node.removeAttribute(HIDDEN_PATH);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mounted only in the editor, before the SDK's message/resize listeners. */
|
|
37
|
+
export function mountVisiblePaths(win: Window, onChange: () => void) {
|
|
38
|
+
let frame = 0;
|
|
39
|
+
const sync = () => syncVisiblePaths(win.document);
|
|
40
|
+
const schedule = () => {
|
|
41
|
+
if (frame) return;
|
|
42
|
+
frame = win.requestAnimationFrame(() => {
|
|
43
|
+
frame = 0;
|
|
44
|
+
sync();
|
|
45
|
+
onChange();
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
// Transform/opacity animations do not change which responsive copy has a box.
|
|
49
|
+
// Scanning every path for each animation frame makes long previews expensive.
|
|
50
|
+
const boxStyle = (style: string | null) =>
|
|
51
|
+
["display", "content-visibility"]
|
|
52
|
+
.map(
|
|
53
|
+
(property) =>
|
|
54
|
+
new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i")
|
|
55
|
+
.exec(style ?? "")?.[1]
|
|
56
|
+
.trim() ?? "",
|
|
57
|
+
)
|
|
58
|
+
.join("|");
|
|
59
|
+
const observer = new MutationObserver((records) => {
|
|
60
|
+
if (
|
|
61
|
+
records.some((record) => {
|
|
62
|
+
const target = record.target as Element;
|
|
63
|
+
if (target.id === "zalify-editor-highlight") return false;
|
|
64
|
+
if (record.type === "attributes" && record.attributeName === "style")
|
|
65
|
+
return (
|
|
66
|
+
boxStyle(record.oldValue) !== boxStyle(target.getAttribute("style"))
|
|
67
|
+
);
|
|
68
|
+
return true;
|
|
69
|
+
})
|
|
70
|
+
)
|
|
71
|
+
schedule();
|
|
72
|
+
});
|
|
73
|
+
observer.observe(win.document.documentElement, {
|
|
74
|
+
subtree: true,
|
|
75
|
+
childList: true,
|
|
76
|
+
attributes: true,
|
|
77
|
+
attributeOldValue: true,
|
|
78
|
+
attributeFilter: [PATH, "class", "style", "hidden"],
|
|
79
|
+
});
|
|
80
|
+
const onMessage = (event: MessageEvent) => {
|
|
81
|
+
if (
|
|
82
|
+
event.data?.z === "zalify-editor-bridge" &&
|
|
83
|
+
["bridge:init", "block:select", "device:set", "template:apply"].includes(
|
|
84
|
+
event.data.type,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
sync();
|
|
88
|
+
};
|
|
89
|
+
win.addEventListener("resize", sync);
|
|
90
|
+
win.addEventListener("message", onMessage);
|
|
91
|
+
sync();
|
|
92
|
+
return () => {
|
|
93
|
+
observer.disconnect();
|
|
94
|
+
win.cancelAnimationFrame(frame);
|
|
95
|
+
win.removeEventListener("resize", sync);
|
|
96
|
+
win.removeEventListener("message", onMessage);
|
|
97
|
+
restoreHiddenPaths(win.document);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Cap for `--editor-viewport-height`: a tall editor pane is not a tall phone. */
|
|
2
|
+
const MAX_EDITOR_VIEWPORT_HEIGHT = 1000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Design mode is the SDK's to declare, not each storefront's: the editor
|
|
6
|
+
* sizes the iframe to its content, so `100vh` inside it grows with every
|
|
7
|
+
* height report. Themes read `--editor-viewport-height` under
|
|
8
|
+
* `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
|
|
9
|
+
*/
|
|
10
|
+
export function enterDesignMode(): () => void {
|
|
11
|
+
const root = document.documentElement;
|
|
12
|
+
root.style.setProperty(
|
|
13
|
+
"--editor-viewport-height",
|
|
14
|
+
`${Math.min(window.innerHeight, MAX_EDITOR_VIEWPORT_HEIGHT)}px`,
|
|
15
|
+
);
|
|
16
|
+
root.setAttribute("data-z-design-mode", "1");
|
|
17
|
+
return () => {
|
|
18
|
+
root.removeAttribute("data-z-design-mode");
|
|
19
|
+
root.style.removeProperty("--editor-viewport-height");
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
const STORAGE_KEY = "zalify-editor-origin";
|
|
12
12
|
|
|
13
|
+
/** Editors allowed to frame a storefront unless the app narrows the list. */
|
|
14
|
+
export const ZALIFY_EDITOR_ORIGINS: readonly string[] = [
|
|
15
|
+
"https://app.zalify.com",
|
|
16
|
+
"http://localhost:3000",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
|
|
13
20
|
export function resolveEditorOrigin(
|
|
14
21
|
origins: readonly string[],
|
|
15
22
|
win: Window = window,
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* Editor preview for storefronts that render templates on the server (React
|
|
4
|
+
* Server Components) instead of through the client theme engine.
|
|
5
|
+
*
|
|
6
|
+
* The app supplies two server functions — one returning its bootstrap, one
|
|
7
|
+
* rendering a draft template to React nodes — and wraps each editable region.
|
|
8
|
+
* Everything else (trust, bridge, design mode, selection, responsive-copy
|
|
9
|
+
* paths, error surface) is here, so a storefront repo never writes it again.
|
|
10
|
+
*
|
|
11
|
+
* <EditorPreviewProvider getBootstrap={…} renderTemplate={…}>
|
|
12
|
+
* <EditorGroupRegion name="header-group">…</EditorGroupRegion>
|
|
13
|
+
* <EditorTemplateRegion name="page.about">…</EditorTemplateRegion>
|
|
14
|
+
* </EditorPreviewProvider>
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
createContext,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
useContext,
|
|
20
|
+
useEffect,
|
|
21
|
+
useRef,
|
|
22
|
+
useState,
|
|
23
|
+
} from "react";
|
|
24
|
+
import { flushSync } from "react-dom";
|
|
25
|
+
import type { FrameBridgeController } from "../editor/frame";
|
|
26
|
+
import type { EditorBootstrap } from "../schemas/bridge";
|
|
27
|
+
import type {
|
|
28
|
+
SectionGroupData,
|
|
29
|
+
SettingsData,
|
|
30
|
+
TemplateData,
|
|
31
|
+
} from "../schemas/data";
|
|
32
|
+
import { enterDesignMode } from "./design-mode";
|
|
33
|
+
import { resolveEditorOrigin, ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
|
|
34
|
+
|
|
35
|
+
export interface RenderTemplateInput {
|
|
36
|
+
templateName: string;
|
|
37
|
+
template: TemplateData;
|
|
38
|
+
groups?: Record<string, SectionGroupData>;
|
|
39
|
+
/** Path + query of the page being previewed, without the editor param. */
|
|
40
|
+
pathname: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Keys are `template:<name>` and `group:<name>`. */
|
|
44
|
+
export type RenderedRegions = Record<string, ReactNode>;
|
|
45
|
+
|
|
46
|
+
export interface EditorPreviewProviderProps {
|
|
47
|
+
children: ReactNode;
|
|
48
|
+
/** Server function: the app's bootstrap (see `createEditorBootstrap`). */
|
|
49
|
+
getBootstrap: () => Promise<EditorBootstrap>;
|
|
50
|
+
/** Server function: render a draft. Must be read-only. */
|
|
51
|
+
renderTemplate: (input: RenderTemplateInput) => Promise<RenderedRegions>;
|
|
52
|
+
/** Map draft theme settings onto the page (CSS variables, usually). */
|
|
53
|
+
applySettings?: (settings: SettingsData, root: HTMLElement) => void;
|
|
54
|
+
/** Defaults to {@link ZALIFY_EDITOR_ORIGINS}. */
|
|
55
|
+
origins?: readonly string[];
|
|
56
|
+
/** A render slower than this fails the edit instead of hanging it. */
|
|
57
|
+
renderTimeoutMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type PreviewContextValue = {
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
selectedPath: string | null;
|
|
63
|
+
regions: RenderedRegions;
|
|
64
|
+
register: (name: string) => () => void;
|
|
65
|
+
};
|
|
66
|
+
const PreviewContext = createContext<PreviewContextValue | null>(null);
|
|
67
|
+
|
|
68
|
+
const CONNECT_TIMEOUT_MS = 30_000;
|
|
69
|
+
const GUARDED_EVENTS = ["click", "auxclick", "dblclick", "submit"];
|
|
70
|
+
|
|
71
|
+
async function withDeadline<T>(request: Promise<T>, ms: number): Promise<T> {
|
|
72
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
73
|
+
try {
|
|
74
|
+
return await Promise.race([
|
|
75
|
+
request,
|
|
76
|
+
new Promise<never>((_, reject) => {
|
|
77
|
+
timer = setTimeout(
|
|
78
|
+
() => reject(new Error("Preview update timed out. Retry the edit.")),
|
|
79
|
+
ms,
|
|
80
|
+
);
|
|
81
|
+
}),
|
|
82
|
+
]);
|
|
83
|
+
} finally {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** No bootstrap payload or bridge code is downloaded by ordinary visitors. */
|
|
89
|
+
export function EditorPreviewProvider({
|
|
90
|
+
children,
|
|
91
|
+
getBootstrap,
|
|
92
|
+
renderTemplate,
|
|
93
|
+
applySettings,
|
|
94
|
+
origins = ZALIFY_EDITOR_ORIGINS,
|
|
95
|
+
renderTimeoutMs = 20_000,
|
|
96
|
+
}: EditorPreviewProviderProps) {
|
|
97
|
+
const [editorOrigin, setEditorOrigin] = useState<string | null>(null);
|
|
98
|
+
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
|
99
|
+
const [templateName, setTemplateName] = useState<string | null>(null);
|
|
100
|
+
const [regions, setRegions] = useState<RenderedRegions>({});
|
|
101
|
+
const [error, setError] = useState<string | null>(null);
|
|
102
|
+
const [retry, setRetry] = useState(0);
|
|
103
|
+
const controllerRef = useRef<FrameBridgeController | null>(null);
|
|
104
|
+
const sequenceRef = useRef(0);
|
|
105
|
+
// Server functions and callbacks may be new identities on every render;
|
|
106
|
+
// they must not reconnect the bridge.
|
|
107
|
+
const handlers = useRef({ getBootstrap, renderTemplate, applySettings });
|
|
108
|
+
handlers.current = { getBootstrap, renderTemplate, applySettings };
|
|
109
|
+
const enabled = editorOrigin !== null;
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
setEditorOrigin(resolveEditorOrigin(origins));
|
|
113
|
+
}, [origins]);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!enabled) return;
|
|
117
|
+
const guard = (event: Event) => {
|
|
118
|
+
if (
|
|
119
|
+
event.type === "click" &&
|
|
120
|
+
event.target instanceof Element &&
|
|
121
|
+
event.target.closest("[data-z-editor-retry]")
|
|
122
|
+
) {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
event.stopImmediatePropagation();
|
|
125
|
+
setRetry((value) => value + 1);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// The bridge owns clicks once connected. Until then nothing in the shop
|
|
129
|
+
// may run; and a preview never submits a real lead, order or login.
|
|
130
|
+
if (event.type === "submit" || !controllerRef.current) {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
event.stopImmediatePropagation();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
for (const type of GUARDED_EVENTS)
|
|
136
|
+
document.addEventListener(type, guard, true);
|
|
137
|
+
return () => {
|
|
138
|
+
for (const type of GUARDED_EVENTS)
|
|
139
|
+
document.removeEventListener(type, guard, true);
|
|
140
|
+
};
|
|
141
|
+
}, [enabled]);
|
|
142
|
+
|
|
143
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: retry intentionally tears down and reconnects the bridge.
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!editorOrigin || !templateName) return;
|
|
146
|
+
let disposed = false;
|
|
147
|
+
let unmountObservers = () => {};
|
|
148
|
+
const leaveDesignMode = enterDesignMode();
|
|
149
|
+
setError(null);
|
|
150
|
+
const timeout = window.setTimeout(
|
|
151
|
+
() =>
|
|
152
|
+
setError("Editor connection timed out. Retry the preview connection."),
|
|
153
|
+
CONNECT_TIMEOUT_MS,
|
|
154
|
+
);
|
|
155
|
+
void (async () => {
|
|
156
|
+
const [{ mountFrameBridge }, selection, visiblePaths, bootstrap] =
|
|
157
|
+
await Promise.all([
|
|
158
|
+
import("../editor/frame"),
|
|
159
|
+
import("../editor/selection"),
|
|
160
|
+
import("../editor/visible-paths"),
|
|
161
|
+
handlers.current.getBootstrap(),
|
|
162
|
+
]);
|
|
163
|
+
if (disposed) return;
|
|
164
|
+
// Both must listen before the bridge does: selection so a component can
|
|
165
|
+
// reveal the selected tab before it is measured, visible paths so a
|
|
166
|
+
// hidden responsive copy never shadows the visible one.
|
|
167
|
+
const unmountSelection = selection.mountEditorSelection(window, (path) =>
|
|
168
|
+
flushSync(() => setSelectedPath(path)),
|
|
169
|
+
);
|
|
170
|
+
const unmountVisiblePaths = visiblePaths.mountVisiblePaths(window, () =>
|
|
171
|
+
controllerRef.current?.reportRects(),
|
|
172
|
+
);
|
|
173
|
+
unmountObservers = () => {
|
|
174
|
+
unmountVisiblePaths();
|
|
175
|
+
unmountSelection();
|
|
176
|
+
};
|
|
177
|
+
window.clearTimeout(timeout);
|
|
178
|
+
setError(null);
|
|
179
|
+
controllerRef.current = mountFrameBridge({
|
|
180
|
+
editorOrigin,
|
|
181
|
+
templateName,
|
|
182
|
+
hash: bootstrap.manifest.hash,
|
|
183
|
+
capabilities: [
|
|
184
|
+
"editor-bootstrap-v1",
|
|
185
|
+
"apply-template-v1",
|
|
186
|
+
"apply-groups-v1",
|
|
187
|
+
"apply-settings-v1",
|
|
188
|
+
"preview-navigation-v1",
|
|
189
|
+
],
|
|
190
|
+
getManifest: () => bootstrap.manifest,
|
|
191
|
+
getBootstrap: () => bootstrap,
|
|
192
|
+
applyTemplate: async (payload) => {
|
|
193
|
+
if (payload.templateName !== templateName) return false;
|
|
194
|
+
const sequence = ++sequenceRef.current;
|
|
195
|
+
try {
|
|
196
|
+
const content = await withDeadline(
|
|
197
|
+
handlers.current.renderTemplate({
|
|
198
|
+
templateName,
|
|
199
|
+
template: payload.template,
|
|
200
|
+
groups: payload.groups,
|
|
201
|
+
pathname: window.location.pathname + window.location.search,
|
|
202
|
+
}),
|
|
203
|
+
renderTimeoutMs,
|
|
204
|
+
);
|
|
205
|
+
// A newer edit is already rendering: this result is stale.
|
|
206
|
+
if (disposed || sequence !== sequenceRef.current) return true;
|
|
207
|
+
setRegions((current) => ({ ...current, ...content }));
|
|
208
|
+
if (payload.settingsData)
|
|
209
|
+
handlers.current.applySettings?.(
|
|
210
|
+
payload.settingsData,
|
|
211
|
+
document.documentElement,
|
|
212
|
+
);
|
|
213
|
+
setError(null);
|
|
214
|
+
return true;
|
|
215
|
+
} catch (cause) {
|
|
216
|
+
if (!disposed && sequence === sequenceRef.current)
|
|
217
|
+
setError(
|
|
218
|
+
cause instanceof Error
|
|
219
|
+
? cause.message
|
|
220
|
+
: "Preview could not update",
|
|
221
|
+
);
|
|
222
|
+
throw cause;
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
})().catch((cause: unknown) => {
|
|
227
|
+
window.clearTimeout(timeout);
|
|
228
|
+
if (!disposed)
|
|
229
|
+
setError(
|
|
230
|
+
cause instanceof Error ? cause.message : "Editor connection failed",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
return () => {
|
|
234
|
+
disposed = true;
|
|
235
|
+
sequenceRef.current++;
|
|
236
|
+
window.clearTimeout(timeout);
|
|
237
|
+
controllerRef.current?.unmount();
|
|
238
|
+
controllerRef.current = null;
|
|
239
|
+
unmountObservers();
|
|
240
|
+
leaveDesignMode();
|
|
241
|
+
};
|
|
242
|
+
}, [editorOrigin, templateName, retry, renderTimeoutMs]);
|
|
243
|
+
|
|
244
|
+
// Measure after each committed region replacement.
|
|
245
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: regions is the trigger.
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
controllerRef.current?.reportRects();
|
|
248
|
+
}, [regions]);
|
|
249
|
+
|
|
250
|
+
const register = (name: string) => {
|
|
251
|
+
setTemplateName(name);
|
|
252
|
+
return () =>
|
|
253
|
+
setTemplateName((current) => (current === name ? null : current));
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<PreviewContext.Provider
|
|
258
|
+
value={{ enabled, selectedPath, regions, register }}
|
|
259
|
+
>
|
|
260
|
+
{children}
|
|
261
|
+
{enabled && error ? (
|
|
262
|
+
<div
|
|
263
|
+
role="alert"
|
|
264
|
+
style={{
|
|
265
|
+
position: "fixed",
|
|
266
|
+
left: 16,
|
|
267
|
+
right: 16,
|
|
268
|
+
bottom: 16,
|
|
269
|
+
zIndex: 2147483647,
|
|
270
|
+
padding: 16,
|
|
271
|
+
background: "#fff1f0",
|
|
272
|
+
color: "#8b1b16",
|
|
273
|
+
border: "1px solid #c63225",
|
|
274
|
+
}}
|
|
275
|
+
>
|
|
276
|
+
{error}{" "}
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
data-z-editor-retry
|
|
280
|
+
onClick={() => setRetry((value) => value + 1)}
|
|
281
|
+
>
|
|
282
|
+
Retry
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
) : null}
|
|
286
|
+
</PreviewContext.Provider>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function useRegion(key: string, children: ReactNode): ReactNode {
|
|
291
|
+
const context = useContext(PreviewContext);
|
|
292
|
+
return context?.enabled && Object.hasOwn(context.regions, key)
|
|
293
|
+
? context.regions[key]
|
|
294
|
+
: children;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The page's template. Also tells the provider which template this route is. */
|
|
298
|
+
export function EditorTemplateRegion({
|
|
299
|
+
name,
|
|
300
|
+
children,
|
|
301
|
+
}: {
|
|
302
|
+
name: string;
|
|
303
|
+
children: ReactNode;
|
|
304
|
+
}) {
|
|
305
|
+
const context = useContext(PreviewContext);
|
|
306
|
+
// Registration follows route identity, not every update of preview state.
|
|
307
|
+
const registerRef = useRef(context?.register);
|
|
308
|
+
registerRef.current = context?.register;
|
|
309
|
+
useEffect(() => registerRef.current?.(name), [name]);
|
|
310
|
+
return useRegion(`template:${name}`, children);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A section group (header-group, footer-group). */
|
|
314
|
+
export function EditorGroupRegion({
|
|
315
|
+
name,
|
|
316
|
+
children,
|
|
317
|
+
}: {
|
|
318
|
+
name: string;
|
|
319
|
+
children: ReactNode;
|
|
320
|
+
}) {
|
|
321
|
+
return useRegion(`group:${name}`, children);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** True inside a trusted editor frame, after hydration. */
|
|
325
|
+
export function useEditorPreview(): boolean {
|
|
326
|
+
return useContext(PreviewContext)?.enabled ?? false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The editor's selected `data-z-path`, so a component can reveal it. */
|
|
330
|
+
export function useEditorSelection(): string | null {
|
|
331
|
+
const context = useContext(PreviewContext);
|
|
332
|
+
return context?.enabled ? context.selectedPath : null;
|
|
333
|
+
}
|
|
@@ -6,35 +6,10 @@ import type { FrameBridgeController } from "../editor/frame";
|
|
|
6
6
|
import type { PreviewContext } from "../schemas/bridge";
|
|
7
7
|
import type { EditorDocuments } from "../editor/bootstrap";
|
|
8
8
|
import { getThemeStore, setThemePreview } from "./engine/store";
|
|
9
|
-
import { resolveEditorOrigin } from "./editor-origin";
|
|
9
|
+
import { resolveEditorOrigin, ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"https://app.zalify.com",
|
|
14
|
-
"http://localhost:3000",
|
|
15
|
-
];
|
|
16
|
-
|
|
17
|
-
/** Cap for `--editor-viewport-height`: a tall editor pane is not a tall phone. */
|
|
18
|
-
const MAX_EDITOR_VIEWPORT_HEIGHT = 1000;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Design mode is the SDK's to declare, not each storefront's: the editor
|
|
22
|
-
* sizes the iframe to its content, so `100vh` inside it grows with every
|
|
23
|
-
* height report. Themes read `--editor-viewport-height` under
|
|
24
|
-
* `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
|
|
25
|
-
*/
|
|
26
|
-
function enterDesignMode(): () => void {
|
|
27
|
-
const root = document.documentElement;
|
|
28
|
-
root.style.setProperty(
|
|
29
|
-
"--editor-viewport-height",
|
|
30
|
-
`${Math.min(window.innerHeight, MAX_EDITOR_VIEWPORT_HEIGHT)}px`,
|
|
31
|
-
);
|
|
32
|
-
root.setAttribute("data-z-design-mode", "1");
|
|
33
|
-
return () => {
|
|
34
|
-
root.removeAttribute("data-z-design-mode");
|
|
35
|
-
root.style.removeProperty("--editor-viewport-height");
|
|
36
|
-
};
|
|
37
|
-
}
|
|
11
|
+
export { ZALIFY_EDITOR_ORIGINS };
|
|
12
|
+
import { enterDesignMode } from "./design-mode";
|
|
38
13
|
|
|
39
14
|
type Options = {
|
|
40
15
|
/** Defaults to {@link ZALIFY_EDITOR_ORIGINS}. */
|