feat: add fixed board scaling
This commit is contained in:
1 parent
9d857377c1
commit
ecc1009e23
36 files changed
+8654
-41
No files matched your search
@@ -62,6 +62,11 @@ Each board has exactly one Mobile and one Base layout. Mobile uses breakpoint `0
|
||||
layouts can use any other breakpoint, and Homarr selects the highest breakpoint that fits the viewport. Preserve layout
|
||||
roles and use the canonical layouts returned by save operations, including generated IDs.
|
||||
|
||||
Board settings expose `fixedScaling` and `fixedItemSize`. The default fitted mode uses `fixedScaling: false`; setting it
|
||||
to `true` keeps each `1 × 1` tile at `fixedItemSize` pixels, which must be an integer from `100` through `400`. The
|
||||
configured size is retained while fixed scaling is disabled. Layout `columnCount` remains the maximum total column count,
|
||||
including fixed sidebars.
|
||||
|
||||
Creating or changing an integration-backed widget requires use access to each newly selected integration.
|
||||
|
||||
## Docker targets
|
||||
|
||||
@@ -27,9 +27,13 @@ Mobile and Base are protected layouts. New boards use three Mobile columns; Base
|
||||
creation. Additional layouts use unique breakpoints. Homarr selects the layout with the highest breakpoint that fits
|
||||
the current viewport.
|
||||
|
||||
Tiles use fixed `200 × 200` logical-pixel cells with a `24px` gap. Homarr scales the complete layout to the available
|
||||
width instead of changing each widget's proportions. **Reset from Base** regenerates a Mobile or custom layout from the
|
||||
current Base layout.
|
||||
By default, Homarr fits the complete fixed-proportion tile grid to the available width. Enable **Fixed scaling** in the
|
||||
board's Layout settings to render every `1 × 1` tile at the exact configured pixel size instead. A fixed dashboard is
|
||||
centred when it fits and scrolls both horizontally and vertically when it does not.
|
||||
|
||||
The column count is the maximum total for that layout, including any fixed sidebars. For example, a Base layout with
|
||||
five columns and no sidebars remains a centred five-column dashboard on an ultrawide screen. **Reset from Base**
|
||||
regenerates a Mobile or custom layout from the current Base layout.
|
||||
|
||||
### Containers and rails
|
||||
|
||||
@@ -59,7 +63,7 @@ click remains available as a shortcut.
|
||||
Board settings cover:
|
||||
|
||||
- **General:** page title, browser title, logo, and favicon.
|
||||
- **Layout:** responsive layouts, breakpoints, columns, and rails.
|
||||
- **Layout:** fitted or fixed scaling, tile size, responsive layouts, breakpoints, maximum columns, and rails.
|
||||
- **Background:** image, attachment, size, and repeat behavior.
|
||||
- **Appearance:** colors, opacity, icon color, and item radius.
|
||||
- **Custom CSS:** styles applied only to the current board.
|
||||
|
||||
+8
@@ -2151,6 +2151,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"fixedScaling": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fixedItemSize": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 400
|
||||
},
|
||||
"disableStatus": {
|
||||
"type": "boolean"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.columns {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.lane {
|
||||
@@ -27,3 +26,10 @@
|
||||
outline-offset: -0.0625rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
:global([data-canvas-scaling="fixed"]) .gutter {
|
||||
--board-gutter-sticky-top: 0px;
|
||||
--board-gutter-viewport-height: calc(
|
||||
var(--board-canvas-fixed-viewport-height) * var(--board-canvas-inverse-scale, 1)
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalGridSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
@@ -33,6 +32,7 @@ import { BoardSelectionToolbar } from "~/components/board/selection/board-select
|
||||
import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
const BOARD_LANE_GAP = 24;
|
||||
|
||||
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
|
||||
<GridEditorRegistryProvider>
|
||||
@@ -63,11 +63,11 @@ export const ClientBoard = () => {
|
||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
||||
.filter((columnCount) => columnCount > 0)
|
||||
.map(getLogicalGridSize);
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const logicalWidth = laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * BOARD_LANE_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||
const fixedItemSize = board.fixedScaling ? board.fixedItemSize : undefined;
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalGridSize(mainColumnCount)}px`,
|
||||
@@ -90,10 +90,11 @@ export const ClientBoard = () => {
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
fixedItemSize={fixedItemSize}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
<div className={classes.columns} style={{ gap: BOARD_LANE_GAP, gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
.scalingSettings,
|
||||
.gutterSettings {
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
|
||||
}
|
||||
|
||||
.fixedItemSizePreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-md);
|
||||
margin: 0;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.fixedItemSizePreviewTile {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
min-width: 1.5rem;
|
||||
min-height: 1.5rem;
|
||||
color: var(--mantine-primary-color-contrast);
|
||||
background: var(--mantine-primary-color-filled);
|
||||
border: 1px solid var(--mantine-primary-color-filled-hover);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
transition:
|
||||
width 180ms ease-out,
|
||||
height 180ms ease-out;
|
||||
}
|
||||
|
||||
.gutterPreview {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -41,6 +65,7 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fixedItemSizePreviewTile,
|
||||
.gutterPreview {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { BOARD_FIXED_ITEM_SIZE_MAX, BOARD_FIXED_ITEM_SIZE_MIN } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -106,10 +107,55 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
const displayedLayouts = form.values.layouts
|
||||
.map((layout, index) => ({ layout, index }))
|
||||
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
|
||||
const fixedItemPreviewSize =
|
||||
Math.min(BOARD_FIXED_ITEM_SIZE_MAX, Math.max(BOARD_FIXED_ITEM_SIZE_MIN, form.values.fixedItemSize)) / 4;
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
<Stack gap="lg">
|
||||
<Paper className={classes.scalingSettings} p="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>{tBoard("setting.section.layout.scaling.title")}</Text>
|
||||
<Switch
|
||||
label={tBoard("field.fixedScaling.label")}
|
||||
description={tBoard("field.fixedScaling.description")}
|
||||
{...form.getInputProps("fixedScaling", { type: "checkbox" })}
|
||||
/>
|
||||
<Group align="center" justify="space-between" gap="xl" wrap="wrap">
|
||||
<NumberInput
|
||||
label={tBoard("field.fixedItemSize.label")}
|
||||
description={tBoard("field.fixedItemSize.description")}
|
||||
min={BOARD_FIXED_ITEM_SIZE_MIN}
|
||||
max={BOARD_FIXED_ITEM_SIZE_MAX}
|
||||
step={10}
|
||||
allowDecimal={false}
|
||||
suffix=" px"
|
||||
w={{ base: "100%", sm: 240 }}
|
||||
{...form.getInputProps("fixedItemSize")}
|
||||
/>
|
||||
<Box
|
||||
component="figure"
|
||||
className={classes.fixedItemSizePreview}
|
||||
aria-label={tBoard("field.fixedItemSize.previewLabel", { size: form.values.fixedItemSize })}
|
||||
>
|
||||
<Box
|
||||
className={classes.fixedItemSizePreviewTile}
|
||||
w={fixedItemPreviewSize}
|
||||
h={fixedItemPreviewSize}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Text size="xs" fw={600}>
|
||||
1 × 1
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="sm" fw={500}>
|
||||
{tBoard("field.fixedItemSize.previewValue", { size: form.values.fixedItemSize })}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2} maw="52rem">
|
||||
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
|
||||
@@ -160,7 +206,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
|
||||
<Stack gap="md">
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
|
||||
<Input.Wrapper label={tLayout("field.columnCount.label")}>
|
||||
<Input.Wrapper
|
||||
label={tLayout("field.columnCount.label")}
|
||||
description={tLayout("field.columnCount.description")}
|
||||
>
|
||||
<Slider
|
||||
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
|
||||
mt="xs"
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
@@ -59,6 +60,8 @@ const PARTIAL_FORM_KEYS = [
|
||||
"itemRadius",
|
||||
"customCss",
|
||||
"disableStatus",
|
||||
"fixedScaling",
|
||||
"fixedItemSize",
|
||||
] as const;
|
||||
|
||||
const buildInitialValues = (board: Board): FormValues => ({
|
||||
@@ -77,6 +80,8 @@ const buildInitialValues = (board: Board): FormValues => ({
|
||||
itemRadius: board.itemRadius,
|
||||
customCss: board.customCss ?? "",
|
||||
disableStatus: board.disableStatus,
|
||||
fixedScaling: board.fixedScaling ?? false,
|
||||
fixedItemSize: board.fixedItemSize ?? BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||
layouts: normalizeMobileLayoutGutters(board.layouts),
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CopyButton,
|
||||
Group,
|
||||
InputWrapper,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconFileTypeCss,
|
||||
IconLayoutGrid,
|
||||
IconPalette,
|
||||
IconPhoto,
|
||||
IconRefresh,
|
||||
@@ -38,7 +40,14 @@ import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import {
|
||||
BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||
BOARD_FIXED_ITEM_SIZE_MAX,
|
||||
BOARD_FIXED_ITEM_SIZE_MIN,
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
} from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
@@ -85,6 +94,8 @@ const normalizeFormValues = (
|
||||
iconColor: values.iconColor ?? "",
|
||||
itemRadius: values.itemRadius,
|
||||
disableStatus: values.disableStatus,
|
||||
fixedScaling: values.fixedScaling ?? false,
|
||||
fixedItemSize: values.fixedItemSize ?? BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -263,6 +274,9 @@ const BoardSettingsForm = ({
|
||||
<Tabs.Tab value="css" leftSection={<IconFileTypeCss size={15} />}>
|
||||
{t("tabs.css")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="layout" leftSection={<IconLayoutGrid size={15} />}>
|
||||
{t("tabs.layout")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="appearance" leftSection={<IconPalette size={15} />}>
|
||||
{t("tabs.appearance")}
|
||||
</Tabs.Tab>
|
||||
@@ -329,6 +343,26 @@ const BoardSettingsForm = ({
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="layout" pt="md">
|
||||
<Stack gap="md">
|
||||
<Switch
|
||||
label={boardT("board.field.fixedScaling.label")}
|
||||
description={boardT("board.field.fixedScaling.description")}
|
||||
{...form.getInputProps("fixedScaling", { type: "checkbox" })}
|
||||
/>
|
||||
<NumberInput
|
||||
label={boardT("board.field.fixedItemSize.label")}
|
||||
description={boardT("board.field.fixedItemSize.description")}
|
||||
min={BOARD_FIXED_ITEM_SIZE_MIN}
|
||||
max={BOARD_FIXED_ITEM_SIZE_MAX}
|
||||
step={10}
|
||||
allowDecimal={false}
|
||||
suffix=" px"
|
||||
{...form.getInputProps("fixedItemSize")}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="appearance" pt="md">
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
|
||||
@@ -16,6 +16,8 @@ export const assistantBoardSettingKeys = [
|
||||
"iconColor",
|
||||
"itemRadius",
|
||||
"disableStatus",
|
||||
"fixedScaling",
|
||||
"fixedItemSize",
|
||||
] as const satisfies readonly (keyof AssistantBoardSettingsChanges)[];
|
||||
|
||||
const comparableValue = (value: unknown) => (value === null ? "" : value);
|
||||
@@ -54,12 +56,14 @@ const backgroundKeys = new Set<keyof AssistantBoardSettingsChanges>([
|
||||
"backgroundImageRepeat",
|
||||
"backgroundImageSize",
|
||||
]);
|
||||
const layoutKeys = new Set<keyof AssistantBoardSettingsChanges>(["fixedScaling", "fixedItemSize"]);
|
||||
|
||||
export const getAssistantBoardSettingsDefaultTab = (
|
||||
changes: AssistantBoardSettingsChanges,
|
||||
): "general" | "appearance" | "background" | "css" => {
|
||||
): "general" | "layout" | "appearance" | "background" | "css" => {
|
||||
if (changes.customCss !== undefined) return "css";
|
||||
const keys = Object.keys(changes) as (keyof AssistantBoardSettingsChanges)[];
|
||||
if (keys.some((key) => layoutKeys.has(key))) return "layout";
|
||||
if (keys.some((key) => appearanceKeys.has(key))) return "appearance";
|
||||
if (keys.some((key) => backgroundKeys.has(key))) return "background";
|
||||
return "general";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
BOARD_FIXED_ITEM_SIZE_MAX,
|
||||
BOARD_FIXED_ITEM_SIZE_MIN,
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
@@ -48,6 +50,17 @@ export const assistantBoardSettingsChangesSchema = z
|
||||
iconColor: boardColorSchema.nullable().optional(),
|
||||
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]).optional(),
|
||||
disableStatus: z.boolean().optional(),
|
||||
fixedScaling: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Keep board tiles at a configured size instead of fitting the complete layout to the viewport."),
|
||||
fixedItemSize: z
|
||||
.number()
|
||||
.int()
|
||||
.min(BOARD_FIXED_ITEM_SIZE_MIN)
|
||||
.max(BOARD_FIXED_ITEM_SIZE_MAX)
|
||||
.optional()
|
||||
.describe("Visible pixel width and height of a 1 by 1 tile when fixedScaling is enabled."),
|
||||
})
|
||||
.describe("Only the board fields the user asked to change.");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
|
||||
|
||||
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { ContainerSectionMockBuilder } from "./container-section-mock";
|
||||
@@ -27,6 +28,8 @@ export class BoardMockBuilder {
|
||||
name: "board",
|
||||
opacity: 100,
|
||||
isPublic: true,
|
||||
fixedScaling: false,
|
||||
fixedItemSize: BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||
disableStatus: false,
|
||||
customCss: "",
|
||||
creatorId: createId(),
|
||||
|
||||
@@ -5,13 +5,15 @@ The board uses fixed logical units:
|
||||
- one grid track has a `212 × 212` logical-pixel footprint;
|
||||
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
|
||||
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
|
||||
- the canvas is zoomed once with one uniform scale derived from its available width.
|
||||
- the canvas is zoomed once with one uniform scale.
|
||||
|
||||
Grid footprints keep the same logical dimensions at every viewport. Widget cards
|
||||
use a scale-compensated inset so their painted size and content measurements
|
||||
match the legacy responsive grid. The visual canvas always fits the available
|
||||
width, every root lane keeps at least one viewport of height, and edit-mode
|
||||
previews extend it downward as needed.
|
||||
match the legacy responsive grid. Responsive scaling derives the zoom from the
|
||||
available width. Fixed scaling derives it from the configured painted `1 x 1`
|
||||
card size and keeps the canvas centered while it fits; otherwise the board's
|
||||
named canvas region scrolls on both axes. Every root lane keeps at least one
|
||||
viewport of height, and edit-mode previews extend it downward as needed.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
|
||||
@@ -5,5 +5,7 @@
|
||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||
export const LOGICAL_GRID_GAP = 12;
|
||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
/** Physical inset between a grid footprint and each edge of its painted tile. */
|
||||
export const BOARD_GRID_ITEM_INSET = 10;
|
||||
|
||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||
@@ -10,6 +10,76 @@
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"] {
|
||||
--board-canvas-fixed-viewport-height: calc(
|
||||
100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding, 1rem) -
|
||||
var(--app-shell-footer-offset, 0px) - var(--app-shell-padding, 1rem)
|
||||
);
|
||||
|
||||
height: var(--board-canvas-fixed-viewport-height);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-track,
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]:hover::-webkit-scrollbar-thumb,
|
||||
.viewport[data-canvas-scaling="fixed"]:focus-visible::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent);
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--mantine-color-dimmed) 70%, transparent);
|
||||
}
|
||||
|
||||
@supports (-moz-appearance: none) {
|
||||
.viewport[data-canvas-scaling="fixed"] {
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]:hover,
|
||||
.viewport[data-canvas-scaling="fixed"]:focus-visible {
|
||||
scrollbar-color: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"][data-canvas-centered="true"] .sizer {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"] .sizer {
|
||||
/* Leave room for edit labels and controls that intentionally sit above first-row tiles. */
|
||||
margin-block-start: calc(2rem * var(--board-canvas-scale, 1));
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"] .canvas {
|
||||
/* Fixed size describes every painted tile, including container sections. */
|
||||
--board-grid-item-inset: calc(var(--board-grid-card-inset, 10px) * var(--board-canvas-inverse-scale, 1));
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"][data-board-hydrated="false"] {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.sizer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { CSSProperties, PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./constants";
|
||||
import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
const BoardCanvasScaleContext = createContext(1);
|
||||
const BoardCanvasViewportHeightContext = createContext<number | null>(null);
|
||||
|
||||
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
||||
export const useBoardCanvasViewportHeight = () => useContext(BoardCanvasViewportHeightContext);
|
||||
|
||||
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||
@@ -18,6 +21,14 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
|
||||
return availableWidth / logicalWidth;
|
||||
};
|
||||
|
||||
export const calculateFixedBoardCanvasScale = (fixedItemSize: number) => {
|
||||
if (!Number.isFinite(fixedItemSize) || fixedItemSize <= 0) return 1;
|
||||
|
||||
// The configured size is the painted card, after the two fixed physical insets
|
||||
// have been removed from its scaled logical grid footprint.
|
||||
return (fixedItemSize + 2 * BOARD_GRID_ITEM_INSET) / LOGICAL_GRID_PITCH;
|
||||
};
|
||||
|
||||
export const calculateBoardUiScale = (canvasScale: number) => {
|
||||
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
|
||||
|
||||
@@ -28,22 +39,24 @@ interface ScaledBoardCanvasProps {
|
||||
logicalWidth: number;
|
||||
initialLogicalHeight: number;
|
||||
initialAvailableWidth: number;
|
||||
fixedItemSize?: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales the complete board as one surface while keeping widget layout in
|
||||
* fixed logical pixels. The complete canvas always fits its viewport so board
|
||||
* content can never widen the document or require horizontal scrolling.
|
||||
* fixed logical pixels. Responsive scaling fits the canvas to its viewport;
|
||||
* fixed scaling contains overflow in a keyboard-scrollable canvas region.
|
||||
*/
|
||||
export const ScaledBoardCanvas = ({
|
||||
logicalWidth,
|
||||
initialLogicalHeight,
|
||||
initialAvailableWidth,
|
||||
fixedItemSize,
|
||||
label,
|
||||
children,
|
||||
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
|
||||
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
|
||||
const { ref: viewportRef, width: availableWidth, height: viewportHeight } = useElementSize<HTMLDivElement>();
|
||||
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
@@ -53,14 +66,17 @@ export const ScaledBoardCanvas = ({
|
||||
|
||||
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
|
||||
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
|
||||
const scale = useMemo(
|
||||
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
|
||||
[logicalWidth, resolvedAvailableWidth],
|
||||
);
|
||||
const isFixedScaling = fixedItemSize !== undefined;
|
||||
const scale = useMemo(() => {
|
||||
if (fixedItemSize !== undefined) return calculateFixedBoardCanvasScale(fixedItemSize);
|
||||
|
||||
return calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth);
|
||||
}, [fixedItemSize, logicalWidth, resolvedAvailableWidth]);
|
||||
const uiScale = calculateBoardUiScale(scale);
|
||||
const inverseScale = scale > 0 ? 1 / scale : 1;
|
||||
const visualWidth = logicalWidth * scale;
|
||||
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
|
||||
const shouldCenterCanvas = isFixedScaling && visualWidth <= resolvedAvailableWidth;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -69,24 +85,41 @@ export const ScaledBoardCanvas = ({
|
||||
className={classes.viewport}
|
||||
data-testid="board-canvas"
|
||||
data-board-hydrated={isHydrated ? "true" : "false"}
|
||||
data-canvas-scaling={isFixedScaling ? "fixed" : "responsive"}
|
||||
data-canvas-scale={scale}
|
||||
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
|
||||
data-canvas-centered={shouldCenterCanvas ? "true" : undefined}
|
||||
data-canvas-initial-height={initialLogicalHeight}
|
||||
data-canvas-initial-width={initialAvailableWidth}
|
||||
aria-label={label}
|
||||
tabIndex={isFixedScaling ? 0 : undefined}
|
||||
>
|
||||
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
|
||||
<Box
|
||||
className={classes.sizer}
|
||||
style={
|
||||
{
|
||||
"--board-canvas-scale": scale,
|
||||
width: visualWidth,
|
||||
height: resolvedLogicalHeight * scale,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className={classes.canvas}
|
||||
style={{
|
||||
"--board-grid-card-inset": `${BOARD_GRID_ITEM_INSET}px`,
|
||||
"--board-canvas-inverse-scale": inverseScale,
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
width: logicalWidth,
|
||||
zoom: scale,
|
||||
}}
|
||||
>
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
<BoardCanvasScaleContext.Provider value={scale}>
|
||||
<BoardCanvasViewportHeightContext.Provider value={isFixedScaling ? viewportHeight : null}>
|
||||
{children}
|
||||
</BoardCanvasViewportHeightContext.Provider>
|
||||
</BoardCanvasScaleContext.Provider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1250,7 +1250,7 @@ const configureEditorPlugins = (defaults: Plugins<DragDropManager>) =>
|
||||
dropAnimation: null,
|
||||
keyboardTransition: { duration: 100 },
|
||||
});
|
||||
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0, y: 0.16 } });
|
||||
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0.16, y: 0.16 } });
|
||||
return plugin;
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
.staticItem[data-type="item"] > .contentMount {
|
||||
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
|
||||
inset: calc(var(--board-grid-card-inset, 10px) * var(--board-canvas-inverse-scale, 1));
|
||||
}
|
||||
|
||||
.staticItem:has([data-board-container-label]) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
BOARD_GRID_ITEM_INSET,
|
||||
getCollapsedDisplayLayout,
|
||||
getEditableCanvasAttributes,
|
||||
getGridRowCountForVisualHeight,
|
||||
@@ -18,7 +19,11 @@ import {
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import {
|
||||
calculateBoardUiScale,
|
||||
useBoardCanvasScale,
|
||||
useBoardCanvasViewportHeight,
|
||||
} from "~/components/board/layout/scaled-board-canvas";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
@@ -50,6 +55,7 @@ export const SectionGrid = ({
|
||||
}: SectionGridProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const canvasScale = useBoardCanvasScale();
|
||||
const canvasViewportHeight = useBoardCanvasViewportHeight();
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const editorRegistry = useGridEditorRegistry();
|
||||
const editorHostRef = useRef<HTMLDivElement>(null);
|
||||
@@ -130,7 +136,11 @@ export const SectionGrid = ({
|
||||
() => innerSections.map((item) => withPlacement(item, placementById.get(item.id))),
|
||||
[innerSections, placementById],
|
||||
);
|
||||
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
|
||||
const minimumViewportRowCount = useMinimumViewportRowCount(
|
||||
section.kind === "empty",
|
||||
canvasScale,
|
||||
canvasViewportHeight,
|
||||
);
|
||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
||||
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
|
||||
@@ -150,9 +160,9 @@ export const SectionGrid = ({
|
||||
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
|
||||
// the same column/row counts used to allocate the *outer*, uninset cell - so without
|
||||
// subtracting that gap back out here, a container's inner grid renders larger than its own
|
||||
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
||||
// card and visually spills past its right/bottom edges.
|
||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
||||
const outerCardInset = section.kind === "container" ? (2 * BOARD_GRID_ITEM_INSET) / effectiveCanvasScale : 0;
|
||||
// A collapsible container's toggle bar (see the `containerToggle` Button in
|
||||
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
|
||||
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
|
||||
@@ -342,11 +352,11 @@ const INTERACTIVE_GRID_SELECTOR =
|
||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
||||
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
||||
|
||||
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
||||
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number, canvasViewportHeight: number | null) => {
|
||||
const [visualHeight, setVisualHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!enabled || canvasViewportHeight !== null) return;
|
||||
const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
|
||||
update();
|
||||
window.addEventListener("resize", update, { passive: true });
|
||||
@@ -355,9 +365,10 @@ const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.visualViewport?.removeEventListener("resize", update);
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [canvasViewportHeight, enabled]);
|
||||
|
||||
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
|
||||
const resolvedVisualHeight = canvasViewportHeight ?? visualHeight;
|
||||
return enabled ? getGridRowCountForVisualHeight(resolvedVisualHeight, canvasScale) : 0;
|
||||
};
|
||||
|
||||
const containerMinimumSizeCache = new WeakMap<
|
||||
|
||||
Reference in new issue
Block a user