/
NavigationManager.ts
138 lines (115 loc) · 5.11 KB
/
NavigationManager.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import '@dotnet/jsinterop';
import { resetScrollAfterNextBatch } from '../Rendering/Renderer';
import { EventDelegator } from '../Rendering/EventDelegator';
let hasEnabledNavigationInterception = false;
let hasRegisteredNavigationEventListeners = false;
// Will be initialized once someone registers
let notifyLocationChangedCallback: ((uri: string, intercepted: boolean) => Promise<void>) | null = null;
// These are the functions we're making available for invocation from .NET
export const internalFunctions = {
listenForNavigationEvents,
enableNavigationInterception,
navigateTo,
getBaseURI: () => document.baseURI,
getLocationHref: () => location.href,
};
function listenForNavigationEvents(callback: (uri: string, intercepted: boolean) => Promise<void>) {
notifyLocationChangedCallback = callback;
if (hasRegisteredNavigationEventListeners) {
return;
}
hasRegisteredNavigationEventListeners = true;
window.addEventListener('popstate', () => notifyLocationChanged(false));
}
function enableNavigationInterception() {
hasEnabledNavigationInterception = true;
}
export function attachToEventDelegator(eventDelegator: EventDelegator) {
// We need to respond to clicks on <a> elements *after* the EventDelegator has finished
// running its simulated bubbling process so that we can respect any preventDefault requests.
// So instead of registering our own native event, register using the EventDelegator.
eventDelegator.notifyAfterClick(event => {
if (!hasEnabledNavigationInterception) {
return;
}
if (event.button !== 0 || eventHasSpecialKey(event)) {
// Don't stop ctrl/meta-click (etc) from opening links in new tabs/windows
return;
}
if (event.defaultPrevented) {
return;
}
// Intercept clicks on all <a> elements where the href is within the <base href> URI space
// We must explicitly check if it has an 'href' attribute, because if it doesn't, the result might be null or an empty string depending on the browser
const anchorTarget = findClosestAncestor(event.target as Element | null, 'A') as HTMLAnchorElement | null;
const hrefAttributeName = 'href';
if (anchorTarget && anchorTarget.hasAttribute(hrefAttributeName)) {
const targetAttributeValue = anchorTarget.getAttribute('target');
const opensInSameFrame = !targetAttributeValue || targetAttributeValue === '_self';
if (!opensInSameFrame) {
return;
}
const href = anchorTarget.getAttribute(hrefAttributeName)!;
const absoluteHref = toAbsoluteUri(href);
if (isWithinBaseUriSpace(absoluteHref)) {
event.preventDefault();
performInternalNavigation(absoluteHref, true);
}
}
});
}
export function navigateTo(uri: string, forceLoad: boolean) {
const absoluteUri = toAbsoluteUri(uri);
if (!forceLoad && isWithinBaseUriSpace(absoluteUri)) {
// It's an internal URL, so do client-side navigation
performInternalNavigation(absoluteUri, false);
} else if (forceLoad && location.href === uri) {
// Force-loading the same URL you're already on requires special handling to avoid
// triggering browser-specific behavior issues.
// For details about what this fixes and why, see https://github.com/aspnet/AspNetCore/pull/10839
const temporaryUri = uri + '?';
history.replaceState(null, '', temporaryUri);
location.replace(uri);
} else {
// It's either an external URL, or forceLoad is requested, so do a full page load
location.href = uri;
}
}
function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean) {
// Since this was *not* triggered by a back/forward gesture (that goes through a different
// code path starting with a popstate event), we don't want to preserve the current scroll
// position, so reset it.
// To avoid ugly flickering effects, we don't want to change the scroll position until the
// we render the new page. As a best approximation, wait until the next batch.
resetScrollAfterNextBatch();
history.pushState(null, /* ignored title */ '', absoluteInternalHref);
notifyLocationChanged(interceptedLink);
}
async function notifyLocationChanged(interceptedLink: boolean) {
if (notifyLocationChangedCallback) {
await notifyLocationChangedCallback(location.href, interceptedLink);
}
}
let testAnchor: HTMLAnchorElement;
function toAbsoluteUri(relativeUri: string) {
testAnchor = testAnchor || document.createElement('a');
testAnchor.href = relativeUri;
return testAnchor.href;
}
function findClosestAncestor(element: Element | null, tagName: string) {
return !element
? null
: element.tagName === tagName
? element
: findClosestAncestor(element.parentElement, tagName);
}
function isWithinBaseUriSpace(href: string) {
const baseUriWithTrailingSlash = toBaseUriWithTrailingSlash(document.baseURI!); // TODO: Might baseURI really be null?
return href.startsWith(baseUriWithTrailingSlash);
}
function toBaseUriWithTrailingSlash(baseUri: string) {
return baseUri.substr(0, baseUri.lastIndexOf('/') + 1);
}
function eventHasSpecialKey(event: MouseEvent) {
return event.ctrlKey || event.shiftKey || event.altKey || event.metaKey;
}