@fullgrainsoftware/portal 0.9.0 → 0.11.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/README.md +3 -1
- package/dist/bookings.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/cms-client.js +1 -1
- package/dist/commerce.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/inquiries.js +1 -1
- package/dist/manifest.d.ts +18 -0
- package/dist/manifest.js +43 -0
- package/dist/preview.d.ts +15 -0
- package/dist/preview.js +121 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @fullgrainsoftware/portal
|
|
2
2
|
|
|
3
|
-
Site SDK for the [Fullgrain Portal](https://
|
|
3
|
+
Site SDK for the [Fullgrain Portal](https://app.fullgrainsoftware.com) CMS: declare your
|
|
4
4
|
site's structure in a typed manifest, read CMS-managed content in your pages,
|
|
5
5
|
and sync structural changes with one command.
|
|
6
6
|
|
|
@@ -47,6 +47,8 @@ export default defineSite({
|
|
|
47
47
|
});
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
A repeatable section whose items are mostly a picture can take photos in bulk from the panel: set `batchUpload: true` (the section has exactly one image field) or `batchUpload: "<image field key>"`. Each picked image becomes one item with every other field empty, so no other field of that section may be `required`.
|
|
51
|
+
|
|
50
52
|
## Read content in your pages
|
|
51
53
|
|
|
52
54
|
```tsx
|
package/dist/bookings.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
its manage token. Server-only — the abbk_ key is a secret — and every
|
|
4
4
|
method returns a typed result instead of throwing for an expected
|
|
5
5
|
outcome, so a site can never show a dead picker. */
|
|
6
|
-
const DEFAULT_BASE_URL = "https://
|
|
6
|
+
const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
|
|
7
7
|
const TIMEOUT_MS = 10_000;
|
|
8
8
|
const versionOf = (o) => (typeof o?.version === "number" ? { version: o.version } : {});
|
|
9
9
|
export function createBookings(opts) {
|
package/dist/cli.js
CHANGED
|
@@ -55,7 +55,7 @@ export async function runSync(argv) {
|
|
|
55
55
|
console.error("AB_CMS_API_KEY is not set");
|
|
56
56
|
return 1;
|
|
57
57
|
}
|
|
58
|
-
const baseUrl = (process.env.AB_CMS_URL ?? "https://
|
|
58
|
+
const baseUrl = (process.env.AB_CMS_URL ?? "https://app.fullgrainsoftware.com").replace(/\/$/, "");
|
|
59
59
|
const jiti = createJiti(import.meta.url);
|
|
60
60
|
let loaded;
|
|
61
61
|
try {
|
package/dist/cms-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const DEFAULT_BASE_URL = "https://
|
|
1
|
+
const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
|
|
2
2
|
export async function fetchPublicSiteData(websiteId, opts) {
|
|
3
3
|
const base = (opts?.baseUrl ?? process.env.AB_CMS_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
4
4
|
const timeoutMs = opts?.timeoutMs ?? 10_000;
|
package/dist/commerce.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
a `store` block; catalog reads keep serving while a store is paused
|
|
7
7
|
(checkoutAvailable tells the site whether to offer buying); checkout
|
|
8
8
|
returns a typed error union instead of throwing for expected outcomes. */
|
|
9
|
-
const DEFAULT_BASE_URL = "https://
|
|
9
|
+
const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
|
|
10
10
|
export function createCommerce(opts) {
|
|
11
11
|
if (typeof window !== "undefined") {
|
|
12
12
|
throw new Error("createCommerce is server-only — never expose COMMERCE_API_KEY to the browser");
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { createCommerce } from "./commerce.js";
|
|
|
9
9
|
export type { CartPreview, CheckoutInput, CheckoutResult, Commerce, LineIssue, OrderStatus, ProductDetail, ProductSummary, StoreStatus, VariantView, } from "./commerce.js";
|
|
10
10
|
export { createInquiries } from "./inquiries.js";
|
|
11
11
|
export type { Inquiries, InquiryInput, InquirySubmitResult } from "./inquiries.js";
|
|
12
|
-
export { initCmsPreview, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
|
|
12
|
+
export { initCmsPreview, normalizeSectionTitle, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
|
|
13
13
|
export type { CmsPreviewOptions, PreviewInbound, PreviewOutbound } from "./preview.js";
|
|
14
14
|
export { createBookings } from "./bookings.js";
|
|
15
15
|
export type { AvailabilityDay, AvailabilityResult, BookInput, BookResult, BookingCustomer, BookingService, BookingStatus, BookingView, Bookings, GetBookingResult, ManageResult, RequestTimeInput, RequestTimeResult, ServicesResult, } from "./bookings.js";
|
package/dist/index.js
CHANGED
|
@@ -4,5 +4,5 @@ export { fetchPublicSiteData } from "./cms-client.js";
|
|
|
4
4
|
export { readReviews } from "./reviews.js";
|
|
5
5
|
export { createCommerce } from "./commerce.js";
|
|
6
6
|
export { createInquiries } from "./inquiries.js";
|
|
7
|
-
export { initCmsPreview, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
|
|
7
|
+
export { initCmsPreview, normalizeSectionTitle, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
|
|
8
8
|
export { createBookings } from "./bookings.js";
|
package/dist/inquiries.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
recipients. Server-only — the abik_ key is a secret — and `submit` never
|
|
4
4
|
throws for an expected outcome: it returns a typed result the site turns
|
|
5
5
|
into its own success, "try again in a few minutes", or fallback state. */
|
|
6
|
-
const DEFAULT_BASE_URL = "https://
|
|
6
|
+
const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
|
|
7
7
|
const TIMEOUT_MS = 10_000;
|
|
8
8
|
export function createInquiries(opts) {
|
|
9
9
|
if (typeof window !== "undefined") {
|
package/dist/manifest.d.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface ManifestSection {
|
|
|
18
18
|
title: string;
|
|
19
19
|
kind: "repeatable" | "singleton";
|
|
20
20
|
allowGroupCreation?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Batch photo uploads in the panel ("Add photos": one item per picked
|
|
23
|
+
* image, every other field empty). `true` when the section has exactly one
|
|
24
|
+
* image field, else that field's key. Repeatable sections only; no other
|
|
25
|
+
* field may be `required`.
|
|
26
|
+
*/
|
|
27
|
+
batchUpload?: true | string;
|
|
21
28
|
fields: Record<string, ManifestField>;
|
|
22
29
|
seed?: readonly ManifestSeedItem[];
|
|
23
30
|
}
|
|
@@ -41,6 +48,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
41
48
|
title: z.ZodString;
|
|
42
49
|
kind: z.ZodEnum<["repeatable", "singleton"]>;
|
|
43
50
|
allowGroupCreation: z.ZodOptional<z.ZodBoolean>;
|
|
51
|
+
batchUpload: z.ZodOptional<z.ZodUnion<[z.ZodLiteral<true>, z.ZodString]>>;
|
|
44
52
|
fields: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
45
53
|
label: z.ZodString;
|
|
46
54
|
type: z.ZodEnum<["text", "image", "color", "audio", "video"]>;
|
|
@@ -90,6 +98,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
90
98
|
generateMp4?: boolean | undefined;
|
|
91
99
|
}>;
|
|
92
100
|
allowGroupCreation?: boolean | undefined;
|
|
101
|
+
batchUpload?: string | true | undefined;
|
|
93
102
|
seed?: {
|
|
94
103
|
values: Record<string, string>;
|
|
95
104
|
name: string;
|
|
@@ -108,6 +117,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
108
117
|
generateMp4?: boolean | undefined;
|
|
109
118
|
}>;
|
|
110
119
|
allowGroupCreation?: boolean | undefined;
|
|
120
|
+
batchUpload?: string | true | undefined;
|
|
111
121
|
seed?: {
|
|
112
122
|
values: Record<string, string>;
|
|
113
123
|
name: string;
|
|
@@ -126,6 +136,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
126
136
|
generateMp4?: boolean | undefined;
|
|
127
137
|
}>;
|
|
128
138
|
allowGroupCreation?: boolean | undefined;
|
|
139
|
+
batchUpload?: string | true | undefined;
|
|
129
140
|
seed?: {
|
|
130
141
|
values: Record<string, string>;
|
|
131
142
|
name: string;
|
|
@@ -144,6 +155,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
144
155
|
generateMp4?: boolean | undefined;
|
|
145
156
|
}>;
|
|
146
157
|
allowGroupCreation?: boolean | undefined;
|
|
158
|
+
batchUpload?: string | true | undefined;
|
|
147
159
|
seed?: {
|
|
148
160
|
values: Record<string, string>;
|
|
149
161
|
name: string;
|
|
@@ -166,6 +178,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
166
178
|
generateMp4?: boolean | undefined;
|
|
167
179
|
}>;
|
|
168
180
|
allowGroupCreation?: boolean | undefined;
|
|
181
|
+
batchUpload?: string | true | undefined;
|
|
169
182
|
seed?: {
|
|
170
183
|
values: Record<string, string>;
|
|
171
184
|
name: string;
|
|
@@ -188,6 +201,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
188
201
|
generateMp4?: boolean | undefined;
|
|
189
202
|
}>;
|
|
190
203
|
allowGroupCreation?: boolean | undefined;
|
|
204
|
+
batchUpload?: string | true | undefined;
|
|
191
205
|
seed?: {
|
|
192
206
|
values: Record<string, string>;
|
|
193
207
|
name: string;
|
|
@@ -213,6 +227,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
213
227
|
generateMp4?: boolean | undefined;
|
|
214
228
|
}>;
|
|
215
229
|
allowGroupCreation?: boolean | undefined;
|
|
230
|
+
batchUpload?: string | true | undefined;
|
|
216
231
|
seed?: {
|
|
217
232
|
values: Record<string, string>;
|
|
218
233
|
name: string;
|
|
@@ -238,6 +253,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
238
253
|
generateMp4?: boolean | undefined;
|
|
239
254
|
}>;
|
|
240
255
|
allowGroupCreation?: boolean | undefined;
|
|
256
|
+
batchUpload?: string | true | undefined;
|
|
241
257
|
seed?: {
|
|
242
258
|
values: Record<string, string>;
|
|
243
259
|
name: string;
|
|
@@ -263,6 +279,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
263
279
|
generateMp4?: boolean | undefined;
|
|
264
280
|
}>;
|
|
265
281
|
allowGroupCreation?: boolean | undefined;
|
|
282
|
+
batchUpload?: string | true | undefined;
|
|
266
283
|
seed?: {
|
|
267
284
|
values: Record<string, string>;
|
|
268
285
|
name: string;
|
|
@@ -288,6 +305,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
288
305
|
generateMp4?: boolean | undefined;
|
|
289
306
|
}>;
|
|
290
307
|
allowGroupCreation?: boolean | undefined;
|
|
308
|
+
batchUpload?: string | true | undefined;
|
|
291
309
|
seed?: {
|
|
292
310
|
values: Record<string, string>;
|
|
293
311
|
name: string;
|
package/dist/manifest.js
CHANGED
|
@@ -29,6 +29,7 @@ const sectionSchema = z
|
|
|
29
29
|
title: z.string().trim().min(1),
|
|
30
30
|
kind: z.enum(["repeatable", "singleton"]),
|
|
31
31
|
allowGroupCreation: z.boolean().optional(),
|
|
32
|
+
batchUpload: z.union([z.literal(true), z.string().trim().min(1)]).optional(),
|
|
32
33
|
fields: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, "field keys are lower-kebab"), fieldSchema),
|
|
33
34
|
seed: z.array(seedItemSchema).optional(),
|
|
34
35
|
})
|
|
@@ -64,6 +65,48 @@ const sectionSchema = z
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
}
|
|
68
|
+
// Batch uploads: the panel fills ONE image field per picked file and
|
|
69
|
+
// leaves every other field empty, so the target must be an image field
|
|
70
|
+
// of a repeatable section and nothing else may be required.
|
|
71
|
+
if (section.batchUpload !== undefined) {
|
|
72
|
+
if (section.kind !== "repeatable") {
|
|
73
|
+
ctx.addIssue({
|
|
74
|
+
code: z.ZodIssueCode.custom,
|
|
75
|
+
message: `batchUpload on section "${section.title}" needs kind: "repeatable"`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const imageKeys = Object.entries(section.fields)
|
|
79
|
+
.filter(([, field]) => field.type === "image")
|
|
80
|
+
.map(([key]) => key);
|
|
81
|
+
let target = null;
|
|
82
|
+
if (section.batchUpload === true) {
|
|
83
|
+
if (imageKeys.length === 1)
|
|
84
|
+
target = imageKeys[0];
|
|
85
|
+
else {
|
|
86
|
+
ctx.addIssue({
|
|
87
|
+
code: z.ZodIssueCode.custom,
|
|
88
|
+
message: `batchUpload: true on section "${section.title}" needs exactly one image field (found ${imageKeys.length}) — name the field key instead`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (imageKeys.includes(section.batchUpload)) {
|
|
93
|
+
target = section.batchUpload;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
ctx.addIssue({
|
|
97
|
+
code: z.ZodIssueCode.custom,
|
|
98
|
+
message: `batchUpload on section "${section.title}" names "${section.batchUpload}", which is not an image field of that section`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
for (const [key, field] of Object.entries(section.fields)) {
|
|
102
|
+
if (key !== target && field.required) {
|
|
103
|
+
ctx.addIssue({
|
|
104
|
+
code: z.ZodIssueCode.custom,
|
|
105
|
+
message: `batchUpload on section "${section.title}" can't fill required field "${key}" — a batch-made item leaves every other field empty`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
67
110
|
});
|
|
68
111
|
const pageSchema = z
|
|
69
112
|
.object({
|
package/dist/preview.d.ts
CHANGED
|
@@ -42,6 +42,21 @@ export interface CmsPreviewOptions {
|
|
|
42
42
|
*/
|
|
43
43
|
allowedOrigins?: string[];
|
|
44
44
|
}
|
|
45
|
+
/** Case- and whitespace-insensitive, the way the panel matches section titles. */
|
|
46
|
+
export declare function normalizeSectionTitle(title: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Among the nodes tagged with one title, the first the visitor can see, else
|
|
49
|
+
* the first in the tree. A site tags both its desktop and mobile renderings
|
|
50
|
+
* of a section (the nav, say); whichever the preview's width hides must not
|
|
51
|
+
* win, or the scroll lands nowhere.
|
|
52
|
+
*/
|
|
53
|
+
export declare function pickSectionNode<T>(nodes: readonly T[], visible: (node: T) => boolean): T | null;
|
|
54
|
+
/**
|
|
55
|
+
* Where the page scrolls to so the section's top clears whatever fixed or
|
|
56
|
+
* sticky bar covers the top of the viewport, honouring the section's own
|
|
57
|
+
* scroll-margin-top when the site set a larger one.
|
|
58
|
+
*/
|
|
59
|
+
export declare function scrollTargetTop(rectTop: number, scrollY: number, obstruction: number, scrollMargin: number): number;
|
|
45
60
|
/**
|
|
46
61
|
* Activate the preview bridge. Safe to call unconditionally: it no-ops on the
|
|
47
62
|
* server and on the public (non-preview) site. Returns a cleanup function.
|
package/dist/preview.js
CHANGED
|
@@ -35,16 +35,11 @@ export function sectionClickedMessage(title) {
|
|
|
35
35
|
export function editItemMessage(id) {
|
|
36
36
|
return { source: "ab-cms-preview", v: PREVIEW_PROTOCOL_VERSION, type: "edit-item", id };
|
|
37
37
|
}
|
|
38
|
-
// The portal's
|
|
39
|
-
//
|
|
38
|
+
// The portal's host since the 2026-10 rebrand; the abdevpanel origins no
|
|
39
|
+
// longer serve the panel (they redirect here), so they are not trusted.
|
|
40
40
|
// The "ab-cms-*" message sources below are the wire protocol, not branding:
|
|
41
41
|
// renaming them would break preview against every installed SDK version.
|
|
42
|
-
const DEFAULT_ORIGINS = [
|
|
43
|
-
"https://www.abdevpanel.com",
|
|
44
|
-
"https://abdevpanel.com",
|
|
45
|
-
"https://app.abdevpanel.com",
|
|
46
|
-
"https://app.fullgrainsoftware.com",
|
|
47
|
-
];
|
|
42
|
+
const DEFAULT_ORIGINS = ["https://app.fullgrainsoftware.com"];
|
|
48
43
|
function isPreviewContext(allowed) {
|
|
49
44
|
if (typeof window === "undefined")
|
|
50
45
|
return false;
|
|
@@ -79,15 +74,73 @@ function collectSectionTitles() {
|
|
|
79
74
|
});
|
|
80
75
|
return titles;
|
|
81
76
|
}
|
|
77
|
+
/** Case- and whitespace-insensitive, the way the panel matches section titles. */
|
|
78
|
+
export function normalizeSectionTitle(title) {
|
|
79
|
+
return title.trim().replace(/\s+/g, " ").toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Among the nodes tagged with one title, the first the visitor can see, else
|
|
83
|
+
* the first in the tree. A site tags both its desktop and mobile renderings
|
|
84
|
+
* of a section (the nav, say); whichever the preview's width hides must not
|
|
85
|
+
* win, or the scroll lands nowhere.
|
|
86
|
+
*/
|
|
87
|
+
export function pickSectionNode(nodes, visible) {
|
|
88
|
+
if (nodes.length === 0)
|
|
89
|
+
return null;
|
|
90
|
+
return nodes.find(visible) ?? nodes[0];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Where the page scrolls to so the section's top clears whatever fixed or
|
|
94
|
+
* sticky bar covers the top of the viewport, honouring the section's own
|
|
95
|
+
* scroll-margin-top when the site set a larger one.
|
|
96
|
+
*/
|
|
97
|
+
export function scrollTargetTop(rectTop, scrollY, obstruction, scrollMargin) {
|
|
98
|
+
return Math.max(0, Math.round(rectTop + scrollY - Math.max(obstruction, scrollMargin)));
|
|
99
|
+
}
|
|
100
|
+
function isVisible(el) {
|
|
101
|
+
if (el.getClientRects().length === 0)
|
|
102
|
+
return false; // display:none, or inside one
|
|
103
|
+
const r = el.getBoundingClientRect();
|
|
104
|
+
return r.width > 0 || r.height > 0;
|
|
105
|
+
}
|
|
106
|
+
function matchingNodes(title) {
|
|
107
|
+
const target = normalizeSectionTitle(title);
|
|
108
|
+
const out = [];
|
|
109
|
+
document.querySelectorAll("[data-cms-section]").forEach((n) => {
|
|
110
|
+
if (normalizeSectionTitle(n.dataset.cmsSection ?? "") === target)
|
|
111
|
+
out.push(n);
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
82
115
|
function findSection(title) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
116
|
+
return pickSectionNode(matchingNodes(title), isVisible);
|
|
117
|
+
}
|
|
118
|
+
/** The bottom edge of any fixed or sticky element sitting at the top of the viewport (a sticky nav), excluding the target itself. */
|
|
119
|
+
function topObstruction(target) {
|
|
120
|
+
if (typeof document.elementsFromPoint !== "function")
|
|
121
|
+
return 0;
|
|
122
|
+
const x = Math.max(1, Math.floor(window.innerWidth / 2));
|
|
123
|
+
let bottom = 0;
|
|
124
|
+
for (const el of document.elementsFromPoint(x, 1)) {
|
|
125
|
+
if (!(el instanceof HTMLElement) || el === target || el.contains(target))
|
|
126
|
+
continue;
|
|
127
|
+
const pos = getComputedStyle(el).position;
|
|
128
|
+
if (pos !== "fixed" && pos !== "sticky")
|
|
129
|
+
continue;
|
|
130
|
+
bottom = Math.max(bottom, el.getBoundingClientRect().bottom);
|
|
88
131
|
}
|
|
89
|
-
return
|
|
132
|
+
return bottom;
|
|
133
|
+
}
|
|
134
|
+
function scrollToSection(el) {
|
|
135
|
+
const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
|
|
136
|
+
const top = scrollTargetTop(el.getBoundingClientRect().top, window.scrollY, topObstruction(el), margin);
|
|
137
|
+
const reduce = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
138
|
+
window.scrollTo({ top, behavior: reduce ? "auto" : "smooth" });
|
|
139
|
+
el.classList.add("cms-preview-flash");
|
|
140
|
+
window.setTimeout(() => el.classList.remove("cms-preview-flash"), 1600);
|
|
90
141
|
}
|
|
142
|
+
/** How long a scroll request waits for a section that has not rendered yet (a client-only leaf, a lazy list). */
|
|
143
|
+
const LATE_SECTION_MS = 5000;
|
|
91
144
|
/**
|
|
92
145
|
* Activate the preview bridge. Safe to call unconditionally: it no-ops on the
|
|
93
146
|
* server and on the public (non-preview) site. Returns a cleanup function.
|
|
@@ -106,7 +159,45 @@ export function initCmsPreview(options = {}) {
|
|
|
106
159
|
}
|
|
107
160
|
}
|
|
108
161
|
};
|
|
109
|
-
|
|
162
|
+
let announced = "";
|
|
163
|
+
const announce = () => {
|
|
164
|
+
const titles = collectSectionTitles();
|
|
165
|
+
const key = titles.join("\u0000");
|
|
166
|
+
if (key === announced)
|
|
167
|
+
return; // the dashboard already has this list
|
|
168
|
+
announced = key;
|
|
169
|
+
post(readyMessage(titles));
|
|
170
|
+
};
|
|
171
|
+
// A scroll request for a section that is not in the DOM yet waits for it
|
|
172
|
+
// (bounded), instead of being dropped; a newer request replaces it.
|
|
173
|
+
let pendingScroll = null;
|
|
174
|
+
const scrollWhenPresent = (title) => {
|
|
175
|
+
pendingScroll?.();
|
|
176
|
+
pendingScroll = null;
|
|
177
|
+
const now = findSection(title);
|
|
178
|
+
if (now) {
|
|
179
|
+
scrollToSection(now);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (typeof MutationObserver === "undefined")
|
|
183
|
+
return;
|
|
184
|
+
const observer = new MutationObserver(() => {
|
|
185
|
+
const el = findSection(title);
|
|
186
|
+
if (!el)
|
|
187
|
+
return;
|
|
188
|
+
stop();
|
|
189
|
+
scrollToSection(el);
|
|
190
|
+
});
|
|
191
|
+
const timer = window.setTimeout(() => stop(), LATE_SECTION_MS);
|
|
192
|
+
const stop = () => {
|
|
193
|
+
observer.disconnect();
|
|
194
|
+
window.clearTimeout(timer);
|
|
195
|
+
if (pendingScroll === stop)
|
|
196
|
+
pendingScroll = null;
|
|
197
|
+
};
|
|
198
|
+
pendingScroll = stop;
|
|
199
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-cms-section", "class", "style", "hidden"] });
|
|
200
|
+
};
|
|
110
201
|
const onMessage = (event) => {
|
|
111
202
|
if (!allowed.includes(event.origin))
|
|
112
203
|
return;
|
|
@@ -117,14 +208,8 @@ export function initCmsPreview(options = {}) {
|
|
|
117
208
|
window.location.reload();
|
|
118
209
|
return;
|
|
119
210
|
}
|
|
120
|
-
if (msg.type === "scroll-to-section")
|
|
121
|
-
|
|
122
|
-
if (!el)
|
|
123
|
-
return;
|
|
124
|
-
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
125
|
-
el.classList.add("cms-preview-flash");
|
|
126
|
-
window.setTimeout(() => el.classList.remove("cms-preview-flash"), 1600);
|
|
127
|
-
}
|
|
211
|
+
if (msg.type === "scroll-to-section")
|
|
212
|
+
scrollWhenPresent(msg.title);
|
|
128
213
|
};
|
|
129
214
|
const onClick = (event) => {
|
|
130
215
|
const target = event.target;
|
|
@@ -157,9 +242,22 @@ export function initCmsPreview(options = {}) {
|
|
|
157
242
|
announce();
|
|
158
243
|
else
|
|
159
244
|
window.addEventListener("load", announce, { once: true });
|
|
245
|
+
// Sections that mount after load (client leaves, lazy lists) reach the
|
|
246
|
+
// dashboard's list too: re-announce when the set of tagged nodes changes.
|
|
247
|
+
let announceTimer = 0;
|
|
248
|
+
const sectionObserver = typeof MutationObserver === "undefined"
|
|
249
|
+
? null
|
|
250
|
+
: new MutationObserver(() => {
|
|
251
|
+
window.clearTimeout(announceTimer);
|
|
252
|
+
announceTimer = window.setTimeout(announce, 300);
|
|
253
|
+
});
|
|
254
|
+
sectionObserver?.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-cms-section"] });
|
|
160
255
|
return () => {
|
|
161
256
|
window.removeEventListener("message", onMessage);
|
|
162
257
|
document.removeEventListener("click", onClick, true);
|
|
258
|
+
sectionObserver?.disconnect();
|
|
259
|
+
window.clearTimeout(announceTimer);
|
|
260
|
+
pendingScroll?.();
|
|
163
261
|
style.remove();
|
|
164
262
|
};
|
|
165
263
|
}
|
package/package.json
CHANGED