The widget options ActionIcon rendered inside the widget Card, whose containerType: size creates a stacking context, trapping its zIndex and letting the dnd-kit resize handles paint over and block it. Hoist BoardItemMenu out of the Card to the item root (same layer as the title badge) and raise it to zIndex 30, above the handles (20). Error recovery after edits is preserved via resetKeys=[item], which replaces the resetErrorBoundary prop the in-card menu used to receive.
518 lines
19 KiB
TypeScript
518 lines
19 KiB
TypeScript
import type { ComponentType, CSSProperties, MutableRefObject } from "react";
|
|
import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import dynamic from "next/dynamic";
|
|
import type { CardProps } from "@mantine/core";
|
|
import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core";
|
|
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
|
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
|
import combineClasses from "clsx";
|
|
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
|
import type { FallbackProps } from "react-error-boundary";
|
|
import { ErrorBoundary } from "react-error-boundary";
|
|
|
|
import { useRequiredBoard } from "@homarr/boards/context";
|
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
|
import { useSettings } from "@homarr/settings";
|
|
import { useI18n } from "@homarr/translation/client";
|
|
import { WidgetError } from "@homarr/widgets/errors";
|
|
import {
|
|
createWidgetRuntimeState,
|
|
getWidgetQueryKeys,
|
|
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
|
} from "@homarr/widgets/definition";
|
|
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
|
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
|
|
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
|
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
|
import { useAdvancedFocus } from "../advanced-focus/context";
|
|
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
|
|
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
|
|
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
|
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
|
import classes from "../sections/item.module.css";
|
|
import { useItemActions } from "./item-actions";
|
|
import itemContentClasses from "./item-content.module.css";
|
|
import { WidgetContextMenu } from "./widget-context-menu";
|
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
|
|
|
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
|
ssr: false,
|
|
});
|
|
|
|
interface BoardItemContentProps {
|
|
item: SectionItem;
|
|
}
|
|
|
|
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
|
|
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
|
|
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
|
if (hasCustomCssClasses) return {};
|
|
return { overflowX: "hidden", overflowY: "auto" } as const;
|
|
};
|
|
|
|
type BoardItemCardProps = CardProps & {
|
|
item: SectionItem;
|
|
innerRef: (element: HTMLDivElement | null) => void;
|
|
};
|
|
|
|
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
|
const board = useRequiredBoard();
|
|
|
|
return (
|
|
<Card
|
|
{...cardProps}
|
|
ref={innerRef}
|
|
w="100%"
|
|
h="100%"
|
|
data-grid-item-content
|
|
className={combineClasses(
|
|
classes.itemCard,
|
|
`${item.kind}-wrapper`,
|
|
"board-grid-item-content",
|
|
item.advancedOptions.customCssClasses.join(" "),
|
|
)}
|
|
radius={board.itemRadius}
|
|
styles={{
|
|
root: {
|
|
"--opacity": board.opacity / 100,
|
|
containerType: "size",
|
|
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
},
|
|
}}
|
|
p={0}
|
|
>
|
|
{children}
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
const WidgetDefinitionLoadError = ({
|
|
error,
|
|
resetErrorBoundary,
|
|
item,
|
|
innerRef,
|
|
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
|
|
<BoardItemCard item={item} innerRef={innerRef}>
|
|
<Center h="100%">
|
|
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
|
</Center>
|
|
</BoardItemCard>
|
|
);
|
|
|
|
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
|
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
|
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
|
|
|
return (
|
|
<ErrorBoundary
|
|
resetKeys={[item.kind]}
|
|
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
|
>
|
|
<Suspense
|
|
fallback={
|
|
<BoardItemCard item={item} innerRef={ref}>
|
|
<Center h="100%">
|
|
<Loader size="sm" />
|
|
</Center>
|
|
</BoardItemCard>
|
|
}
|
|
>
|
|
<LoadedBoardItemContent
|
|
item={item}
|
|
width={width}
|
|
height={height}
|
|
widgetStateRef={widgetStateRef}
|
|
widgetRuntimeRef={widgetRuntimeRef}
|
|
contentRef={ref}
|
|
/>
|
|
</Suspense>
|
|
</ErrorBoundary>
|
|
);
|
|
};
|
|
|
|
interface LoadedBoardItemContentProps {
|
|
item: SectionItem;
|
|
width: number;
|
|
height: number;
|
|
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
|
widgetRuntimeRef: WidgetRuntimeRef;
|
|
contentRef: (element: HTMLDivElement | null) => void;
|
|
}
|
|
|
|
const LoadedBoardItemContent = ({
|
|
item,
|
|
width,
|
|
height,
|
|
widgetStateRef,
|
|
widgetRuntimeRef,
|
|
contentRef,
|
|
}: LoadedBoardItemContentProps) => {
|
|
const { definition, Component } = use(loadWidgetResources(item.kind));
|
|
const sourceRef = useRef<HTMLDivElement>(null);
|
|
const advancedFocusTriggerRef = useRef<HTMLButtonElement>(null);
|
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
const previewEntranceFrameRef = useRef<number | null>(null);
|
|
const board = useRequiredBoard();
|
|
const t = useI18n();
|
|
const [isEditMode] = useEditMode();
|
|
const settings = useSettings();
|
|
const menuItem = useMemo(
|
|
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
|
[definition, item, settings],
|
|
);
|
|
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
|
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
|
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
|
const { width: viewportWidth, height: viewportHeight } = viewportSize;
|
|
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
|
const widgetName = t(`widget.${item.kind}.name`);
|
|
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
|
const advancedViewId = `advanced-focus-${item.id}`;
|
|
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
|
const isAdvanced = activeFocus !== null;
|
|
const advancedRect = activeFocus
|
|
? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
|
|
: null;
|
|
const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null;
|
|
const isPreview = activeFocus?.activation === "preview";
|
|
const isManual = activeFocus?.activation === "manual";
|
|
|
|
useIsomorphicEffect(() => {
|
|
if (!supportsAdvancedFocus) return;
|
|
const target = document.createElement("div");
|
|
target.className = advancedFocusClasses.surfacePortalTarget ?? "";
|
|
setSurfacePortalTarget(target);
|
|
return () => target.remove();
|
|
}, [supportsAdvancedFocus]);
|
|
|
|
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
|
|
|
|
const openAdvancedView = () => {
|
|
if (sourceRef.current)
|
|
open(item.id, sourceRef.current, {
|
|
activation: "manual",
|
|
autofocusClose: true,
|
|
restoreFocusTarget: advancedFocusTriggerRef.current ?? sourceRef.current,
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
const surface = isManual ? manualSurface : cardRef.current;
|
|
if (!isAdvanced || !surface) return;
|
|
|
|
const handleWheel = (event: WheelEvent) => {
|
|
if (!event.shiftKey) return;
|
|
const multiplier = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? surface.clientHeight : 1;
|
|
const delta = (event.deltaY || event.deltaX) * multiplier;
|
|
if (!redirectShiftWheel(surface, event.target, delta)) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
surface.addEventListener("wheel", handleWheel, { capture: true, passive: false });
|
|
return () => surface.removeEventListener("wheel", handleWheel, { capture: true });
|
|
}, [isAdvanced, isManual, manualSurface]);
|
|
|
|
const cancelPreviewEntrance = useCallback(() => {
|
|
if (previewEntranceFrameRef.current === null) return;
|
|
cancelAnimationFrame(previewEntranceFrameRef.current);
|
|
previewEntranceFrameRef.current = null;
|
|
}, []);
|
|
|
|
const mountCompactHost = useCallback(
|
|
(host: HTMLDivElement | null) => {
|
|
if (!host || !surfacePortalTarget || isAdvanced) return;
|
|
cancelPreviewEntrance();
|
|
host.append(surfacePortalTarget);
|
|
},
|
|
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
|
|
);
|
|
|
|
const mountPreviewHost = useCallback(
|
|
(host: HTMLDivElement | null) => {
|
|
if (!host || !surfacePortalTarget || !isPreview) {
|
|
if (!host) cancelPreviewEntrance();
|
|
return;
|
|
}
|
|
|
|
if (activeFocus?.phase !== "visible") {
|
|
host.append(surfacePortalTarget);
|
|
return;
|
|
}
|
|
const card = cardRef.current;
|
|
const readyClass = advancedFocusClasses.surfaceReady;
|
|
if (!card || !readyClass) return;
|
|
|
|
cancelPreviewEntrance();
|
|
previewEntranceFrameRef.current = startAdvancedFocusEntrance(surfacePortalTarget, host, card, readyClass);
|
|
},
|
|
[activeFocus?.phase, cancelPreviewEntrance, isPreview, surfacePortalTarget],
|
|
);
|
|
|
|
const mountManualHost = useCallback(
|
|
(host: HTMLDivElement | null) => {
|
|
if (!host || !surfacePortalTarget || !isManual) return;
|
|
cancelPreviewEntrance();
|
|
host.append(surfacePortalTarget);
|
|
},
|
|
[cancelPreviewEntrance, isManual, surfacePortalTarget],
|
|
);
|
|
|
|
useEffect(() => cancelPreviewEntrance, [cancelPreviewEntrance]);
|
|
|
|
const previewStyle =
|
|
isPreview && advancedRect && activeFocus
|
|
? ({
|
|
left: advancedRect.left,
|
|
top: advancedRect.top,
|
|
width: advancedRect.width,
|
|
height: advancedRect.height,
|
|
"--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`,
|
|
"--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`,
|
|
"--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1,
|
|
"--focus-scale-y": advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1,
|
|
} as CSSProperties)
|
|
: undefined;
|
|
|
|
const widgetCard = (
|
|
<Card
|
|
ref={cardRef}
|
|
id={isPreview ? advancedViewId : undefined}
|
|
role={isPreview ? "region" : undefined}
|
|
aria-label={isPreview ? advancedViewLabel : undefined}
|
|
data-advanced-focus-surface={isPreview || undefined}
|
|
data-grid-item-content
|
|
radius={board.itemRadius}
|
|
p={isAdvanced ? undefined : 0}
|
|
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
|
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
|
className={combineClasses(
|
|
classes.itemCard,
|
|
`${item.kind}-wrapper`,
|
|
"board-grid-item-content",
|
|
isPreview && advancedFocusClasses.surface,
|
|
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
|
item.advancedOptions.customCssClasses.join(" "),
|
|
)}
|
|
styles={{
|
|
root: {
|
|
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
|
|
containerType: "size",
|
|
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
},
|
|
}}
|
|
style={previewStyle}
|
|
>
|
|
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
|
<ErrorBoundary
|
|
resetKeys={[item]}
|
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
|
<div
|
|
className={itemContentClasses.editModeInertContent}
|
|
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
|
inert={isEditMode}
|
|
>
|
|
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
|
</div>
|
|
)}
|
|
>
|
|
<InnerContent
|
|
item={item}
|
|
width={width}
|
|
height={height}
|
|
widgetStateRef={widgetStateRef}
|
|
widgetRuntimeRef={widgetRuntimeRef}
|
|
displayMode={isAdvanced ? "advanced" : "compact"}
|
|
definition={definition}
|
|
Component={Component}
|
|
/>
|
|
</ErrorBoundary>
|
|
</Box>
|
|
</Card>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
|
|
<WidgetContextMenu
|
|
item={item}
|
|
definition={definition}
|
|
widgetStateRef={widgetStateRef}
|
|
widgetRuntimeRef={widgetRuntimeRef}
|
|
sourceRef={sourceRef}
|
|
>
|
|
<Box
|
|
ref={sourceRef}
|
|
w="100%"
|
|
h="100%"
|
|
data-advanced-focus-source
|
|
data-advanced-focus-enabled={supportsAdvancedFocus || undefined}
|
|
data-item-id={item.id}
|
|
role={!isEditMode && supportsAdvancedFocus ? "group" : undefined}
|
|
aria-label={!isEditMode && supportsAdvancedFocus ? widgetName : undefined}
|
|
onPointerEnter={() => {
|
|
if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current);
|
|
}}
|
|
onPointerLeave={() => {
|
|
if (supportsAdvancedFocus) leave(item.id);
|
|
}}
|
|
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
|
|
>
|
|
{!isEditMode && supportsAdvancedFocus && (
|
|
<Button
|
|
ref={advancedFocusTriggerRef}
|
|
type="button"
|
|
size="compact-xs"
|
|
className={itemContentClasses.advancedFocusTrigger}
|
|
aria-expanded={isAdvanced}
|
|
aria-controls={isAdvanced ? advancedViewId : undefined}
|
|
aria-keyshortcuts="Shift+Enter"
|
|
data-advanced-focus-trigger
|
|
onClick={openAdvancedView}
|
|
>
|
|
{t("item.advancedFocus.open")}
|
|
</Button>
|
|
)}
|
|
<div ref={mountCompactHost} className={advancedFocusClasses.surfaceHost}>
|
|
{!supportsAdvancedFocus && widgetCard}
|
|
</div>
|
|
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)}
|
|
{isPreview && (
|
|
<Portal reuseTargetNode={false}>
|
|
<div ref={mountPreviewHost} className={advancedFocusClasses.surfaceHostAdvanced} />
|
|
</Portal>
|
|
)}
|
|
{isManual && activeFocus && advancedRect && closePosition && (
|
|
<AdvancedFocusManualSurface
|
|
opened={activeFocus.phase === "visible"}
|
|
phase={activeFocus.phase}
|
|
id={advancedViewId}
|
|
label={advancedViewLabel}
|
|
closeLabel={t("item.advancedFocus.close")}
|
|
rect={advancedRect}
|
|
closePosition={closePosition}
|
|
sourceRect={activeFocus.sourceRect}
|
|
radius={board.itemRadius}
|
|
contentRef={setManualSurface}
|
|
onClose={() => close()}
|
|
>
|
|
<div ref={mountManualHost} className={advancedFocusClasses.manualSurfaceMount} />
|
|
</AdvancedFocusManualSurface>
|
|
)}
|
|
</Box>
|
|
</WidgetContextMenu>
|
|
{!isAdvanced && item.advancedOptions.title?.trim() && (
|
|
<Badge
|
|
pos="absolute"
|
|
style={{ zIndex: "var(--mantine-z-index-app)" }}
|
|
top={2}
|
|
left={16}
|
|
size="xs"
|
|
radius={board.itemRadius}
|
|
styles={{
|
|
root: {
|
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
"--opacity": board.opacity / 100,
|
|
},
|
|
}}
|
|
className={itemContentClasses.badge}
|
|
c="var(--mantine-color-text)"
|
|
>
|
|
{item.advancedOptions.title}
|
|
</Badge>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface InnerContentProps {
|
|
item: SectionItem;
|
|
width: number;
|
|
height: number;
|
|
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
|
widgetRuntimeRef: WidgetRuntimeRef;
|
|
displayMode: "compact" | "advanced";
|
|
definition: WidgetDefinition;
|
|
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
|
}
|
|
|
|
const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) {
|
|
const settings = useSettings();
|
|
const board = useRequiredBoard();
|
|
const [isEditMode] = useEditMode();
|
|
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
|
const { removeItem, updateItemOptions } = useItemActions();
|
|
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
|
updateItemOptions({ itemId: item.id, newOptions });
|
|
const widgetSupportsIntegrations =
|
|
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
|
const queryClient = useQueryClient();
|
|
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
|
|
|
return (
|
|
<QueryErrorResetBoundary>
|
|
{({ reset }) => (
|
|
<ErrorBoundary
|
|
onReset={() => {
|
|
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
|
reset();
|
|
}}
|
|
resetKeys={[item]}
|
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
|
<div
|
|
className={itemContentClasses.editModeInertContent}
|
|
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
|
inert={isEditMode}
|
|
>
|
|
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
|
</div>
|
|
)}
|
|
>
|
|
<Throw
|
|
error={new NoIntegrationSelectedError()}
|
|
when={
|
|
widgetSupportsIntegrations &&
|
|
item.integrationIds.length === 0 &&
|
|
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
|
}
|
|
/>
|
|
<div
|
|
className={itemContentClasses.editModeInertContent}
|
|
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
|
data-homarr-widget-ready={item.kind}
|
|
inert={isEditMode}
|
|
>
|
|
<Component
|
|
options={options as never}
|
|
integrationIds={item.integrationIds}
|
|
isEditMode={isEditMode}
|
|
boardId={board.id}
|
|
itemId={item.id}
|
|
removeItem={() => removeItem({ itemId: item.id })}
|
|
setOptions={(partialNewOptions) =>
|
|
updateOptions({
|
|
newOptions: {
|
|
...options,
|
|
...partialNewOptions.newOptions,
|
|
},
|
|
})
|
|
}
|
|
{...dimensions}
|
|
/>
|
|
</div>
|
|
</ErrorBoundary>
|
|
)}
|
|
</QueryErrorResetBoundary>
|
|
);
|
|
});
|
|
|
|
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
|
|
if (when) throw error;
|
|
return null;
|
|
};
|