diff --git a/DeepDrftShared.Client/Components/ParallaxImage.razor b/DeepDrftShared.Client/Components/ParallaxImage.razor new file mode 100644 index 0000000..e8aa3b7 --- /dev/null +++ b/DeepDrftShared.Client/Components/ParallaxImage.razor @@ -0,0 +1,20 @@ +@inherits ParallaxImageBase + +
+ +
+
+ + @if (Image2 != null) + { +
+
+ } +
diff --git a/DeepDrftShared.Client/Components/ParallaxImage.razor.cs b/DeepDrftShared.Client/Components/ParallaxImage.razor.cs new file mode 100644 index 0000000..2ced165 --- /dev/null +++ b/DeepDrftShared.Client/Components/ParallaxImage.razor.cs @@ -0,0 +1,124 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace DeepDrftShared.Client.Components; + +/// +/// Scroll-driven parallax image window. As the component scrolls up through the viewport, +/// the image pans through the window faster than the page scrolls. An optional second image +/// crossfades in on hover (pure CSS). A flag breaks the container out +/// of parent padding to span the viewport. +/// +/// +/// Progressive enhancement: renders a static framed image at SSR; the parallax attaches after +/// interactive boot via OnAfterRenderAsync(firstRender). When +/// is left null, the window renders at 300px and recomputes to naturalHeight/2 once the image +/// decodes — this can cause a one-time layout shift on first paint. Pass an explicit +/// for above-the-fold hero usage to avoid the shift. +/// +public abstract class ParallaxImageBase : ComponentBase, IAsyncDisposable +{ + [Inject] private IJSRuntime JS { get; set; } = default!; + + /// Primary image URL, shown at rest. + [Parameter, EditorRequired] public string Image1 { get; set; } = default!; + + /// Optional hover image (assumed same dimensions as ). + [Parameter] public string? Image2 { get; set; } + + /// Accessible name for . When null, the window is decorative. + [Parameter] public string? Alt1 { get; set; } + + /// Accessible name for (only relevant if it adds semantic meaning). + [Parameter] public string? Alt2 { get; set; } + + /// CSS height of the parallax window. When null, renders at 300px and recomputes to naturalHeight/2. + [Parameter] public string? WindowHeight { get; set; } + + /// background-size width. + [Parameter] public string ImageWidth { get; set; } = "auto"; + + /// background-size height. + [Parameter] public string ImageHeight { get; set; } = "auto"; + + /// When true, stretches the container to 100vw via a negative-margin breakout. + [Parameter] public bool FullWidth { get; set; } + + /// Speed multiplier, clamped to [0,1] in the JS module. + [Parameter] public double ParallaxSpeed { get; set; } = 0.5; + + /// When false: top-on-entry to bottom-at-top. When true: inverted. + [Parameter] public bool InvertDirection { get; set; } + + /// Extra CSS classes on the outer element. + [Parameter] public string? Class { get; set; } + + protected ElementReference WindowRef; + protected string WindowHeightValue { get; private set; } = "300px"; + + private IJSObjectReference? _module; + private DotNetObjectReference? _dotNetRef; + private string? _handle; + + protected override void OnParametersSet() + { + WindowHeightValue = WindowHeight ?? "300px"; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + _module = await JS.InvokeAsync( + "import", "./_content/DeepDrftShared.Client/js/parallax/parallax.js"); + + var reportNaturalHeight = WindowHeight == null; + if (reportNaturalHeight) + { + _dotNetRef = DotNetObjectReference.Create(this); + } + + _handle = await _module.InvokeAsync( + "register", + WindowRef, + new { speed = ParallaxSpeed, invertDirection = InvertDirection, onNaturalHeight = reportNaturalHeight, image1 = Image1 }, + _dotNetRef); + } + + /// + /// Invoked by the JS module once the background image decodes, when the consumer left + /// null. Sets the window height to half the image's natural height. + /// + [JSInvokable] + public void SetNaturalHeight(double naturalHeightPx) + { + WindowHeightValue = $"{(int)(naturalHeightPx / 2)}px"; + StateHasChanged(); + } + + public async ValueTask DisposeAsync() + { + if (_module != null) + { + try + { + if (_handle != null) + { + await _module.InvokeVoidAsync("unregister", _handle); + } + + await _module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // Circuit already torn down (e.g. browser navigated away) — nothing to clean up. + } + } + + _dotNetRef?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/DeepDrftShared.Client/Components/ParallaxImage.razor.css b/DeepDrftShared.Client/Components/ParallaxImage.razor.css new file mode 100644 index 0000000..25283a3 --- /dev/null +++ b/DeepDrftShared.Client/Components/ParallaxImage.razor.css @@ -0,0 +1,48 @@ +.parallax-window { + position: relative; + overflow: hidden; + height: var(--window-height, 300px); + width: 100%; +} + +.parallax-window.full-width { + width: 100vw; + position: relative; + left: 50%; + right: 50%; + margin-left: -50vw; + margin-right: -50vw; +} + +.layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-repeat: no-repeat; + background-position: 50% var(--parallax-pos, 50%); +} + +.layer-1 { + opacity: 1; +} + +.layer-2 { + opacity: 0; + transition: opacity 400ms ease; +} + +.parallax-window:hover .layer-2 { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .parallax-window { + --parallax-pos: 0%; + } + + .layer-2 { + transition-duration: 0ms; + } +} diff --git a/DeepDrftShared.Client/DeepDrftShared.Client.csproj b/DeepDrftShared.Client/DeepDrftShared.Client.csproj index aa30580..9e3db0a 100644 --- a/DeepDrftShared.Client/DeepDrftShared.Client.csproj +++ b/DeepDrftShared.Client/DeepDrftShared.Client.csproj @@ -19,4 +19,28 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + false + Latest + true + true + + + + + + Never + Never + + + diff --git a/DeepDrftShared.Client/Interop/parallax/parallax.ts b/DeepDrftShared.Client/Interop/parallax/parallax.ts new file mode 100644 index 0000000..2cb06af --- /dev/null +++ b/DeepDrftShared.Client/Interop/parallax/parallax.ts @@ -0,0 +1,137 @@ +/** + * parallax - scroll-driven background-position panning for ParallaxImage. + * + * Single Responsibility: own the parallax math and scroll/observer lifecycle. + * Blazor owns the component lifecycle and calls register/unregister; this module + * writes only the `--parallax-pos` CSS custom property — never concrete style. + */ + +interface RegisterOptions { + speed: number; // ParallaxSpeed, clamped [0,1] + invertDirection: boolean; // selects the formula branch + onNaturalHeight?: boolean; // if true, report natural height via dotNetRef callback + image1?: string; // primary image URL, used by reportNaturalHeight probe +} + +interface DotNetObjectReference { + invokeMethodAsync(methodName: string, ...args: unknown[]): Promise; +} + +interface Handle { + element: HTMLElement; + options: RegisterOptions; + observer: IntersectionObserver; + scrollListener: (() => void) | null; + rafId: number | null; + dotNetRef: DotNetObjectReference | null; +} + +const handles = new Map(); + +let _handleCounter = 0; + +const reducedMotion = (): boolean => + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function applyParallax(handle: Handle): void { + const { element, options } = handle; + const rect = element.getBoundingClientRect(); + const viewportH = window.innerHeight; + + let progress = options.invertDirection + ? rect.top / viewportH + : 1 - rect.top / viewportH; + progress = clamp(progress, 0, 1); + + const pos = progress * clamp(options.speed, 0, 1) * 100; + element.style.setProperty('--parallax-pos', `${pos}%`); +} + +function attachScrollListener(handle: Handle): void { + if (handle.scrollListener || reducedMotion()) return; + + const listener = (): void => { + if (handle.rafId !== null) return; + handle.rafId = requestAnimationFrame(() => { + handle.rafId = null; + applyParallax(handle); + }); + }; + + handle.scrollListener = listener; + window.addEventListener('scroll', listener, { passive: true }); + // Prime position immediately so entry isn't a frame behind the first scroll. + applyParallax(handle); +} + +function detachScrollListener(handle: Handle): void { + if (handle.scrollListener) { + window.removeEventListener('scroll', handle.scrollListener); + handle.scrollListener = null; + } + if (handle.rafId !== null) { + cancelAnimationFrame(handle.rafId); + handle.rafId = null; + } +} + +function reportNaturalHeight(handle: Handle): void { + if (!handle.dotNetRef || !handle.options.image1) return; + + const probe = new Image(); + probe.onload = (): void => { + handle.dotNetRef?.invokeMethodAsync('SetNaturalHeight', probe.naturalHeight); + }; + probe.src = handle.options.image1; +} + +export function register( + element: HTMLElement, + options: RegisterOptions, + dotNetRef?: DotNetObjectReference, +): string { + const id = `parallax-${++_handleCounter}`; + + const realObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + attachScrollListener(handle); + } else { + detachScrollListener(handle); + } + } + }); + + const handle: Handle = { + element, + options: { ...options, speed: clamp(options.speed, 0, 1) }, + observer: realObserver, + scrollListener: null, + rafId: null, + dotNetRef: dotNetRef ?? null, + }; + + handle.observer.observe(element); + + handles.set(id, handle); + + if (options.onNaturalHeight) { + reportNaturalHeight(handle); + } + + return id; +} + +export function unregister(handleId: string): void { + const handle = handles.get(handleId); + if (!handle) return; + + detachScrollListener(handle); + handle.observer.disconnect(); + handle.dotNetRef = null; + handles.delete(handleId); +} diff --git a/DeepDrftShared.Client/tsconfig.json b/DeepDrftShared.Client/tsconfig.json new file mode 100644 index 0000000..14e35a1 --- /dev/null +++ b/DeepDrftShared.Client/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "noEmitOnError": true, + "removeComments": false, + "sourceMap": true, + "rootDir": "Interop", + "outDir": "wwwroot/js", + "sourceRoot": "/Interop", + "mapRoot": "/js" + }, + "include": [ + "Interop/**/*.ts" + ], + "exclude": [ + "node_modules", + "bin/**/*", + "obj/**/*", + "publish/**/*" + ] +}