@uniweb/runtime 0.2.1 → 0.2.2
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/package.json +2 -2
- package/src/components/Blocks.jsx +26 -0
- package/src/components/Layout.jsx +100 -0
- package/src/components/PageRenderer.jsx +26 -21
- package/src/components/WebsiteRenderer.jsx +5 -0
- package/src/hooks/useHeadMeta.js +188 -0
- package/src/hooks/useRememberScroll.js +105 -0
- package/src/hooks/useScrollDepth.js +71 -0
- package/src/index.jsx +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=20.19"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@uniweb/core": "0.1.
|
|
32
|
+
"@uniweb/core": "0.1.5"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocks
|
|
3
|
+
*
|
|
4
|
+
* Renders an array of blocks for a layout area (header, body, footer, panels).
|
|
5
|
+
* Used by the Layout component to pre-render each area.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import BlockRenderer from './BlockRenderer.jsx'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render a list of blocks
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} props
|
|
15
|
+
* @param {Block[]} props.blocks - Array of Block instances to render
|
|
16
|
+
* @param {Object} [props.extra] - Extra props to pass to each block
|
|
17
|
+
*/
|
|
18
|
+
export default function Blocks({ blocks, extra = {} }) {
|
|
19
|
+
if (!blocks || blocks.length === 0) return null
|
|
20
|
+
|
|
21
|
+
return blocks.map((block, index) => (
|
|
22
|
+
<React.Fragment key={block.id || index}>
|
|
23
|
+
<BlockRenderer block={block} extra={extra} />
|
|
24
|
+
</React.Fragment>
|
|
25
|
+
))
|
|
26
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates page rendering by assembling layout areas (header, body, footer, panels).
|
|
5
|
+
* Supports foundation-provided custom Layout components via website.getRemoteLayout().
|
|
6
|
+
*
|
|
7
|
+
* Layout Areas:
|
|
8
|
+
* - header: Top navigation, branding (from @header page)
|
|
9
|
+
* - body: Main page content (from page sections)
|
|
10
|
+
* - footer: Bottom navigation, copyright (from @footer page)
|
|
11
|
+
* - left: Left sidebar/panel (from @left page)
|
|
12
|
+
* - right: Right sidebar/panel (from @right page)
|
|
13
|
+
*
|
|
14
|
+
* Custom Layouts:
|
|
15
|
+
* Foundations can export a Layout component that receives pre-rendered areas as props:
|
|
16
|
+
*
|
|
17
|
+
* ```jsx
|
|
18
|
+
* export const site = {
|
|
19
|
+
* Layout: ({ page, website, header, body, footer, left, right }) => (
|
|
20
|
+
* <div className="my-layout">
|
|
21
|
+
* <header>{header}</header>
|
|
22
|
+
* <aside>{left}</aside>
|
|
23
|
+
* <main>{body}</main>
|
|
24
|
+
* <aside>{right}</aside>
|
|
25
|
+
* <footer>{footer}</footer>
|
|
26
|
+
* </div>
|
|
27
|
+
* )
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import React from 'react'
|
|
33
|
+
import Blocks from './Blocks.jsx'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default layout - renders header, body, footer in sequence
|
|
37
|
+
* (no panels in default layout)
|
|
38
|
+
*/
|
|
39
|
+
function DefaultLayout({ header, body, footer }) {
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
{header}
|
|
43
|
+
{body}
|
|
44
|
+
{footer}
|
|
45
|
+
</>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Layout component
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} props
|
|
53
|
+
* @param {Page} props.page - Current page instance
|
|
54
|
+
* @param {Website} props.website - Website instance
|
|
55
|
+
*/
|
|
56
|
+
export default function Layout({ page, website }) {
|
|
57
|
+
// Check if foundation provides a custom Layout
|
|
58
|
+
const RemoteLayout = website.getRemoteLayout()
|
|
59
|
+
|
|
60
|
+
// Get block groups from page (respects layout preferences)
|
|
61
|
+
const headerBlocks = page.getHeaderBlocks()
|
|
62
|
+
const bodyBlocks = page.getBodyBlocks()
|
|
63
|
+
const footerBlocks = page.getFooterBlocks()
|
|
64
|
+
const leftBlocks = page.getLeftBlocks()
|
|
65
|
+
const rightBlocks = page.getRightBlocks()
|
|
66
|
+
|
|
67
|
+
// Pre-render each area as React elements
|
|
68
|
+
const headerElement = headerBlocks ? <Blocks blocks={headerBlocks} /> : null
|
|
69
|
+
const bodyElement = bodyBlocks ? <Blocks blocks={bodyBlocks} /> : null
|
|
70
|
+
const footerElement = footerBlocks ? <Blocks blocks={footerBlocks} /> : null
|
|
71
|
+
const leftElement = leftBlocks ? <Blocks blocks={leftBlocks} /> : null
|
|
72
|
+
const rightElement = rightBlocks ? <Blocks blocks={rightBlocks} /> : null
|
|
73
|
+
|
|
74
|
+
// Use foundation's custom Layout if provided
|
|
75
|
+
if (RemoteLayout) {
|
|
76
|
+
return (
|
|
77
|
+
<RemoteLayout
|
|
78
|
+
page={page}
|
|
79
|
+
website={website}
|
|
80
|
+
header={headerElement}
|
|
81
|
+
body={bodyElement}
|
|
82
|
+
footer={footerElement}
|
|
83
|
+
left={leftElement}
|
|
84
|
+
right={rightElement}
|
|
85
|
+
// Aliases for backwards compatibility
|
|
86
|
+
leftPanel={leftElement}
|
|
87
|
+
rightPanel={rightElement}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Default layout
|
|
93
|
+
return (
|
|
94
|
+
<DefaultLayout
|
|
95
|
+
header={headerElement}
|
|
96
|
+
body={bodyElement}
|
|
97
|
+
footer={footerElement}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PageRenderer
|
|
3
3
|
*
|
|
4
|
-
* Renders a page
|
|
4
|
+
* Renders a page using the Layout component for proper orchestration
|
|
5
|
+
* of header, body, footer, and panel areas.
|
|
6
|
+
* Manages head meta tags for SEO and social sharing.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
import React
|
|
9
|
+
import React from 'react'
|
|
8
10
|
import BlockRenderer from './BlockRenderer.jsx'
|
|
11
|
+
import Layout from './Layout.jsx'
|
|
12
|
+
import { useHeadMeta } from '../hooks/useHeadMeta.js'
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* ChildBlocks - renders child blocks of a block
|
|
@@ -23,17 +27,27 @@ export function ChildBlocks({ block, childBlocks, pure = false, extra = {} }) {
|
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* PageRenderer component
|
|
30
|
+
*
|
|
31
|
+
* Renders the current page using the Layout system which supports:
|
|
32
|
+
* - Header, body, footer areas
|
|
33
|
+
* - Left and right panels
|
|
34
|
+
* - Foundation-provided custom layouts
|
|
35
|
+
* - Per-page layout preferences
|
|
26
36
|
*/
|
|
27
37
|
export default function PageRenderer() {
|
|
28
|
-
const
|
|
29
|
-
const
|
|
38
|
+
const uniweb = globalThis.uniweb
|
|
39
|
+
const website = uniweb?.activeWebsite
|
|
40
|
+
const page = website?.activePage
|
|
41
|
+
const siteName = website?.name || ''
|
|
42
|
+
|
|
43
|
+
// Get head metadata from page (uses Page.getHeadMeta() if available)
|
|
44
|
+
const headMeta = page?.getHeadMeta?.() || {
|
|
45
|
+
title: page?.title || 'Website',
|
|
46
|
+
description: page?.description || ''
|
|
47
|
+
}
|
|
30
48
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return () => {
|
|
34
|
-
document.title = 'Website'
|
|
35
|
-
}
|
|
36
|
-
}, [pageTitle])
|
|
49
|
+
// Manage head meta tags
|
|
50
|
+
useHeadMeta(headMeta, { siteName })
|
|
37
51
|
|
|
38
52
|
if (!page) {
|
|
39
53
|
return (
|
|
@@ -43,15 +57,6 @@ export default function PageRenderer() {
|
|
|
43
57
|
)
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return (
|
|
49
|
-
<>
|
|
50
|
-
{blocks.map((block, index) => (
|
|
51
|
-
<React.Fragment key={block.id || index}>
|
|
52
|
-
<BlockRenderer block={block} />
|
|
53
|
-
</React.Fragment>
|
|
54
|
-
))}
|
|
55
|
-
</>
|
|
56
|
-
)
|
|
60
|
+
// Use Layout component for proper orchestration
|
|
61
|
+
return <Layout page={page} website={website} />
|
|
57
62
|
}
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* WebsiteRenderer
|
|
3
3
|
*
|
|
4
4
|
* Top-level renderer that sets up theme styles and renders pages.
|
|
5
|
+
* Manages scroll memory for navigation and optional analytics.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import React from 'react'
|
|
8
9
|
import PageRenderer from './PageRenderer.jsx'
|
|
10
|
+
import { useRememberScroll } from '../hooks/useRememberScroll.js'
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Build CSS custom properties from theme data
|
|
@@ -57,6 +59,9 @@ function Fonts({ fontsData }) {
|
|
|
57
59
|
export default function WebsiteRenderer() {
|
|
58
60
|
const website = globalThis.uniweb?.activeWebsite
|
|
59
61
|
|
|
62
|
+
// Enable scroll memory for navigation
|
|
63
|
+
useRememberScroll({ enabled: true })
|
|
64
|
+
|
|
60
65
|
if (!website) {
|
|
61
66
|
return (
|
|
62
67
|
<div className="website-loading" style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useHeadMeta Hook
|
|
3
|
+
*
|
|
4
|
+
* Manages document head meta tags for SEO and social sharing.
|
|
5
|
+
* Updates meta tags when page changes in SPA navigation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef } from 'react'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Meta tag definitions for easy management
|
|
12
|
+
*/
|
|
13
|
+
const META_TAGS = {
|
|
14
|
+
description: { name: 'description' },
|
|
15
|
+
keywords: { name: 'keywords' },
|
|
16
|
+
robots: { name: 'robots' },
|
|
17
|
+
'og:title': { property: 'og:title' },
|
|
18
|
+
'og:description': { property: 'og:description' },
|
|
19
|
+
'og:image': { property: 'og:image' },
|
|
20
|
+
'og:url': { property: 'og:url' },
|
|
21
|
+
'og:type': { property: 'og:type' },
|
|
22
|
+
'twitter:card': { name: 'twitter:card' },
|
|
23
|
+
'twitter:title': { name: 'twitter:title' },
|
|
24
|
+
'twitter:description': { name: 'twitter:description' },
|
|
25
|
+
'twitter:image': { name: 'twitter:image' }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get or create a meta tag element
|
|
30
|
+
* @param {string} key - Meta tag key (e.g., 'description', 'og:title')
|
|
31
|
+
* @returns {HTMLMetaElement}
|
|
32
|
+
*/
|
|
33
|
+
function getOrCreateMetaTag(key) {
|
|
34
|
+
const config = META_TAGS[key]
|
|
35
|
+
if (!config) return null
|
|
36
|
+
|
|
37
|
+
const selector = config.property
|
|
38
|
+
? `meta[property="${config.property}"]`
|
|
39
|
+
: `meta[name="${config.name}"]`
|
|
40
|
+
|
|
41
|
+
let element = document.querySelector(selector)
|
|
42
|
+
|
|
43
|
+
if (!element) {
|
|
44
|
+
element = document.createElement('meta')
|
|
45
|
+
if (config.property) {
|
|
46
|
+
element.setAttribute('property', config.property)
|
|
47
|
+
} else {
|
|
48
|
+
element.setAttribute('name', config.name)
|
|
49
|
+
}
|
|
50
|
+
document.head.appendChild(element)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return element
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get or create a link element (for canonical)
|
|
58
|
+
* @param {string} rel - Link rel attribute
|
|
59
|
+
* @returns {HTMLLinkElement}
|
|
60
|
+
*/
|
|
61
|
+
function getOrCreateLinkTag(rel) {
|
|
62
|
+
let element = document.querySelector(`link[rel="${rel}"]`)
|
|
63
|
+
|
|
64
|
+
if (!element) {
|
|
65
|
+
element = document.createElement('link')
|
|
66
|
+
element.setAttribute('rel', rel)
|
|
67
|
+
document.head.appendChild(element)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return element
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Set or remove a meta tag's content
|
|
75
|
+
* @param {string} key - Meta tag key
|
|
76
|
+
* @param {string|null} content - Content value (null to remove)
|
|
77
|
+
*/
|
|
78
|
+
function setMetaContent(key, content) {
|
|
79
|
+
const element = getOrCreateMetaTag(key)
|
|
80
|
+
if (!element) return
|
|
81
|
+
|
|
82
|
+
if (content) {
|
|
83
|
+
element.setAttribute('content', content)
|
|
84
|
+
} else {
|
|
85
|
+
// Remove the tag if no content
|
|
86
|
+
element.remove()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* useHeadMeta hook
|
|
92
|
+
*
|
|
93
|
+
* @param {Object} meta - Head metadata
|
|
94
|
+
* @param {string} meta.title - Page title
|
|
95
|
+
* @param {string} meta.description - Meta description
|
|
96
|
+
* @param {string} meta.keywords - Meta keywords (string or array)
|
|
97
|
+
* @param {string} meta.canonical - Canonical URL
|
|
98
|
+
* @param {string} meta.robots - Robots directive (e.g., 'noindex, nofollow')
|
|
99
|
+
* @param {Object} meta.og - Open Graph metadata
|
|
100
|
+
* @param {string} meta.og.title - OG title
|
|
101
|
+
* @param {string} meta.og.description - OG description
|
|
102
|
+
* @param {string} meta.og.image - OG image URL
|
|
103
|
+
* @param {string} meta.og.url - OG URL
|
|
104
|
+
* @param {Object} options - Hook options
|
|
105
|
+
* @param {string} options.siteName - Site name for title suffix
|
|
106
|
+
* @param {string} options.titleSeparator - Separator between page title and site name
|
|
107
|
+
*/
|
|
108
|
+
export function useHeadMeta(meta, options = {}) {
|
|
109
|
+
const {
|
|
110
|
+
siteName = '',
|
|
111
|
+
titleSeparator = ' | '
|
|
112
|
+
} = options
|
|
113
|
+
|
|
114
|
+
// Track created elements for cleanup
|
|
115
|
+
const createdElements = useRef([])
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!meta) return
|
|
119
|
+
|
|
120
|
+
// Update document title
|
|
121
|
+
if (meta.title) {
|
|
122
|
+
const fullTitle = siteName
|
|
123
|
+
? `${meta.title}${titleSeparator}${siteName}`
|
|
124
|
+
: meta.title
|
|
125
|
+
document.title = fullTitle
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Update meta description
|
|
129
|
+
setMetaContent('description', meta.description || null)
|
|
130
|
+
|
|
131
|
+
// Update meta keywords
|
|
132
|
+
const keywords = Array.isArray(meta.keywords)
|
|
133
|
+
? meta.keywords.join(', ')
|
|
134
|
+
: meta.keywords
|
|
135
|
+
setMetaContent('keywords', keywords || null)
|
|
136
|
+
|
|
137
|
+
// Update robots
|
|
138
|
+
setMetaContent('robots', meta.robots || null)
|
|
139
|
+
|
|
140
|
+
// Update Open Graph tags
|
|
141
|
+
if (meta.og) {
|
|
142
|
+
setMetaContent('og:title', meta.og.title || meta.title || null)
|
|
143
|
+
setMetaContent('og:description', meta.og.description || meta.description || null)
|
|
144
|
+
setMetaContent('og:image', meta.og.image || null)
|
|
145
|
+
setMetaContent('og:url', meta.og.url || null)
|
|
146
|
+
setMetaContent('og:type', 'website')
|
|
147
|
+
|
|
148
|
+
// Twitter cards (fallback to OG values)
|
|
149
|
+
setMetaContent('twitter:card', meta.og.image ? 'summary_large_image' : 'summary')
|
|
150
|
+
setMetaContent('twitter:title', meta.og.title || meta.title || null)
|
|
151
|
+
setMetaContent('twitter:description', meta.og.description || meta.description || null)
|
|
152
|
+
setMetaContent('twitter:image', meta.og.image || null)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Update canonical link
|
|
156
|
+
if (meta.canonical) {
|
|
157
|
+
const canonicalLink = getOrCreateLinkTag('canonical')
|
|
158
|
+
canonicalLink.setAttribute('href', meta.canonical)
|
|
159
|
+
} else {
|
|
160
|
+
// Remove canonical if not set
|
|
161
|
+
const existingCanonical = document.querySelector('link[rel="canonical"]')
|
|
162
|
+
if (existingCanonical) {
|
|
163
|
+
existingCanonical.remove()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Cleanup function - reset to defaults on unmount
|
|
168
|
+
return () => {
|
|
169
|
+
// We don't remove tags on cleanup because another page will set them
|
|
170
|
+
// Just reset title as a fallback
|
|
171
|
+
document.title = siteName || 'Website'
|
|
172
|
+
}
|
|
173
|
+
}, [
|
|
174
|
+
meta?.title,
|
|
175
|
+
meta?.description,
|
|
176
|
+
meta?.keywords,
|
|
177
|
+
meta?.canonical,
|
|
178
|
+
meta?.robots,
|
|
179
|
+
meta?.og?.title,
|
|
180
|
+
meta?.og?.description,
|
|
181
|
+
meta?.og?.image,
|
|
182
|
+
meta?.og?.url,
|
|
183
|
+
siteName,
|
|
184
|
+
titleSeparator
|
|
185
|
+
])
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export default useHeadMeta
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useRememberScroll Hook
|
|
3
|
+
*
|
|
4
|
+
* Remembers scroll position per page and restores it on navigation.
|
|
5
|
+
* Works with Page.scrollY property for persistence.
|
|
6
|
+
*
|
|
7
|
+
* Behavior:
|
|
8
|
+
* - Saves scroll position when navigating away from a page
|
|
9
|
+
* - Restores scroll position when returning to a previously visited page
|
|
10
|
+
* - Scrolls to top for newly visited pages
|
|
11
|
+
* - Optionally resets block states on scroll restoration
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useEffect, useRef } from 'react'
|
|
15
|
+
import { useLocation } from 'react-router-dom'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* useRememberScroll hook
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} options
|
|
21
|
+
* @param {boolean} options.enabled - Enable scroll memory (default: true)
|
|
22
|
+
* @param {boolean} options.resetBlockStates - Reset block states on restore (default: true)
|
|
23
|
+
* @param {number} options.scrollDelay - Delay before restoring scroll (default: 0)
|
|
24
|
+
*/
|
|
25
|
+
export function useRememberScroll(options = {}) {
|
|
26
|
+
const { enabled = true, resetBlockStates = true, scrollDelay = 0 } = options
|
|
27
|
+
|
|
28
|
+
const location = useLocation()
|
|
29
|
+
const previousPathRef = useRef(location.pathname)
|
|
30
|
+
const isFirstRender = useRef(true)
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!enabled) return
|
|
34
|
+
|
|
35
|
+
const uniweb = globalThis.uniweb
|
|
36
|
+
const website = uniweb?.activeWebsite
|
|
37
|
+
if (!website) return
|
|
38
|
+
|
|
39
|
+
// Get current and previous pages
|
|
40
|
+
const currentPage = website.activePage
|
|
41
|
+
const previousPath = previousPathRef.current
|
|
42
|
+
const currentPath = location.pathname
|
|
43
|
+
|
|
44
|
+
// Skip on first render
|
|
45
|
+
if (isFirstRender.current) {
|
|
46
|
+
isFirstRender.current = false
|
|
47
|
+
previousPathRef.current = currentPath
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Path hasn't changed (might be hash or search change)
|
|
52
|
+
if (previousPath === currentPath) {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Save scroll position from previous page
|
|
57
|
+
const previousPage = website.getPage(previousPath)
|
|
58
|
+
if (previousPage) {
|
|
59
|
+
previousPage.scrollY = window.scrollY
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Restore or reset scroll for current page
|
|
63
|
+
if (currentPage) {
|
|
64
|
+
const targetScroll = currentPage.scrollY || 0
|
|
65
|
+
|
|
66
|
+
// Reset block states if requested (for animations, etc.)
|
|
67
|
+
if (resetBlockStates && typeof currentPage.resetBlockStates === 'function') {
|
|
68
|
+
currentPage.resetBlockStates()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Restore scroll position
|
|
72
|
+
const restore = () => {
|
|
73
|
+
window.scrollTo(0, targetScroll)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (scrollDelay > 0) {
|
|
77
|
+
setTimeout(restore, scrollDelay)
|
|
78
|
+
} else {
|
|
79
|
+
// Use requestAnimationFrame for smoother restoration
|
|
80
|
+
requestAnimationFrame(restore)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Update previous path ref
|
|
85
|
+
previousPathRef.current = currentPath
|
|
86
|
+
}, [location.pathname, enabled, resetBlockStates, scrollDelay])
|
|
87
|
+
|
|
88
|
+
// Save scroll position before page unload
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!enabled) return
|
|
91
|
+
|
|
92
|
+
const handleBeforeUnload = () => {
|
|
93
|
+
const uniweb = globalThis.uniweb
|
|
94
|
+
const page = uniweb?.activeWebsite?.activePage
|
|
95
|
+
if (page) {
|
|
96
|
+
page.scrollY = window.scrollY
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
101
|
+
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
102
|
+
}, [enabled])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default useRememberScroll
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useScrollDepth Hook
|
|
3
|
+
*
|
|
4
|
+
* Tracks scroll depth and reports to uniweb.analytics at milestones (25%, 50%, 75%, 100%).
|
|
5
|
+
* Only tracks if analytics is enabled.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef } from 'react'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calculate current scroll depth percentage
|
|
12
|
+
* @returns {number} Scroll depth 0-100
|
|
13
|
+
*/
|
|
14
|
+
function getScrollDepth() {
|
|
15
|
+
const scrollTop = window.scrollY
|
|
16
|
+
const docHeight = document.documentElement.scrollHeight - window.innerHeight
|
|
17
|
+
|
|
18
|
+
if (docHeight <= 0) return 100 // Page fits in viewport
|
|
19
|
+
|
|
20
|
+
return Math.min(100, Math.round((scrollTop / docHeight) * 100))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* useScrollDepth hook
|
|
25
|
+
*
|
|
26
|
+
* Uses globalThis.uniweb.analytics for tracking.
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} options
|
|
29
|
+
* @param {boolean} options.enabled - Enable tracking (default: true)
|
|
30
|
+
* @param {number} options.throttleMs - Throttle scroll events (default: 200)
|
|
31
|
+
*/
|
|
32
|
+
export function useScrollDepth(options = {}) {
|
|
33
|
+
const { enabled = true, throttleMs = 200 } = options
|
|
34
|
+
|
|
35
|
+
const lastCheck = useRef(0)
|
|
36
|
+
const reportedMilestones = useRef(new Set())
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const analytics = globalThis.uniweb?.analytics
|
|
40
|
+
|
|
41
|
+
// Skip if analytics not available or disabled
|
|
42
|
+
if (!enabled || !analytics?.isEnabled?.()) return
|
|
43
|
+
|
|
44
|
+
// Reset milestones on mount (new page)
|
|
45
|
+
reportedMilestones.current.clear()
|
|
46
|
+
|
|
47
|
+
const handleScroll = () => {
|
|
48
|
+
const now = Date.now()
|
|
49
|
+
if (now - lastCheck.current < throttleMs) return
|
|
50
|
+
lastCheck.current = now
|
|
51
|
+
|
|
52
|
+
const depth = getScrollDepth()
|
|
53
|
+
const milestones = [25, 50, 75, 100]
|
|
54
|
+
|
|
55
|
+
for (const milestone of milestones) {
|
|
56
|
+
if (depth >= milestone && !reportedMilestones.current.has(milestone)) {
|
|
57
|
+
reportedMilestones.current.add(milestone)
|
|
58
|
+
analytics.trackScrollDepth(milestone)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check initial scroll position
|
|
64
|
+
handleScroll()
|
|
65
|
+
|
|
66
|
+
window.addEventListener('scroll', handleScroll, { passive: true })
|
|
67
|
+
return () => window.removeEventListener('scroll', handleScroll)
|
|
68
|
+
}, [enabled, throttleMs])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default useScrollDepth
|
package/src/index.jsx
CHANGED
|
@@ -13,6 +13,8 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
|
|
13
13
|
import { ChildBlocks } from './components/PageRenderer.jsx'
|
|
14
14
|
import WebsiteRenderer from './components/WebsiteRenderer.jsx'
|
|
15
15
|
import ErrorBoundary from './components/ErrorBoundary.jsx'
|
|
16
|
+
import Layout from './components/Layout.jsx'
|
|
17
|
+
import Blocks from './components/Blocks.jsx'
|
|
16
18
|
|
|
17
19
|
// Core factory from @uniweb/core
|
|
18
20
|
import { createUniweb } from '@uniweb/core'
|