diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 3fb9467ef..cfc02a4d5 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -38,7 +38,8 @@ Underscores and hyphen-minus are allowed. The column count defines how many fixed-width cells are available in each row. One cell contains a `200 × 200` logical-pixel tile. -Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small. +Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling. +The main canvas and enabled rails stay at least one viewport tall, and the canvas grows automatically while you drag or resize items near its lower edge. ### Public @@ -100,7 +101,7 @@ A `2 × 1` tile therefore contains two fixed cell widths plus the gap between th Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller. When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size. On larger canvases, the complete board grows together like browser zoom. -The scale never drops below 75%; on narrower screens the canvas scrolls horizontally instead of shrinking controls below an accessible size. +The complete canvas always fits the available width and never adds horizontal board scrolling. The position and size of every item are independent in each responsive layout. Moving or resizing an item in one layout does not change any other layout. @@ -108,8 +109,8 @@ When adding a new layout, its initial positions and sizes are generated from the New items use their widget's default grid size, while a new Container starts at a useful `4 × 3` size when the layout has enough columns. Both are placed in the first available position. -The current board edge is a hard drag boundary, so moving a tile outside the canvas cancels that placement instead of creating empty rows. -Adding content can still create the rows required for the new tile. +The canvas is at least one window tall, even when it is empty. +Its left and right edges are hard drag boundaries, while its bottom edge extends automatically as a tile is dragged or resized downward. Nested Containers keep their own resizable bounds. #### Name @@ -133,8 +134,8 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f Enabling or widening a rail reflows canvas items into the remaining columns. Disabling a rail moves its contents back into the next available canvas positions. -Rails remain visible while the page scrolls. -Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode. +Rails fill the available window height and remain fixed while the page scrolls. +Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary. #### Breakpoint diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css index 158ad16ae..8514e86d9 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -9,14 +9,48 @@ } .gutter { - position: relative; - top: 0; + --board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1)); + --board-gutter-viewport-height: calc( + (100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1) + ); + + position: sticky; + top: var(--board-gutter-sticky-top); align-self: start; + box-sizing: border-box; + height: var(--board-gutter-viewport-height); padding-top: 24px; margin-top: -24px; - max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md)); + overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; - transform: translateY(var(--board-gutter-sticky-offset, 0px)); +} + +.gutter[data-board-editing="true"] { + outline: 1px dashed var(--mantine-color-default-border); + outline-offset: -1px; + border-radius: var(--mantine-radius-md); +} + +.gutter[data-board-editing="true"]::before { + position: sticky; + top: 0; + inset-inline-start: 0; + z-index: 2; + display: block; + width: fit-content; + height: 24px; + padding-inline: var(--mantine-spacing-xs); + margin-top: -24px; + overflow: hidden; + font-size: var(--mantine-font-size-xs); + line-height: 24px; + color: var(--mantine-color-dimmed); + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + content: attr(aria-label); + background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent); + border-radius: var(--mantine-radius-sm); } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 689cb95ee..19bf1d0fa 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; import { Box } from "@mantine/core"; import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; import { useScopedI18n } from "@homarr/translation/client"; import { @@ -23,66 +23,12 @@ import classes from "./_client.module.css"; const APP_SHELL_INLINE_PADDING = 32; -const useFixedBoardGutters = () => { - const [columns, setColumns] = useState(null); - - useEffect(() => { - if (!columns) return; - - let frame = 0; - const update = () => { - frame = 0; - const columnsRect = columns.getBoundingClientRect(); - const headerBottom = document.querySelector("[data-app-shell-header]")?.getBoundingClientRect().bottom ?? 0; - const stickyTop = Math.max(0, headerBottom + 16); - - for (const gutter of columns.querySelectorAll("[data-board-gutter]")) { - const rect = gutter.getBoundingClientRect(); - if (gutter.offsetWidth <= 0) continue; - - const effectiveScale = rect.width / gutter.offsetWidth; - if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue; - - const previousOffset = Number(gutter.dataset.stickyOffset ?? 0); - const uncorrectedTop = rect.top - previousOffset * effectiveScale; - const maximumTop = columnsRect.bottom - rect.height; - const targetTop = Math.min(stickyTop, maximumTop); - const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale; - - gutter.dataset.stickyOffset = String(nextOffset); - gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`); - } - }; - const scheduleUpdate = () => { - if (frame !== 0) return; - frame = window.requestAnimationFrame(update); - }; - - const resizeObserver = new ResizeObserver(scheduleUpdate); - resizeObserver.observe(columns); - document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true }); - window.addEventListener("scroll", scheduleUpdate, { passive: true }); - window.addEventListener("resize", scheduleUpdate, { passive: true }); - scheduleUpdate(); - - return () => { - window.cancelAnimationFrame(frame); - resizeObserver.disconnect(); - document.removeEventListener("scroll", scheduleUpdate, { capture: true }); - window.removeEventListener("scroll", scheduleUpdate); - window.removeEventListener("resize", scheduleUpdate); - }; - }, [columns]); - - return setColumns; -}; - export const ClientBoard = () => { const board = useRequiredBoard(); const t = useScopedI18n("board.landmark"); + const [isEditMode] = useEditMode(); const currentLayoutId = useCurrentLayout(); const initialViewportWidth = useInitialViewportWidth(); - const columnsRef = useFixedBoardGutters(); const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0); if (!currentLayout) throw new Error("Expected the board to contain a layout"); @@ -122,16 +68,13 @@ export const ClientBoard = () => { > -
+
{leftColumnCount > 0 && leftSection && (