diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 3ddbecebe872..e5ccdb25f1ba 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -4,6 +4,8 @@ Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager. Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! *REMOVED*Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string! relativeUri) -> System.Uri! Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri! +Microsoft.AspNetCore.Components.Routing.IScrollToLocationHash +Microsoft.AspNetCore.Components.Routing.IScrollToLocationHash.RefreshScrollPositionForHash(string! locationAbsolute) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.Rendering.ComponentState Microsoft.AspNetCore.Components.Rendering.ComponentState.Component.get -> Microsoft.AspNetCore.Components.IComponent! Microsoft.AspNetCore.Components.Rendering.ComponentState.ComponentId.get -> int diff --git a/src/Components/Components/src/Routing/IScrollToLocationHash.cs b/src/Components/Components/src/Routing/IScrollToLocationHash.cs new file mode 100644 index 000000000000..516d86f4d089 --- /dev/null +++ b/src/Components/Components/src/Routing/IScrollToLocationHash.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Routing; + +/// +/// Contract to setup scroll to location hash. +/// +public interface IScrollToLocationHash +{ + /// + /// Refreshes scroll position for hash on the client. + /// + /// Absolute URL of location + /// A that represents the asynchronous operation. + Task RefreshScrollPositionForHash(string locationAbsolute); +} diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index 8c9888125686..92b88c8251fc 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -26,6 +26,9 @@ static readonly IReadOnlyDictionary _emptyParametersDictionary bool _navigationInterceptionEnabled; ILogger _logger; + private Type? _updateScrollPositionForHashLastHandlerType; + private bool _updateScrollPositionForHash; + private CancellationTokenSource _onNavigateCts; private Task _previousOnNavigateTask = Task.CompletedTask; @@ -38,6 +41,8 @@ static readonly IReadOnlyDictionary _emptyParametersDictionary [Inject] private INavigationInterception NavigationInterception { get; set; } + [Inject] private IScrollToLocationHash ScrollToLocationHash { get; set; } + [Inject] private ILoggerFactory LoggerFactory { get; set; } /// @@ -206,6 +211,13 @@ internal virtual void Refresh(bool isNavigationIntercepted) context.Handler, context.Parameters ?? _emptyParametersDictionary); _renderHandle.Render(Found(routeData)); + + // If you navigate to a different page, then after the next render we'll update the scroll position + if (context.Handler != _updateScrollPositionForHashLastHandlerType) + { + _updateScrollPositionForHashLastHandlerType = context.Handler; + _updateScrollPositionForHash = true; + } } else { @@ -276,15 +288,19 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args) } } - Task IHandleAfterRender.OnAfterRenderAsync() + async Task IHandleAfterRender.OnAfterRenderAsync() { if (!_navigationInterceptionEnabled) { _navigationInterceptionEnabled = true; - return NavigationInterception.EnableNavigationInterceptionAsync(); + await NavigationInterception.EnableNavigationInterceptionAsync(); } - return Task.CompletedTask; + if (_updateScrollPositionForHash) + { + _updateScrollPositionForHash = false; + await ScrollToLocationHash.RefreshScrollPositionForHash(_locationAbsolute); + } } private static partial class Log diff --git a/src/Components/Components/test/Routing/RouterTest.cs b/src/Components/Components/test/Routing/RouterTest.cs index 01323d256845..ba6cebcb3e6e 100644 --- a/src/Components/Components/test/Routing/RouterTest.cs +++ b/src/Components/Components/test/Routing/RouterTest.cs @@ -23,6 +23,7 @@ public RouterTest() services.AddSingleton(NullLoggerFactory.Instance); services.AddSingleton(_navigationManager); services.AddSingleton(); + services.AddSingleton(); var serviceProvider = services.BuildServiceProvider(); _renderer = new TestRenderer(serviceProvider); @@ -220,6 +221,14 @@ public Task EnableNavigationInterceptionAsync() } } + internal sealed class TestScrollToLocationHash : IScrollToLocationHash + { + public Task RefreshScrollPositionForHash(string locationAbsolute) + { + return Task.CompletedTask; + } + } + [Route("feb")] public class FebComponent : ComponentBase { } diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 833ac3aaf75d..d2a47e7c533c 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -42,6 +42,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(sp => sp.GetRequiredService().State); services.TryAddScoped(); diff --git a/src/Components/Endpoints/src/DependencyInjection/UnsupportedScrollToLocationHash.cs b/src/Components/Endpoints/src/DependencyInjection/UnsupportedScrollToLocationHash.cs new file mode 100644 index 000000000000..3b5d049c760c --- /dev/null +++ b/src/Components/Endpoints/src/DependencyInjection/UnsupportedScrollToLocationHash.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Routing; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal sealed class UnsupportedScrollToLocationHash : IScrollToLocationHash +{ + public Task RefreshScrollPositionForHash(string locationAbsolute) + { + throw new InvalidOperationException("Scroll to location hash calls cannot be issued during server-side static rendering, because the page has not yet loaded in the browser."); + } +} diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index 1a2bd05a37fc..08bcc72673d1 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.cs @@ -47,12 +47,14 @@ public async ValueTask CreateCircuitHostAsync( var navigationManager = (RemoteNavigationManager)scope.ServiceProvider.GetRequiredService(); var navigationInterception = (RemoteNavigationInterception)scope.ServiceProvider.GetRequiredService(); + var scrollToLocationHash = (RemoteScrollToLocationHash)scope.ServiceProvider.GetRequiredService(); if (client.Connected) { navigationManager.AttachJsRuntime(jsRuntime); navigationManager.Initialize(baseUri, uri); navigationInterception.AttachJSRuntime(jsRuntime); + scrollToLocationHash.AttachJSRuntime(jsRuntime); } else { diff --git a/src/Components/Server/src/Circuits/RemoteScrollToLocationHash.cs b/src/Components/Server/src/Circuits/RemoteScrollToLocationHash.cs new file mode 100644 index 000000000000..ff969fac5034 --- /dev/null +++ b/src/Components/Server/src/Circuits/RemoteScrollToLocationHash.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.JSInterop; +using Interop = Microsoft.AspNetCore.Components.Web.BrowserNavigationManagerInterop; + +namespace Microsoft.AspNetCore.Components.Server.Circuits; + +internal sealed class RemoteScrollToLocationHash : IScrollToLocationHash +{ + private IJSRuntime _jsRuntime; + + public void AttachJSRuntime(IJSRuntime jsRuntime) + { + if (HasAttachedJSRuntime) + { + throw new InvalidOperationException("JSRuntime has already been initialized."); + } + + _jsRuntime = jsRuntime; + } + + public bool HasAttachedJSRuntime => _jsRuntime != null; + + public async Task RefreshScrollPositionForHash(string locationAbsolute) + { + if (!HasAttachedJSRuntime) + { + // We should generally never get here in the ordinary case. Router will only call this API once pre-rendering is complete. + // This would guard any unusual usage of this API. + throw new InvalidOperationException("Navigation commands can not be issued at this time. This is because the component is being " + + "prerendered and the page has not yet loaded in the browser or because the circuit is currently disconnected. " + + "Components must wrap any navigation calls in conditional logic to ensure those navigation calls are not " + + "attempted during prerendering or while the client is disconnected."); + } + + var hashIndex = locationAbsolute.IndexOf("#", StringComparison.Ordinal); + + if (hashIndex > -1 && locationAbsolute.Length > hashIndex + 1) + { + var elementId = locationAbsolute[(hashIndex + 1)..]; + + await _jsRuntime.InvokeVoidAsync(Interop.ScrollToElement, elementId); + } + } +} diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index cfd91538cdb3..d8f7b78a306b 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -77,6 +77,7 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.TryAddScoped(); services.TryAddEnumerable(ServiceDescriptor.Singleton, CircuitOptionsJSInteropDetailedErrorsConfiguration>()); diff --git a/src/Components/Shared/src/BrowserNavigationManagerInterop.cs b/src/Components/Shared/src/BrowserNavigationManagerInterop.cs index d1ee8fc175ee..b96976b847e4 100644 --- a/src/Components/Shared/src/BrowserNavigationManagerInterop.cs +++ b/src/Components/Shared/src/BrowserNavigationManagerInterop.cs @@ -17,4 +17,6 @@ internal static class BrowserNavigationManagerInterop public const string NavigateTo = Prefix + "navigateTo"; public const string SetHasLocationChangingListeners = Prefix + "setHasLocationChangingListeners"; + + public const string ScrollToElement = Prefix + "scrollToElement"; } diff --git a/src/Components/Web.JS/src/DomWrapper.ts b/src/Components/Web.JS/src/DomWrapper.ts index 5483985be7fd..5bc5f8058536 100644 --- a/src/Components/Web.JS/src/DomWrapper.ts +++ b/src/Components/Web.JS/src/DomWrapper.ts @@ -5,7 +5,7 @@ import '@microsoft/dotnet-js-interop'; export const domFunctions = { focus, - focusBySelector, + focusBySelector }; function focus(element: HTMLOrSVGElement, preventScroll: boolean): void { @@ -22,7 +22,7 @@ function focus(element: HTMLOrSVGElement, preventScroll: boolean): void { } } -function focusBySelector(selector: string): void { +function focusBySelector(selector: string, preventScroll: boolean): void { const element = document.querySelector(selector) as HTMLElement; if (element) { // If no explicit tabindex is defined, mark it as programmatically-focusable. @@ -32,6 +32,6 @@ function focusBySelector(selector: string): void { element.tabIndex = -1; } - element.focus(); + element.focus({ preventScroll: true }); } -} +} \ No newline at end of file diff --git a/src/Components/Web.JS/src/Services/NavigationManager.ts b/src/Components/Web.JS/src/Services/NavigationManager.ts index c3321da6d9a3..a30f9687542f 100644 --- a/src/Components/Web.JS/src/Services/NavigationManager.ts +++ b/src/Components/Web.JS/src/Services/NavigationManager.ts @@ -27,6 +27,7 @@ export const internalFunctions = { navigateTo: navigateToFromDotNet, getBaseURI: (): string => document.baseURI, getLocationHref: (): string => location.href, + scrollToElement }; function listenForNavigationEvents( @@ -53,6 +54,18 @@ function setHasLocationChangingListeners(hasListeners: boolean) { hasLocationChangingEventListeners = hasListeners; } +export function scrollToElement(identifier: string): boolean { + const element = document.getElementById(identifier) + || document.getElementsByName(identifier)[0]; + + if (element) { + element.scrollIntoView(); + return true; + } + + return false; +} + export function attachToEventDelegator(eventDelegator: EventDelegator): void { // We need to respond to clicks on elements *after* the EventDelegator has finished // running its simulated bubbling process so that we can respect any preventDefault requests. @@ -76,8 +89,13 @@ export function attachToEventDelegator(eventDelegator: EventDelegator): void { const anchorTarget = findAnchorTarget(event); if (anchorTarget && canProcessAnchor(anchorTarget)) { - const href = anchorTarget.getAttribute('href')!; - const absoluteHref = toAbsoluteUri(href); + let anchorHref = anchorTarget.getAttribute('href')!; + if (anchorHref.startsWith('#')) { + // Preserve the existing URL but set the hash to match the link that was clicked + anchorHref = `${location.origin}${location.pathname}${location.search}${anchorHref}`; + } + + const absoluteHref = toAbsoluteUri(anchorHref); if (isWithinBaseUriSpace(absoluteHref)) { event.preventDefault(); @@ -87,6 +105,23 @@ export function attachToEventDelegator(eventDelegator: EventDelegator): void { }); } +function isSamePageWithHash(absoluteHref: string): boolean { + const hashIndex = absoluteHref.indexOf('#'); + return hashIndex > -1 && location.href.replace(location.hash, '') === absoluteHref.substring(0, hashIndex); +} + +function performScrollToElementOnTheSamePage(absoluteHref : string, replace: boolean, state: string | undefined = undefined): void { + saveToBrowserHistory(absoluteHref, replace, state); + + const hashIndex = absoluteHref.indexOf('#'); + if (hashIndex == absoluteHref.length - 1) { + return; + } + + const identifier = absoluteHref.substring(hashIndex + 1); + scrollToElement(identifier); +} + // For back-compat, we need to accept multiple overloads export function navigateTo(uri: string, options: NavigationOptions): void; export function navigateTo(uri: string, forceLoad: boolean): void; @@ -138,6 +173,11 @@ function performExternalNavigation(uri: string, replace: boolean) { async function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean, replace: boolean, state: string | undefined = undefined, skipLocationChangingCallback = false) { ignorePendingNavigation(); + if (isSamePageWithHash(absoluteInternalHref)) { + performScrollToElementOnTheSamePage(absoluteInternalHref, replace, state); + return; + } + if (!skipLocationChangingCallback && hasLocationChangingEventListeners) { const shouldContinueNavigation = await notifyLocationChanging(absoluteInternalHref, state, interceptedLink); if (!shouldContinueNavigation) { @@ -152,6 +192,12 @@ async function performInternalNavigation(absoluteInternalHref: string, intercept // we render the new page. As a best approximation, wait until the next batch. resetScrollAfterNextBatch(); + saveToBrowserHistory(absoluteInternalHref, replace, state); + + await notifyLocationChanged(interceptedLink); +} + +function saveToBrowserHistory(absoluteInternalHref: string, replace: boolean, state: string | undefined = undefined): void { if (!replace) { currentHistoryIndex++; history.pushState({ @@ -164,8 +210,6 @@ async function performInternalNavigation(absoluteInternalHref: string, intercept _index: currentHistoryIndex, }, /* ignored title */ '', absoluteInternalHref); } - - await notifyLocationChanged(interceptedLink); } function navigateHistoryWithoutPopStateCallback(delta: number): Promise { diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index b2beee5c220e..b7396f7b5e6f 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -249,6 +249,7 @@ internal void InitializeDefaultServices() Services.AddSingleton(DefaultWebAssemblyJSRuntime.Instance); Services.AddSingleton(WebAssemblyNavigationManager.Instance); Services.AddSingleton(WebAssemblyNavigationInterception.Instance); + Services.AddSingleton(WebAssemblyScrollToLocationHash.Instance); Services.AddSingleton(new LazyAssemblyLoader(DefaultWebAssemblyJSRuntime.Instance)); Services.AddSingleton(); Services.AddSingleton(sp => sp.GetRequiredService().State); diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/IInternalJSImportMethods.cs b/src/Components/WebAssembly/WebAssembly/src/Services/IInternalJSImportMethods.cs index 82a121cd1871..4762de390810 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/IInternalJSImportMethods.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/IInternalJSImportMethods.cs @@ -13,6 +13,8 @@ internal interface IInternalJSImportMethods void NavigationManager_EnableNavigationInterception(); + void NavigationManager_ScrollToElement(string id); + string NavigationManager_GetLocationHref(); string NavigationManager_GetBaseUri(); diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/InternalJSImportMethods.cs b/src/Components/WebAssembly/WebAssembly/src/Services/InternalJSImportMethods.cs index 29ad3a881328..84377f84dbe7 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/InternalJSImportMethods.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/InternalJSImportMethods.cs @@ -25,6 +25,9 @@ public string GetApplicationEnvironment() public void NavigationManager_EnableNavigationInterception() => NavigationManager_EnableNavigationInterceptionCore(); + public void NavigationManager_ScrollToElement(string id) + => NavigationManager_ScrollToElementCore(id); + public string NavigationManager_GetLocationHref() => NavigationManager_GetLocationHrefCore(); @@ -64,6 +67,9 @@ public string RegisteredComponents_GetParameterValues(int id) [JSImport(BrowserNavigationManagerInterop.EnableNavigationInterception, "blazor-internal")] private static partial void NavigationManager_EnableNavigationInterceptionCore(); + [JSImport(BrowserNavigationManagerInterop.ScrollToElement, "blazor-internal")] + private static partial void NavigationManager_ScrollToElementCore(string id); + [JSImport(BrowserNavigationManagerInterop.GetLocationHref, "blazor-internal")] private static partial string NavigationManager_GetLocationHrefCore(); diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyScrollToLocationHash.cs b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyScrollToLocationHash.cs new file mode 100644 index 000000000000..10f642582130 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyScrollToLocationHash.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Routing; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Services; + +internal sealed class WebAssemblyScrollToLocationHash : IScrollToLocationHash +{ + public static readonly WebAssemblyScrollToLocationHash Instance = new WebAssemblyScrollToLocationHash(); + + public Task RefreshScrollPositionForHash(string locationAbsolute) + { + var hashIndex = locationAbsolute.IndexOf("#", StringComparison.Ordinal); + + if (hashIndex > -1 && locationAbsolute.Length > hashIndex + 1) + { + var elementId = locationAbsolute[(hashIndex + 1)..]; + + InternalJSImportMethods.Instance.NavigationManager_ScrollToElement(elementId); + } + + return Task.CompletedTask; + } +} diff --git a/src/Components/WebAssembly/WebAssembly/test/TestInternalJSImportMethods.cs b/src/Components/WebAssembly/WebAssembly/test/TestInternalJSImportMethods.cs index e76e5af67374..d3c0d5f0b8cb 100644 --- a/src/Components/WebAssembly/WebAssembly/test/TestInternalJSImportMethods.cs +++ b/src/Components/WebAssembly/WebAssembly/test/TestInternalJSImportMethods.cs @@ -25,6 +25,8 @@ public string GetPersistedState() public void NavigationManager_EnableNavigationInterception() { } + public void NavigationManager_ScrollToElement(string id) { } + public string NavigationManager_GetBaseUri() => "https://www.example.com/awesome-part-that-will-be-truncated-in-tests"; diff --git a/src/Components/WebView/Samples/PhotinoPlatform/src/BlazorWindow.cs b/src/Components/WebView/Samples/PhotinoPlatform/src/BlazorWindow.cs index eb9e6d5250cc..84b5a93d7f9f 100644 --- a/src/Components/WebView/Samples/PhotinoPlatform/src/BlazorWindow.cs +++ b/src/Components/WebView/Samples/PhotinoPlatform/src/BlazorWindow.cs @@ -14,6 +14,7 @@ public class BlazorWindow { private readonly PhotinoWindow _window; private readonly PhotinoWebViewManager _manager; + private readonly string _pathBase; /// /// Constructs an instance of . @@ -22,11 +23,13 @@ public class BlazorWindow /// The path to the host page. /// The service provider. /// A callback that configures the window. + /// The pathbase for the application. URLs will be resolved relative to this. public BlazorWindow( string title, string hostPage, IServiceProvider services, - Action? configureWindow = null) + Action? configureWindow = null, + string? pathBase = null) { _window = new PhotinoWindow(title, options => { @@ -42,7 +45,15 @@ public BlazorWindow( var dispatcher = new PhotinoDispatcher(_window); var jsComponents = new JSComponentConfigurationStore(); - _manager = new PhotinoWebViewManager(_window, services, dispatcher, new Uri(PhotinoWebViewManager.AppBaseUri), fileProvider, jsComponents, hostPageRelativePath); + + _pathBase = (pathBase ?? string.Empty); + if (!_pathBase.EndsWith('/')) + { + _pathBase += "/"; + } + var appBaseUri = new Uri(new Uri(PhotinoWebViewManager.AppBaseOrigin), _pathBase); + + _manager = new PhotinoWebViewManager(_window, services, dispatcher, appBaseUri, fileProvider, jsComponents, hostPageRelativePath); RootComponents = new BlazorWindowRootComponents(_manager, jsComponents); } @@ -61,7 +72,7 @@ public BlazorWindow( /// public void Run() { - _manager.Navigate("/"); + _manager.Navigate(_pathBase); _window.WaitForClose(); } diff --git a/src/Components/WebView/Samples/PhotinoPlatform/src/PhotinoWebViewManager.cs b/src/Components/WebView/Samples/PhotinoPlatform/src/PhotinoWebViewManager.cs index 98a7f9ab0381..8ef1bb814f99 100644 --- a/src/Components/WebView/Samples/PhotinoPlatform/src/PhotinoWebViewManager.cs +++ b/src/Components/WebView/Samples/PhotinoPlatform/src/PhotinoWebViewManager.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Components.WebView.Photino; internal class PhotinoWebViewManager : WebViewManager { private readonly PhotinoWindow _window; + private readonly Uri _appBaseUri; // On Windows, we can't use a custom scheme to host the initial HTML, // because webview2 won't let you do top-level navigation to such a URL. @@ -20,12 +21,13 @@ internal class PhotinoWebViewManager : WebViewManager ? "http" : "app"; - internal static readonly string AppBaseUri + internal static readonly string AppBaseOrigin = $"{BlazorAppScheme}://0.0.0.0/"; public PhotinoWebViewManager(PhotinoWindow window, IServiceProvider provider, Dispatcher dispatcher, Uri appBaseUri, IFileProvider fileProvider, JSComponentConfigurationStore jsComponents, string hostPageRelativePath) : base(provider, dispatcher, appBaseUri, fileProvider, jsComponents, hostPageRelativePath) { + _appBaseUri = appBaseUri; _window = window ?? throw new ArgumentNullException(nameof(window)); _window.WebMessageReceived += (sender, message) => { @@ -35,7 +37,7 @@ public PhotinoWebViewManager(PhotinoWindow window, IServiceProvider provider, Di // TODO: Fix this. Photino should ideally tell us the URL that the message comes from so we // know whether to trust it. Currently it's hardcoded to trust messages from any source, including // if the webview is somehow navigated to an external URL. - var messageOriginUrl = new Uri(AppBaseUri); + var messageOriginUrl = _appBaseUri; MessageReceived(messageOriginUrl, (string)message!); }, message, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); @@ -48,7 +50,7 @@ public PhotinoWebViewManager(PhotinoWindow window, IServiceProvider provider, Di // since we're not, guess. var hasFileExtension = url.LastIndexOf('.') > url.LastIndexOf('/'); - if (url.StartsWith(AppBaseUri, StringComparison.Ordinal) + if (_appBaseUri.IsBaseOf(new Uri(url)) && TryGetResponseContent(url, !hasFileExtension, out _, out _, out var content, out var headers)) { headers.TryGetValue("Content-Type", out contentType); diff --git a/src/Components/WebView/Samples/PhotinoPlatform/testassets/PhotinoTestApp/Program.cs b/src/Components/WebView/Samples/PhotinoPlatform/testassets/PhotinoTestApp/Program.cs index 6f6d5fd506af..ca21b2958214 100644 --- a/src/Components/WebView/Samples/PhotinoPlatform/testassets/PhotinoTestApp/Program.cs +++ b/src/Components/WebView/Samples/PhotinoPlatform/testassets/PhotinoTestApp/Program.cs @@ -20,7 +20,8 @@ static void Main(string[] args) var mainWindow = new BlazorWindow( title: "Hello, world!", hostPage: "wwwroot/webviewhost.html", - services: serviceCollection.BuildServiceProvider()); + services: serviceCollection.BuildServiceProvider(), + pathBase: "/subdir"); // The content in BasicTestApp assumes this AppDomain.CurrentDomain.UnhandledException += (sender, error) => { diff --git a/src/Components/WebView/Samples/PhotinoPlatform/testassets/PhotinoTestApp/wwwroot/webviewhost.html b/src/Components/WebView/Samples/PhotinoPlatform/testassets/PhotinoTestApp/wwwroot/webviewhost.html index 6c7958a33931..bace7c75cec3 100644 --- a/src/Components/WebView/Samples/PhotinoPlatform/testassets/PhotinoTestApp/wwwroot/webviewhost.html +++ b/src/Components/WebView/Samples/PhotinoPlatform/testassets/PhotinoTestApp/wwwroot/webviewhost.html @@ -5,7 +5,7 @@ PhotinoTestApp - + diff --git a/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs index cb822b313c62..6de9a2d66f74 100644 --- a/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs +++ b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs @@ -25,6 +25,7 @@ public static IServiceCollection AddBlazorWebView(this IServiceCollection servic services.AddLogging(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); return services; diff --git a/src/Components/WebView/WebView/src/PageContext.cs b/src/Components/WebView/WebView/src/PageContext.cs index 0230ccd99c54..9b198fb8d0df 100644 --- a/src/Components/WebView/WebView/src/PageContext.cs +++ b/src/Components/WebView/WebView/src/PageContext.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web.Infrastructure; using Microsoft.AspNetCore.Components.WebView.Services; @@ -46,6 +47,9 @@ public PageContext( var loggerFactory = ServiceProvider.GetRequiredService(); var jsComponents = new JSComponentInterop(jsComponentsConfiguration); Renderer = new WebViewRenderer(ServiceProvider, dispatcher, ipcSender, loggerFactory, JSRuntime, jsComponents); + + var webViewScrollToLocationHash = (WebViewScrollToLocationHash)ServiceProvider.GetRequiredService(); + webViewScrollToLocationHash.AttachJSRuntime(JSRuntime); } public async ValueTask DisposeAsync() diff --git a/src/Components/WebView/WebView/src/Services/WebViewScrollToLocationHash.cs b/src/Components/WebView/WebView/src/Services/WebViewScrollToLocationHash.cs new file mode 100644 index 000000000000..c7fa89be4377 --- /dev/null +++ b/src/Components/WebView/WebView/src/Services/WebViewScrollToLocationHash.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components.WebView.Services; + +internal sealed class WebViewScrollToLocationHash : IScrollToLocationHash +{ + private IJSRuntime _jsRuntime; + + public void AttachJSRuntime(IJSRuntime jsRuntime) + { + if (HasAttachedJSRuntime) + { + throw new InvalidOperationException("JSRuntime has already been initialized."); + } + + _jsRuntime = jsRuntime; + } + + public bool HasAttachedJSRuntime => _jsRuntime != null; + + public async Task RefreshScrollPositionForHash(string locationAbsolute) + { + if (!HasAttachedJSRuntime) + { + throw new InvalidOperationException("JSRuntime has not been attached."); + } + + var hashIndex = locationAbsolute.IndexOf("#", StringComparison.Ordinal); + + if (hashIndex > -1 && locationAbsolute.Length > hashIndex + 1) + { + var elementId = locationAbsolute[(hashIndex + 1)..]; + + await _jsRuntime.InvokeVoidAsync("Blazor._internal.navigationManager.scrollToElement", elementId).AsTask(); + } + } +} diff --git a/src/Components/test/E2ETest/Tests/RoutingTest.cs b/src/Components/test/E2ETest/Tests/RoutingTest.cs index 1ac194638530..8bdfb75eaa35 100644 --- a/src/Components/test/E2ETest/Tests/RoutingTest.cs +++ b/src/Components/test/E2ETest/Tests/RoutingTest.cs @@ -1546,9 +1546,73 @@ public void CanNavigateBetweenPagesWithQueryStrings() AssertHighlightedLinks("With query parameters (none)", "With query parameters (passing string value)"); } + [Fact] + public void AnchorWithHrefStartingWithHash_ScrollsToElementWithIdOnTheSamePage() + { + SetUrlViaPushState("/"); + var app = Browser.MountTestComponent(); + app.FindElement(By.LinkText("Long page with hash")).Click(); + + app.FindElement(By.Id("anchor-test1")).Click(); + + var currentWindowScrollY = BrowserScrollY; + var test1VerticalLocation = app.FindElement(By.Id("test1")).Location.Y; + var currentRelativeUrl = _serverFixture.RootUri.MakeRelativeUri(new Uri(Browser.Url)).ToString(); + Assert.Equal("subdir/LongPageWithHash#test1", currentRelativeUrl); + Assert.Equal(test1VerticalLocation, currentWindowScrollY); + } + + [Fact] + public void AnchorWithHrefContainingHash_NavigatesToPageAndScrollsToElementWithName() + { + SetUrlViaPushState("/"); + var app = Browser.MountTestComponent(); + app.FindElement(By.LinkText("Long page with hash")).Click(); + + app.FindElement(By.Id("anchor-test2")).Click(); + + var currentWindowScrollY = BrowserScrollY; + var test2VerticalLocation = app.FindElement(By.Name("test2")).Location.Y; + var currentRelativeUrl = _serverFixture.RootUri.MakeRelativeUri(new Uri(Browser.Url)).ToString(); + Assert.Equal("subdir/LongPageWithHash2#test2", currentRelativeUrl); + Assert.Equal(test2VerticalLocation, currentWindowScrollY); + } + + [Fact] + public void NavigatationManagerNavigateToSameUrlWithHash_ScrollsToElementWithIdOnTheSamePage() + { + SetUrlViaPushState("/"); + var app = Browser.MountTestComponent(); + app.FindElement(By.LinkText("Long page with hash")).Click(); + + app.FindElement(By.Id("navigate-test2")).Click(); + + var currentWindowScrollY = BrowserScrollY; + var test2VerticalLocation = app.FindElement(By.Name("test2")).Location.Y; + var currentRelativeUrl = _serverFixture.RootUri.MakeRelativeUri(new Uri(Browser.Url)).ToString(); + Assert.Equal("subdir/LongPageWithHash2#test2", currentRelativeUrl); + Assert.Equal(test2VerticalLocation, currentWindowScrollY); + } + + [Fact] + public void NavigatationManagerNavigateToAnotherUrlWithHash_NavigatesToPageAndScrollsToElementWithName() + { + SetUrlViaPushState("/"); + var app = Browser.MountTestComponent(); + app.FindElement(By.LinkText("Long page with hash")).Click(); + + app.FindElement(By.Id("navigate-test1")).Click(); + + var currentWindowScrollY = BrowserScrollY; + var test1VerticalLocation = app.FindElement(By.Id("test1")).Location.Y; + var currentRelativeUrl = _serverFixture.RootUri.MakeRelativeUri(new Uri(Browser.Url)).ToString(); + Assert.Equal("subdir/LongPageWithHash#test1", currentRelativeUrl); + Assert.Equal(test1VerticalLocation, currentWindowScrollY); + } + private long BrowserScrollY { - get => (long)((IJavaScriptExecutor)Browser).ExecuteScript("return window.scrollY"); + get => Convert.ToInt64(((IJavaScriptExecutor)Browser).ExecuteScript("return window.scrollY"), CultureInfo.CurrentCulture); set => ((IJavaScriptExecutor)Browser).ExecuteScript($"window.scrollTo(0, {value})"); } diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor index c473154908b0..9a1a5c6b81c0 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/Links.razor @@ -33,6 +33,7 @@
  • Download Me
  • Null href never matches
  • +
  • Long page with hash
  • +
    + + +
    + Scroll past me to find the links +
    + +

    Test1

    + +
    + Scroll past me to find the links +
    + +@code { + void GoToTest1() + { + NavigationManager.NavigateTo("/subdir/LongPageWithHash#test1"); + } + + void GoToTest2() + { + NavigationManager.NavigateTo("/subdir/LongPageWithHash2#test2"); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/LongPageWithHash2.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/LongPageWithHash2.razor new file mode 100644 index 000000000000..7225a5f51483 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/LongPageWithHash2.razor @@ -0,0 +1,12 @@ +@page "/LongPageWithHash2" +@inject NavigationManager NavigationManager + +
    + Scroll past me to find the links +
    + +

    Test2

    + +
    + Scroll past me to find the links +