From 85b303e38725b08526fb32e70aad7ad349a497d2 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Thu, 2 Jul 2026 01:57:00 +0300 Subject: [PATCH] feat(ai-chat): dock the floating chat window into the sidebar (closes #276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag the floating AI-chat window onto the sidebar and release over it to DOCK it — the window pins to the live navbar rect, overlaying the page tree; a drop-zone highlight shows while dragging over it. Closing the chat re-shows the tree. Undock via a header button or by dragging the docked window back onto content (pops out floating at the drop point). The docked/floating mode persists in localStorage and the docked window follows the navbar width (manual resize, space<->shared route change) via a ResizeObserver + sidebar-toggle/transitionend re-sync; when the navbar is collapsed/absent the window falls back to floating instead of vanishing. Dock/undock only flips a mode atom + geometry — ChatThread is never remounted, so an in-flight response stream is not interrupted. Frontend only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/en-US/translation.json | 2 + .../public/locales/ru-RU/translation.json | 2 + .../layouts/global/global-app-shell.tsx | 2 + .../global/hooks/atoms/sidebar-atom.ts | 6 + .../features/ai-chat/atoms/ai-chat-atom.ts | 12 + .../components/ai-chat-window.module.css | 29 ++ .../ai-chat/components/ai-chat-window.tsx | 307 ++++++++++++++++-- .../ai-chat/utils/dock-helpers.test.ts | 28 ++ .../features/ai-chat/utils/dock-helpers.ts | 32 ++ 9 files changed, 395 insertions(+), 25 deletions(-) create mode 100644 apps/client/src/features/ai-chat/utils/dock-helpers.test.ts create mode 100644 apps/client/src/features/ai-chat/utils/dock-helpers.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 08fae9a7..8f54c6fd 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -257,6 +257,8 @@ "Copy": "Copy", "Copy to space": "Copy to space", "Copy chat": "Copy chat", + "Dock to sidebar": "Dock to sidebar", + "Undock": "Undock", "Copied": "Copied", "Failed to export chat": "Failed to export chat", "Duplicate": "Duplicate", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 88629662..92a47b45 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -715,6 +715,8 @@ "Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.", "Ask the AI agent…": "Спросите AI-агента…", "Copy chat": "Копировать чат", + "Dock to sidebar": "Закрепить в боковой панели", + "Undock": "Открепить", "Created successfully": "Успешно создано", "Context size / model limit": "Размер контекста / лимит модели", "Context window (tokens)": "Окно контекста (токены)", diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index b756bdde..41d3886f 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import SettingsSidebar from "@/components/settings/settings-sidebar.tsx"; import { useAtom } from "jotai"; import { + APP_NAVBAR_ID, asideStateAtom, desktopSidebarAtom, mobileSidebarAtom, @@ -106,6 +107,7 @@ export default function GlobalAppShell({ ). Declared here — +// alongside the sidebar atoms — rather than in the chat window so the AI chat +// window can reference the navbar by id without importing the app shell (which +// would create a shell -> chat-window -> shell import cycle). +export const APP_NAVBAR_ID = "app-shell-navbar"; + export const mobileSidebarAtom = atom(false); export const desktopSidebarAtom = atomWithWebStorage( diff --git a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts index abf63729..3b56a30a 100644 --- a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts +++ b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts @@ -18,6 +18,18 @@ export const aiChatWindowGeomAtom = atomWithStorage( null, ); +/** + * Whether the AI chat window is docked into the sidebar (page-tree navbar). + * Persisted to localStorage so the docked/floating mode survives a full page + * reload and close/reopen. `false` = the default floating window. When docked, + * the SAME window instance pins itself to the live bounding rect of the app + * navbar (see AiChatWindow), overlaying the page tree. + */ +export const aiChatWindowDockedAtom = atomWithStorage( + "ai-chat-window-docked", + false, +); + /** * The currently selected chat id. `null` means a fresh (not-yet-created) chat: * the server creates the chat row on the first streamed message and echoes its diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css index 5758a018..659ca8cb 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css @@ -35,6 +35,35 @@ background: transparent; } +/* Docked into the sidebar: the window pins itself to the live navbar rect + (position/size supplied inline). It sits flush inside the navbar area, so we + drop the floating chrome — no border-radius, drop shadow or user resize — and + remove the floating min/max clamps so the size is driven ENTIRELY by the + inline navbar rect (which may be narrower than the floating min-width of + 300px, e.g. the 220px navbar minimum). z-index 105 keeps it above the page + tree (navbar 101) but below the header and Mantine overlays. */ +.docked { + border-radius: 0; + box-shadow: none; + resize: none; + min-width: 0; + min-height: 0; + max-width: none; + max-height: none; +} + +/* Drop-zone highlight shown over the navbar bounds while a floating window is + dragged onto the sidebar. Sits just above the docked window (106) so the cue + is visible; purely decorative, so it never intercepts pointer events. */ +.dockHighlight { + position: fixed; + z-index: 106; + border: 2px dashed light-dark(var(--mantine-color-blue-5), var(--mantine-color-blue-4)); + background: light-dark(rgba(34, 139, 230, 0.08), rgba(34, 139, 230, 0.14)); + border-radius: var(--mantine-radius-sm); + pointer-events: none; +} + /* When minimized the window collapses to the header only: auto height, no resize. Width/height inline values are overridden. */ .minimized { diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 3df60ddb..94923771 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -13,21 +13,29 @@ import { IconChevronDown, IconCopy, IconGripVertical, + IconLayoutSidebarLeftCollapse, + IconLayoutSidebarLeftExpand, IconMinus, IconPlus, IconX, } from "@tabler/icons-react"; import { useAtom, useSetAtom } from "jotai"; -import { useMatch } from "react-router-dom"; +import { useLocation, useMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, aiChatWindowGeomAtom, + aiChatWindowDockedAtom, aiChatDraftAtom, selectedAiRoleIdAtom, } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; +import { + APP_NAVBAR_ID, + desktopSidebarAtom, + mobileSidebarAtom, +} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { extractPageSlugId } from "@/lib"; import { @@ -46,6 +54,10 @@ import { isHeaderClick, } from "@/features/ai-chat/utils/collapse-helpers.ts"; import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts"; +import { + isPointWithinRect, + type NavbarRect, +} from "@/features/ai-chat/utils/dock-helpers.ts"; import { useClipboard } from "@/hooks/use-clipboard"; import { notifications } from "@mantine/notifications"; import classes from "@/features/ai-chat/components/ai-chat-window.module.css"; @@ -112,6 +124,27 @@ function clampGeom(g: { }; } +// Live bounding rect of the app-shell navbar (the page-tree sidebar), by its +// stable id. Returns null when the navbar is absent OR collapsed: Mantine +// collapses the navbar by translating it off-screen (its right edge lands at or +// left of the viewport), so a zero-size or off-screen rect is treated as "no +// navbar" — the docked window then falls back to floating instead of pinning to +// an off-screen box. Reads the DOM, so call it inside effects / handlers only. +function getNavbarRect(): NavbarRect | null { + const el = document.getElementById(APP_NAVBAR_ID); + if (!el) return null; + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0 || r.right <= 0) return null; + return { left: r.left, top: r.top, width: r.width, height: r.height }; +} + +// Whether a viewport point falls within the (visible) navbar bounds. Used to +// decide dock-on-drop and undock-on-drag-out. The point-in-rect math is the pure +// isPointWithinRect helper (unit-tested); this only supplies the live rect. +function isPointerOverNavbar(x: number, y: number): boolean { + return isPointWithinRect(x, y, getNavbarRect()); +} + /** * Floating, draggable, resizable, minimizable AI chat window. Replaces the * former right-aside `AiChatPanel`: it owns ALL chat orchestration (active @@ -138,6 +171,43 @@ export default function AiChatWindow() { const minimizedRef = useRef(minimized); minimizedRef.current = minimized; + // Docked-into-sidebar mode (#276). Persisted so it survives reload + reopen. + // When docked the SAME window instance pins itself to the navbar rect below. + const [docked, setDocked] = useAtom(aiChatWindowDockedAtom); + // Mirror for the useCallback([]) drag handlers (same reason as minimizedRef). + const dockedRef = useRef(docked); + dockedRef.current = docked; + // Live navbar rect the docked window is pinned to; synced before paint by the + // layout effect below. null = navbar absent/collapsed -> floating fallback. + const [dockRect, setDockRect] = useState(null); + // While dragging a FLOATING window over the navbar: show the drop-zone hint. + const [dockHint, setDockHint] = useState(false); + // Live window position during a drag. Normally the drag is fully imperative + // (el.style updated per mousemove, no re-render — matching the pre-#276 + // behavior), so this stays null. It is set ONLY at a navbar-boundary crossing: + // that crossing already forces a re-render (dockHint flips), which would + // otherwise re-apply the committed geom and snap the box back for a frame — so + // we hand the render the live position at that instant instead. Cleared on drop. + const [dragPos, setDragPos] = useState<{ left: number; top: number } | null>( + null, + ); + + // Subscribed (read-only) so this component re-renders — and the dockRect-sync + // effect below re-runs — when the sidebar is collapsed/expanded via the header + // toggle. Mantine collapses the navbar with a transform (width/border-box + // unchanged), so the navbar's ResizeObserver never fires; these deps + the + // navbar `transitionend` listener are what re-measure the rect on toggle. + const [desktopSidebarOpen] = useAtom(desktopSidebarAtom); + const [mobileSidebarOpen] = useAtom(mobileSidebarAtom); + + // Dock mode is only EFFECTIVE when a navbar rect is available. When docked but + // the navbar is absent/collapsed (dockRect === null) the window falls back to + // the floating look, so effects gated on "is docked" must use this — not the + // raw `docked` flag — or a fallback-floating window would behave half-docked. + const useDock = docked && dockRect !== null; + + const location = useLocation(); + const winRef = useRef(null); // Live window geometry (position + size); persisted to localStorage so a // drag/resize survives a full page reload (and close/reopen). `null` means @@ -325,6 +395,47 @@ export default function AiChatWindow() { setMinimized(false); }, [windowOpen]); + // While docked, keep the window pinned to the navbar's LIVE rect. useLayoutEffect + // (not useEffect) so dockRect is measured/committed before the browser paints, + // avoiding a first-frame jump. Re-measures on: navbar size changes (manual + // sidebar resize -> ResizeObserver), viewport resize (window `resize`), and + // route changes that swap the navbar width (space <-> shared/global sidebar are + // 300px vs sidebarWidth -> re-run on location.pathname). If the navbar is + // absent/collapsed, getNavbarRect() returns null and the render falls back to + // the floating look (the window does NOT vanish). + useLayoutEffect(() => { + if (!windowOpen || !docked) return; + const sync = () => setDockRect(getNavbarRect()); + sync(); + const navbar = document.getElementById(APP_NAVBAR_ID); + let ro: ResizeObserver | null = null; + if (navbar) { + ro = new ResizeObserver(sync); + ro.observe(navbar); + // Collapsing/expanding the sidebar translates the navbar off-screen WITHOUT + // changing its width/border-box, so the ResizeObserver never fires and the + // effect's initial sync() may measure mid-transition (stale). Re-measure at + // transitionend so getNavbarRect() sees the final position: null once the + // navbar is translated off (right <= 0) -> fall back to floating; the real + // rect once it slides back -> re-dock. The sidebar-state deps below force + // this effect (and the immediate sync) to re-run on each toggle, covering + // the reduced-motion case where no transition -> no transitionend. + navbar.addEventListener("transitionend", sync); + } + window.addEventListener("resize", sync); + return () => { + ro?.disconnect(); + navbar?.removeEventListener("transitionend", sync); + window.removeEventListener("resize", sync); + }; + }, [ + windowOpen, + docked, + location.pathname, + desktopSidebarOpen, + mobileSidebarOpen, + ]); + // Auto-collapse the window into its header as soon as the user interacts with // anything outside it (clicks the page/editor). Armed ONLY while the window is // open and expanded, so it never fires repeatedly and never collapses on the @@ -333,7 +444,12 @@ export default function AiChatWindow() { // (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside // the window or inside Mantine portals (kebab menu, delete-confirm modal). useEffect(() => { - if (!windowOpen || minimized) return; + // Disabled while EFFECTIVELY docked: a docked window intentionally overlays + // the page tree, so a click on the surrounding page must NOT auto-collapse + // it. Gated on useDock (not raw `docked`) so a fallback-floating window + // (docked but navbar absent/collapsed) still auto-collapses like a normal + // floating window. + if (!windowOpen || minimized || useDock) return; const onPointerDown = (e: MouseEvent): void => { if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) { setMinimized(true); @@ -341,13 +457,18 @@ export default function AiChatWindow() { }; document.addEventListener("mousedown", onPointerDown, true); return () => document.removeEventListener("mousedown", onPointerDown, true); - }, [windowOpen, minimized]); + }, [windowOpen, minimized, useDock]); // Persist the user's resize into state so it survives close/reopen. Skipped // while minimized so the collapsed (auto) height is never captured. The // equality guard avoids an update loop. useEffect(() => { - if (!windowOpen || minimized) return; + // Disabled while EFFECTIVELY docked: in dock mode the size is driven by the + // navbar rect, not a user resize, so we must not capture the navbar-sized box + // into the persisted floating geom (it would clobber the remembered floating + // size). Gated on useDock so a fallback-floating window (docked but navbar + // absent) still persists user resizes like a normal floating window. + if (!windowOpen || minimized || useDock) return; const el = winRef.current; // `geom` is in the deps so this re-runs once geometry is settled and the // window is actually rendered (on the first open `geom` is still null on the @@ -365,18 +486,30 @@ export default function AiChatWindow() { }); ro.observe(el); return () => ro.disconnect(); - }, [windowOpen, minimized, geom !== null]); + }, [windowOpen, minimized, useDock, geom !== null]); const startDrag = useCallback((e: React.MouseEvent): void => { - // Ignore drags that originate on a button (minimize/close/new chat). + // Ignore drags that originate on a button (dock/minimize/close/new chat). if ((e.target as HTMLElement).closest("button")) return; const el = winRef.current; if (!el) return; const sx = e.clientX; const sy = e.clientY; + // Starting position: the element's current inline left/top, whether it was + // placed by the floating geom or pinned to the navbar rect (both render as + // "px"). getBoundingClientRect would work too, but the inline values keep + // the drag math identical to the pre-#276 floating behavior. const ol = parseFloat(el.style.left) || 0; const ot = parseFloat(el.style.top) || 0; + // Freeze the box size for the drag: a docked window keeps its navbar size + // while being pulled out, a floating window keeps its own size. + const dragW = el.offsetWidth; + const dragH = el.offsetHeight; + + // Latch for the drop-zone hint so setState fires only when the pointer + // actually crosses the navbar boundary, not on every mousemove. + let overNavbar = false; const move = (ev: MouseEvent): void => { let nl = ol + (ev.clientX - sx); @@ -385,20 +518,58 @@ export default function AiChatWindow() { // with position: fixed) with an 8px margin. nl = Math.max( EDGE_MARGIN, - Math.min(nl, window.innerWidth - el.offsetWidth - EDGE_MARGIN), + Math.min(nl, window.innerWidth - dragW - EDGE_MARGIN), ); nt = Math.max( EDGE_MARGIN, - Math.min(nt, window.innerHeight - el.offsetHeight - EDGE_MARGIN), + Math.min(nt, window.innerHeight - dragH - EDGE_MARGIN), ); el.style.left = `${nl}px`; el.style.top = `${nt}px`; + // Drop-zone highlight: only meaningful when dragging a FLOATING window in + // to dock it (a docked window is already over the navbar). + if (!dockedRef.current) { + const nowOver = isPointerOverNavbar(ev.clientX, ev.clientY); + if (nowOver !== overNavbar) { + overNavbar = nowOver; + // This re-render would re-apply the committed geom; hand it the live + // position so the box does not snap back for a frame. + setDragPos({ left: nl, top: nt }); + setDockHint(nowOver); + } + } }; const up = (ev: MouseEvent): void => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.style.userSelect = ""; + setDragPos(null); + setDockHint(false); + const overNavbarNow = isPointerOverNavbar(ev.clientX, ev.clientY); + + if (dockedRef.current) { + // Docked window: releasing OUTSIDE the navbar pops it out as a floating + // window at the drop point (clamped to the viewport). Released over the + // navbar -> stays docked (a header click is a no-op here). The response + // stream is untouched — only the mode flag / geom change. + if (!overNavbarNow) { + const el2 = winRef.current; + const dropLeft = el2 ? parseFloat(el2.style.left) || 0 : 0; + const dropTop = el2 ? parseFloat(el2.style.top) || 0 : 0; + setGeom((prev) => + clampGeom({ + ...(prev ?? computeInitialGeom()), + left: dropLeft, + top: dropTop, + }), + ); + setDocked(false); + } + return; + } + + // Floating window. // Treat a near-zero-movement press as a click (not a drag). When the // window is minimized, a header click expands it; nothing to persist // because the position did not change. minimizedRef avoids the stale @@ -410,6 +581,13 @@ export default function AiChatWindow() { setMinimized(false); return; } + // Released over the navbar -> dock. The layout effect then pins the window + // to the navbar rect; the last floating geom is left untouched so a later + // undock/close restores the remembered floating placement. + if (overNavbarNow) { + setDocked(true); + return; + } const el2 = winRef.current; // Persist the final position back into state (preserving the size) so // re-renders keep it. @@ -432,6 +610,20 @@ export default function AiChatWindow() { e.preventDefault(); }, []); + // Dock/undock via the header button. Docking pins the window to the navbar; + // undocking restores the floating window at its last remembered geom. On + // undock we re-clamp that geom to the current viewport (matching drag-undock's + // clampGeom) so a viewport shrink while docked can't leave the popped-out + // window partly off-screen. The chat thread stays mounted across the toggle, + // so a live stream is intact. dockedRef gives the live value inside this + // useCallback([]) handler. + const toggleDock = useCallback((): void => { + if (dockedRef.current) { + setGeom((prev) => (prev ? clampGeom(prev) : prev)); + } + setDocked((d) => !d); + }, [setDocked, setGeom]); + // Just toggle the flag. The `.minimized` CSS handles the collapsed height and // disables resize, and `.minimized .content` hides the body while keeping // ChatThread mounted (so an in-flight stream is not aborted). @@ -441,17 +633,45 @@ export default function AiChatWindow() { if (!windowOpen || !geom) return null; - return ( -
+
{/* drag bar / header. Mouse users expand a minimized window by clicking anywhere on the bar (the click-vs-drag logic in startDrag, which @@ -471,11 +691,11 @@ export default function AiChatWindow() { is a plain, non-focusable label. */} { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); @@ -531,15 +751,35 @@ export default function AiChatWindow() { )} )} + {/* Dock/undock toggle. Docked -> "Undock" (expand icon) pops the window + back out to floating; floating -> "Dock to sidebar" (collapse icon) + pins it into the navbar. */} + {/* Minimize (collapse to header) makes no sense while docked — the + window fills the navbar — so it is hidden in dock mode. */} + {!useDock && ( + + )}
- {/* resize affordance icon (drawn manually; native resizer is hidden) */} - {!minimized && ( + {/* resize affordance icon (drawn manually; native resizer is hidden). + Hidden while docked — the docked size follows the navbar, not a manual + resize. */} + {!showMinimized && !useDock && ( )}
+ {/* Drop-zone highlight over the navbar while dragging a floating window in + to dock it. Sibling of the window (position: fixed) so it tracks the + navbar bounds, not the moving window. */} + {hintRect && ( +
+ )} + ); } diff --git a/apps/client/src/features/ai-chat/utils/dock-helpers.test.ts b/apps/client/src/features/ai-chat/utils/dock-helpers.test.ts new file mode 100644 index 00000000..7d288ef1 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/dock-helpers.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { isPointWithinRect, type NavbarRect } from "./dock-helpers.ts"; + +const NAVBAR: NavbarRect = { left: 0, top: 45, width: 300, height: 800 }; + +describe("isPointWithinRect", () => { + it("returns true for a point inside the navbar", () => { + expect(isPointWithinRect(150, 400, NAVBAR)).toBe(true); + }); + + it("treats the boundary edges as inside (drop exactly on the edge docks)", () => { + // Top-left corner and bottom-right corner are both inclusive. + expect(isPointWithinRect(0, 45, NAVBAR)).toBe(true); + expect(isPointWithinRect(300, 845, NAVBAR)).toBe(true); + }); + + it("returns false for a point in the content area (to the right)", () => { + expect(isPointWithinRect(500, 400, NAVBAR)).toBe(false); + }); + + it("returns false above the navbar (in the header band)", () => { + expect(isPointWithinRect(150, 10, NAVBAR)).toBe(false); + }); + + it("returns false when the navbar rect is null (absent/collapsed)", () => { + expect(isPointWithinRect(150, 400, null)).toBe(false); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/dock-helpers.ts b/apps/client/src/features/ai-chat/utils/dock-helpers.ts new file mode 100644 index 00000000..b2e7f2f1 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/dock-helpers.ts @@ -0,0 +1,32 @@ +// Pure geometry helper for the AI chat window dock/undock decision (#276). Kept +// free of React and the DOM so it can be unit-tested in isolation (see +// dock-helpers.test.ts). The DOM-reading getNavbarRect() lives in the window +// component; this is only the point-in-rect math that decides dock-on-drop and +// undock-on-drag-out from the measured navbar rect. + +export type NavbarRect = { + left: number; + top: number; + width: number; + height: number; +}; + +/** + * Whether a viewport point (x, y) falls within `rect`. Edges are inclusive so a + * drop exactly on the navbar boundary counts as "over the navbar". Returns false + * when the rect is null (navbar absent/collapsed) so the caller falls back to the + * floating behavior. + */ +export function isPointWithinRect( + x: number, + y: number, + rect: NavbarRect | null, +): boolean { + if (!rect) return false; + return ( + x >= rect.left && + x <= rect.left + rect.width && + y >= rect.top && + y <= rect.top + rect.height + ); +}