diff --git a/app/features/vods/routes/vods.new.module.css b/app/features/vods/routes/vods.new.module.css index 7aa7d29b9..d41559c11 100644 --- a/app/features/vods/routes/vods.new.module.css +++ b/app/features/vods/routes/vods.new.module.css @@ -6,20 +6,22 @@ margin-block: var(--s-2); } -@media (min-width: 1425px) { - .embedContainer { - position: fixed; - left: max(24px, calc((100vw - 800px) / 2 - 320px - 48px)); - top: 120px; - width: 320px; - margin-block: 0; - } -} +/* When the form's left margin can fit the embed (measured in JS so it accounts + for the side nav being collapsed and the chat sidebar being open), float it + just left of the form. The rail is anchored to the form's own left edge, so + the side nav offset cancels out and it never overlaps the nav. Its width is + set inline by the same JS. The rail spans the full form height while the + embed sticks within it, so it stays in view while scrolling the long form. */ +.embedRail.floating { + position: absolute; + top: 0; + bottom: 0; + right: calc(100% + var(--s-6)); -@media (min-width: 1700px) { - .embedContainer { - left: max(24px, calc((100vw - 800px) / 2 - 400px - 48px)); - width: 400px; + & .embedContainer { + position: sticky; + top: calc(var(--layout-nav-height) + var(--s-4)); + margin-block: 0; } } diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx index 4a6757f7a..1a87a48ec 100644 --- a/app/features/vods/routes/vods.new.tsx +++ b/app/features/vods/routes/vods.new.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLoaderData } from "react-router"; @@ -13,6 +14,7 @@ import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper"; import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField"; import type { FormRenderProps } from "~/form/SendouForm"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; +import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons"; import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; import { useHasRole } from "~/modules/permissions/hooks"; @@ -127,6 +129,7 @@ function YouTubeEmbedWrapper({ onPlayerReady: (player: YT.Player) => void; }) { const { values } = useFormFieldContext(); + const floatWidth = useFloatingEmbedWidth(); const youtubeUrl = values.youtubeUrl as string | undefined; if (!youtubeUrl) return null; @@ -135,12 +138,55 @@ function YouTubeEmbedWrapper({ if (!videoId) return null; return ( -
- +
+
+ +
); } +const EMBED_RAIL_GAP = 24; // mirrors var(--s-6) +const EMBED_FLOAT_WIDTHS = [400, 320] as const; + +/** + * Returns the width to float the embed at when the form's left margin can fit + * it (widest that fits), or `null` to leave it in flow above the fields. + * Measures the form's actual left margin so it accounts for the side nav being + * collapsed and the chat sidebar being open, neither of which a media query can + * see. + */ +function useFloatingEmbedWidth(): number | null { + const [leftMargin, setLeftMargin] = useState(0); + + useIsomorphicLayoutEffect(() => { + const main = document.querySelector("main"); + const container = main?.parentElement; + if (!main || !container) return; + + const measure = () => { + setLeftMargin( + main.getBoundingClientRect().left - + container.getBoundingClientRect().left, + ); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + + return () => observer.disconnect(); + }, []); + + return ( + EMBED_FLOAT_WIDTHS.find((width) => leftMargin >= width + EMBED_RAIL_GAP) ?? + null + ); +} + type VodFormFieldComponent = FormRenderProps< typeof vodFormBaseSchema.shape >["FormField"];