@uniweb/runtime 0.2.4 → 0.2.5
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
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import React from 'react'
|
|
9
9
|
import PageRenderer from './PageRenderer.jsx'
|
|
10
10
|
import { useRememberScroll } from '../hooks/useRememberScroll.js'
|
|
11
|
+
import { useLinkInterceptor } from '../hooks/useLinkInterceptor.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Build CSS custom properties from theme data
|
|
@@ -59,6 +60,9 @@ function Fonts({ fontsData }) {
|
|
|
59
60
|
export default function WebsiteRenderer() {
|
|
60
61
|
const website = globalThis.uniweb?.activeWebsite
|
|
61
62
|
|
|
63
|
+
// Enable SPA navigation for links rendered as plain HTML
|
|
64
|
+
useLinkInterceptor({ enabled: true })
|
|
65
|
+
|
|
62
66
|
// Enable scroll memory for navigation
|
|
63
67
|
useRememberScroll({ enabled: true })
|
|
64
68
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLinkInterceptor Hook
|
|
3
|
+
*
|
|
4
|
+
* Intercepts clicks on internal links rendered as plain <a> tags
|
|
5
|
+
* (e.g., from markdown content) and uses React Router navigation
|
|
6
|
+
* instead of full page reloads.
|
|
7
|
+
*
|
|
8
|
+
* This enables SPA-style navigation for links that were rendered
|
|
9
|
+
* as raw HTML via dangerouslySetInnerHTML.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect } from 'react'
|
|
13
|
+
import { useNavigate } from 'react-router-dom'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if a URL is internal (same origin, no external protocol)
|
|
17
|
+
* @param {string} href - The href to check
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
function isInternalLink(href) {
|
|
21
|
+
if (!href) return false
|
|
22
|
+
|
|
23
|
+
// Hash-only links are internal
|
|
24
|
+
if (href.startsWith('#')) return true
|
|
25
|
+
|
|
26
|
+
// Relative paths are internal
|
|
27
|
+
if (href.startsWith('/') && !href.startsWith('//')) return true
|
|
28
|
+
|
|
29
|
+
// Check if same origin
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(href, window.location.origin)
|
|
32
|
+
return url.origin === window.location.origin
|
|
33
|
+
} catch {
|
|
34
|
+
// Invalid URL, treat as internal relative path
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if the click should trigger navigation
|
|
41
|
+
* @param {MouseEvent} event - The click event
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function shouldNavigate(event) {
|
|
45
|
+
// Ignore if default was already prevented
|
|
46
|
+
if (event.defaultPrevented) return false
|
|
47
|
+
|
|
48
|
+
// Ignore modified clicks (new tab, etc.)
|
|
49
|
+
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return false
|
|
50
|
+
|
|
51
|
+
// Ignore right-clicks
|
|
52
|
+
if (event.button !== 0) return false
|
|
53
|
+
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find the closest anchor element from an event target
|
|
59
|
+
* @param {EventTarget} target - The event target
|
|
60
|
+
* @returns {HTMLAnchorElement|null}
|
|
61
|
+
*/
|
|
62
|
+
function findAnchorElement(target) {
|
|
63
|
+
let element = target
|
|
64
|
+
while (element && element !== document.body) {
|
|
65
|
+
if (element.tagName === 'A') {
|
|
66
|
+
return element
|
|
67
|
+
}
|
|
68
|
+
element = element.parentElement
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* useLinkInterceptor hook
|
|
75
|
+
*
|
|
76
|
+
* @param {Object} options
|
|
77
|
+
* @param {boolean} options.enabled - Enable link interception (default: true)
|
|
78
|
+
*/
|
|
79
|
+
export function useLinkInterceptor(options = {}) {
|
|
80
|
+
const { enabled = true } = options
|
|
81
|
+
const navigate = useNavigate()
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!enabled) return
|
|
85
|
+
|
|
86
|
+
function handleClick(event) {
|
|
87
|
+
// Check if we should handle this click
|
|
88
|
+
if (!shouldNavigate(event)) return
|
|
89
|
+
|
|
90
|
+
// Find the anchor element
|
|
91
|
+
const anchor = findAnchorElement(event.target)
|
|
92
|
+
if (!anchor) return
|
|
93
|
+
|
|
94
|
+
// Get the href
|
|
95
|
+
const href = anchor.getAttribute('href')
|
|
96
|
+
if (!href) return
|
|
97
|
+
|
|
98
|
+
// Check if it's an internal link
|
|
99
|
+
if (!isInternalLink(href)) return
|
|
100
|
+
|
|
101
|
+
// Check for download attribute
|
|
102
|
+
if (anchor.hasAttribute('download')) return
|
|
103
|
+
|
|
104
|
+
// Check for target="_blank" or other non-self targets
|
|
105
|
+
const target = anchor.getAttribute('target')
|
|
106
|
+
if (target && target !== '_self') return
|
|
107
|
+
|
|
108
|
+
// Prevent the default browser navigation
|
|
109
|
+
event.preventDefault()
|
|
110
|
+
|
|
111
|
+
// Handle hash-only links
|
|
112
|
+
if (href.startsWith('#')) {
|
|
113
|
+
// Scroll to element or top
|
|
114
|
+
const elementId = href.slice(1)
|
|
115
|
+
if (elementId) {
|
|
116
|
+
const element = document.getElementById(elementId)
|
|
117
|
+
if (element) {
|
|
118
|
+
element.scrollIntoView({ behavior: 'smooth' })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Use React Router navigation
|
|
125
|
+
navigate(href)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Add click listener to document
|
|
129
|
+
document.addEventListener('click', handleClick)
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
document.removeEventListener('click', handleClick)
|
|
133
|
+
}
|
|
134
|
+
}, [enabled, navigate])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default useLinkInterceptor
|