fix: preserve widget preview dimensions

This commit is contained in:
ajnart committed 2026-08-23 01:19:08 +02:00
1 parent 5188ed89d6
commit 763e1324e8
6 files changed
+86 -60

No files matched your search

@@ -180,6 +180,7 @@ const LoadedBoardItemContent = ({
const { width: viewportWidth, height: viewportHeight } = viewportSize;
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = getWidgetName(item.kind, t);
const previewDimensions = useMemo(() => ({ width, height }), [height, width]);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
@@ -350,10 +351,11 @@ const LoadedBoardItemContent = ({
return (
<>
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} previewDimensions={previewDimensions} />}
<WidgetContextMenu
item={item}
definition={definition}
previewDimensions={previewDimensions}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
@@ -21,6 +21,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
interface BoardItemMenuProps {
item: SectionItem;
definition: WidgetDefinition;
previewDimensions: { width: number; height: number };
resetErrorBoundary?: () => void;
}
@@ -31,7 +32,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
return <BoardItemMenuInner {...props} />;
};
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
@@ -89,7 +90,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
integrationSupport: "supportedIntegrations" in definition,
settings,
itemId: item.id,
previewDimensions: { width: item.width * 200, height: item.height * 200 },
previewDimensions,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
@@ -7,7 +7,7 @@ import type { FallbackProps } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { createModal, useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
@@ -160,7 +160,7 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
defaultTitle(t) {
return t("item.edit.title");
},
size: modalSizeSelect,
size: "100rem",
transitionProps: {
transition: "pop",
duration: 180,
@@ -35,6 +35,7 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps {
item: SectionItem;
definition: WidgetDefinition;
previewDimensions: { width: number; height: number };
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>;
@@ -44,6 +45,7 @@ interface WidgetContextMenuProps {
export const WidgetContextMenu = ({
item,
definition,
previewDimensions,
widgetRuntimeRef,
sourceRef,
children,
@@ -191,7 +193,7 @@ export const WidgetContextMenu = ({
settings,
itemId: item.id,
boardId: board.id,
previewDimensions: { width: item.width * 200, height: item.height * 200 },
previewDimensions,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -207,6 +209,7 @@ export const WidgetContextMenu = ({
handleRefetch,
openModal,
persistBoard,
previewDimensions,
settings,
updateItemAdvancedOptions,
updateItemIntegrations,
@@ -6,7 +6,7 @@
}
.workspace[data-with-preview] {
grid-template-columns: minmax(0, 0.95fr) minmax(24rem, 1.05fr);
grid-template-columns: minmax(0, 3fr) minmax(34rem, 2fr);
}
.previewPanel {
@@ -28,7 +28,7 @@
}
.previewCanvas {
min-height: 23rem;
height: clamp(24rem, calc(100dvh - 17rem), 44rem);
padding: var(--mantine-spacing-lg);
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
@@ -40,12 +40,19 @@
background-size: 2rem 2rem;
}
.previewWidget {
.previewViewport {
position: relative;
flex: 0 0 auto;
animation: dock-widget 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.previewWidget {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
box-shadow: var(--mantine-shadow-lg);
animation: dock-widget 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: top left;
}
.previewTitle {
@@ -134,7 +141,7 @@
}
}
@media (max-width: 62em) {
@media (max-width: 75em) {
.workspace[data-with-preview] {
grid-template-columns: 1fr;
}
@@ -144,7 +151,7 @@
}
.previewCanvas {
min-height: 18rem;
height: 24rem;
}
.settingsPanel {
@@ -154,7 +161,7 @@
}
@media (prefers-reduced-motion: reduce) {
.previewWidget {
.previewViewport {
animation: none;
}
}
@@ -9,6 +9,7 @@ import { schemaResolver } from "@mantine/form";
import { useElementSize } from "@mantine/hooks";
import { IconArrowLeft, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import combineClasses from "clsx";
import { ErrorBoundary } from "react-error-boundary";
import { z } from "zod/v4";
@@ -16,7 +17,7 @@ import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { createModal, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
@@ -71,6 +72,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
}
interface WidgetEditPreviewProps {
kind: WidgetKind;
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
definition: WidgetDefinition;
state: WidgetEditModalState;
@@ -81,6 +83,7 @@ interface WidgetEditPreviewProps {
}
const WidgetEditPreview = ({
kind,
Component,
definition,
state,
@@ -90,18 +93,15 @@ const WidgetEditPreview = ({
onChangeOptions,
}: WidgetEditPreviewProps) => {
const tItem = useI18n("item.edit");
const { ref, width: availableWidth } = useElementSize<HTMLDivElement>();
const { ref, width: availableWidth, height: availableHeight } = useElementSize<HTMLDivElement>();
const sourceWidth = Math.max(dimensions.width, 1);
const sourceHeight = Math.max(dimensions.height, 1);
const aspectRatio = sourceWidth / sourceHeight;
const maximumWidth = Math.max(availableWidth, 280);
let previewWidth = maximumWidth;
let previewHeight = previewWidth / aspectRatio;
if (previewHeight > 320) {
previewHeight = 320;
previewWidth = previewHeight * aspectRatio;
let previewScale = 1;
if (availableWidth > 0 && availableHeight > 0) {
previewScale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight, 1);
}
const previewWidth = sourceWidth * previewScale;
const previewHeight = sourceHeight * previewScale;
const hasIntegrationSupport = "supportedIntegrations" in definition;
const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false;
@@ -118,43 +118,55 @@ const WidgetEditPreview = ({
{tItem("preview.integrationRequired")}
</Alert>
) : (
<Card
className={classes.previewWidget}
w={previewWidth}
h={previewHeight}
p={previewHeight >= 96 ? undefined : 4}
style={{ borderColor: state.advancedOptions.borderColor || undefined }}
>
{state.advancedOptions.title && (
<Badge className={classes.previewTitle} size="sm" variant="default">
{state.advancedOptions.title}
</Badge>
)}
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[state.options, state.integrationIds]}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<div className={classes.previewContent} inert>
<Component
options={state.options as never}
integrationIds={state.integrationIds}
width={previewWidth}
height={previewHeight}
isEditMode={false}
boardId={boardId}
itemId={itemId}
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
/>
</div>
</ErrorBoundary>
<Box className={classes.previewViewport} w={previewWidth} h={previewHeight}>
<Card
className={combineClasses(
classes.previewWidget,
`${kind}-wrapper`,
"board-grid-item-content",
state.advancedOptions.customCssClasses,
)}
</QueryErrorResetBoundary>
</Card>
w={sourceWidth}
h={sourceHeight}
p={0}
style={{
borderColor: state.advancedOptions.borderColor || undefined,
containerType: "size",
transform: `scale(${previewScale})`,
}}
>
{state.advancedOptions.title && (
<Badge className={classes.previewTitle} size="sm" variant="default">
{state.advancedOptions.title}
</Badge>
)}
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[state.options, state.integrationIds]}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<div className={classes.previewContent} inert>
<Component
options={state.options as never}
integrationIds={state.integrationIds}
width={sourceWidth}
height={sourceHeight}
isEditMode={false}
displayMode="compact"
boardId={boardId}
itemId={itemId}
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
/>
</div>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</Card>
</Box>
)}
</Center>
</Stack>
@@ -356,6 +368,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<Box className={classes.workspace} data-with-preview={innerProps.previewComponent ? true : undefined}>
{innerProps.previewComponent && (
<WidgetEditPreview
kind={innerProps.kind}
Component={innerProps.previewComponent}
definition={definition}
state={{ ...form.values, advancedOptions }}
@@ -562,7 +575,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
defaultTitle(t) {
return t("item.edit.title");
},
size: modalSizeSelect,
size: "100rem",
transitionProps: {
transition: "pop",
duration: 180,