@blox-cms/preview 0.0.0-stage → 0.1.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/README.md +86 -2
- package/package.json +8 -4
- package/src/PreviewOverlay.tsx +226 -0
- package/src/SectionControls.tsx +65 -0
- package/src/index.ts +3 -0
- package/src/protocol.ts +85 -0
package/README.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Blox CMS preview interactions 0.1.1
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared React controls and preview bridge protocol v4. This package contains no
|
|
4
|
+
CMS persistence, credentials, site templates or database code. The npm package
|
|
5
|
+
name is `@blox-cms/preview`; this release provides the preview integration,
|
|
6
|
+
not a complete CMS SDK or content delivery client.
|
|
7
|
+
|
|
8
|
+
## Integration
|
|
9
|
+
|
|
10
|
+
- Managed previews use `SectionToolbar` and `SectionInsertionBoundary` directly.
|
|
11
|
+
- Dedicated client previews mount `PreviewOverlay` as a **sibling** of the site's
|
|
12
|
+
content root. Keep the public renderer memoized; hover/geometry state belongs
|
|
13
|
+
only to the overlay. `src/next/LivePreviewClient.tsx` is the reference adapter.
|
|
14
|
+
- Pass the page's own section IDs/types, current selection and a document key.
|
|
15
|
+
Each rendered section must expose `data-blockforge-block-id`. Descendants of
|
|
16
|
+
compositions/shared references resolve to their owning page section. Child
|
|
17
|
+
editing remains in the existing sidebar; page actions never mutate a shared
|
|
18
|
+
source or guess which nested container to alter.
|
|
19
|
+
- Transport action intents, never content mutations. Check frame source, exact
|
|
20
|
+
origin, document ID, route path, action allowlist and section membership in the
|
|
21
|
+
host. The CMS owns persistence, selection and structural undo.
|
|
22
|
+
- In Tailwind v4, explicitly include this package's sources in the consuming
|
|
23
|
+
stylesheet: `@source "../node_modules/@blox-cms/preview/src";` (adjust the
|
|
24
|
+
relative path to the stylesheet). The monorepo already scans `packages`.
|
|
25
|
+
- Consumers need React 18/19 and a TS/TSX-capable bundler. No new database tables,
|
|
26
|
+
migrations, public-site scripts or server secrets are required.
|
|
27
|
+
|
|
28
|
+
## Controlled distribution
|
|
29
|
+
|
|
30
|
+
Install the published package in a client repository:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm install --save-exact @blox-cms/preview@0.1.1
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Import the controls from `@blox-cms/preview` or the protocol from
|
|
37
|
+
`@blox-cms/preview/protocol`. Consumers need a TS/TSX-capable bundler.
|
|
38
|
+
For local verification before publication, use `npm pack ./packages/preview`
|
|
39
|
+
and install the resulting artifact.
|
|
40
|
+
Do not copy and maintain separate control implementations. Release/pin a new
|
|
41
|
+
package version for future client upgrades. Publishing and deployment are
|
|
42
|
+
separate explicit release steps; changing the CMS does not update client builds.
|
|
43
|
+
|
|
44
|
+
### Content renderer capabilities (0.1.1)
|
|
45
|
+
|
|
46
|
+
The optional `contentCapabilities` list in the ready handshake describes the
|
|
47
|
+
**website renderer**, independently of overlay protocol v4. Advertise
|
|
48
|
+
`content-spacer-v1` only after both `SPACER` sections (`content.height`) and
|
|
49
|
+
rich-text `spacer` nodes (`attrs.height`) are implemented in public and preview
|
|
50
|
+
rendering, using the 4/8/12/16/20px presets. Updating this package alone does not
|
|
51
|
+
add a content renderer. The CMS gates both insertion locations until the client
|
|
52
|
+
advertises support; missing/legacy capabilities are treated as unsupported.
|
|
53
|
+
|
|
54
|
+
Deploy the CMS host first. v2/v3 clients retain the old subject updates/insertion
|
|
55
|
+
behaviour and visible sidebar controls; the CMS reports missing capabilities.
|
|
56
|
+
Then deploy one client with the package, adapter and Tailwind source inclusion.
|
|
57
|
+
Only show section-action controls after the host advertises `section-actions` in
|
|
58
|
+
its render handshake. Verify the host reports both insertion and section actions
|
|
59
|
+
before rolling out the same artifact to other clients.
|
|
60
|
+
|
|
61
|
+
## Acceptance checks
|
|
62
|
+
|
|
63
|
+
1. Hover shows a label/outline without changing sidebar selection or rendering
|
|
64
|
+
the site again. Click/tap/Enter selects the owning section.
|
|
65
|
+
2. Insertion lines span the section, track resize/scroll/image loading, and never
|
|
66
|
+
represent an offscreen boundary as a viewport-edge insertion point.
|
|
67
|
+
3. Up/down, duplicate, remove, add-before/after and empty-page insertion work.
|
|
68
|
+
Undo move/remove/duplicate preserves unrelated field edits. It is structural
|
|
69
|
+
undo (last 30 actions), not global text undo/redo or editor navigation history.
|
|
70
|
+
4. Keyboard toolbar navigation skips disabled actions; Escape dismisses controls
|
|
71
|
+
and restores focus. Sidebar actions remain available at all screen sizes.
|
|
72
|
+
5. Browse mode stops intercepting site interactions. The managed CMS switches to
|
|
73
|
+
its public renderer; dedicated clients keep their existing renderer. Remote
|
|
74
|
+
navigation links open in a new tab to keep the preview bridge connected.
|
|
75
|
+
6. Reject stale-page, unknown-action and nested-child mutation messages.
|
|
76
|
+
|
|
77
|
+
Local checks:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
node --import tsx --test tests/preview-interactions.test.ts
|
|
81
|
+
node --test tests/preview-overlay.browser.test.mjs
|
|
82
|
+
npm run typecheck -- --incremental false
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Browser checks use only an in-memory fixture and localhost, never production
|
|
86
|
+
content. Field-level remote inspection and freeform canvas dragging are later
|
|
87
|
+
capabilities; the sidebar remains the precise nested-editing/reordering surface.
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blox-cms/preview",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Blox CMS React preview controls and bridge protocol for independent client websites",
|
|
6
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
7
|
+
"files": ["src", "README.md"],
|
|
8
|
+
"exports": { ".": "./src/index.ts", "./protocol": "./src/protocol.ts" },
|
|
9
|
+
"peerDependencies": { "react": ">=18 <20" }
|
|
10
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
4
|
+
import { SectionInsertionBoundary, SectionToolbar } from "./SectionControls";
|
|
5
|
+
import { sectionLabel, type PreviewSection, type SectionAction } from "./protocol";
|
|
6
|
+
|
|
7
|
+
type Box = { id: string; left: number; top: number; width: number; height: number };
|
|
8
|
+
type Geometry = { selected: Box | null; hovered: Box | null; boundary: (Box & { before: boolean }) | null };
|
|
9
|
+
const emptyGeometry: Geometry = { selected: null, hovered: null, boundary: null };
|
|
10
|
+
|
|
11
|
+
export function findOwningSection(target: Element | null, root: HTMLElement, ids: Set<string>): HTMLElement | null {
|
|
12
|
+
let owner: HTMLElement | null = null;
|
|
13
|
+
for (let element = target; element && element !== root; element = element.parentElement) {
|
|
14
|
+
const id = element.getAttribute("data-blockforge-block-id");
|
|
15
|
+
if (id && ids.has(id)) owner = element as HTMLElement;
|
|
16
|
+
}
|
|
17
|
+
return owner;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Sibling overlay: pointer/geometry updates never render the site's content tree. */
|
|
21
|
+
export function PreviewOverlay({ rootRef, sections, activeBlockId, documentKey, canAct, onSelect, onAction, onInsert }: {
|
|
22
|
+
rootRef: RefObject<HTMLDivElement | null>;
|
|
23
|
+
sections: PreviewSection[];
|
|
24
|
+
activeBlockId: string | null;
|
|
25
|
+
documentKey: string;
|
|
26
|
+
canAct: boolean;
|
|
27
|
+
onSelect: (id: string) => void;
|
|
28
|
+
onAction: (id: string, action: SectionAction) => void;
|
|
29
|
+
onInsert: (options: { afterBlockId?: string; atIndex?: number }) => void;
|
|
30
|
+
}) {
|
|
31
|
+
const [browsing, setBrowsing] = useState(false);
|
|
32
|
+
const [geometry, setGeometry] = useState<Geometry>(emptyGeometry);
|
|
33
|
+
const [dismissed, setDismissed] = useState(false);
|
|
34
|
+
const hoveredRef = useRef<{ id: string; before: boolean } | null>(null);
|
|
35
|
+
const previousOrderRef = useRef<string | null>(null);
|
|
36
|
+
const callbacks = useRef({ onSelect, onAction, onInsert });
|
|
37
|
+
callbacks.current = { onSelect, onAction, onInsert };
|
|
38
|
+
const sectionKey = JSON.stringify(sections.map(({ id, type }) => [id, type]));
|
|
39
|
+
|
|
40
|
+
useEffect(() => { setDismissed(false); hoveredRef.current = null; }, [documentKey, activeBlockId]);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const root = rootRef.current;
|
|
44
|
+
if (!root) return;
|
|
45
|
+
const previous = root.getAttribute("data-blockforge-editing");
|
|
46
|
+
root.setAttribute("data-blockforge-editing", String(!browsing));
|
|
47
|
+
return () => {
|
|
48
|
+
if (previous === null) root.removeAttribute("data-blockforge-editing");
|
|
49
|
+
else root.setAttribute("data-blockforge-editing", previous);
|
|
50
|
+
};
|
|
51
|
+
}, [rootRef, documentKey, browsing]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const root = rootRef.current;
|
|
55
|
+
if (!root) { setGeometry(emptyGeometry); return; }
|
|
56
|
+
const doc = root.ownerDocument;
|
|
57
|
+
const win = doc.defaultView!;
|
|
58
|
+
if (browsing) {
|
|
59
|
+
setGeometry(emptyGeometry);
|
|
60
|
+
const openLink = (event: MouseEvent) => {
|
|
61
|
+
const anchor = (event.target as Element).closest<HTMLAnchorElement>("a[href]");
|
|
62
|
+
if (!anchor || anchor.hasAttribute("download")) return;
|
|
63
|
+
const destination = new URL(anchor.href, win.location.href);
|
|
64
|
+
if (!["http:", "https:"].includes(destination.protocol)) return;
|
|
65
|
+
if (destination.hash && destination.origin === win.location.origin
|
|
66
|
+
&& destination.pathname === win.location.pathname && destination.search === win.location.search) return;
|
|
67
|
+
// Keep the bridge alive while allowing the actual destination to open.
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
event.stopPropagation();
|
|
70
|
+
win.open(destination.href, "_blank", "noopener,noreferrer");
|
|
71
|
+
};
|
|
72
|
+
root.addEventListener("click", openLink, true);
|
|
73
|
+
return () => root.removeEventListener("click", openLink, true);
|
|
74
|
+
}
|
|
75
|
+
const ids = new Set(sections.map((section) => section.id));
|
|
76
|
+
let frame = 0;
|
|
77
|
+
const elements = () => Array.from(root.querySelectorAll<HTMLElement>("[data-blockforge-block-id]"))
|
|
78
|
+
.filter((element) => ids.has(element.dataset.blockforgeBlockId!) && findOwningSection(element, root, ids) === element);
|
|
79
|
+
const nodes = elements();
|
|
80
|
+
const byId = new Map(nodes.map((node) => [node.dataset.blockforgeBlockId, node]));
|
|
81
|
+
if (previousOrderRef.current !== null && previousOrderRef.current !== sectionKey && activeBlockId) {
|
|
82
|
+
byId.get(activeBlockId)?.scrollIntoView({ block: "nearest", behavior: "instant" });
|
|
83
|
+
}
|
|
84
|
+
previousOrderRef.current = sectionKey;
|
|
85
|
+
const boxFor = (id: string | null): Box | null => {
|
|
86
|
+
const element = id ? byId.get(id) : null;
|
|
87
|
+
if (!element || !id) return null;
|
|
88
|
+
const rect = element.getBoundingClientRect();
|
|
89
|
+
if (rect.width <= 0 || rect.height <= 0 || rect.bottom <= 0 || rect.top >= win.innerHeight) return null;
|
|
90
|
+
return { id, left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
|
91
|
+
};
|
|
92
|
+
const measure = () => {
|
|
93
|
+
frame = 0;
|
|
94
|
+
const selected = boxFor(activeBlockId);
|
|
95
|
+
const hovered = boxFor(hoveredRef.current?.id ?? null);
|
|
96
|
+
const boundaryBox = hovered ?? selected;
|
|
97
|
+
const before = hovered ? hoveredRef.current?.before ?? false : false;
|
|
98
|
+
const y = boundaryBox ? boundaryBox.top + (before ? 0 : boundaryBox.height) : -1;
|
|
99
|
+
// Never pin an offscreen insertion point to a false viewport boundary.
|
|
100
|
+
const boundary = boundaryBox && y >= 16 && y <= win.innerHeight - 16 ? { ...boundaryBox, before, top: y } : null;
|
|
101
|
+
const next = { selected, hovered, boundary };
|
|
102
|
+
setGeometry((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);
|
|
103
|
+
};
|
|
104
|
+
const schedule = () => { if (!frame) frame = win.requestAnimationFrame(measure); };
|
|
105
|
+
const hover = (event: PointerEvent) => {
|
|
106
|
+
if (event.pointerType !== "mouse" && event.pointerType !== "pen") return;
|
|
107
|
+
const target = event.target as Element;
|
|
108
|
+
if (target.closest("[data-blockforge-preview-control]")) return;
|
|
109
|
+
const owner = findOwningSection(target, root, ids);
|
|
110
|
+
const rect = owner?.getBoundingClientRect();
|
|
111
|
+
const id = owner?.dataset.blockforgeBlockId;
|
|
112
|
+
if (id !== hoveredRef.current?.id) setDismissed(false);
|
|
113
|
+
hoveredRef.current = id && rect ? { id, before: event.clientY - rect.top < Math.min(40, rect.height / 2) } : null;
|
|
114
|
+
schedule();
|
|
115
|
+
};
|
|
116
|
+
const select = (event: MouseEvent) => {
|
|
117
|
+
const target = event.target as Element;
|
|
118
|
+
if (target.closest("[data-blockforge-preview-control]")) return;
|
|
119
|
+
const owner = findOwningSection(target, root, ids);
|
|
120
|
+
if (owner || target.closest("a,button,input,textarea,select,summary")) {
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
event.stopPropagation();
|
|
123
|
+
}
|
|
124
|
+
if (owner?.dataset.blockforgeBlockId) {
|
|
125
|
+
setDismissed(false);
|
|
126
|
+
callbacks.current.onSelect(owner.dataset.blockforgeBlockId);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const keydown = (event: KeyboardEvent) => {
|
|
130
|
+
if (event.key === "Escape") { hoveredRef.current = null; setDismissed(true); return; }
|
|
131
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
132
|
+
const owner = findOwningSection(event.target as Element, root, ids);
|
|
133
|
+
if (owner !== event.target) return;
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
setDismissed(false);
|
|
136
|
+
if (owner?.dataset.blockforgeBlockId) callbacks.current.onSelect(owner.dataset.blockforgeBlockId);
|
|
137
|
+
};
|
|
138
|
+
const focus = (event: FocusEvent) => {
|
|
139
|
+
const owner = findOwningSection(event.target as Element, root, ids);
|
|
140
|
+
if (!owner) return;
|
|
141
|
+
setDismissed(false);
|
|
142
|
+
hoveredRef.current = { id: owner.dataset.blockforgeBlockId!, before: false };
|
|
143
|
+
schedule();
|
|
144
|
+
};
|
|
145
|
+
const leave = (event: PointerEvent) => {
|
|
146
|
+
if ((event.relatedTarget as Element | null)?.closest?.("[data-blockforge-preview-control]")) return;
|
|
147
|
+
hoveredRef.current = null; schedule();
|
|
148
|
+
};
|
|
149
|
+
const previousAttributes = nodes.map((node) => [node, node.getAttribute("tabindex"), node.getAttribute("aria-label"), node.getAttribute("role"), node.classList.contains("cursor-pointer")] as const);
|
|
150
|
+
nodes.forEach((node) => {
|
|
151
|
+
node.tabIndex = 0;
|
|
152
|
+
node.setAttribute("role", "group");
|
|
153
|
+
node.setAttribute("aria-label", `Select ${sectionLabel(sections.find((section) => section.id === node.dataset.blockforgeBlockId)!)} section`);
|
|
154
|
+
node.classList.add("cursor-pointer");
|
|
155
|
+
});
|
|
156
|
+
const observer = new ResizeObserver(schedule);
|
|
157
|
+
observer.observe(root);
|
|
158
|
+
nodes.forEach((node) => observer.observe(node));
|
|
159
|
+
root.addEventListener("pointermove", hover);
|
|
160
|
+
root.addEventListener("pointerleave", leave);
|
|
161
|
+
root.addEventListener("click", select, true);
|
|
162
|
+
root.addEventListener("keydown", keydown);
|
|
163
|
+
root.addEventListener("focusin", focus);
|
|
164
|
+
root.addEventListener("load", schedule, true);
|
|
165
|
+
win.addEventListener("scroll", schedule, true);
|
|
166
|
+
win.addEventListener("resize", schedule);
|
|
167
|
+
schedule();
|
|
168
|
+
return () => {
|
|
169
|
+
observer.disconnect(); win.cancelAnimationFrame(frame);
|
|
170
|
+
root.removeEventListener("pointermove", hover); root.removeEventListener("pointerleave", leave);
|
|
171
|
+
root.removeEventListener("click", select, true); root.removeEventListener("keydown", keydown);
|
|
172
|
+
root.removeEventListener("focusin", focus); root.removeEventListener("load", schedule, true);
|
|
173
|
+
win.removeEventListener("scroll", schedule, true); win.removeEventListener("resize", schedule);
|
|
174
|
+
previousAttributes.forEach(([node, tabIndex, label, role, hadCursorClass]) => {
|
|
175
|
+
if (tabIndex === null) node.removeAttribute("tabindex"); else node.setAttribute("tabindex", tabIndex);
|
|
176
|
+
if (label === null) node.removeAttribute("aria-label"); else node.setAttribute("aria-label", label);
|
|
177
|
+
if (role === null) node.removeAttribute("role"); else node.setAttribute("role", role);
|
|
178
|
+
if (!hadCursorClass) node.classList.remove("cursor-pointer");
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
}, [rootRef, documentKey, sectionKey, activeBlockId, browsing]);
|
|
182
|
+
|
|
183
|
+
const selected = sections.find((section) => section.id === activeBlockId);
|
|
184
|
+
const hovered = sections.find((section) => section.id === geometry.hovered?.id);
|
|
185
|
+
const boundarySection = sections.find((section) => section.id === geometry.boundary?.id);
|
|
186
|
+
const selectedIndex = sections.findIndex((section) => section.id === activeBlockId);
|
|
187
|
+
const outline = (box: Box, active: boolean) => <div key={box.id} aria-hidden="true"
|
|
188
|
+
className={`pointer-events-none fixed z-[2147483645] ${active ? "border-2 border-violet-600" : "border border-violet-400"}`}
|
|
189
|
+
style={{ left: box.left, top: box.top, width: box.width, height: box.height }} />;
|
|
190
|
+
return <>
|
|
191
|
+
<div data-blockforge-preview-control className="fixed bottom-3 right-3 z-[2147483647] flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 shadow-sm">
|
|
192
|
+
<span className="hidden sm:inline">{browsing ? "Links open in a new tab" : "Select a section to edit or move it"}</span>
|
|
193
|
+
<button type="button" aria-pressed={browsing} onClick={() => { setBrowsing(!browsing); setDismissed(false); }}
|
|
194
|
+
className="min-h-8 rounded-md px-2 font-bold text-violet-700 hover:bg-violet-50 focus-visible:ring-2 focus-visible:ring-violet-500">{browsing ? "Return to editing" : "Browse preview"}</button>
|
|
195
|
+
</div>
|
|
196
|
+
{!browsing && !dismissed && <>
|
|
197
|
+
{geometry.selected && selected && geometry.selected.id === selected.id && outline(geometry.selected, true)}
|
|
198
|
+
{geometry.hovered && hovered && geometry.hovered.id !== activeBlockId && outline(geometry.hovered, false)}
|
|
199
|
+
{geometry.hovered && hovered && geometry.hovered.id !== activeBlockId && <span aria-hidden="true"
|
|
200
|
+
className="pointer-events-none fixed z-[2147483646] rounded bg-violet-600 px-2 py-1 text-xs font-bold text-white"
|
|
201
|
+
style={{ left: geometry.hovered.left + 4, top: Math.max(0, geometry.hovered.top) }}>{sectionLabel(hovered)}</span>}
|
|
202
|
+
{canAct && geometry.selected && selected && geometry.selected.id === selected.id && <div className="fixed z-[2147483646] max-w-[calc(100vw-16px)]"
|
|
203
|
+
style={{ left: Math.max(8, geometry.selected.left), top: Math.max(4, geometry.selected.top - 44), maxWidth: `calc(100vw - ${Math.max(8, geometry.selected.left) + 8}px)` }}>
|
|
204
|
+
<SectionToolbar key={selected.id} label={sectionLabel(selected)} isFirst={selectedIndex === 0} isLast={selectedIndex === sections.length - 1}
|
|
205
|
+
onDismiss={() => {
|
|
206
|
+
Array.from(rootRef.current?.querySelectorAll<HTMLElement>("[data-blockforge-block-id]") ?? []).find((node) => node.dataset.blockforgeBlockId === selected.id)?.focus({ preventScroll: true });
|
|
207
|
+
setDismissed(true);
|
|
208
|
+
}} onAction={(action) => callbacks.current.onAction(selected.id, action)} />
|
|
209
|
+
</div>}
|
|
210
|
+
{geometry.boundary && boundarySection && <div className="fixed z-[2147483647] -translate-y-1/2"
|
|
211
|
+
style={{ left: geometry.boundary.left, top: geometry.boundary.top, width: geometry.boundary.width }}>
|
|
212
|
+
<SectionInsertionBoundary label={`Add section ${geometry.boundary.before ? "before" : "after"} ${sectionLabel(boundarySection)}`}
|
|
213
|
+
onInsert={() => {
|
|
214
|
+
const boundary = geometry.boundary!;
|
|
215
|
+
if (boundary.before && canAct) callbacks.current.onAction(boundary.id, "insert-before");
|
|
216
|
+
else callbacks.current.onInsert(boundary.before
|
|
217
|
+
? { atIndex: sections.findIndex((section) => section.id === boundary.id) } : { afterBlockId: boundary.id });
|
|
218
|
+
}} />
|
|
219
|
+
</div>}
|
|
220
|
+
{sections.length === 0 && <div className="fixed left-6 right-6 top-1/2 z-[2147483646] rounded-xl bg-white p-6 text-center shadow-lg">
|
|
221
|
+
<p className="mb-4 text-sm font-semibold text-slate-700">Add your first section</p>
|
|
222
|
+
<SectionInsertionBoundary label="Add first section" onInsert={() => callbacks.current.onInsert({ atIndex: 0 })} />
|
|
223
|
+
</div>}
|
|
224
|
+
</>}
|
|
225
|
+
</>;
|
|
226
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import type { SectionAction } from "./protocol";
|
|
5
|
+
|
|
6
|
+
const buttonClass = "inline-flex min-h-8 shrink-0 items-center justify-center rounded-md px-2 text-xs font-semibold text-slate-700 hover:bg-violet-50 hover:text-violet-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 disabled:cursor-not-allowed disabled:opacity-35";
|
|
7
|
+
|
|
8
|
+
export function SectionToolbar({ label, isFirst, isLast, onAction, onDismiss }: {
|
|
9
|
+
label: string;
|
|
10
|
+
isFirst: boolean;
|
|
11
|
+
isLast: boolean;
|
|
12
|
+
onAction: (action: SectionAction) => void;
|
|
13
|
+
onDismiss?: () => void;
|
|
14
|
+
}) {
|
|
15
|
+
const [expanded, setExpanded] = useState(false);
|
|
16
|
+
const [tabIndex, setTabIndex] = useState(0);
|
|
17
|
+
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
18
|
+
const actions: Array<{ action: SectionAction; label: string; text: string; disabled?: boolean }> = [
|
|
19
|
+
{ action: "edit", label: "Edit section fields", text: "Edit" },
|
|
20
|
+
];
|
|
21
|
+
const focusIndex = actions[tabIndex]?.disabled ? 0 : tabIndex;
|
|
22
|
+
return (
|
|
23
|
+
<div data-blockforge-preview-control data-cms-inspector-control className="max-w-full rounded-lg border border-violet-200 bg-white p-1 text-slate-900 shadow-lg shadow-slate-950/10" onClick={(event) => event.stopPropagation()}>
|
|
24
|
+
<div ref={toolbarRef} role="toolbar" aria-label={`${label} section actions`} className="flex max-w-full items-center gap-0.5 overflow-x-auto"
|
|
25
|
+
onKeyDown={(event) => {
|
|
26
|
+
if (event.key === "Escape") { setExpanded(false); onDismiss?.(); return; }
|
|
27
|
+
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"));
|
|
30
|
+
const current = buttons.indexOf(event.target as HTMLButtonElement);
|
|
31
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1
|
|
32
|
+
: (current + (event.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length;
|
|
33
|
+
buttons[next]?.focus();
|
|
34
|
+
}}>
|
|
35
|
+
<span className="hidden max-w-40 truncate px-2 text-xs font-bold text-violet-700 sm:inline">{label}</span>
|
|
36
|
+
{actions.map((item, index) => <button key={item.action} type="button" className={buttonClass} title={item.label} aria-label={item.label}
|
|
37
|
+
disabled={item.disabled} tabIndex={focusIndex === index ? 0 : -1} onFocus={() => setTabIndex(index)} onClick={() => onAction(item.action)}>{item.text}</button>)}
|
|
38
|
+
<button type="button" className={buttonClass} aria-label="More section actions" title="More section actions" aria-expanded={expanded}
|
|
39
|
+
tabIndex={focusIndex === actions.length ? 0 : -1} onFocus={() => setTabIndex(actions.length)} onClick={() => setExpanded(!expanded)}>•••</button>
|
|
40
|
+
</div>
|
|
41
|
+
{expanded && <div className="mt-1 flex flex-col items-stretch gap-0.5 border-t border-slate-200 pt-1" onKeyDown={(event) => {
|
|
42
|
+
if (event.key === "Escape") { event.stopPropagation(); setExpanded(false); toolbarRef.current?.querySelector<HTMLButtonElement>("[aria-expanded]")?.focus(); }
|
|
43
|
+
}}>
|
|
44
|
+
{([ ["move-up", "Move up", isFirst], ["move-down", "Move down", isLast], ["duplicate", "Duplicate section", false], ["insert-before", "Add before", false], ["insert-after", "Add after", false], ["remove", "Remove section", false] ] as const).map(([action, text, disabled]) =>
|
|
45
|
+
<button type="button" key={action} disabled={disabled} className={`${buttonClass} justify-start ${action === "remove" ? "text-rose-700" : ""}`} onClick={() => {
|
|
46
|
+
setExpanded(false);
|
|
47
|
+
toolbarRef.current?.querySelector<HTMLButtonElement>("[aria-expanded]")?.focus();
|
|
48
|
+
onAction(action);
|
|
49
|
+
}}>{text}</button>)}
|
|
50
|
+
{label.startsWith("Shared section") && <p className="w-full px-2 py-1 text-xs text-slate-500">Remove affects this instance, not the shared source.</p>}
|
|
51
|
+
</div>}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function SectionInsertionBoundary({ label, onInsert }: { label: string; onInsert: () => void }) {
|
|
57
|
+
return <div data-blockforge-preview-control className="flex w-full items-center gap-2" onClick={(event) => event.stopPropagation()}>
|
|
58
|
+
<span aria-hidden="true" className="h-px min-w-0 flex-1 bg-violet-400" />
|
|
59
|
+
<button type="button" title={label} aria-label={label} onClick={onInsert}
|
|
60
|
+
className="inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-full border border-violet-400 bg-white px-3 text-xs font-bold text-violet-700 shadow-sm hover:bg-violet-50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-violet-200">
|
|
61
|
+
<span aria-hidden="true">+</span> Add section
|
|
62
|
+
</button>
|
|
63
|
+
<span aria-hidden="true" className="h-px min-w-0 flex-1 bg-violet-400" />
|
|
64
|
+
</div>;
|
|
65
|
+
}
|
package/src/index.ts
ADDED
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export const previewMessageType = {
|
|
2
|
+
ready: "blockforge:preview:ready",
|
|
3
|
+
render: "blockforge:preview:render",
|
|
4
|
+
updateSubject: "blockforge:preview:update-subject",
|
|
5
|
+
setActiveBlock: "blockforge:preview:set-active-block",
|
|
6
|
+
selectBlock: "blockforge:preview:select-block",
|
|
7
|
+
requestSectionInsert: "blockforge:preview:request-section-insert",
|
|
8
|
+
sectionAction: "blockforge:preview:section-action",
|
|
9
|
+
scroll: "blockforge:preview:scroll",
|
|
10
|
+
scrollTo: "blockforge:preview:scroll-to",
|
|
11
|
+
scrollToElement: "blockforge:preview:scroll-to-element",
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export const previewProtocolVersion = 4;
|
|
15
|
+
|
|
16
|
+
export const previewCapability = {
|
|
17
|
+
subjectUpdates: "subject-updates",
|
|
18
|
+
sectionInsertion: "section-insertion",
|
|
19
|
+
sectionActions: "section-actions",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type PreviewCapability = typeof previewCapability[keyof typeof previewCapability];
|
|
23
|
+
|
|
24
|
+
export type RemotePreviewRoute = {
|
|
25
|
+
mediaPreview?: import("../../../src/images/PrivateImages").MediaPreviewAccess | null;
|
|
26
|
+
graph: any;
|
|
27
|
+
subject: any;
|
|
28
|
+
routeType: "page" | "entry";
|
|
29
|
+
path: string;
|
|
30
|
+
page?: number;
|
|
31
|
+
collection?: any;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ParentToPreviewMessage =
|
|
35
|
+
| { type: typeof previewMessageType.render; route: RemotePreviewRoute; activeBlockId?: string | null; capabilities?: PreviewCapability[] }
|
|
36
|
+
| { type: typeof previewMessageType.updateSubject; subject: any }
|
|
37
|
+
| { type: typeof previewMessageType.setActiveBlock; activeBlockId?: string | null }
|
|
38
|
+
| { type: typeof previewMessageType.scrollTo; position: { x: number; y: number } }
|
|
39
|
+
| { type: typeof previewMessageType.scrollToElement; attribute: string; value: string };
|
|
40
|
+
|
|
41
|
+
export type PreviewToParentMessage =
|
|
42
|
+
| { type: typeof previewMessageType.ready; protocolVersion?: number; capabilities?: PreviewCapability[]; contentCapabilities?: string[] }
|
|
43
|
+
| ({ type: typeof previewMessageType.selectBlock; blockId: string } & Partial<PreviewScope>)
|
|
44
|
+
| ({ type: typeof previewMessageType.requestSectionInsert; afterBlockId?: string; atIndex?: number } & Partial<PreviewScope>)
|
|
45
|
+
| ({ type: typeof previewMessageType.sectionAction } & SectionActionRequest)
|
|
46
|
+
| { type: typeof previewMessageType.scroll; position: { x: number; y: number } };
|
|
47
|
+
|
|
48
|
+
export const isPreviewMessage = (value: unknown): value is { type: string } =>
|
|
49
|
+
Boolean(value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string");
|
|
50
|
+
|
|
51
|
+
export const sectionActions = ["edit", "move-up", "move-down", "duplicate", "remove", "insert-before", "insert-after"] as const;
|
|
52
|
+
export type SectionAction = typeof sectionActions[number];
|
|
53
|
+
export type PreviewSection = { id: string; type: string };
|
|
54
|
+
export type PreviewScope = { subjectId: string; path: string };
|
|
55
|
+
export type SectionActionRequest = PreviewScope & { blockId: string; action: SectionAction };
|
|
56
|
+
|
|
57
|
+
export function matchesPreviewScope(request: Partial<PreviewScope>, scope: PreviewScope, allowLegacy = false) {
|
|
58
|
+
if (request.subjectId === undefined && request.path === undefined) return allowLegacy;
|
|
59
|
+
return request.subjectId === scope.subjectId && request.path === scope.path;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveSectionInsertion(request: { afterBlockId?: unknown; atIndex?: unknown }, sections: PreviewSection[]) {
|
|
63
|
+
if (typeof request.afterBlockId === "string") {
|
|
64
|
+
return request.atIndex === undefined && sections.some((section) => section.id === request.afterBlockId)
|
|
65
|
+
? { afterBlockId: request.afterBlockId } : null;
|
|
66
|
+
}
|
|
67
|
+
return request.afterBlockId === undefined && typeof request.atIndex === "number"
|
|
68
|
+
&& Number.isInteger(request.atIndex) && request.atIndex >= 0 && request.atIndex <= sections.length
|
|
69
|
+
? { atIndex: request.atIndex } : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Only page-owned sections may be mutated by the page-level action handlers.
|
|
73
|
+
export function isSectionActionRequest(value: unknown, scope: PreviewScope, sections: PreviewSection[]): value is SectionActionRequest {
|
|
74
|
+
if (!value || typeof value !== "object") return false;
|
|
75
|
+
const request = value as Record<string, unknown>;
|
|
76
|
+
return request.subjectId === scope.subjectId && request.path === scope.path
|
|
77
|
+
&& typeof request.blockId === "string" && sections.some((section) => section.id === request.blockId)
|
|
78
|
+
&& sectionActions.includes(request.action as SectionAction);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function sectionLabel(section: PreviewSection) {
|
|
82
|
+
if (section.type === "SHARED_BLOCK") return "Shared section · instance";
|
|
83
|
+
if (section.type === "TWO_COLUMN") return "Columns";
|
|
84
|
+
return section.type.replace(/[_-]/g, " ").toLowerCase().replace(/^./, (letter) => letter.toUpperCase());
|
|
85
|
+
}
|